Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/updates-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ M1 之后 M2 → M3 与 M4 并行;M5.1、M5.2 与 tinyui 0.3.0(npm 三包 +
| 8 | JS 侧填错目标宿主版本 | 发布前核对(updates.md §1.3):`tinyui build` 把每页用到的能力名与宿主组件名写进 manifest 的 `requires`;`publish` 上传前从热下发服务读目标版本的宿主快照比对,不通过即拒绝发布(`bundle` 保持离线)。快照由宿主 CI 在发 App 版本时上传,每版本只写一次。铁律:`host.call` 的名字必须是字面量、宿主组件必须能静态解析(build-chain.md §5.1)。设备侧核对暂不做 |
| 9 | 服务端凭据分层 | 在实例 `ADMIN_TOKEN` 与包的发布 token 之间补 app token:一个 app 的完整管理权(注册包、公钥、签发发布 token、上传宿主快照),暂不细分权限范围;宿主 CI 用它上传快照;本 app 的发布 token 可读快照。权限与 URL 挂在 app(一个宿主 App)上,不挂在租户(`org`,可改可转让)上(updates.md §6) |
| 10 | 宿主契约怎么枚举 | 能力名的真值在宿主 Kotlin;能力像组件一样 App 级注册(`CapabilityRegistry`),`HostCapability.call(argsJson, page)` 经 `PageLocal` 拿某一屏的对象;组件注册表、能力注册表、`PageSink` 合成 App 级 `TinyUIHost` 交给 `TinyUIPage` / `UpdatesPage`(native-api.md §7)。快照由库的 `HostSnapshot.render` 生成,检查是宿主自己的 host 侧单元测试,`-Ptinyui.updateHostSnapshot` 写入;已发版本的冻结只靠服务端 409(updates.md §4.1) |
| 11 | 指针指向不新于内置的版本 | 期望状态即跑内置:等于内置版本时直接 `UpToDate`(不取 manifest、不看 rollout);已装了更新版本的删掉,结果 `Reverted`,下次启动跑内置;比内置更旧的同样回到内置。内置包由 `tinyui pull` 取 `production` 当时那版(updates.md §1.4、§4.3) |

交付:

Expand Down
18 changes: 16 additions & 2 deletions docs/updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,16 @@ dist/ota/<pkg>/<hostVersion>/<version>/pages/**/*.bin

核对的是名字,覆盖不到已有能力的参数形状与行为,那部分仍按 §4.1 的 bump 规则由人判断。绕过 `publish` 直接调发布端点的,目前拦不住;`requires` 进 manifest 并受签名覆盖,日后服务端在 `PUT current.json` 时同样可以核对。快照的上传与读取见 §6.4。

### 1.4 发 App 时取内置包:`tinyui pull`

```
tinyui pull --channel production --host-version <n> --out <宿主资源目录>/<pkg> [--app <a>] [--root <dir>]
```

内置包取服务端上 `production` 当前指向的那一版,而不是本地重新构建一份(M6 拍板第 7 点):线上与 App 自带的是同一个版本,回滚到"App 自带的那版"就是回滚到一个服务端认得的版本(§4.3)。`pull` 走公开投递端点,不要 token;像设备一样用 `tinyui.config.json` 的公钥验签、核对 name / version / hostVersion、逐文件核对 sha256,任一步不过就一个文件都不写;忽略 `rollout`。输出与 `tinyui build` 的产物同布局(`manifest.json`、`runtime/`、`pages/`),整个替换 `--out`,manifest 是下载来的原始字节(带 `hostVersion`,内置包不读它)。包里没有 `.js.map`:内置包的栈同热下发的包一样,按 `buildId` 离线对映射(build-chain.md §7)。

宿主仓不跑 Node 时(F-Droid 从源码构建),由 JS 工程在发 App 前跑它并把结果提交进宿主仓。

## 2. 投递协议

客户端只会发两种 GET,路径相对宿主配置的 base URL:
Expand Down Expand Up @@ -173,14 +183,15 @@ class Updates(
| `suspend fun check(pkg: String): CheckResult` | 只查一个包 |
| 顶层 `@Composable fun UpdatesPage(updates, name, host, services, propsJson, modifier, error, onHost)` | 与 `TinyUIPage` 同参外加 `updates`;`name` 是含包名的模块名,第一个 `/` 之前即包,`current(pkg).page(name)` → `TinyUIPage`;`error` 前先走 §4.5 的回退 |

`CheckResult`:`UpToDate` / `Installed(version)` / `Skipped(reason)` / `Failed(stage, cause)`。`UpdateEvent` 是同一组事实加 `Running` 与 `RolledBack`,给宿主打日志与埋点;**所有事件都带 `pkg`**。宿主的分析口径会依赖它,所以发布后字段同样只增不改:
`CheckResult`:`UpToDate` / `Installed(version)` / `Reverted(version)` / `Skipped(reason)` / `Failed(stage, cause)`。`UpdateEvent` 是同一组事实加 `Running` 与 `RolledBack`,给宿主打日志与埋点;**所有事件都带 `pkg`**。宿主的分析口径会依赖它,所以发布后字段同样只增不改:

| 事件 | 何时 | 字段(均含 `pkg`) |
|---|---|---|
| `Running` | 构造时每包一次 | `version`(内置或 installed 的 manifest `version`)、`source`:`EMBEDDED` / `INSTALLED` |
| `UpToDate` | `check()` | `version` |
| `Skipped` | `check()` | `version`、`reason`:`INCOMPATIBLE` / `FAILED_BEFORE` / `OLDER_THAN_EMBEDDED` / `ROLLOUT`;`INCOMPATIBLE` 附 `mismatch`:`NAME` / `VERSION` / `ENGINE` / `PROTOCOL` / `HOST_VERSION` 的集合 |
| `Installed` | `check()` | `version` |
| `Reverted` | `check()` | `version`:指针指向的、不新于内置的版本;已装的包被删,下次启动跑内置(§4.3) |
| `Failed` | `check()` | `version`(manifest 解析失败时为空)、`stage`:`POINTER` / `MANIFEST` / `SIGNATURE` / `DOWNLOAD` / `INTEGRITY` / `STORAGE`;`INTEGRITY` 也在启动校验(§4.4)时发出、`message`(`SIGNATURE` 时注明下载 manifest 的 `publicKey` 是否等于内置——区分被篡改与公钥已轮换) |
| `RolledBack` | §4.5 | `version`、`page`(模块名)、`kind`(`E2` / `E6`)、`buildId`、`message` |

Expand Down Expand Up @@ -215,14 +226,15 @@ class Updates(
fetch <pkg>/<hostVersion>/current.json ─解析失败──────────────────────────────▶ Failed(pointer)
│
├─ version == installed.version 或 version ∈ failed ────────────────▶ UpToDate / Skipped(failed)
├─ version == embedded.version ─有 installed─删 installed─────────────▶ Reverted // 否则 UpToDate;不取 manifest、不看 rollout
├─ hash(installId + ":" + pkg + ":" + version) % 100 ≥ rollout ─────▶ Skipped(rollout)
│
▼ fetch <pkg>/<hostVersion>/<version>/manifest.json,拿到原始字节
├─ 用内置 publicKey 对原始字节验 signature 失败 ─────────────────────▶ Failed(signature) // 上报
├─ 解析失败 ────────────────────────────────────────────────────────▶ Failed(manifest)
├─ name ≠ pkg / version ≠ 指针 version / hostVersion ≠ 宿主值
│ / engine ≠ QuickJs.upstreamCommit / protocol ≠ PROTOCOL ─────────▶ Skipped(incompatible) // 发错目录,上报
├─ createdAt ≤ embedded.createdAt ───────────────────────────────────▶ Skipped(older-than-embedded)
├─ createdAt ≤ embedded.createdAt ─有 installed─删 installed─────────▶ Reverted // 否则 Skipped(older-than-embedded)
│
▼ 逐文件 fetch <pkg>/<hostVersion>/<version>/<file>.bin → <pkg>/staging/<version>/,每个核对 sha256
├─ 任一失败 ─删 staging──────────────────────────────────────────────▶ Failed(download | integrity)
Expand All @@ -232,6 +244,8 @@ fetch <pkg>/<hostVersion>/current.json ─解析失败────────

manifest 一到手先验签再解析:签名不对的 manifest 里任何字段都不可信。验签用的公钥永远是内置 manifest 的,下载 manifest 的 `publicKey` 不参与验签。指针不在签名内,篡改它最多让客户端跳过更新或去取一个本就合法的包,与切断网络等价。掷骰哈希是 sha256 前 4 字节按大端读作无符号整数再取模 100,以 `pkg` 与 `version` 为盐:每个包每次发布独立抽样(monorepo 里同一次 CI 产出的多个包 `version` 可能相同,不加 `pkg` 会让它们的灰度人群重合);已装上的用户不因 `rollout` 下调而回退。

指针指向不新于内置的版本(2026-09-23 定,M6)时,期望状态就是跑内置:发 App 时内置包取自服务端的 production(§1.4),回滚到 App 自带的那一版因此是最常见的回滚,已装了更新版本的设备必须收得到。比内置还旧的目标同样回到内置——设备不会跑比 App 自带更旧的包,内置是离目标最近的那一版。指针正好指向内置版本时连 manifest 都不取,每次启动只多一个指针请求。

### 4.4 启动选择

存储布局,每包一份:
Expand Down
22 changes: 22 additions & 0 deletions packages/cli/src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { ADMIN_TOKEN_ENV, DEFAULT_URL, resolveUrl, requireToken, TOKEN_ENV, Upda
import { loadConfig, requireName } from "./config.ts";
import { generateKeyPair } from "./keys.ts";
import { publish } from "./publish.ts";
import { pull } from "./pull.ts";
import { parseHostSnapshot } from "./snapshot.ts";
import { generateKt, generateTs } from "./schema/generate.ts";
import { loadSchema } from "./schema/load.ts";
Expand All @@ -18,6 +19,7 @@ const USAGE = `usage: tinyui build [--root <dir>] [--out <dir>] [--qjsc <path>]
tinyui keys generate [--out <pem>]
tinyui schema --entry <schema.ts> [--ts <file>] [--kt <file> --package <pkg> [--object <Name>]] [--check]
tinyui publish --channel <c> [--app <a>] [--dir <dir>] [--rollout <0-100>]
tinyui pull --channel <c> --host-version <n> --out <dir> [--app <a>] [--root <dir>]
tinyui apps create <id> --name <n> [--org <o>]
tinyui apps tokens create <app>
tinyui apps tokens revoke <app> --id <token-id>
Expand Down Expand Up @@ -60,6 +62,12 @@ publish upload a bundle and move the channel pointer (docs/updates.md §6.1)
--dir a tinyui bundle output root or one <pkg>/<hostVersion> inside it (default: ./dist/ota)
--rollout overrides the percentage current.json was bundled with

pull the version a channel points at, verified, as the host's embedded package (docs/updates.md §1.4)
--channel the channel to take it from, usually production; never defaulted
--out replaced as a whole with manifest.json, runtime/ and pages/
--root project whose tinyui.config.json gives the package name and the key to verify with (default: cwd)
no token: it reads the public delivery endpoints

the publishing and management commands talk to --url, default ${DEFAULT_URL} (or $TINYUI_UPDATES_URL)
$${TOKEN_ENV} publish token, for publish and releases
$${ADMIN_TOKEN_ENV} the instance's admin token or an app token, for apps, packages, tokens and hosts;
Expand Down Expand Up @@ -209,6 +217,20 @@ const COMMANDS: Record<string, { options: Options; run: (v: Values, positionals:
return 0;
},
},
pull: {
options: { ...URL_OPTION, app: { type: "string" }, channel: { type: "string" }, "host-version": { type: "string" }, out: { type: "string" }, root: { type: "string" } },
run: async (v) => {
const config = await loadConfig(resolve((v["root"] as string | undefined) ?? "."));
const channel = requireName("--channel", required(v["channel"], "pull: --channel is required"));
const hostVersion = required(v["host-version"], "pull: --host-version is required");
if (!isHostVersion(hostVersion)) throw new Error(`--host-version must be a positive integer, got "${hostVersion}"`);
const out = required(v["out"], "pull: --out is required; it is replaced as a whole");
const result = await pull({ url: resolveUrl(v["url"] as string | undefined), app: await appId(v), channel, pkg: config.name, hostVersion, publicKey: config.publicKey, out });
process.stderr.write(`${config.name} ${channel} -> ${result.version}, ${result.files.length} files in ${out}\n`);
process.stdout.write(`${result.version}\n`);
return 0;
},
},
hosts: {
options: { ...URL_OPTION, app: { type: "string" }, "host-version": { type: "string" } },
run: async (v, positionals) => {
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export type { TinyUIConfig } from "./config.ts";
export { generateKeyPair, publicKeyOf, isPublicKey, sign, verify } from "./keys.ts";
export type { KeyPair } from "./keys.ts";
export { publish } from "./publish.ts";
export { pull } from "./pull.ts";
export type { PullOptions, PullResult } from "./pull.ts";
export { parseHostSnapshot } from "./snapshot.ts";
export type { HostSnapshot } from "./snapshot.ts";
export type { PublishOptions, PublishResult } from "./publish.ts";
Expand Down
97 changes: 97 additions & 0 deletions packages/cli/src/pull.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { createHash } from "node:crypto";
import { mkdir, mkdtemp, rename, rm, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { isPathSegment } from "./bundle.ts";
import { requireSecureUrl, segments } from "./client.ts";
import { verify } from "./keys.ts";

export interface PullOptions {
/** Instance url; the delivery endpoints are public, no token. */
url: string;
app: string;
channel: string;
pkg: string;
hostVersion: string;
/** The key the package is signed with, from `tinyui.config.json`: what the host will trust too. */
publicKey: string;
/** Replaced as a whole with the package: `manifest.json`, `runtime/`, `pages/`. */
out: string;
/** Injected by the tests; defaults to the global `fetch`. */
fetch?: typeof globalThis.fetch;
}

export interface PullResult {
version: string;
files: string[];
}

/**
* The version a channel points at, as the host's embedded package (docs/updates.md §1.4): verified the way a
* device verifies it, then written in the layout `tinyui build` produces. Nothing is written unless all of it checks out.
*/
export async function pull(options: PullOptions): Promise<PullResult> {
const fetchImpl = options.fetch ?? globalThis.fetch;
const base = requireSecureUrl(options.url).replace(/\/+$/, "") + segments(options.app, options.channel, options.pkg, options.hostVersion);
const get = async (path: string): Promise<Uint8Array> => {
const response = await fetchImpl(`${base}/${path}`);
if (!response.ok) throw new Error(`GET ${base}/${path}: ${response.status}`);
return new Uint8Array(await response.arrayBuffer());
};

const pointer = JSON.parse(new TextDecoder().decode(await get("current.json"))) as { version?: unknown; signature?: unknown };
const version = pointer.version;
if (typeof version !== "string" || !isPathSegment(version)) throw new Error(`current.json: version ${JSON.stringify(version)} is not a path segment`);
if (typeof pointer.signature !== "string") throw new Error("current.json has no signature");

const manifestBytes = await get(`${version}/manifest.json`);
if (!verify(options.publicKey, manifestBytes, pointer.signature)) {
throw new Error(`${version}/manifest.json does not verify against the publicKey in tinyui.config.json`);
}
const manifest = JSON.parse(new TextDecoder().decode(manifestBytes)) as {
name: string;
version: string;
hostVersion?: string;
runtime: string[];
pages: string[];
files: Record<string, string>;
hashes: Record<string, string>;
};
if (manifest.name !== options.pkg) throw new Error(`the manifest is for package ${manifest.name}, not ${options.pkg}`);
if (manifest.version !== version) throw new Error(`the manifest says version ${manifest.version}, the pointer ${version}`);
if (manifest.hostVersion !== options.hostVersion) throw new Error(`the manifest targets host version ${manifest.hostVersion}, not ${options.hostVersion}`);

const files = new Map<string, Uint8Array>();
for (const module of [...manifest.runtime, ...manifest.pages]) {
const path = `${manifest.files[module]}.bin`;
if (!path.split("/").every(isPathSegment)) throw new Error(`manifest.files[${module}] is not a relative path: ${path}`);
const bytes = await get(`${version}/${path}`);
if (createHash("sha256").update(bytes).digest("hex") !== manifest.hashes[module]) throw new Error(`${path} does not match manifest.hashes`);
files.set(path, bytes);
}
files.set("manifest.json", manifestBytes);

// assembled beside the target and swapped in: a failure at any step leaves the previous package in place
const out = resolve(options.out);
await mkdir(dirname(out), { recursive: true });
const staging = await mkdtemp(`${out}.pulling-`);
const backup = `${staging}.previous`;
let moved = false;
try {
for (const [path, bytes] of files) {
await mkdir(dirname(join(staging, path)), { recursive: true });
await writeFile(join(staging, path), bytes);
}
moved = await rename(out, backup).then(() => true, (e: NodeJS.ErrnoException) => {
if (e.code === "ENOENT") return false;
throw e;
});
await rename(staging, out);
} catch (e) {
if (moved) await rename(backup, out).catch(() => {});
await rm(staging, { recursive: true, force: true });
throw e;
}
// the new package is in place; a backup that will not go away is litter, not a failure
await rm(backup, { recursive: true, force: true }).catch(() => {});
return { version, files: [...files.keys()].sort() };
}
Loading
Loading