diff --git a/docs/updates-plan.md b/docs/updates-plan.md index 1f8d8c3..2456de5 100644 --- a/docs/updates-plan.md +++ b/docs/updates-plan.md @@ -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) | 交付: diff --git a/docs/updates.md b/docs/updates.md index c84c197..6834a16 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -77,6 +77,16 @@ dist/ota////pages/**/*.bin 核对的是名字,覆盖不到已有能力的参数形状与行为,那部分仍按 §4.1 的 bump 规则由人判断。绕过 `publish` 直接调发布端点的,目前拦不住;`requires` 进 manifest 并受签名覆盖,日后服务端在 `PUT current.json` 时同样可以核对。快照的上传与读取见 §6.4。 +### 1.4 发 App 时取内置包:`tinyui pull` + +``` +tinyui pull --channel production --host-version --out <宿主资源目录>/ [--app ] [--root ] +``` + +内置包取服务端上 `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: @@ -173,7 +183,7 @@ 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`) | |---|---|---| @@ -181,6 +191,7 @@ class Updates( | `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` | @@ -215,6 +226,7 @@ class Updates( fetch //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 ///manifest.json,拿到原始字节 @@ -222,7 +234,7 @@ fetch //current.json ─解析失败──────── ├─ 解析失败 ────────────────────────────────────────────────────────▶ 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 ///.bin → /staging//,每个核对 sha256 ├─ 任一失败 ─删 staging──────────────────────────────────────────────▶ Failed(download | integrity) @@ -232,6 +244,8 @@ fetch //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 启动选择 存储布局,每包一份: diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 66a3733..20e99e3 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -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"; @@ -18,6 +19,7 @@ const USAGE = `usage: tinyui build [--root ] [--out ] [--qjsc ] tinyui keys generate [--out ] tinyui schema --entry [--ts ] [--kt --package [--object ]] [--check] tinyui publish --channel [--app ] [--dir ] [--rollout <0-100>] + tinyui pull --channel --host-version --out [--app ] [--root ] tinyui apps create --name [--org ] tinyui apps tokens create tinyui apps tokens revoke --id @@ -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 / 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; @@ -209,6 +217,20 @@ const COMMANDS: Record { + 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) => { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 8a3f5de..77c47d2 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -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"; diff --git a/packages/cli/src/pull.ts b/packages/cli/src/pull.ts new file mode 100644 index 0000000..e81604b --- /dev/null +++ b/packages/cli/src/pull.ts @@ -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 { + 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 => { + 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; + hashes: Record; + }; + 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(); + 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() }; +} diff --git a/packages/cli/test/pull.test.ts b/packages/cli/test/pull.test.ts new file mode 100644 index 0000000..2761882 --- /dev/null +++ b/packages/cli/test/pull.test.ts @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, before, describe, it } from "node:test"; +import { bundle } from "../src/bundle.ts"; +import { generateKeyPair } from "../src/keys.ts"; +import { pull } from "../src/pull.ts"; +import { fakeDist } from "./helpers.ts"; + +const pair = generateKeyPair(); +const URL = "https://updates.example"; + +describe("tinyui pull", () => { + let tmp: string; + let hostDir: string; + let requests: string[]; + + /** The delivery endpoints of docs/updates.md §2 over one bundled directory: `/demo/production///…`. */ + const fetch: typeof globalThis.fetch = async (input) => { + const url = String(input); + requests.push(url); + const prefix = `${URL}/demo/production/fixture/1/`; + if (!url.startsWith(prefix)) return new Response("not found", { status: 404 }); + try { + return new Response(await readFile(join(hostDir, url.slice(prefix.length)))); + } catch { + return new Response("not found", { status: 404 }); + } + }; + + const options = (out: string) => ({ url: URL, app: "demo", channel: "production", pkg: "fixture", hostVersion: "1", publicKey: pair.publicKey, out, fetch }); + + before(async () => { + tmp = await mkdtemp(join(tmpdir(), "tinyui-pull-")); + const signingKey = join(tmp, "key.pem"); + await writeFile(signingKey, pair.privateKeyPem); + const dist = await fakeDist(join(tmp, "dist"), pair.publicKey); + hostDir = (await bundle({ dist, hostVersion: "1", signingKey })).dir; + }); + after(async () => { + await rm(tmp, { recursive: true, force: true }); + }); + + it("writes the pointed version in the build layout, replacing what was there", async () => { + requests = []; + const out = join(tmp, "embedded"); + await mkdir(join(out, "pages"), { recursive: true }); + await writeFile(join(out, "pages", "removed.bin"), "stale"); + + const result = await pull(options(out)); + + assert.equal(result.version, "20260922T090000Z-3f2a1c"); + assert.deepEqual(result.files, ["manifest.json", "pages/home.bin", "pages/orders.bin", "runtime/core.bin", "runtime/native.bin"]); + assert.deepEqual((await readdir(join(out, "pages"))).sort(), ["home.bin", "orders.bin"]); + const served = join(hostDir, result.version); + assert.deepEqual(await readFile(join(out, "manifest.json")), await readFile(join(served, "manifest.json")), "the signed bytes, untouched"); + assert.deepEqual(await readFile(join(out, "pages/home.bin")), await readFile(join(served, "pages/home.bin"))); + assert.ok(requests.every((r) => r.startsWith(`${URL}/demo/production/fixture/1/`))); + }); + + it("writes nothing when the signature, a hash or the host version is off", async () => { + const out = join(tmp, "kept"); + await mkdir(out, { recursive: true }); + await writeFile(join(out, "marker"), "untouched"); + + const other = generateKeyPair(); + await assert.rejects(pull({ ...options(out), publicKey: other.publicKey }), /does not verify/); + + const page = join(hostDir, "20260922T090000Z-3f2a1c", "pages", "home.bin"); + const original = await readFile(page); + await writeFile(page, "tampered"); + try { + await assert.rejects(pull(options(out)), /pages\/home\.bin does not match manifest\.hashes/); + } finally { + await writeFile(page, original); + } + + await assert.rejects(pull({ ...options(out), hostVersion: "2" }), /404/); + assert.equal(await readFile(join(out, "marker"), "utf8"), "untouched"); + }); +}); diff --git a/updates/api/updates.klib.api b/updates/api/updates.klib.api index 9d05fd9..6f76040 100644 --- a/updates/api/updates.klib.api +++ b/updates/api/updates.klib.api @@ -103,6 +103,19 @@ sealed class app.tinyui.updates/CheckResult { // app.tinyui.updates/CheckResult| final fun toString(): kotlin/String // app.tinyui.updates/CheckResult.Installed.toString|toString(){}[0] } + final class Reverted : app.tinyui.updates/CheckResult { // app.tinyui.updates/CheckResult.Reverted|null[0] + constructor (kotlin/String) // app.tinyui.updates/CheckResult.Reverted.|(kotlin.String){}[0] + + final val version // app.tinyui.updates/CheckResult.Reverted.version|{}version[0] + final fun (): kotlin/String // app.tinyui.updates/CheckResult.Reverted.version.|(){}[0] + + final fun component1(): kotlin/String // app.tinyui.updates/CheckResult.Reverted.component1|component1(){}[0] + final fun copy(kotlin/String = ...): app.tinyui.updates/CheckResult.Reverted // app.tinyui.updates/CheckResult.Reverted.copy|copy(kotlin.String){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // app.tinyui.updates/CheckResult.Reverted.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // app.tinyui.updates/CheckResult.Reverted.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // app.tinyui.updates/CheckResult.Reverted.toString|toString(){}[0] + } + final class Skipped : app.tinyui.updates/CheckResult { // app.tinyui.updates/CheckResult.Skipped|null[0] constructor (kotlin/String, app.tinyui.updates/SkipReason, kotlin.collections/Set = ...) // app.tinyui.updates/CheckResult.Skipped.|(kotlin.String;app.tinyui.updates.SkipReason;kotlin.collections.Set){}[0] @@ -178,6 +191,22 @@ sealed class app.tinyui.updates/UpdateEvent { // app.tinyui.updates/UpdateEvent| final fun toString(): kotlin/String // app.tinyui.updates/UpdateEvent.Installed.toString|toString(){}[0] } + final class Reverted : app.tinyui.updates/UpdateEvent { // app.tinyui.updates/UpdateEvent.Reverted|null[0] + constructor (kotlin/String, kotlin/String) // app.tinyui.updates/UpdateEvent.Reverted.|(kotlin.String;kotlin.String){}[0] + + final val pkg // app.tinyui.updates/UpdateEvent.Reverted.pkg|{}pkg[0] + final fun (): kotlin/String // app.tinyui.updates/UpdateEvent.Reverted.pkg.|(){}[0] + final val version // app.tinyui.updates/UpdateEvent.Reverted.version|{}version[0] + final fun (): kotlin/String // app.tinyui.updates/UpdateEvent.Reverted.version.|(){}[0] + + final fun component1(): kotlin/String // app.tinyui.updates/UpdateEvent.Reverted.component1|component1(){}[0] + final fun component2(): kotlin/String // app.tinyui.updates/UpdateEvent.Reverted.component2|component2(){}[0] + final fun copy(kotlin/String = ..., kotlin/String = ...): app.tinyui.updates/UpdateEvent.Reverted // app.tinyui.updates/UpdateEvent.Reverted.copy|copy(kotlin.String;kotlin.String){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // app.tinyui.updates/UpdateEvent.Reverted.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // app.tinyui.updates/UpdateEvent.Reverted.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // app.tinyui.updates/UpdateEvent.Reverted.toString|toString(){}[0] + } + final class RolledBack : app.tinyui.updates/UpdateEvent { // app.tinyui.updates/UpdateEvent.RolledBack|null[0] constructor (kotlin/String, kotlin/String, kotlin/String, kotlin/String, kotlin/String, kotlin/String) // app.tinyui.updates/UpdateEvent.RolledBack.|(kotlin.String;kotlin.String;kotlin.String;kotlin.String;kotlin.String;kotlin.String){}[0] @@ -267,11 +296,13 @@ sealed class app.tinyui.updates/UpdateEvent { // app.tinyui.updates/UpdateEvent| final val app.tinyui.updates/app_tinyui_updates_CheckResult$stableprop // app.tinyui.updates/app_tinyui_updates_CheckResult$stableprop|#static{}app_tinyui_updates_CheckResult$stableprop[0] final val app.tinyui.updates/app_tinyui_updates_CheckResult_Failed$stableprop // app.tinyui.updates/app_tinyui_updates_CheckResult_Failed$stableprop|#static{}app_tinyui_updates_CheckResult_Failed$stableprop[0] final val app.tinyui.updates/app_tinyui_updates_CheckResult_Installed$stableprop // app.tinyui.updates/app_tinyui_updates_CheckResult_Installed$stableprop|#static{}app_tinyui_updates_CheckResult_Installed$stableprop[0] +final val app.tinyui.updates/app_tinyui_updates_CheckResult_Reverted$stableprop // app.tinyui.updates/app_tinyui_updates_CheckResult_Reverted$stableprop|#static{}app_tinyui_updates_CheckResult_Reverted$stableprop[0] final val app.tinyui.updates/app_tinyui_updates_CheckResult_Skipped$stableprop // app.tinyui.updates/app_tinyui_updates_CheckResult_Skipped$stableprop|#static{}app_tinyui_updates_CheckResult_Skipped$stableprop[0] final val app.tinyui.updates/app_tinyui_updates_CheckResult_UpToDate$stableprop // app.tinyui.updates/app_tinyui_updates_CheckResult_UpToDate$stableprop|#static{}app_tinyui_updates_CheckResult_UpToDate$stableprop[0] final val app.tinyui.updates/app_tinyui_updates_UpdateEvent$stableprop // app.tinyui.updates/app_tinyui_updates_UpdateEvent$stableprop|#static{}app_tinyui_updates_UpdateEvent$stableprop[0] final val app.tinyui.updates/app_tinyui_updates_UpdateEvent_Failed$stableprop // app.tinyui.updates/app_tinyui_updates_UpdateEvent_Failed$stableprop|#static{}app_tinyui_updates_UpdateEvent_Failed$stableprop[0] final val app.tinyui.updates/app_tinyui_updates_UpdateEvent_Installed$stableprop // app.tinyui.updates/app_tinyui_updates_UpdateEvent_Installed$stableprop|#static{}app_tinyui_updates_UpdateEvent_Installed$stableprop[0] +final val app.tinyui.updates/app_tinyui_updates_UpdateEvent_Reverted$stableprop // app.tinyui.updates/app_tinyui_updates_UpdateEvent_Reverted$stableprop|#static{}app_tinyui_updates_UpdateEvent_Reverted$stableprop[0] final val app.tinyui.updates/app_tinyui_updates_UpdateEvent_RolledBack$stableprop // app.tinyui.updates/app_tinyui_updates_UpdateEvent_RolledBack$stableprop|#static{}app_tinyui_updates_UpdateEvent_RolledBack$stableprop[0] final val app.tinyui.updates/app_tinyui_updates_UpdateEvent_Running$stableprop // app.tinyui.updates/app_tinyui_updates_UpdateEvent_Running$stableprop|#static{}app_tinyui_updates_UpdateEvent_Running$stableprop[0] final val app.tinyui.updates/app_tinyui_updates_UpdateEvent_Skipped$stableprop // app.tinyui.updates/app_tinyui_updates_UpdateEvent_Skipped$stableprop|#static{}app_tinyui_updates_UpdateEvent_Skipped$stableprop[0] @@ -283,11 +314,13 @@ final fun app.tinyui.updates/UpdatesPage(app.tinyui.updates/Updates, kotlin/Stri final fun app.tinyui.updates/app_tinyui_updates_CheckResult$stableprop_getter(): kotlin/Int // app.tinyui.updates/app_tinyui_updates_CheckResult$stableprop_getter|app_tinyui_updates_CheckResult$stableprop_getter(){}[0] final fun app.tinyui.updates/app_tinyui_updates_CheckResult_Failed$stableprop_getter(): kotlin/Int // app.tinyui.updates/app_tinyui_updates_CheckResult_Failed$stableprop_getter|app_tinyui_updates_CheckResult_Failed$stableprop_getter(){}[0] final fun app.tinyui.updates/app_tinyui_updates_CheckResult_Installed$stableprop_getter(): kotlin/Int // app.tinyui.updates/app_tinyui_updates_CheckResult_Installed$stableprop_getter|app_tinyui_updates_CheckResult_Installed$stableprop_getter(){}[0] +final fun app.tinyui.updates/app_tinyui_updates_CheckResult_Reverted$stableprop_getter(): kotlin/Int // app.tinyui.updates/app_tinyui_updates_CheckResult_Reverted$stableprop_getter|app_tinyui_updates_CheckResult_Reverted$stableprop_getter(){}[0] final fun app.tinyui.updates/app_tinyui_updates_CheckResult_Skipped$stableprop_getter(): kotlin/Int // app.tinyui.updates/app_tinyui_updates_CheckResult_Skipped$stableprop_getter|app_tinyui_updates_CheckResult_Skipped$stableprop_getter(){}[0] final fun app.tinyui.updates/app_tinyui_updates_CheckResult_UpToDate$stableprop_getter(): kotlin/Int // app.tinyui.updates/app_tinyui_updates_CheckResult_UpToDate$stableprop_getter|app_tinyui_updates_CheckResult_UpToDate$stableprop_getter(){}[0] final fun app.tinyui.updates/app_tinyui_updates_UpdateEvent$stableprop_getter(): kotlin/Int // app.tinyui.updates/app_tinyui_updates_UpdateEvent$stableprop_getter|app_tinyui_updates_UpdateEvent$stableprop_getter(){}[0] final fun app.tinyui.updates/app_tinyui_updates_UpdateEvent_Failed$stableprop_getter(): kotlin/Int // app.tinyui.updates/app_tinyui_updates_UpdateEvent_Failed$stableprop_getter|app_tinyui_updates_UpdateEvent_Failed$stableprop_getter(){}[0] final fun app.tinyui.updates/app_tinyui_updates_UpdateEvent_Installed$stableprop_getter(): kotlin/Int // app.tinyui.updates/app_tinyui_updates_UpdateEvent_Installed$stableprop_getter|app_tinyui_updates_UpdateEvent_Installed$stableprop_getter(){}[0] +final fun app.tinyui.updates/app_tinyui_updates_UpdateEvent_Reverted$stableprop_getter(): kotlin/Int // app.tinyui.updates/app_tinyui_updates_UpdateEvent_Reverted$stableprop_getter|app_tinyui_updates_UpdateEvent_Reverted$stableprop_getter(){}[0] final fun app.tinyui.updates/app_tinyui_updates_UpdateEvent_RolledBack$stableprop_getter(): kotlin/Int // app.tinyui.updates/app_tinyui_updates_UpdateEvent_RolledBack$stableprop_getter|app_tinyui_updates_UpdateEvent_RolledBack$stableprop_getter(){}[0] final fun app.tinyui.updates/app_tinyui_updates_UpdateEvent_Running$stableprop_getter(): kotlin/Int // app.tinyui.updates/app_tinyui_updates_UpdateEvent_Running$stableprop_getter|app_tinyui_updates_UpdateEvent_Running$stableprop_getter(){}[0] final fun app.tinyui.updates/app_tinyui_updates_UpdateEvent_Skipped$stableprop_getter(): kotlin/Int // app.tinyui.updates/app_tinyui_updates_UpdateEvent_Skipped$stableprop_getter|app_tinyui_updates_UpdateEvent_Skipped$stableprop_getter(){}[0] diff --git a/updates/src/commonMain/kotlin/app/tinyui/updates/Events.kt b/updates/src/commonMain/kotlin/app/tinyui/updates/Events.kt index 60a01a5..19e2930 100644 --- a/updates/src/commonMain/kotlin/app/tinyui/updates/Events.kt +++ b/updates/src/commonMain/kotlin/app/tinyui/updates/Events.kt @@ -14,6 +14,8 @@ enum class FailStage { POINTER, MANIFEST, SIGNATURE, DOWNLOAD, INTEGRITY, STORAG sealed class CheckResult { data class UpToDate(val version: String) : CheckResult() data class Installed(val version: String) : CheckResult() + /** The pointer targets [version], no newer than embedded: the installed package was dropped, embedded runs from the next start. */ + data class Reverted(val version: String) : CheckResult() data class Skipped(val version: String, val reason: SkipReason, val mismatch: Set = emptySet()) : CheckResult() data class Failed(val version: String?, val stage: FailStage, val message: String) : CheckResult() } @@ -26,6 +28,7 @@ sealed class UpdateEvent { data class UpToDate(override val pkg: String, val version: String) : UpdateEvent() data class Skipped(override val pkg: String, val version: String, val reason: SkipReason, val mismatch: Set = emptySet()) : UpdateEvent() data class Installed(override val pkg: String, val version: String) : UpdateEvent() + data class Reverted(override val pkg: String, val version: String) : UpdateEvent() data class Failed(override val pkg: String, val version: String?, val stage: FailStage, val message: String) : UpdateEvent() data class RolledBack(override val pkg: String, val version: String, val page: String, val kind: String, val buildId: String, val message: String) : UpdateEvent() } @@ -33,6 +36,7 @@ sealed class UpdateEvent { internal fun CheckResult.toEvent(pkg: String): UpdateEvent = when (this) { is CheckResult.UpToDate -> UpdateEvent.UpToDate(pkg, version) is CheckResult.Installed -> UpdateEvent.Installed(pkg, version) + is CheckResult.Reverted -> UpdateEvent.Reverted(pkg, version) is CheckResult.Skipped -> UpdateEvent.Skipped(pkg, version, reason, mismatch) is CheckResult.Failed -> UpdateEvent.Failed(pkg, version, stage, message) } diff --git a/updates/src/commonMain/kotlin/app/tinyui/updates/Updates.kt b/updates/src/commonMain/kotlin/app/tinyui/updates/Updates.kt index 925f165..1d49e23 100644 --- a/updates/src/commonMain/kotlin/app/tinyui/updates/Updates.kt +++ b/updates/src/commonMain/kotlin/app/tinyui/updates/Updates.kt @@ -189,6 +189,7 @@ class Updates internal constructor( val version = pointer.version if (!isPathSegment(version)) return CheckResult.Failed(version, FailStage.POINTER, "version \"$version\" is not a path segment") if (version == installed) return CheckResult.UpToDate(version) + if (version == embedded.manifest.version) return revertToEmbedded(version) if (version in failed) return CheckResult.Skipped(version, SkipReason.FAILED_BEFORE) if (!inRollout(name, version, pointer.rollout)) return CheckResult.Skipped(version, SkipReason.ROLLOUT) @@ -219,7 +220,9 @@ class Updates internal constructor( if (manifest.protocol != protocol) add(Mismatch.PROTOCOL) } if (mismatch.isNotEmpty()) return CheckResult.Skipped(version, SkipReason.INCOMPATIBLE, mismatch) - if (manifest.createdAt <= embedded.manifest.createdAt) return CheckResult.Skipped(version, SkipReason.OLDER_THAN_EMBEDDED) + if (manifest.createdAt <= embedded.manifest.createdAt) { + return if (installed != null) revertToEmbedded(version) else CheckResult.Skipped(version, SkipReason.OLDER_THAN_EMBEDDED) + } val stagingRoot = root / STAGING val staging = stagingRoot / version @@ -250,6 +253,21 @@ class Updates internal constructor( return CheckResult.Installed(version) } + /** The pointer targets no newer than embedded: embedded is what should run, from the next start (docs/updates.md §4.3). */ + private fun revertToEmbedded(version: String): CheckResult { + val drop = installed ?: return CheckResult.UpToDate(version) + installed = null + try { + saveState() + } catch (e: IOException) { + installed = drop + return CheckResult.Failed(version, FailStage.STORAGE, e.message ?: e.toString()) + } + // state.json no longer names it: a leftover directory is swept at the next start + runCatching { fs.deleteRecursively(root / INSTALLED / drop) } + return CheckResult.Reverted(version) + } + /** Every file of [manifest] into [staging], each checked against `hashes`; null once all are there. */ private suspend fun downloadInto(staging: Path, manifest: BuildManifest, manifestBytes: ByteArray): CheckResult.Failed? { val version = manifest.version diff --git a/updates/src/commonTest/kotlin/app/tinyui/updates/UpdatesTest.kt b/updates/src/commonTest/kotlin/app/tinyui/updates/UpdatesTest.kt index 302bbbd..2151a77 100644 --- a/updates/src/commonTest/kotlin/app/tinyui/updates/UpdatesTest.kt +++ b/updates/src/commonTest/kotlin/app/tinyui/updates/UpdatesTest.kt @@ -132,6 +132,33 @@ class UpdatesTest { assertEquals(CheckResult.Skipped("v1", SkipReason.OLDER_THAN_EMBEDDED), u.check("shop")) } + @Test + fun aPointerAtTheEmbeddedVersionIsUpToDateWithoutFetchingItsManifest() = runTest { + server["shop/1/current.json"] = """{"version":"v0","rollout":100,"signature":"ok"}""".encodeToByteArray() + assertEquals(CheckResult.UpToDate("v0"), updates(embedded()).check("shop"), "v0 is embedded, no manifest on the server was needed") + } + + @Test + fun rollingThePointerBackToEmbeddedOrOlderDropsTheInstalledPackage() = runTest { + publish(manifest()) + assertEquals(CheckResult.Installed("v1"), updates(embedded()).check("shop")) + server["shop/1/current.json"] = """{"version":"v0","rollout":0,"signature":"ok"}""".encodeToByteArray() + events.clear() + val u = updates(embedded()) + assertEquals("HOME-v1", pageBytes(u.current("shop"))) + assertEquals(CheckResult.Reverted("v0"), u.check("shop"), "back to embedded whatever the rollout") + assertEquals(UpdateEvent.Reverted("shop", "v0"), events.last()) + assertEquals("HOME-v1", pageBytes(u.current("shop")), "the process keeps what it started with") + assertFalse(fs.exists(dir / "shop/installed/v1")) + assertEquals("HOME-v0", pageBytes(updates(embedded()).current("shop"))) + + publish(manifest()) + assertEquals(CheckResult.Installed("v1"), updates(embedded()).check("shop")) + publish(manifest(version = "vOld", createdAt = "2026-09-22T08:00:00Z")) + assertEquals(CheckResult.Reverted("vOld"), updates(embedded()).check("shop"), "older than embedded: embedded is the closest") + assertEquals(CheckResult.Skipped("vOld", SkipReason.OLDER_THAN_EMBEDDED), updates(embedded()).check("shop"), "nothing left to drop") + } + @Test fun rolloutIsARepeatableDiceRollPerInstallPackageAndVersion() = runTest { publish(manifest(), rollout = 0)