diff --git a/compose/api/compose.klib.api b/compose/api/compose.klib.api index c983a7d..15b1926 100644 --- a/compose/api/compose.klib.api +++ b/compose/api/compose.klib.api @@ -248,7 +248,7 @@ final class app.tinyui.schema/ComponentSchema { // app.tinyui.schema/ComponentSc } final class app.tinyui/BuildManifest { // app.tinyui/BuildManifest|null[0] - constructor (kotlin.collections/List, kotlin.collections/List, kotlin.collections/Map, kotlin.collections/Map, kotlin/String, kotlin/String, kotlin/String, kotlin/String, kotlin/String, kotlin/Int, kotlin.collections/Map, kotlin/String = ..., kotlin.collections/Map = ..., kotlin/String? = ...) // app.tinyui/BuildManifest.|(kotlin.collections.List;kotlin.collections.List;kotlin.collections.Map;kotlin.collections.Map;kotlin.String;kotlin.String;kotlin.String;kotlin.String;kotlin.String;kotlin.Int;kotlin.collections.Map;kotlin.String;kotlin.collections.Map;kotlin.String?){}[0] + constructor (kotlin.collections/List, kotlin.collections/List, kotlin.collections/Map, kotlin.collections/Map, kotlin/String, kotlin/String, kotlin/String, kotlin/String, kotlin/String, kotlin.collections/Map, kotlin/String = ..., kotlin.collections/Map = ..., kotlin/String? = ...) // app.tinyui/BuildManifest.|(kotlin.collections.List;kotlin.collections.List;kotlin.collections.Map;kotlin.collections.Map;kotlin.String;kotlin.String;kotlin.String;kotlin.String;kotlin.String;kotlin.collections.Map;kotlin.String;kotlin.collections.Map;kotlin.String?){}[0] final val buildIds // app.tinyui/BuildManifest.buildIds|{}buildIds[0] final fun (): kotlin.collections/Map // app.tinyui/BuildManifest.buildIds.|(){}[0] @@ -266,8 +266,6 @@ final class app.tinyui/BuildManifest { // app.tinyui/BuildManifest|null[0] final fun (): kotlin/String // app.tinyui/BuildManifest.name.|(){}[0] final val pages // app.tinyui/BuildManifest.pages|{}pages[0] final fun (): kotlin.collections/List // app.tinyui/BuildManifest.pages.|(){}[0] - final val protocol // app.tinyui/BuildManifest.protocol|{}protocol[0] - final fun (): kotlin/Int // app.tinyui/BuildManifest.protocol.|(){}[0] final val publicKey // app.tinyui/BuildManifest.publicKey|{}publicKey[0] final fun (): kotlin/String // app.tinyui/BuildManifest.publicKey.|(){}[0] final val requires // app.tinyui/BuildManifest.requires|{}requires[0] @@ -459,8 +457,6 @@ final class app.tinyui/PageHost { // app.tinyui/PageHost|null[0] final object Companion { // app.tinyui/PageHost.Companion|null[0] final const val MODULE_SCHEME // app.tinyui/PageHost.Companion.MODULE_SCHEME|{}MODULE_SCHEME[0] final fun (): kotlin/String // app.tinyui/PageHost.Companion.MODULE_SCHEME.|(){}[0] - final const val PROTOCOL // app.tinyui/PageHost.Companion.PROTOCOL|{}PROTOCOL[0] - final fun (): kotlin/Int // app.tinyui/PageHost.Companion.PROTOCOL.|(){}[0] } } diff --git a/compose/src/androidHostTest/kotlin/app/tinyui/BundleSmokeTest.kt b/compose/src/androidHostTest/kotlin/app/tinyui/BundleSmokeTest.kt index 423712c..40d094d 100644 --- a/compose/src/androidHostTest/kotlin/app/tinyui/BundleSmokeTest.kt +++ b/compose/src/androidHostTest/kotlin/app/tinyui/BundleSmokeTest.kt @@ -26,8 +26,8 @@ class BundleSmokeTest { for (fn in listOf("__host_apply", "__host_query", "__host_call", "__host_send", "__host_report")) engine.registerFunction(fn) { JsValue.Undefined } assertEquals("tinyui-core", engine.registerModule(out.resolve("runtime/core.bin").readBytes())) assertEquals("tinyui-native", engine.registerModule(out.resolve("runtime/native.bin").readBytes())) - engine.evaluateModule("import \"tinyui-core\"; export const p = globalThis.__tinyui.protocol;").use { - assertEquals(JsValue.Num(PageHost.PROTOCOL), it.get("p")) + engine.evaluateModule("import \"tinyui-core\"; export const v = globalThis.__tinyui.version;").use { + assertEquals(JsValue.Str(TinyUI.version), it.get("v")) } engine.runBytecode(out.resolve("pages/counter.bin").readBytes()).let { if (it is AutoCloseable) it.close() } } diff --git a/compose/src/commonMain/kotlin/app/tinyui/BuildManifest.kt b/compose/src/commonMain/kotlin/app/tinyui/BuildManifest.kt index 7b41e19..09a096f 100644 --- a/compose/src/commonMain/kotlin/app/tinyui/BuildManifest.kt +++ b/compose/src/commonMain/kotlin/app/tinyui/BuildManifest.kt @@ -2,7 +2,6 @@ package app.tinyui import kotlinx.serialization.json.Json import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive @@ -18,7 +17,6 @@ class BuildManifest( val version: String, val createdAt: String, val engine: String, - val protocol: Int, val hashes: Map, /** The tinyui-core version the runtime modules came from. */ val tinyui: String = "", @@ -53,7 +51,6 @@ class BuildManifest( version = string("version"), createdAt = string("createdAt"), engine = string("engine"), - protocol = root["protocol"]?.jsonPrimitive?.intOrNull ?: 0, hashes = strings("hashes"), tinyui = string("tinyui"), requires = root["requires"]?.jsonObject?.mapValues { (_, v) -> diff --git a/compose/src/commonMain/kotlin/app/tinyui/PackageCheck.kt b/compose/src/commonMain/kotlin/app/tinyui/PackageCheck.kt index a6bf9b9..e6f6d2d 100644 --- a/compose/src/commonMain/kotlin/app/tinyui/PackageCheck.kt +++ b/compose/src/commonMain/kotlin/app/tinyui/PackageCheck.kt @@ -7,7 +7,6 @@ object PackageCheck { /** Every reason [manifest]'s package cannot run on [host]; empty when it can. */ fun problems(manifest: BuildManifest, host: TinyUIHost): List = buildList { if (manifest.engine != QuickJs.upstreamCommit) add("built for engine ${manifest.engine}, the host embeds ${QuickJs.upstreamCommit}") - if (manifest.protocol != PageHost.PROTOCOL) add("built for protocol ${manifest.protocol}, the host implements ${PageHost.PROTOCOL}") if (manifest.tinyui != TinyUI.version) add("built with tinyui ${manifest.tinyui.ifEmpty { "(unknown)" }}, the host has ${TinyUI.version}") val capabilities = FrameworkCapabilities.toSet() + host.capabilities.names for ((page, uses) in manifest.requires.entries.sortedBy { it.key }) { diff --git a/compose/src/commonMain/kotlin/app/tinyui/PageHost.kt b/compose/src/commonMain/kotlin/app/tinyui/PageHost.kt index 55ed1bd..42361b5 100644 --- a/compose/src/commonMain/kotlin/app/tinyui/PageHost.kt +++ b/compose/src/commonMain/kotlin/app/tinyui/PageHost.kt @@ -125,10 +125,10 @@ class PageHost( val namespace = runBytecode(page.bytecode, ObjectTransport.REF) as JsRef if (namespace.isPromise) { namespace.close(); error("page module is still pending after microtasks were drained") } val self = evaluate("__tinyui", objects = ObjectTransport.REF) as JsRef + val version = (self.get("version") as? JsValue.Str)?.value + check(version == TinyUI.version) { "runtime module is tinyui $version, the host has ${TinyUI.version}" } val fns = ENTRY_NAMES.associateWith { self.get(it, ObjectTransport.REF) as JsRef } val e = Entries(self, fns).also { entries = it } - val protocol = (self.get("protocol") as? JsValue.Num)?.value?.toInt() - check(protocol == PROTOCOL) { "protocol $protocol from the runtime module, host implements $PROTOCOL" } namespace.use { ns -> (ns.get("default", ObjectTransport.REF) as JsRef).use { default -> e.call("mount", default, JsValue.Str(propsJson), JsValue.Str(registry.manifest(FrameworkCapabilities + host.capabilities.names))) @@ -373,8 +373,6 @@ class PageHost( companion object { /** `import.meta.url` of a page module is `tinyui:` (docs/adr-005-engine.md). */ const val MODULE_SCHEME = "tinyui" - /** docs/patch-protocol.md §6 */ - const val PROTOCOL = 1 private val ENTRY_NAMES = listOf("mount", "unmount", "visible", "dispatch", "resolve", "reject", "emit", "flush") } } diff --git a/compose/src/commonTest/kotlin/app/tinyui/BundleTest.kt b/compose/src/commonTest/kotlin/app/tinyui/BundleTest.kt index 3ccf96b..61e63e8 100644 --- a/compose/src/commonTest/kotlin/app/tinyui/BundleTest.kt +++ b/compose/src/commonTest/kotlin/app/tinyui/BundleTest.kt @@ -29,7 +29,7 @@ class BundleTest { "version": "20260922T090000Z-3f2a1c", "createdAt": "2026-09-22T09:00:00Z", "engine": "04be246001599f5995fa2f2d8c91a0f198d3f34c", - "protocol": 1, + "tinyui": "0.5.0", "hashes": { "tinyui-core": "00", "tinyui-native": "01", "shop/home": "02", "shop/cart": "03" } } """.trimIndent() @@ -50,7 +50,7 @@ class BundleTest { val bundle = Bundle.load(files()) assertEquals("shop", bundle.name) assertEquals("20260922T090000Z-3f2a1c", bundle.manifest.version) - assertEquals(1, bundle.manifest.protocol) + assertEquals("0.5.0", bundle.manifest.tinyui) assertNull(bundle.manifest.hostVersion, "an embedded manifest has no hostVersion") val page = bundle.page("shop/home") assertEquals("shop/home", page.module.name) diff --git a/compose/src/commonTest/kotlin/app/tinyui/PackageCheckTest.kt b/compose/src/commonTest/kotlin/app/tinyui/PackageCheckTest.kt index cc7da9a..e2e9762 100644 --- a/compose/src/commonTest/kotlin/app/tinyui/PackageCheckTest.kt +++ b/compose/src/commonTest/kotlin/app/tinyui/PackageCheckTest.kt @@ -23,13 +23,12 @@ class PackageCheckTest { private fun manifest( engine: String = QuickJs.upstreamCommit, - protocol: Int = PageHost.PROTOCOL, tinyui: String = TinyUI.version, requires: Map = mapOf("shop/home" to PageRequires(listOf("ta.Icon"), listOf("checkout.start", "store.get"))), ) = BuildManifest( runtime = emptyList(), pages = listOf("shop/home"), files = emptyMap(), buildIds = emptyMap(), name = "shop", publicKey = "k", version = "v1", createdAt = "2026-09-24T00:00:00Z", - engine = engine, protocol = protocol, hashes = emptyMap(), tinyui = tinyui, requires = requires, + engine = engine, hashes = emptyMap(), tinyui = tinyui, requires = requires, ) @Test @@ -42,7 +41,6 @@ class PackageCheckTest { val problems = PackageCheck.problems( manifest( engine = "0".repeat(40), - protocol = 99, tinyui = "0.0.1", requires = mapOf("shop/home" to PageRequires(listOf("ta.Rating"), listOf("coupon.apply"))), ), @@ -51,7 +49,6 @@ class PackageCheckTest { assertEquals( listOf( "built for engine ${"0".repeat(40)}, the host embeds ${QuickJs.upstreamCommit}", - "built for protocol 99, the host implements ${PageHost.PROTOCOL}", "built with tinyui 0.0.1, the host has ${TinyUI.version}", "shop/home uses component ta.Rating, which the host does not register", "shop/home calls coupon.apply, which the host does not provide", diff --git a/compose/src/commonTest/kotlin/app/tinyui/PageHostTest.kt b/compose/src/commonTest/kotlin/app/tinyui/PageHostTest.kt index d18a5a6..8b74f42 100644 --- a/compose/src/commonTest/kotlin/app/tinyui/PageHostTest.kt +++ b/compose/src/commonTest/kotlin/app/tinyui/PageHostTest.kt @@ -26,7 +26,7 @@ class PageHostTest { private val core = module("tinyui-core", """ let patches = [], count = 0, handler = null, page = null, emits = 0, settled = []; const settle = (line) => { settled.push(line); patches.push(["p",1,"text", settled.slice().sort().join(";")]); }; globalThis.__tinyui = { - protocol: 1, + version: "$VERSION", mount(p, props, host) { page = p; const name = JSON.parse(props).name ?? "world"; patches.push(["c",1,"Text"],["p",1,"text","hello " + name + " / " + Object.keys(JSON.parse(host).components).length], @@ -210,12 +210,13 @@ class PageHostTest { } @Test - fun protocolMismatchIsE6() = runTest { - val badCore = module("tinyui-core", "globalThis.__tinyui = { protocol: 99 }; export const VERSION = 'x';") + fun runtimeFromAnotherTinyuiIsE6() = runTest { + val badCore = module("tinyui-core", "globalThis.__tinyui = { version: '0.0.1' }; export const VERSION = '0.0.1';") val host = PageHost(RuntimeBundle(badCore, native), page, tinyui) host.start() host.await { host.failure != null } assertEquals("E6", host.failure!!.kind) + assertEquals("runtime module is tinyui 0.0.1, the host has ${TinyUI.version}", host.failure!!.message) assertEquals("E6", errors.single().kind) host.close() } diff --git a/docs/adr-006-hot-updates.md b/docs/adr-006-hot-updates.md index 05b6adf..efd7f6e 100644 --- a/docs/adr-006-hot-updates.md +++ b/docs/adr-006-hot-updates.md @@ -15,7 +15,7 @@ ADR-005 把热下发划出当期,条件是"内核稳定后另立 ADR"。Trendi |---|---|---| | 页面 = 模块 = 字节码,自包含无共享 chunk | build-chain.md §3 | 包只是页面的集合,分发单元不用再设计 | | 字节码与字长无关、只绑引擎 commit,文件头自带 commit,加载时自校验 | quickjs-kmp `docs/decisions.md` | Android / iOS 共用同一份包;引擎不匹配是确定性的 E6 | -| `PROTOCOL` 在 K0 核对 → E6;schema 不匹配走 E5 跳过 + `Placeholder`;`__mount` 清单让 JS `host.has()` 降级 | patch-protocol.md §6–7、ADR-003 | 硬兼容 / 软兼容的分界已画好,热下发只需把硬的两条提前到下载前 | +| tinyui 版本在 K0 核对 → E6;schema 不匹配走 E5 跳过 + `Placeholder`;`__mount` 清单让 JS `host.has()` 降级 | patch-protocol.md §6–7、ADR-003 | 硬兼容 / 软兼容的分界已画好,热下发只需把硬的两条提前到下载前 | | `PageHost` 只收字节,库零 I/O,读资源在宿主 | `PageHost.kt`、TrendingAI `TinyUIHost.kt` | 热下发对库来说是"多一个字节来源 + 选哪个" | | release 不带 map,靠 `buildId` 离线对映射 | build-chain.md §7 | 错误上报链路不变 | @@ -42,7 +42,7 @@ ADR-005 把热下发划出当期,条件是"内核稳定后另立 ADR"。Trendi | 谁负责判断 | 人(发版时) | 服务端逻辑 | 客户端解析 | | 静态托管可行 | 是(作路径) | 否 | 勉强 | -选 hostVersion,它同时是服务端目录名。`engine`(引擎 commit)与 `protocol` 写进 manifest 只做校验,防"发错目录"这种人为错误,不参与匹配逻辑。 +选 hostVersion,它同时是服务端目录名。`engine`(引擎 commit)与 `tinyui`(版本)写进 manifest 只做校验,防"发错目录"这种人为错误,不参与匹配逻辑。 兼容键回答的是"这一版页面能发给哪些已装好的 App"。页面依赖宿主给它的东西——宿主组件、宿主能力、tinyui 本身——而宿主只能随 App 发版改,页面随时可以热下发,两边的新旧必然错开。以一个订阅页为例: @@ -154,7 +154,7 @@ ADR-005 把热下发划出当期,条件是"内核稳定后另立 ADR"。Trendi |---|---| | 包 | App 由 N≥1 个包组成;一个包 = 一次 `tinyui build` 的完整产物(`runtime/*.bin` + `pages/**/*.bin` + `manifest.json`),整包原子生效,各包独立;不做单页下发、不做跨包共享模块、不做 zip、不做 diff | | 包名与模块名 | 包名 `[a-z0-9-]+` 来自 `tinyui.config.json`;模块名 = 路由键 = `/<相对路径>`,无 `pages/` 段 | -| 兼容键 | 宿主声明的 `hostVersion`,作服务端路径;`engine` / `protocol` 只校验 | +| 兼容键 | 宿主声明的 `hostVersion`,作服务端路径;`engine` / `tinyui` 只校验 | | 启用规则 | 下发包 `version ≠ installed` 且 `createdAt` 新于内置才装;App 升级带来更新的内置包时自动弃掉 installed;不能用下发把 App 降到比内置更老 | | 生效时机 | 下次进程启动 | | 失败语义 | 下发包的页面报 E2 / E6 → 当场用内置包重挂这一页,整包持久化拉黑并上报;E5 不算失败。启动时重算 installed 包 sha256,不一致同样拉黑。进程级崩溃不在库的回退范围:出口是灰度 + 回滚 + "`check()` 先于任何页面挂载"的接入约定,挂载标记见 §4.3。一个 (runtime, page) 对永远来自同一个包;不同页面可以短暂来自不同包(各自独立 Runtime) | @@ -163,7 +163,7 @@ ADR-005 把热下发划出当期,条件是"内核稳定后另立 ADR"。Trendi | 灰度 | manifest `rollout` 百分比,客户端按 `installId` 掷骰 | | 回滚 | 用旧内容发新版本,指针只往前走;无 `rollBackToEmbedded` | | 完整性 | 逐文件 sha256 + manifest 签名(ECDSA P-256,覆盖 `/manifest.json` 的原始字节,无规范化);密钥按 (app, pkg),发布方持私钥,服务端与客户端各验一次;公钥写在 `tinyui.config.json` 随 build 进 manifest,客户端只信内置包的 | -| CLI | `tinyui.config.json`(`name` / `publicKey` / `pages`);`tinyui build` 的 manifest 加 `name` / `publicKey` / `version` / `createdAt` / `engine` / `protocol` / `hashes`;`tinyui bundle --host-version --signing-key` 产出上传目录 `dist/ota///`(`current.json` + `/`);`tinyui publish` 走发布协议;`keys` / `apps` / `packages` / `tokens` / `releases` 子命令是全部管理面 | +| CLI | `tinyui.config.json`(`name` / `publicKey` / `pages`);`tinyui build` 的 manifest 加 `name` / `publicKey` / `version` / `createdAt` / `engine` / `tinyui` / `hashes`;`tinyui bundle --host-version --signing-key` 产出上传目录 `dist/ota///`(`current.json` + `/`);`tinyui publish` 走发布协议;`keys` / `apps` / `packages` / `tokens` / `releases` 子命令是全部管理面 | | 服务端 | 独立开源仓 `tinyui-updates-server`(MIT,托管前重评):Hono,单 R2 桶,`Storage` 接口后置;内容按 (app, pkg, hostVersion, version) 存,channel 是指针;托管实例 `updates.tinyui.app`,私有化 = 部署同一份代码;没有控制台 | | 仓边界 | `tinyui-updates` 在主仓 `updates/` 模块,与 core 同版本发;服务端独立仓;协议只写在 updates.md,发布后字段只增不改 | diff --git a/docs/build-chain.md b/docs/build-chain.md index 208f16f..3832eb8 100644 --- a/docs/build-chain.md +++ b/docs/build-chain.md @@ -23,7 +23,7 @@ tinyui-native 同上(对它而言 tinyui-core 是 external) | esbuild | TS 类型擦除;JSX → `h()`(`jsxFactory: "h"`,`inject` 一个内存模块自动引入 `h` / `Fragment`,页面不手写);业务内部相对 import 合并进页面模块;`tinyui-core` / `tinyui-native` 在插件 `onResolve` 里标 `external` + `sideEffects: false`,保持裸说明符且未用到时整条 import 被去掉;`format: "esm"`,`target: "esnext"`;输出 source map | 任何语法降级;对运行时模块的解析 | | qjsc-kmp | 源码模块 → 字节码,`-n` 给定模块名,`--strip-source` 保留行号去源码 | 校验模块图(引擎加载时查表) | -**页面名即模块名即路由键**:`/`——包 `subscription` 的 `src/pages/home.tsx` → `subscription/home`,`src/pages/plans/detail.tsx` → `subscription/plans/detail`,无后缀。包名来自工程根的 `tinyui.config.json`(`{ "name", "publicKey", "pages": "src/pages" }`,`name` 匹配 `[a-z0-9-]+`,`publicKey` 见 updates.md §7),是命名空间,所以没有 `pages/` 段;一个 App 挂几个包(ADR-006 §2.10),路由键就靠这个前缀不撞。页面路径的每一段限定 `[A-Za-z0-9._-]+`(`tinyui build` 拒绝其余的,含中文文件名):它随产物路径进热下发的投递 URL,而 percent-encoding 要在 CLI、服务端、Kotlin 客户端三处各对称实现一次,任一处偏差就是客户端收不到更新且只能发 App 修(updates.md §6.1、§7 同一类风险)。页面内容与组件文案不受影响,只有文件名受限;日后若要放开,改 build 一处把模块名与输出路径解耦即可(`files` 本就是映射),协议与另外两端不动。运行时模块名固定 `tinyui-core`、`tinyui-native`,产物文件是 `runtime/core`、`runtime/native`。产物目录 `runtime/*.bin`、`pages/**/*.bin`、`manifest.json`(页面与运行时模块清单、每个模块的产物路径 `files` 与 `buildIds`,包身份 `name` / `publicKey` 与热下发用的 `version` / `createdAt` / `engine` / `protocol` / `hashes` 见 [updates.md](./updates.md) §1.1;Kotlin 侧路由表的来源,宿主用 `BuildManifest.file(name)` 定位 `.bin` / `.js.map`,见 [app-model.md](./app-model.md))。 +**页面名即模块名即路由键**:`/`——包 `subscription` 的 `src/pages/home.tsx` → `subscription/home`,`src/pages/plans/detail.tsx` → `subscription/plans/detail`,无后缀。包名来自工程根的 `tinyui.config.json`(`{ "name", "publicKey", "pages": "src/pages" }`,`name` 匹配 `[a-z0-9-]+`,`publicKey` 见 updates.md §7),是命名空间,所以没有 `pages/` 段;一个 App 挂几个包(ADR-006 §2.10),路由键就靠这个前缀不撞。页面路径的每一段限定 `[A-Za-z0-9._-]+`(`tinyui build` 拒绝其余的,含中文文件名):它随产物路径进热下发的投递 URL,而 percent-encoding 要在 CLI、服务端、Kotlin 客户端三处各对称实现一次,任一处偏差就是客户端收不到更新且只能发 App 修(updates.md §6.1、§7 同一类风险)。页面内容与组件文案不受影响,只有文件名受限;日后若要放开,改 build 一处把模块名与输出路径解耦即可(`files` 本就是映射),协议与另外两端不动。运行时模块名固定 `tinyui-core`、`tinyui-native`,产物文件是 `runtime/core`、`runtime/native`。产物目录 `runtime/*.bin`、`pages/**/*.bin`、`manifest.json`(页面与运行时模块清单、每个模块的产物路径 `files` 与 `buildIds`,包身份 `name` / `publicKey` 与热下发用的 `version` / `createdAt` / `engine` / `tinyui` / `hashes` 见 [updates.md](./updates.md) §1.1;Kotlin 侧路由表的来源,宿主用 `BuildManifest.file(name)` 定位 `.bin` / `.js.map`,见 [app-model.md](./app-model.md))。 业务工程的 tsconfig 用 `jsx: "react-jsx"` + `jsxImportSource: "tinyui-core"` 做类型检查([jsx-transform.md](./jsx-transform.md) §4),而 esbuild 会读到这份 tsconfig 并按它产出 `import { jsx } from "tinyui-core/jsx-runtime"`,即使 `build()` 显式传了 `jsx: "transform"`;引擎里没有这个模块,加载报 `module 'tinyui-core/jsx-runtime' is not registered`(2026-09-16 CI 实测)。页面构建要传 `tsconfigRaw` 覆盖 tsconfig 的 jsx 三项。 diff --git a/docs/patch-protocol.md b/docs/patch-protocol.md index 232dfa5..1281382 100644 --- a/docs/patch-protocol.md +++ b/docs/patch-protocol.md @@ -61,9 +61,9 @@ Kotlin 侧承诺(ADR-003 §3.2): ## 6. 版本 -消息本身不带版本号:版本是运行时模块的属性,每页加载一次、核对一次。运行时导出 `PROTOCOL = 1`,并作为 `__tinyui.protocol` 暴露;Kotlin 在 K0 之后核对,与自身实现的版本不等即 E6(页面失败,同"字节码与引擎不匹配")。 +本协议没有独立的版本号。运行时模块随包下发、Kotlin 侧随宿主固定,两侧之间的全部约定(本文的 op、K 入口、`__host_*` 函数、`mount` 清单格式)靠"同一个 tinyui 版本"对齐:运行时把 `tinyui-core` 的 `VERSION` 作为 `__tinyui.version` 暴露,Kotlin 在 K0 之后核对它等于 `TinyUI.version`,不等即 E6(页面失败,同"字节码与引擎不匹配")。热下发把同一条提前到下载前(updates.md §1.3、§4.3)。 -递增规则:改本文任何一条语义(op 字段位置、`index` 含义、`null` 含义、事件标记形态)或新增 op 种类都递增;schema 层面的变化(新组件、新 prop、新事件)不递增,那由 ADR-003 的清单下发与 E5 跳过处理。 +schema 层面的变化(新组件、新 prop、新事件)也随 tinyui 版本走;同一版本内宿主组件的差异由 ADR-003 的清单下发与 E5 跳过处理。 ## 7. Kotlin 侧的 E5 处理 diff --git a/docs/roadmap.md b/docs/roadmap.md index a02b4b5..d1dd13e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -57,6 +57,7 @@ | 返回栈深页引擎回收策略 | ADR-002 | 真机内存数据出来后定,可能提前 | | 应用级服务 Runtime(不挂 UI、随 App 生命周期的引擎) | app-model.md | 业务出现不属于任何页面的常驻 JS 逻辑 | | 手势组合内置组件 | ADR-004 | 业务需求出现 | +| 运行时 ABI 版本(放宽"包与宿主 tinyui 版本相等",边界不变的 tinyui 升级不必加 `hostVersion`) | patch-protocol.md §6 | 宿主升级 tinyui 导致的整批重新发布成为负担;先划定 JS 运行时与 Kotlin 侧的边界,再定加 1 规则并以快照测试守住 | | `Image` 与图片加载管线(coil3 vs 宿主 loader) | components.md §3 | 出现需要图片的页面 | | ~~`weight`~~ / `alignSelf` 等需要父作用域的布局 prop | components.md §2 | `weight`、`border`、`Column` / `Row` 的 `scroll` 已加(2026-09-17,TrendingAI 订阅页触发);`alignSelf` 等需求 | diff --git a/docs/runtime-api.md b/docs/runtime-api.md index 1f93f85..4eee6c0 100644 --- a/docs/runtime-api.md +++ b/docs/runtime-api.md @@ -25,7 +25,7 @@ | | `setTimeout` / `clearTimeout` | 全局,经宿主定时器实现 | | 页面 | `pageVisible()` | 页面是否可见(K1 `visible`) | | | `manifest()` | Kotlin 下发的组件 / 能力清单 | -| 版本 | `VERSION`、`PROTOCOL` | 包版本;patch 协议版本号 | +| 版本 | `VERSION` | 包版本,也是运行时与 Kotlin 侧对齐的依据(patch-protocol.md §6) | | 内部 | `internal.{call, query, send, onEmit}` | core 与 `tinyui-native` 之间的桥契约(§9),业务不用 | 不在 v1:`createContext`、`ErrorBoundary`、`Suspense`、`Switch/Match`、`Index`、`Portal`、`lazy`、`setInterval`。页内跨组件共享状态直接用模块顶层的 `signal` / `observable`(每页一个引擎,模块作用域就是页面作用域)。 @@ -246,7 +246,7 @@ export default function Home(props: HomeProps): Node { … } | K2 | `dispatch(nodeId: number, event: string, payloadJson: string)` | | | K3 | `resolve(cbId: number, resultJson: string)` / `reject(cbId: number, errorJson: string)` | `errorJson` 为 `{ code: string, message: string }` | | K5 | `emit(topic: string, payloadJson: string)` | | -| — | `protocol: number` | patch 协议版本,与 `PROTOCOL` 相同;Kotlin 在 K0 核对,不等则 E6 | +| — | `version: string` | 与 `VERSION` 相同;Kotlin 在 K0 核对它等于 `TinyUI.version`,不等则 E6 | 每个 K 入口在 Kotlin 侧是同一次 `withEngine` 里的**两次调用**:先调入口函数,再调 `flush()`(理由见 [js-runtime.html](./js-runtime.html) §2)。 diff --git a/docs/updates-plan.md b/docs/updates-plan.md index 764ae75..4024c16 100644 --- a/docs/updates-plan.md +++ b/docs/updates-plan.md @@ -140,7 +140,6 @@ M6 收尾(2026-09-24)评审中发现、与 M6 正交的问题,各自独立 | 问题 | 在哪 | 现状与后果 | 方向 | |---|---|---|---| | 公钥无法轮换 | tinyui-updates-server 包记录、updates.md §7 | 服务端每个包只登记一把公钥;换钥后仍信旧钥的已发 App 永久收不到更新 | 服务端支持一个包登记多把公钥,或文档写明"轮换须同时加 `hostVersion`、接受旧版本停更" | -| `PROTOCOL` 形同虚设 | tinyui `PageHost.PROTOCOL` | 从 M1 起一直是 1,改 mount 参数、加 `host.call` 时都没加;"运行时 ABI"没有定义 | 定义 ABI 范围与加 1 规则;以后若要放宽"tinyui 版本相等"必须先有它 | | TrendingAI 没有 PR 级 CI | TrendingAI `.github/workflows` | 宿主测试只在 push 到 main(快照 workflow)与发版时跑,契约改了忘加 `HOST_VERSION` 要到合入后才红 | 加 PR 检查:`HostSnapshotTest` 与常规测试 | | 看不到设备在跑哪一版 | TrendingAI `TinyUIUpdates.report()` | `Running` 事件没上报,发现不了"一直停在内置包"或指针 404 | 抽样上报 `Running` | | 快照盲区 | updates.md §4.1、§6.4 | 能力的参数与返回形状、页面 props、store key、`PageLocal` 都不在快照里,这些变了该不该加 `hostVersion` 全靠人判断 | 先记录;有需要时给能力加修订号或形状描述 | diff --git a/docs/updates.md b/docs/updates.md index 89151be..882e242 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -35,9 +35,8 @@ pages/**/*.bin | `version` | `tinyui build` | 包标识,目录名,只求唯一:`-`,如 `20260918T100212Z-3f2a1c9e04b7`;`--version` 可覆盖 | | `createdAt` | `tinyui build` | ISO 8601 UTC,新旧比较只看它 | | `engine` | `tinyui build` | 字节码文件头里的引擎 commit(40 位 hex,所有 `.bin` 一致,取第一个) | -| `protocol` | `tinyui build` | 所含 `tinyui-core` 的 `PROTOCOL`,读自子路径导出 `tinyui-core/protocol`(只含常量,不在 Node 里执行运行时模块) | | `hashes` | `tinyui build` | 模块名 → 该模块 `.bin` 的 sha256 hex,键与 `files` 一致 | -| `tinyui` | `tinyui build` | 运行时模块取自的 `tinyui-core` 版本,决定页面能用哪些内置组件;`publish` 核对它等于目标宿主的 tinyui 版本(§1.3),客户端不读 | +| `tinyui` | `tinyui build` | 运行时模块取自的 `tinyui-core` 版本,决定页面能用哪些内置组件;`publish` 核对它等于目标宿主的 tinyui 版本(§1.3),客户端核对它等于 `TinyUI.version`(§4.3);运行时与 Kotlin 侧的全部约定靠它对齐(patch-protocol.md §6) | | `requires` | `tinyui build` | 页面模块名 → `{ components, capabilities }`:该页用到的带点宿主组件名与 `host.call` 能力名,各自排序;内置组件不列。`publish` 据此核对目标宿主版本(§1.3),客户端不读 | | `hostVersion` | `tinyui bundle` | 发布目标,等于宿主声明值;写入后文件定稿,签名覆盖它的原始字节(§7) | @@ -83,7 +82,7 @@ dist/ota////pages/**/*.bin tinyui pull --channel production --host-version --out <宿主资源目录>/ [--app ] [--pkg

--accept-key ] ``` -内置包是 App 的地板,只要求**兼容**:是给当前 `hostVersion` 发布过的某个版本,引擎、协议、tinyui 版本与宿主一致,用到的宿主东西宿主都有(宿主测试用 `PackageCheck` 核对,§4.1)。它是不是当前的 `production` 不作要求,新旧尽力而为:旧一点,新装用户首启后 `check()` 会补上(M6 拍板第 12 点)。 +内置包是 App 的地板,只要求**兼容**:是给当前 `hostVersion` 发布过的某个版本,引擎、tinyui 版本与宿主一致,用到的宿主东西宿主都有(宿主测试用 `PackageCheck` 核对,§4.1)。它是不是当前的 `production` 不作要求,新旧尽力而为:旧一点,新装用户首启后 `check()` 会补上(M6 拍板第 12 点)。 `pull` 在宿主仓里跑(宿主的发版冒烟脚本顺手调用),把 `--channel` 当前指向的版本拉进内置包目录: @@ -154,7 +153,7 @@ class LoadedPage(val runtime: RuntimeBundle, val module: PageModule, val sourceM ```kotlin class Updates( - packages: List, // 内置包,各自 manifest.name 即包名;重名 → 构造抛错;engine / protocol 与宿主不符 → 发 EmbeddedIncompatible 后照常构造(§4.4) + packages: List, // 内置包,各自 manifest.name 即包名;重名 → 构造抛错;engine / tinyui 与宿主不符 → 发 EmbeddedIncompatible 后照常构造(§4.4) val hostVersion: String, dir: Path, // 宿主给的目录,如 Android filesDir/tinyui、iOS Application Support/tinyui;库内按

// 分 installId: String, // 稳定的安装标识;库不生成、不持久化、不上传 @@ -198,10 +197,10 @@ class Updates( | 事件 | 何时 | 字段(均含 `pkg`) | |---|---|---| -| `EmbeddedIncompatible` | 构造时,内置包不能在本宿主上跑 | `version`、`mismatch`:`ENGINE` / `PROTOCOL` 的集合(§4.4) | +| `EmbeddedIncompatible` | 构造时,内置包不能在本宿主上跑 | `version`、`mismatch`:`ENGINE` / `TINYUI` 的集合(§4.4) | | `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` 的集合 | +| `Skipped` | `check()` | `version`、`reason`:`INCOMPATIBLE` / `FAILED_BEFORE` / `OLDER_THAN_EMBEDDED` / `ROLLOUT`;`INCOMPATIBLE` 附 `mismatch`:`NAME` / `VERSION` / `ENGINE` / `TINYUI` / `HOST_VERSION` 的集合 | | `Installed` | `check()` | `version` | | `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` | @@ -244,7 +243,7 @@ fetch //current.json ─解析失败──────── ├─ 用内置 publicKey 对原始字节验 signature 失败 ─────────────────────▶ Failed(signature) // 上报 ├─ 解析失败 ────────────────────────────────────────────────────────▶ Failed(manifest) ├─ name ≠ pkg / version ≠ 指针 version / hostVersion ≠ 宿主值 - │ / engine ≠ QuickJs.upstreamCommit / protocol ≠ PROTOCOL ─────────▶ Skipped(incompatible) // 发错目录,上报 + │ / engine ≠ QuickJs.upstreamCommit / tinyui ≠ TinyUI.version ─────▶ Skipped(incompatible) // 发错目录,上报 ├─ createdAt ≤ embedded.createdAt(内置可用)────────────────────────▶ Skipped(older-than-embedded) │ ▼ 逐文件 fetch ///.bin → /staging//,每个核对 sha256 @@ -269,9 +268,9 @@ manifest 一到手先验签再解析:签名不对的 manifest 里任何字段 `state.json` 写入走临时文件加 rename,任何时刻磁盘上都是一份完整的它。 -构造时对每个包:`installed` 存在、`name` 等于包名、`hostVersion` 等于宿主值、不在 `failed`、`createdAt` 新于 `embedded`、**逐文件重算 sha256 与 manifest 一致** → `current(pkg) = installed`,否则 `= embedded`。sha256 不一致的(磁盘损坏、半个文件)按失败处理:`failed += version`、`installed = null`、`Failed(integrity)`;校验时读进内存的字节直接作为该包 `Bundle` 的来源,进页面不再读磁盘。`createdAt` 不新于内置或 `hostVersion` 不等于宿主值的 installed 当场删除(App 升级带来了更新的内置包或 bump 了 hostVersion);残留的 `staging/` 删除;`` 下不属于任何内置包的子目录删除(App 升级去掉了某个包)。不重验签:签名在落盘前验过,之后文件内容由 sha256 锁住;整包不到 100 KB,重算不到 1 毫秒。 +构造时对每个包:`installed` 存在、`name` 等于包名、`hostVersion` / `engine` / `tinyui` 等于宿主值、不在 `failed`、`createdAt` 新于 `embedded`、**逐文件重算 sha256 与 manifest 一致** → `current(pkg) = installed`,否则 `= embedded`。sha256 不一致的(磁盘损坏、半个文件)按失败处理:`failed += version`、`installed = null`、`Failed(integrity)`;校验时读进内存的字节直接作为该包 `Bundle` 的来源,进页面不再读磁盘。`createdAt` 不新于内置或 `hostVersion` / `engine` / `tinyui` 不等于宿主值的 installed 当场删除(App 升级带来了更新的内置包、bump 了 hostVersion 或换了引擎 / tinyui);残留的 `staging/` 删除;`` 下不属于任何内置包的子目录删除(App 升级去掉了某个包)。不重验签:签名在落盘前验过,之后文件内容由 sha256 锁住;整包不到 100 KB,重算不到 1 毫秒。 -内置包的引擎或协议与宿主不符时不抛错(M6 拍板第 14 点):发 `EmbeddedIncompatible`,该内置包只在名义上是地板——它的页面加载即 E6、走宿主的 `error` 槽,不参与上面的 `createdAt` 比较,也不参与 §4.3 的"等于内置版本"与 older-than-embedded;`check()` 照常跑,装上兼容的版本后下次启动恢复。这只是兜底,发版前由宿主测试(§4.1)拦住。 +内置包的引擎或 tinyui 版本与宿主不符时不抛错(M6 拍板第 14 点):发 `EmbeddedIncompatible`,该内置包只在名义上是地板——它的页面加载即 E6、走宿主的 `error` 槽,不参与上面的 `createdAt` 比较,也不参与 §4.3 的"等于内置版本"与 older-than-embedded;`check()` 照常跑,装上兼容的版本后下次启动恢复。这只是兜底,发版前由宿主测试(§4.1)拦住。 ### 4.5 失败回退 diff --git a/packages/cli/src/build.ts b/packages/cli/src/build.ts index 3884dec..c116b34 100644 --- a/packages/cli/src/build.ts +++ b/packages/cli/src/build.ts @@ -4,7 +4,6 @@ import { createHash } from "node:crypto"; import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; import { createRequire } from "node:module"; import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; -import { pathToFileURL } from "node:url"; import { promisify } from "node:util"; import { isPathSegment } from "./bundle.ts"; import { loadConfig, type TinyUIConfig } from "./config.ts"; @@ -59,7 +58,6 @@ export interface Manifest { createdAt: string; /** Engine commit the bytecode is bound to; empty when built with `jsOnly`. */ engine: string; - protocol: number; /** Version of the `tinyui-core` the runtime modules came from: which built-in components the pages may use. */ tinyui: string; /** Module name → sha256 hex of its `.bin`; empty when built with `jsOnly`. */ @@ -114,7 +112,6 @@ export async function build(options: BuildOptions): Promise { version: options.version ?? `${createdAt.replace(/[-:]/g, "")}-${await gitShortSha(root)}`, createdAt, engine: runtime[0]?.bin ? await engineCommit(runtime[0].bin) : "", - protocol: await runtimeProtocol(root), tinyui: await tinyuiVersion(root), hashes, requires, @@ -222,17 +219,9 @@ async function engineCommit(bin: string): Promise { return commit; } -/** `PROTOCOL` of the `tinyui-core` the pages resolve to, through its constants-only subpath export. */ -async function runtimeProtocol(root: string): Promise { - const file = createRequire(join(root, "package.json")).resolve("tinyui-core/protocol"); - const { PROTOCOL } = (await import(pathToFileURL(file).href)) as { PROTOCOL: unknown }; - if (typeof PROTOCOL !== "number") throw new Error(`${file} does not export PROTOCOL`); - return PROTOCOL; -} - async function tinyuiVersion(root: string): Promise { // the package's exports map hides package.json, so walk up from a file it does export - let dir = dirname(createRequire(join(root, "package.json")).resolve("tinyui-core/protocol")); + let dir = dirname(createRequire(join(root, "package.json")).resolve("tinyui-core")); for (;;) { const pkg = JSON.parse(await readFile(join(dir, "package.json"), "utf8").catch(() => "{}")) as { name?: string; version?: string }; if (pkg.name === "tinyui-core" && pkg.version) return pkg.version; diff --git a/packages/cli/src/bundle.ts b/packages/cli/src/bundle.ts index 2c06dc2..b9518b8 100644 --- a/packages/cli/src/bundle.ts +++ b/packages/cli/src/bundle.ts @@ -123,10 +123,9 @@ async function readManifest(dist: string): Promise { } catch (e) { throw new Error(`cannot read ${file}: ${(e as Error).message}`); } - for (const key of ["name", "publicKey", "version", "createdAt", "engine"] as const) { + for (const key of ["name", "publicKey", "version", "createdAt", "engine", "tinyui"] as const) { if (typeof raw[key] !== "string") throw new Error(`${file} has no "${key}"; rebuild with a current tinyui-cli`); } - if (typeof raw.protocol !== "number") throw new Error(`${file} has no "protocol"; rebuild with a current tinyui-cli`); const names = (key: "runtime" | "pages") => { const list = raw[key]; if (!Array.isArray(list) || !list.every((m) => typeof m === "string")) throw new Error(`${file}: "${key}" must list module names`); diff --git a/packages/cli/test/build.test.ts b/packages/cli/test/build.test.ts index 498cc5d..b0cdd42 100644 --- a/packages/cli/test/build.test.ts +++ b/packages/cli/test/build.test.ts @@ -112,7 +112,7 @@ describe("tinyui build", () => { assert.match(manifest.createdAt, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/); assert.match(manifest.version, /^\d{8}T\d{6}Z-([0-9a-f]{12}|nogit)$/); assert.ok(manifest.version.startsWith(manifest.createdAt.replace(/[-:]/g, "")), `${manifest.version} starts with the compact createdAt`); - assert.equal(manifest.protocol, 1); + assert.equal(manifest.tinyui, JSON.parse(await readFile(join(import.meta.dirname, "..", "..", "core", "package.json"), "utf8")).version); if (qjsc) { assert.match(manifest.engine, /^[0-9a-f]{40}$/); assert.deepEqual(Object.keys(manifest.hashes), [...manifest.runtime, ...manifest.pages]); diff --git a/packages/cli/test/helpers.ts b/packages/cli/test/helpers.ts index 7ed5055..f6178f0 100644 --- a/packages/cli/test/helpers.ts +++ b/packages/cli/test/helpers.ts @@ -24,7 +24,6 @@ export async function fakeDist(dir: string, publicKey: string, edit: (m: Manifes version: "20260922T090000Z-3f2a1c", createdAt: "2026-09-22T09:00:00Z", engine: "a".repeat(40), - protocol: 1, tinyui: "0.3.0", hashes, requires: { diff --git a/packages/core/package.json b/packages/core/package.json index 8cff210..d0e26c9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -16,10 +16,6 @@ "./jsx-dev-runtime": { "types": "./dist/jsx-runtime.d.ts", "default": "./dist/jsx-runtime.js" - }, - "./protocol": { - "types": "./dist/protocol.d.ts", - "default": "./dist/protocol.js" } }, "files": [ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 382c6ac..43fe1c5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,5 @@ -export const VERSION = "0.5.0"; -export { PROTOCOL, pageVisible, manifest } from "./page.ts"; +export { VERSION } from "./version.ts"; +export { pageVisible, manifest } from "./page.ts"; export type { HostManifest } from "./page.ts"; export { signal, memo, effect, onCleanup, untrack } from "./reactive.ts"; export { observable, unwrap } from "./observable.ts"; diff --git a/packages/core/src/page.ts b/packages/core/src/page.ts index 80d04c3..21857b5 100644 --- a/packages/core/src/page.ts +++ b/packages/core/src/page.ts @@ -3,9 +3,7 @@ import { createOwner, disposeOwner, insideRender, onCleanup, runPending, runWith import { handlers, patches, resetIds, type Node, type Props } from "./node.ts"; import { apply, rejectPending, report, resolvePending } from "./host.ts"; import { installGlobals } from "./timers.ts"; -import { PROTOCOL } from "./protocol.ts"; - -export { PROTOCOL }; +import { VERSION } from "./version.ts"; export interface HostManifest { components: Record; @@ -44,7 +42,7 @@ function guarded(entry: string, fn: () => void): void { } export const entries = { - protocol: PROTOCOL, + version: VERSION, mount(page: (props: Props) => Node, propsJson: string, hostJson: string): void { if (root) throw new Error("mount() called twice"); diff --git a/packages/core/src/protocol.ts b/packages/core/src/protocol.ts deleted file mode 100644 index 4e2e5d6..0000000 --- a/packages/core/src/protocol.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** Patch protocol version the runtime speaks; the host checks it at K0 (docs/patch-protocol.md). */ -export const PROTOCOL = 1; diff --git a/packages/core/src/version.ts b/packages/core/src/version.ts new file mode 100644 index 0000000..49de9ea --- /dev/null +++ b/packages/core/src/version.ts @@ -0,0 +1,2 @@ +/** The tinyui-core version; the host checks it at K0, a package runs only on the tinyui it was built with (docs/updates.md §1.3). */ +export const VERSION = "0.5.0"; diff --git a/packages/core/test/async.test.ts b/packages/core/test/async.test.ts index c52bf80..bd2fe5a 100644 --- a/packages/core/test/async.test.ts +++ b/packages/core/test/async.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { afterEach, describe, it } from "node:test"; import { bridge } from "./host-stub.ts"; import { drain, mount, tinyui, transaction, unmount } from "./helpers.ts"; -import { Column, h, HostError, internal, pageVisible, resource, signal, Text, thunk } from "../src/index.ts" +import { Column, h, HostError, internal, pageVisible, resource, signal, Text, thunk, VERSION } from "../src/index.ts" const { call, query, onEmit } = internal;; import * as timers from "../src/timers.ts"; @@ -91,8 +91,8 @@ describe("other entries", () => { tinyui().visible(true); }); - it("exposes the protocol version", () => { - assert.equal(tinyui().protocol, 1); + it("exposes the version", () => { + assert.equal(tinyui().version, VERSION); g; }); }); diff --git a/updates/api/updates.klib.api b/updates/api/updates.klib.api index 28a6f2a..0bff657 100644 --- a/updates/api/updates.klib.api +++ b/updates/api/updates.klib.api @@ -25,7 +25,7 @@ final enum class app.tinyui.updates/Mismatch : kotlin/Enum = emptySet()) : UpdateEvent() data class Installed(override val pkg: String, val version: String) : UpdateEvent() - /** At construction: the embedded package cannot run on this host (an engine or protocol [mismatch]); its pages fail until an update installs. */ + /** At construction: the embedded package cannot run on this host (an engine or tinyui [mismatch]); its pages fail until an update installs. */ data class EmbeddedIncompatible(override val pkg: String, val version: String, val mismatch: Set) : 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() diff --git a/updates/src/commonMain/kotlin/app/tinyui/updates/Updates.kt b/updates/src/commonMain/kotlin/app/tinyui/updates/Updates.kt index 5d1464c..b9a72d6 100644 --- a/updates/src/commonMain/kotlin/app/tinyui/updates/Updates.kt +++ b/updates/src/commonMain/kotlin/app/tinyui/updates/Updates.kt @@ -3,7 +3,7 @@ package app.tinyui.updates import app.tinyui.BuildManifest import app.tinyui.Bundle import app.tinyui.PageFailure -import app.tinyui.PageHost +import app.tinyui.TinyUI import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.async @@ -38,7 +38,7 @@ class Updates internal constructor( private val onEvent: (UpdateEvent) -> Unit, private val fs: FileSystem, private val engine: String, - private val protocol: Int, + private val tinyui: String, private val verifier: SignatureVerifier, ) { constructor( @@ -48,7 +48,7 @@ class Updates internal constructor( installId: String, fetch: suspend (path: String) -> ByteArray, onEvent: (UpdateEvent) -> Unit = {}, - ) : this(packages, hostVersion, dir, installId, fetch, onEvent, platformFileSystem, QuickJs.upstreamCommit, PageHost.PROTOCOL, PlatformSignatureVerifier) + ) : this(packages, hostVersion, dir, installId, fetch, onEvent, platformFileSystem, QuickJs.upstreamCommit, TinyUI.version, PlatformSignatureVerifier) private val packages: Map private val checkLock = Mutex() @@ -143,7 +143,7 @@ class Updates internal constructor( /** Why embedded cannot run on this host; it then stays the floor only in name: pages fail, installs do not compare against it. */ val unusable: Set = buildSet { if (embedded.manifest.engine != engine) add(Mismatch.ENGINE) - if (embedded.manifest.protocol != protocol) add(Mismatch.PROTOCOL) + if (embedded.manifest.tinyui != tinyui) add(Mismatch.TINYUI) } private val embeddedCreatedAt: String? get() = embedded.manifest.createdAt.takeIf { unusable.isEmpty() } @@ -164,10 +164,11 @@ class Updates internal constructor( fs.deleteRecursively(installedDir / keep) } - /** The installed package when it is still the one to run: newer than embedded, this host version, intact. */ + /** The installed package when it is still the one to run: newer than embedded, this host version, engine and tinyui, intact. */ private fun loadInstalled(dir: Path): Bundle? { val manifest = runCatching { BuildManifest.parse(fs.read(dir / MANIFEST) { readUtf8() }) }.getOrNull() ?: return null if (manifest.name != name || manifest.hostVersion != hostVersion || manifest.version in failed) return null + if (manifest.engine != engine || manifest.tinyui != tinyui) return null if (embeddedCreatedAt?.let { manifest.createdAt <= it } == true) return null val files = HashMap() for (module in manifest.runtime + manifest.pages) { @@ -223,7 +224,7 @@ class Updates internal constructor( if (manifest.version != version) add(Mismatch.VERSION) if (manifest.hostVersion != hostVersion) add(Mismatch.HOST_VERSION) if (manifest.engine != engine) add(Mismatch.ENGINE) - if (manifest.protocol != protocol) add(Mismatch.PROTOCOL) + if (manifest.tinyui != tinyui) add(Mismatch.TINYUI) } if (mismatch.isNotEmpty()) return CheckResult.Skipped(version, SkipReason.INCOMPATIBLE, mismatch) if (embeddedCreatedAt?.let { manifest.createdAt <= it } == true) return CheckResult.Skipped(version, SkipReason.OLDER_THAN_EMBEDDED) diff --git a/updates/src/commonTest/kotlin/app/tinyui/updates/Fixture.kt b/updates/src/commonTest/kotlin/app/tinyui/updates/Fixture.kt index 8351220..4ba9d5e 100644 --- a/updates/src/commonTest/kotlin/app/tinyui/updates/Fixture.kt +++ b/updates/src/commonTest/kotlin/app/tinyui/updates/Fixture.kt @@ -2,10 +2,11 @@ package app.tinyui.updates /** A `/manifest.json` as `tinyui bundle` wrote it, with the `current.json` signature Node's crypto produced for these exact bytes. */ object Fixture { - const val PUBLIC_KEY = "BCsQWJxiruNm+rZEwQBeGs/1nTV3QGB2TObvtD61sZlSbBGee/gnfxIWuOMCRUcrCRWGONKylaGqkcRiEq4Qy3g=" + const val PUBLIC_KEY = "BNWxlTQYds/K39D4GXR39VXCy3lkqLnYC5sPcEQzXeKOz/Uh35/M6gdJng3aOLdtNiftlEb4j3oaDCPiMYVBSMY=" const val OTHER_PUBLIC_KEY = "BMxPERlI+YlzaKiP61LhtPrGll7Qv3U0jzrb9OlKgXO3GxfxPdV7G+QQxJ0Lr5GWpxfPewUwyWn3URiJ/+/Eh3k=" - const val SIGNATURE = "MEUCIHX9BKQtT7O1VUQCLj7czqmjDjWpSXQa8vcf1dZXBMC6AiEAqtfPgEn72H8PnwD9w3+wKFLe8wd3FnnyckEdtsico5A=" + const val SIGNATURE = "MEUCICYDN1aFFq/VMY2n9MMK05I0oYgNp17QHTAprnGQHYL0AiEA9vR1+GdENGNaKxW57GKSje0P6cNsK0818PDB4bVieiA=" const val ENGINE = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + const val TINYUI = "0.5.0" const val VERSION = "20260922T100000Z-abcdef1" val MANIFEST = """ { @@ -27,11 +28,11 @@ object Fixture { "shop/home": "cccccccc" }, "name": "shop", - "publicKey": "BCsQWJxiruNm+rZEwQBeGs/1nTV3QGB2TObvtD61sZlSbBGee/gnfxIWuOMCRUcrCRWGONKylaGqkcRiEq4Qy3g=", + "publicKey": "BNWxlTQYds/K39D4GXR39VXCy3lkqLnYC5sPcEQzXeKOz/Uh35/M6gdJng3aOLdtNiftlEb4j3oaDCPiMYVBSMY=", "version": "20260922T100000Z-abcdef1", "createdAt": "2026-09-22T10:00:00Z", "engine": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", - "protocol": 1, + "tinyui": "0.5.0", "hashes": { "tinyui-core": "d63180d8a590da5a74a6a62a700b4f1c1ccf8eb1286e0509e6b30fc72d92697b", "tinyui-native": "8a65da82b504e482deb6e49382cd8b2abca80b6c6ad67ff82aa642f76c8fcc4d", diff --git a/updates/src/commonTest/kotlin/app/tinyui/updates/UpdatesTest.kt b/updates/src/commonTest/kotlin/app/tinyui/updates/UpdatesTest.kt index 114ace8..e87f20e 100644 --- a/updates/src/commonTest/kotlin/app/tinyui/updates/UpdatesTest.kt +++ b/updates/src/commonTest/kotlin/app/tinyui/updates/UpdatesTest.kt @@ -41,7 +41,7 @@ class UpdatesTest { version: String = "v1", createdAt: String = "2026-09-22T10:00:00Z", engine: String = Fixture.ENGINE, - protocol: Int = 1, + tinyui: String = Fixture.TINYUI, publicKey: String = Fixture.PUBLIC_KEY, files: Map = mapOf("runtime/core.bin" to "CORE-$version", "runtime/native.bin" to "NATIVE-$version", "pages/home.bin" to "HOME-$version"), hostVersion: String? = "1", @@ -49,7 +49,7 @@ class UpdatesTest { val modules = mapOf("tinyui-core" to "runtime/core", "tinyui-native" to "runtime/native", "$name/home" to "pages/home") val hashes = modules.entries.joinToString(",") { (m, p) -> "\"$m\":\"${files.getValue("$p.bin").encodeUtf8().sha256().hex()}\"" } val hostVersion = if (hostVersion == null) "" else ",\"hostVersion\":\"$hostVersion\"" - return """{"runtime":["tinyui-core","tinyui-native"],"pages":["$name/home"],"files":{${modules.entries.joinToString(",") { "\"${it.key}\":\"${it.value}\"" }}},"buildIds":{},"name":"$name","publicKey":"$publicKey","version":"$version","createdAt":"$createdAt","engine":"$engine","protocol":$protocol,"hashes":{$hashes}$hostVersion}""" + return """{"runtime":["tinyui-core","tinyui-native"],"pages":["$name/home"],"files":{${modules.entries.joinToString(",") { "\"${it.key}\":\"${it.value}\"" }}},"buildIds":{},"name":"$name","publicKey":"$publicKey","version":"$version","createdAt":"$createdAt","engine":"$engine","tinyui":"$tinyui","hashes":{$hashes}$hostVersion}""" } private fun embedded(name: String = "shop", version: String = "v0", createdAt: String = "2026-09-22T09:00:00Z"): Bundle { @@ -68,8 +68,8 @@ class UpdatesTest { for ((path, bytes) in content) server["$pkg/$hostVersion/$version/$path"] = bytes.encodeToByteArray() } - private fun updates(vararg bundles: Bundle, installId: String = "install-1", hostVersion: String = "1", verifier: SignatureVerifier = lenient, engine: String = Fixture.ENGINE) = - Updates(bundles.toList(), hostVersion, dir, installId, fetch, { events += it }, fs, engine, 1, verifier) + private fun updates(vararg bundles: Bundle, installId: String = "install-1", hostVersion: String = "1", verifier: SignatureVerifier = lenient, engine: String = Fixture.ENGINE, tinyui: String = Fixture.TINYUI) = + Updates(bundles.toList(), hostVersion, dir, installId, fetch, { events += it }, fs, engine, tinyui, verifier) private suspend fun pageBytes(bundle: Bundle) = bundle.page("shop/home").module.bytecode.decodeToString() @@ -93,6 +93,19 @@ class UpdatesTest { assertEquals(CheckResult.UpToDate("v1"), second.check("shop")) } + @Test + fun anInstalledPackageForAnotherEngineOrTinyuiIsNotRunAfterTheHostChanges() = runTest { + publish(manifest()) + for (host in listOf<() -> Updates>({ updates(embedded(), tinyui = "0.0.2") }, { updates(embedded(), engine = "f".repeat(40)) })) { + assertEquals(CheckResult.Installed("v1"), updates(embedded()).check("shop")) + events.clear() + val u = host() + assertEquals(Source.EMBEDDED, events.filterIsInstance().single().source) + assertEquals("HOME-v0", pageBytes(u.current("shop"))) + assertFalse(fs.exists(dir / "shop/installed/v1")) + } + } + @Test fun aBrokenOrEscapingPointerFails() = runTest { val u = updates(embedded()) @@ -124,8 +137,8 @@ class UpdatesTest { val u = updates(embedded()) publish(manifest(name = "other")) assertEquals(CheckResult.Skipped("v1", SkipReason.INCOMPATIBLE, setOf(Mismatch.NAME)), u.check("shop")) - publish(manifest(engine = "f".repeat(40), protocol = 2, hostVersion = "2")) - assertEquals(setOf(Mismatch.ENGINE, Mismatch.PROTOCOL, Mismatch.HOST_VERSION), (u.check("shop") as CheckResult.Skipped).mismatch) + publish(manifest(engine = "f".repeat(40), tinyui = "0.0.2", hostVersion = "2")) + assertEquals(setOf(Mismatch.ENGINE, Mismatch.TINYUI, Mismatch.HOST_VERSION), (u.check("shop") as CheckResult.Skipped).mismatch) publish(manifest(version = "v9"), pointerVersion = "v1") assertEquals(setOf(Mismatch.VERSION), (u.check("shop") as CheckResult.Skipped).mismatch) publish(manifest(createdAt = "2026-09-22T09:00:00Z")) @@ -154,11 +167,11 @@ class UpdatesTest { assertEquals(CheckResult.Skipped("v1", SkipReason.ROLLOUT), updates(embedded()).check("shop")) publish(manifest(), rollout = 50) val hits = (1..200).count { i -> - Updates(listOf(embedded()), "1", root / "roll-$i", "install-$i", fetch, {}, fs, Fixture.ENGINE, 1, lenient).check("shop") is CheckResult.Installed + Updates(listOf(embedded()), "1", root / "roll-$i", "install-$i", fetch, {}, fs, Fixture.ENGINE, Fixture.TINYUI, lenient).check("shop") is CheckResult.Installed } assertTrue(hits in 70..130, "about half of 200 installs are in a 50% rollout, got $hits") - val once = Updates(listOf(embedded()), "1", root / "once", "install-7", fetch, {}, fs, Fixture.ENGINE, 1, lenient).check("shop") - val again = Updates(listOf(embedded()), "1", root / "again", "install-7", fetch, {}, fs, Fixture.ENGINE, 1, lenient).check("shop") + val once = Updates(listOf(embedded()), "1", root / "once", "install-7", fetch, {}, fs, Fixture.ENGINE, Fixture.TINYUI, lenient).check("shop") + val again = Updates(listOf(embedded()), "1", root / "again", "install-7", fetch, {}, fs, Fixture.ENGINE, Fixture.TINYUI, lenient).check("shop") assertEquals(once::class, again::class, "the same install rolls the same") } @@ -276,7 +289,7 @@ class UpdatesTest { val u = updates(embedded(), verifier = PlatformSignatureVerifier) assertEquals(CheckResult.Installed(Fixture.VERSION), u.check("shop")) assertEquals("HOME2", pageBytes(updates(embedded(), verifier = PlatformSignatureVerifier).current("shop"))) - server["shop/1/${Fixture.VERSION}/manifest.json"] = Fixture.MANIFEST.replace("\"protocol\": 1", "\"protocol\": 2").encodeToByteArray() + server["shop/1/${Fixture.VERSION}/manifest.json"] = Fixture.MANIFEST.replace("\"tinyui\": \"${Fixture.TINYUI}\"", "\"tinyui\": \"0.0.2\"").encodeToByteArray() fs.deleteRecursively(dir) assertEquals(FailStage.SIGNATURE, (updates(embedded(), verifier = PlatformSignatureVerifier).check("shop") as CheckResult.Failed).stage) }