From 5f294d370a4830acdc7c94eeadf77dd27915ac2f Mon Sep 17 00:00:00 2001 From: HarlonWang <81813780@qq.com> Date: Thu, 24 Sep 2026 10:18:16 +0800 Subject: [PATCH 1/3] =?UTF-8?q?M6=20=E5=90=8E=E7=BB=AD=EF=BC=9A=E5=86=85?= =?UTF-8?q?=E7=BD=AE=E5=8C=85=E5=8F=AA=E6=B1=82=E5=85=BC=E5=AE=B9=E3=80=81?= =?UTF-8?q?=E5=AE=BF=E4=B8=BB=20pull=20=E5=88=B7=E6=96=B0=EF=BC=9B?= =?UTF-8?q?=E4=B8=8D=E5=85=BC=E5=AE=B9=E9=99=8D=E7=BA=A7=E4=B8=8D=E5=B4=A9?= =?UTF-8?q?=E6=BA=83=EF=BC=9B=E6=8C=87=E9=92=88=E5=8F=AA=E5=BE=80=E5=89=8D?= =?UTF-8?q?=E8=B5=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PackageCheck:核对一个包能否跑在某个 TinyUIHost 上(引擎、协议、tinyui 版本、用到的能力与组件); BuildManifest 补解析 tinyui、requires;sample 带 EmbeddedPackageTest 作宿主样板 - Updates:内置包引擎或协议不符时不再抛错,发 EmbeddedIncompatible,页面走 error 槽、不参与新旧比较; 删 Reverted(指针只往前走,设备侧不再有指回旧版本的分支) - CLI pull 改在宿主仓跑:信任锚是已提交的内置包(包名、公钥),换钥须 --accept-key, 灰度中或不更新时保持现状;删 releases rollback - 文档:updates.md §1.4、§2、§4.1–§4.4、§6.2、§6.4,ADR-006 §2.8 / §4.2,计划拍板第 12–15 点 Co-Authored-By: Claude Opus 5.5 --- compose/api/compose.klib.api | 23 ++++- .../kotlin/app/tinyui/BuildManifest.kt | 15 ++++ .../kotlin/app/tinyui/PackageCheck.kt | 18 ++++ .../kotlin/app/tinyui/PackageCheckTest.kt | 72 ++++++++++++++++ docs/adr-006-hot-updates.md | 12 +-- docs/updates-plan.md | 8 +- docs/updates.md | 59 ++++++++----- packages/cli/src/bin.ts | 31 ++++--- packages/cli/src/pull.ts | 85 ++++++++++++------ packages/cli/test/admin.test.ts | 6 +- packages/cli/test/pull.test.ts | 86 +++++++++++-------- sample/shared/build.gradle.kts | 16 ++-- .../app/tinyui/sample/EmbeddedPackageTest.kt | 19 ++++ .../app/tinyui/sample/HostSnapshotTest.kt | 2 +- updates/api/updates.klib.api | 54 +++++------- .../kotlin/app/tinyui/updates/Events.kt | 6 +- .../kotlin/app/tinyui/updates/Updates.kt | 31 +++---- .../kotlin/app/tinyui/updates/UpdatesTest.kt | 28 ++---- 18 files changed, 378 insertions(+), 193 deletions(-) create mode 100644 compose/src/commonMain/kotlin/app/tinyui/PackageCheck.kt create mode 100644 compose/src/commonTest/kotlin/app/tinyui/PackageCheckTest.kt create mode 100644 sample/shared/src/androidHostTest/kotlin/app/tinyui/sample/EmbeddedPackageTest.kt diff --git a/compose/api/compose.klib.api b/compose/api/compose.klib.api index fbc7e84..c983a7d 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? = ...) // 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?){}[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] final val buildIds // app.tinyui/BuildManifest.buildIds|{}buildIds[0] final fun (): kotlin.collections/Map // app.tinyui/BuildManifest.buildIds.|(){}[0] @@ -270,8 +270,12 @@ final class app.tinyui/BuildManifest { // app.tinyui/BuildManifest|null[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] + final fun (): kotlin.collections/Map // app.tinyui/BuildManifest.requires.|(){}[0] final val runtime // app.tinyui/BuildManifest.runtime|{}runtime[0] final fun (): kotlin.collections/List // app.tinyui/BuildManifest.runtime.|(){}[0] + final val tinyui // app.tinyui/BuildManifest.tinyui|{}tinyui[0] + final fun (): kotlin/String // app.tinyui/BuildManifest.tinyui.|(){}[0] final val version // app.tinyui/BuildManifest.version|{}version[0] final fun (): kotlin/String // app.tinyui/BuildManifest.version.|(){}[0] @@ -471,6 +475,15 @@ final class app.tinyui/PageModule { // app.tinyui/PageModule|null[0] final fun (): kotlin/String // app.tinyui/PageModule.name.|(){}[0] } +final class app.tinyui/PageRequires { // app.tinyui/PageRequires|null[0] + constructor (kotlin.collections/List, kotlin.collections/List) // app.tinyui/PageRequires.|(kotlin.collections.List;kotlin.collections.List){}[0] + + final val capabilities // app.tinyui/PageRequires.capabilities|{}capabilities[0] + final fun (): kotlin.collections/List // app.tinyui/PageRequires.capabilities.|(){}[0] + final val components // app.tinyui/PageRequires.components|{}components[0] + final fun (): kotlin.collections/List // app.tinyui/PageRequires.components.|(){}[0] +} + final class app.tinyui/RuntimeBundle { // app.tinyui/RuntimeBundle|null[0] constructor (kotlin/ByteArray, kotlin/ByteArray) // app.tinyui/RuntimeBundle.|(kotlin.ByteArray;kotlin.ByteArray){}[0] @@ -660,6 +673,10 @@ final object app.tinyui/HostSnapshot { // app.tinyui/HostSnapshot|null[0] final fun render(app.tinyui/TinyUIHost, kotlin/String): kotlin/String // app.tinyui/HostSnapshot.render|render(app.tinyui.TinyUIHost;kotlin.String){}[0] } +final object app.tinyui/PackageCheck { // app.tinyui/PackageCheck|null[0] + final fun problems(app.tinyui/BuildManifest, app.tinyui/TinyUIHost): kotlin.collections/List // app.tinyui/PackageCheck.problems|problems(app.tinyui.BuildManifest;app.tinyui.TinyUIHost){}[0] +} + final object app.tinyui/TinyUI { // app.tinyui/TinyUI|null[0] final val engineVersion // app.tinyui/TinyUI.engineVersion|{}engineVersion[0] final fun (): kotlin/String // app.tinyui/TinyUI.engineVersion.|(){}[0] @@ -710,6 +727,7 @@ final val app.tinyui/app_tinyui_HttpResponse$stableprop // app.tinyui/app_tinyui final val app.tinyui/app_tinyui_InMemoryStore$stableprop // app.tinyui/app_tinyui_InMemoryStore$stableprop|#static{}app_tinyui_InMemoryStore$stableprop[0] final val app.tinyui/app_tinyui_LoadedPage$stableprop // app.tinyui/app_tinyui_LoadedPage$stableprop|#static{}app_tinyui_LoadedPage$stableprop[0] final val app.tinyui/app_tinyui_Navigator_None$stableprop // app.tinyui/app_tinyui_Navigator_None$stableprop|#static{}app_tinyui_Navigator_None$stableprop[0] +final val app.tinyui/app_tinyui_PackageCheck$stableprop // app.tinyui/app_tinyui_PackageCheck$stableprop|#static{}app_tinyui_PackageCheck$stableprop[0] final val app.tinyui/app_tinyui_PageContext$stableprop // app.tinyui/app_tinyui_PageContext$stableprop|#static{}app_tinyui_PageContext$stableprop[0] final val app.tinyui/app_tinyui_PageError$stableprop // app.tinyui/app_tinyui_PageError$stableprop|#static{}app_tinyui_PageError$stableprop[0] final val app.tinyui/app_tinyui_PageFailure$stableprop // app.tinyui/app_tinyui_PageFailure$stableprop|#static{}app_tinyui_PageFailure$stableprop[0] @@ -717,6 +735,7 @@ final val app.tinyui/app_tinyui_PageHost$stableprop // app.tinyui/app_tinyui_Pag final val app.tinyui/app_tinyui_PageLocal$stableprop // app.tinyui/app_tinyui_PageLocal$stableprop|#static{}app_tinyui_PageLocal$stableprop[0] final val app.tinyui/app_tinyui_PageLocalValue$stableprop // app.tinyui/app_tinyui_PageLocalValue$stableprop|#static{}app_tinyui_PageLocalValue$stableprop[0] final val app.tinyui/app_tinyui_PageModule$stableprop // app.tinyui/app_tinyui_PageModule$stableprop|#static{}app_tinyui_PageModule$stableprop[0] +final val app.tinyui/app_tinyui_PageRequires$stableprop // app.tinyui/app_tinyui_PageRequires$stableprop|#static{}app_tinyui_PageRequires$stableprop[0] final val app.tinyui/app_tinyui_RuntimeBundle$stableprop // app.tinyui/app_tinyui_RuntimeBundle$stableprop|#static{}app_tinyui_RuntimeBundle$stableprop[0] final val app.tinyui/app_tinyui_SourceMap_Position$stableprop // app.tinyui/app_tinyui_SourceMap_Position$stableprop|#static{}app_tinyui_SourceMap_Position$stableprop[0] final val app.tinyui/app_tinyui_SourceMaps$stableprop // app.tinyui/app_tinyui_SourceMaps$stableprop|#static{}app_tinyui_SourceMaps$stableprop[0] @@ -765,6 +784,7 @@ final fun app.tinyui/app_tinyui_HttpResponse$stableprop_getter(): kotlin/Int // final fun app.tinyui/app_tinyui_InMemoryStore$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_InMemoryStore$stableprop_getter|app_tinyui_InMemoryStore$stableprop_getter(){}[0] final fun app.tinyui/app_tinyui_LoadedPage$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_LoadedPage$stableprop_getter|app_tinyui_LoadedPage$stableprop_getter(){}[0] final fun app.tinyui/app_tinyui_Navigator_None$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_Navigator_None$stableprop_getter|app_tinyui_Navigator_None$stableprop_getter(){}[0] +final fun app.tinyui/app_tinyui_PackageCheck$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_PackageCheck$stableprop_getter|app_tinyui_PackageCheck$stableprop_getter(){}[0] final fun app.tinyui/app_tinyui_PageContext$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_PageContext$stableprop_getter|app_tinyui_PageContext$stableprop_getter(){}[0] final fun app.tinyui/app_tinyui_PageError$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_PageError$stableprop_getter|app_tinyui_PageError$stableprop_getter(){}[0] final fun app.tinyui/app_tinyui_PageFailure$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_PageFailure$stableprop_getter|app_tinyui_PageFailure$stableprop_getter(){}[0] @@ -772,6 +792,7 @@ final fun app.tinyui/app_tinyui_PageHost$stableprop_getter(): kotlin/Int // app. final fun app.tinyui/app_tinyui_PageLocal$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_PageLocal$stableprop_getter|app_tinyui_PageLocal$stableprop_getter(){}[0] final fun app.tinyui/app_tinyui_PageLocalValue$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_PageLocalValue$stableprop_getter|app_tinyui_PageLocalValue$stableprop_getter(){}[0] final fun app.tinyui/app_tinyui_PageModule$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_PageModule$stableprop_getter|app_tinyui_PageModule$stableprop_getter(){}[0] +final fun app.tinyui/app_tinyui_PageRequires$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_PageRequires$stableprop_getter|app_tinyui_PageRequires$stableprop_getter(){}[0] final fun app.tinyui/app_tinyui_RuntimeBundle$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_RuntimeBundle$stableprop_getter|app_tinyui_RuntimeBundle$stableprop_getter(){}[0] final fun app.tinyui/app_tinyui_SourceMap_Position$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_SourceMap_Position$stableprop_getter|app_tinyui_SourceMap_Position$stableprop_getter(){}[0] final fun app.tinyui/app_tinyui_SourceMaps$stableprop_getter(): kotlin/Int // app.tinyui/app_tinyui_SourceMaps$stableprop_getter|app_tinyui_SourceMaps$stableprop_getter(){}[0] diff --git a/compose/src/commonMain/kotlin/app/tinyui/BuildManifest.kt b/compose/src/commonMain/kotlin/app/tinyui/BuildManifest.kt index ed5f222..7b41e19 100644 --- a/compose/src/commonMain/kotlin/app/tinyui/BuildManifest.kt +++ b/compose/src/commonMain/kotlin/app/tinyui/BuildManifest.kt @@ -20,6 +20,10 @@ class BuildManifest( val engine: String, val protocol: Int, val hashes: Map, + /** The tinyui-core version the runtime modules came from. */ + val tinyui: String = "", + /** Page module name → the host things it uses (docs/updates.md §1.1). */ + val requires: Map = emptyMap(), /** Present only in a manifest `tinyui bundle` wrote: the embedded package has none. */ val hostVersion: String? = null, ) { @@ -51,8 +55,19 @@ class BuildManifest( engine = string("engine"), protocol = root["protocol"]?.jsonPrimitive?.intOrNull ?: 0, hashes = strings("hashes"), + tinyui = string("tinyui"), + requires = root["requires"]?.jsonObject?.mapValues { (_, v) -> + val page = v.jsonObject + PageRequires( + components = page["components"]?.jsonArray?.map { it.jsonPrimitive.content } ?: emptyList(), + capabilities = page["capabilities"]?.jsonArray?.map { it.jsonPrimitive.content } ?: emptyList(), + ) + } ?: emptyMap(), hostVersion = root["hostVersion"]?.jsonPrimitive?.contentOrNull, ) } } } + +/** Dotted host components and `host.call` capability names one page uses. */ +class PageRequires(val components: List, val capabilities: List) diff --git a/compose/src/commonMain/kotlin/app/tinyui/PackageCheck.kt b/compose/src/commonMain/kotlin/app/tinyui/PackageCheck.kt new file mode 100644 index 0000000..a6bf9b9 --- /dev/null +++ b/compose/src/commonMain/kotlin/app/tinyui/PackageCheck.kt @@ -0,0 +1,18 @@ +package app.tinyui + +import wang.harlon.quickjs.QuickJs + +/** Whether a package can run on a host (docs/updates.md §4.1): for a host's test over its embedded packages. */ +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 }) { + uses.components.filter { host.components.schema(it) == null }.forEach { add("$page uses component $it, which the host does not register") } + uses.capabilities.filter { it !in capabilities }.forEach { add("$page calls $it, which the host does not provide") } + } + } +} diff --git a/compose/src/commonTest/kotlin/app/tinyui/PackageCheckTest.kt b/compose/src/commonTest/kotlin/app/tinyui/PackageCheckTest.kt new file mode 100644 index 0000000..cc7da9a --- /dev/null +++ b/compose/src/commonTest/kotlin/app/tinyui/PackageCheckTest.kt @@ -0,0 +1,72 @@ +package app.tinyui + +import app.tinyui.components.registerBuiltins +import app.tinyui.schema.ComponentRegistry +import app.tinyui.schema.ComponentSchema +import wang.harlon.quickjs.QuickJs +import kotlin.test.Test +import kotlin.test.assertEquals + +class PackageCheckTest { + private val sink = object : PageSink { + override fun error(error: PageError) {} + override fun log(line: String) {} + } + + private val host = TinyUIHost( + ComponentRegistry().registerBuiltins().apply { + register(ComponentSchema("ta.Icon", emptyMap(), emptyMap(), emptyMap(), children = false, layout = true)) {} + }, + sink, + CapabilityRegistry().apply { register("checkout.start") { _, _ -> null } }, + ) + + 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, + ) + + @Test + fun aPackageBuiltForThisHostHasNoProblems() { + assertEquals(emptyList(), PackageCheck.problems(manifest(), host)) + } + + @Test + fun namesEveryMismatch() { + 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"))), + ), + host, + ) + 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", + ), + problems, + ) + } + + @Test + fun readsTinyuiAndRequiresFromTheManifest() { + val parsed = BuildManifest.parse( + """{"name":"shop","publicKey":"k","tinyui":"0.5.0","requires":{"shop/home":{"components":["ta.Icon"],"capabilities":["checkout.start"]}}}""", + ) + assertEquals("0.5.0", parsed.tinyui) + assertEquals(listOf("ta.Icon"), parsed.requires.getValue("shop/home").components) + assertEquals(listOf("checkout.start"), parsed.requires.getValue("shop/home").capabilities) + } +} diff --git a/docs/adr-006-hot-updates.md b/docs/adr-006-hot-updates.md index 664b5a0..05b6adf 100644 --- a/docs/adr-006-hot-updates.md +++ b/docs/adr-006-hot-updates.md @@ -1,7 +1,7 @@ # ADR-006 · 热下发:整包原子、宿主 hostVersion 为兼容键、内置包是地板 -- 状态:已定(2026-09-18;2026-09-19 修订:服务端定为开源可私有化的托管服务,签名进 MVP,客户端与服务端分仓;2026-09-19 再修订:多包模型——App 由 N≥1 个包组成,包名进模块名与 URL,公钥进 manifest;2026-09-22 修订:签名覆盖不可变 manifest 的原始字节而非规范化 JSON,指针文件只含 version / rollout / signature;启动时重算 installed 包 sha256,挂载标记作推迟项;2026-09-23 §2.2 补精确匹配与 `>=` 范围的对比,bump 口径进 updates.md §4.1;同日兼容键改名 `hostVersion`,见 updates.md §4.1;同日按 M6 拍板改 §4.2:TrendingAI 的包名为 `trendingai`,内置包由 `tinyui pull` 取 production 那版,见 updates-plan.md 第 7、11 点);实现进度见 updates-plan.md -- 结论:**App 由 N≥1 个包组成,包是所有权单元与分发单元,一个包是一次 `tinyui build` 的完整产物,整包原子生效、下次启动切换,各包独立发布、独立回退;包名进模块名(`subscription/home`)与投递 URL,密钥与发布 token 按包发,验签公钥随包进 manifest、以内置包的为信任锚;兼容键是宿主声明的 `hostVersion`,引擎 commit 与 patch 协议号只做校验;内置包永远是地板,下发包失败即回退内置并拉黑;库(独立 artifact `tinyui-updates`)以一组包为单位做校验 / 落盘 / 选择 / 回退,不做网络、调度、UI;服务端协议是两次 GET——可变指针 + 不可变内容,指针背后是静态文件还是动态端点客户端不关心;灰度靠 manifest 里的百分比 + 客户端掷骰;回滚 = 指针换回上一个好包;完整性 = 逐文件 sha256 + manifest 签名(ECDSA P-256,发布方持私钥);服务端参考实现 `tinyui-updates-server` 独立开源仓、Cloudflare 为主、可私有化,托管实例 `updates.tinyui.app`** +- 状态:已定(2026-09-18;2026-09-19 修订:服务端定为开源可私有化的托管服务,签名进 MVP,客户端与服务端分仓;2026-09-19 再修订:多包模型——App 由 N≥1 个包组成,包名进模块名与 URL,公钥进 manifest;2026-09-22 修订:签名覆盖不可变 manifest 的原始字节而非规范化 JSON,指针文件只含 version / rollout / signature;启动时重算 installed 包 sha256,挂载标记作推迟项;2026-09-23 §2.2 补精确匹配与 `>=` 范围的对比,bump 口径进 updates.md §4.1;同日兼容键改名 `hostVersion`,见 updates.md §4.1;同日按 M6 拍板改 §4.2:TrendingAI 的包名为 `trendingai`,内置包由 `tinyui pull` 取 production 那版,见 updates-plan.md 第 7、11 点;2026-09-24 按 M6 拍板第 12–15 点修订 §2.8 回滚与 §4.2 内置包:指针只往前走、回滚是用旧内容发新版本,内置包只求兼容、由宿主 pull 刷新);实现进度见 updates-plan.md +- 结论:**App 由 N≥1 个包组成,包是所有权单元与分发单元,一个包是一次 `tinyui build` 的完整产物,整包原子生效、下次启动切换,各包独立发布、独立回退;包名进模块名(`subscription/home`)与投递 URL,密钥与发布 token 按包发,验签公钥随包进 manifest、以内置包的为信任锚;兼容键是宿主声明的 `hostVersion`,引擎 commit 与 patch 协议号只做校验;内置包永远是地板,下发包失败即回退内置并拉黑;库(独立 artifact `tinyui-updates`)以一组包为单位做校验 / 落盘 / 选择 / 回退,不做网络、调度、UI;服务端协议是两次 GET——可变指针 + 不可变内容,指针背后是静态文件还是动态端点客户端不关心;灰度靠 manifest 里的百分比 + 客户端掷骰;回滚 = 用旧内容发一个新版本(指针只往前走);完整性 = 逐文件 sha256 + manifest 签名(ECDSA P-256,发布方持私钥);服务端参考实现 `tinyui-updates-server` 独立开源仓、Cloudflare 为主、可私有化,托管实例 `updates.tinyui.app`** - 契约(manifest 字段、投递与发布协议、签名、客户端状态机、API 面):[updates.md](./updates.md) - 影响:docs/README.md 首段与 ADR-005 决策表的"热下发不在本期"改为指向本文;roadmap D 组该项转入实现;build-chain.md §2 的模块名加包名前缀、manifest 字段扩展、`tinyui.config.json`;app-model.md 的路由键与跨包约定;ADR-005 §4 的模块名规则 @@ -112,7 +112,7 @@ ADR-005 把热下发划出当期,条件是"内核稳定后另立 ADR"。Trendi 灰度:manifest 带 `rollout` 百分比,客户端用宿主给的稳定 `installId` 哈希落桶(CodePush 做法)。静态托管也能灰度,是"全量推坏包"这一最大风险的止血阀,v1 就做。代价是库多收一个 `installId`(宿主给,库不生成不持久化、不上传)。 -回滚:只有"把指针改回上一个好包"。启用规则是"≠ installed 且新于内置"而不是"新于 installed",所以指针回退天然可行;服务端按 version 保存已发布的 manifest,回滚不重新上传。Expo 的 `rollBackToEmbedded` 指令不做:服务端不知道每个 App 版本内置的是哪个包,多 App 版本共存时该语义本来就含糊。 +回滚:用旧内容发一个新 version 再晋级,指针只往前走,服务端拒绝把指针指向不更新的版本(2026-09-24 修订,M6 拍板第 15 点)。新 version 比所有设备上的版本都新,包括内置了坏版本的新装用户,所以回滚一定落得到;设备侧没有"指回旧版本"的分支,心智上只有一个方向。代价是回滚要走一次构建与发布,比改指针慢几分钟。Expo 的 `rollBackToEmbedded` 指令不做:服务端不知道每个 App 版本内置的是哪个包,多 App 版本共存时该语义本来就含糊。 ### 2.9 完整性与来源 @@ -157,11 +157,11 @@ ADR-005 把热下发划出当期,条件是"内核稳定后另立 ADR"。Trendi | 兼容键 | 宿主声明的 `hostVersion`,作服务端路径;`engine` / `protocol` 只校验 | | 启用规则 | 下发包 `version ≠ installed` 且 `createdAt` 新于内置才装;App 升级带来更新的内置包时自动弃掉 installed;不能用下发把 App 降到比内置更老 | | 生效时机 | 下次进程启动 | -| 失败语义 | 下发包的页面报 E2 / E6 → 当场用内置包重挂这一页,整包持久化拉黑并上报;E5 不算失败。启动时重算 installed 包 sha256,不一致同样拉黑。进程级崩溃不在库的回退范围:出口是灰度 + 指针回滚 + "`check()` 先于任何页面挂载"的接入约定,挂载标记见 §4.3。一个 (runtime, page) 对永远来自同一个包;不同页面可以短暂来自不同包(各自独立 Runtime) | +| 失败语义 | 下发包的页面报 E2 / E6 → 当场用内置包重挂这一页,整包持久化拉黑并上报;E5 不算失败。启动时重算 installed 包 sha256,不一致同样拉黑。进程级崩溃不在库的回退范围:出口是灰度 + 回滚 + "`check()` 先于任何页面挂载"的接入约定,挂载标记见 §4.3。一个 (runtime, page) 对永远来自同一个包;不同页面可以短暂来自不同包(各自独立 Runtime) | | 库的边界 | `tinyui-updates` 独立 artifact;`Updates(packages: List, …)` 以一组包为单位做校验 / 落盘 / 选择 / 回退;不做网络、调度、UI;宿主给内置包列表、`fetch(path)`、`installId`、存储目录、`hostVersion`,不传公钥 | | 服务端协议 | 指针 + 内容;`///current.json`(`no-store`,只含 `version` / `rollout` / `signature`)+ `////…`(`immutable`,含 `manifest.json`);请求不带参数;base = `//`,组织不进 URL | | 灰度 | manifest `rollout` 百分比,客户端按 `installId` 掷骰 | -| 回滚 | 重发上一个好包;无 `rollBackToEmbedded` | +| 回滚 | 用旧内容发新版本,指针只往前走;无 `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` 子命令是全部管理面 | | 服务端 | 独立开源仓 `tinyui-updates-server`(MIT,托管前重评):Hono,单 R2 桶,`Storage` 接口后置;内容按 (app, pkg, hostVersion, version) 存,channel 是指针;托管实例 `updates.tinyui.app`,私有化 = 部署同一份代码;没有控制台 | @@ -175,7 +175,7 @@ App Store Review Guidelines 2.5.2 只豁免由 WebKit / JavaScriptCore 执行的 ### 4.2 首批租户 -TrendingAI 与第二个 App 都作为 `updates.tinyui.app` 的 app 接入,各自一个 `app` id、自己 bump 的 `hostVersion`;TrendingAI 一个包(`trendingai`,页面 `trendingai/subscription`),第二个 App 按业务线分包,每个包自己的密钥对与 token。宿主后端不需要任何改动,只配 base URL。发布由各包 JS 工程的 CI 完成:`tinyui bundle --signing-key` → `tinyui publish`。内置包是发 App 时用 `tinyui pull` 取的 `production` 当前那一版(updates.md §1.4),与线上是同一个 `version`:回滚到 App 自带的那版即回到内置(updates.md §4.3)。TrendingAI 的页面模块名已从 `pages/…` 改为 `trendingai/…`(M6)。 +TrendingAI 与第二个 App 都作为 `updates.tinyui.app` 的 app 接入,各自一个 `app` id、自己 bump 的 `hostVersion`;TrendingAI 一个包(`trendingai`,页面 `trendingai/subscription`),第二个 App 按业务线分包,每个包自己的密钥对与 token。宿主后端不需要任何改动,只配 base URL。发布由各包 JS 工程的 CI 完成:`tinyui bundle --signing-key` → `tinyui publish`。内置包只求兼容:是给当前 `hostVersion` 发布过的某个版本,由宿主仓用 `tinyui pull` 刷新(updates.md §1.4),新旧尽力而为。TrendingAI 的页面模块名已从 `pages/…` 改为 `trendingai/…`(M6)。 ### 4.3 推迟项(记 roadmap D 组) diff --git a/docs/updates-plan.md b/docs/updates-plan.md index 3e8ddbf..e265edf 100644 --- a/docs/updates-plan.md +++ b/docs/updates-plan.md @@ -113,7 +113,11 @@ 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) | +| 11 | ~~指针指向不新于内置的版本~~ | ~~回到内置(`Reverted`);内置包取 `production` 当时那版~~ 2026-09-24 被第 12、15 点取代 | +| 12 | 内置包的口径 | 只要求兼容:是给当前 `hostVersion` 发布过的某个版本,与宿主引擎、协议、tinyui 一致,用到的宿主东西宿主都有;是否等于当前 `production` 不作要求(新旧尽力而为)。宿主仓用 `tinyui pull` 刷新,信任锚是已提交的内置包(包名、公钥),换钥须 `--accept-key`;灰度中或不更新时保持现状(updates.md §1.4) | +| 13 | 宿主快照何时上传 | `hostVersion` 加 1 合入宿主 main 时由 main 的 CI 上传并冻结,发 App 时的上传只作幂等复核;合入后契约不能再改,只能再加 1(updates.md §4.1、§6.4) | +| 14 | 内置包不兼容 | 库不再抛错:发 `EmbeddedIncompatible`,页面走 `error` 槽,`check()` 装上兼容版本后恢复。发版前由宿主测试用 `PackageCheck` 拦(updates.md §4.1、§4.4) | +| 15 | 回滚 | 指针只往前走:服务端拒绝把指针指向不更新的版本;回滚 = 用旧内容发一个新 version 再晋级,所有设备都收得到。删 `releases rollback`(updates.md §6.2) | 交付: @@ -123,7 +127,7 @@ M1 之后 M2 → M3 与 M4 并行;M5.1、M5.2 与 tinyui 0.3.0(npm 三包 + - **服务端(线上,不可逆,执行前列命令确认)**:`apps create trendingai --name TrendingAI`(`org` 留空);签一枚 app token 给 TrendingAI 宿主 CI 上传快照;`packages create trendingai --app trendingai`(登记公钥);两枚发布 token,CI 用的限 `staging`,production environment 用的限 `production` - **TrendingAI**:依赖升到带 `TinyUIHost` 的 tinyui(0.4.0)并加 `tinyui-updates`;宿主的 TinyUI 入口改用 `Updates(listOf(embedded), hostVersion = "1", dir, installId = getOrCreateInstallId(), fetch = ktor)`,base URL `https://updates.tinyui.app/trendingai/`;设置页隐藏的 channel 开关(持久化,切回即恢复 `production`);订阅页改用 `UpdatesPage`,`PAGE = "trendingai/subscription"`;App 启动即 `check()`(先于任何 TinyUI 页挂载);`onEvent` 接埋点;五个能力搬进 App 级 `CapabilityRegistry`(`ui.snackbar` 经 `PageLocal` 取当前屏的 snackbar),`shared/tinyui-host/1.txt` 与快照测试进 CI,发版时 CI 把快照上传到热下发服务;内置包改为发 App 时从服务端拉当时 `production` 的那一版进 `composeResources/files/tinyui/trendingai/`(`pnpm sync` 只再生成 `HostSchemas.kt`),脚本还是 CLI 子命令做时定 -验收:合 main → CI 发 `staging` → 商店版 App 切到 staging 看到新文案 → 批准 workflow 晋级 `production` → 切回、重启看到;往 staging 发坏包 → 回退到内置并在埋点里看到 `RolledBack`;production workflow `rollback` → 重启回到上一版;宿主加一个能力而不加 `hostVersion` → 宿主 CI 失败。 +验收:合 main → CI 发 `staging` → 商店版 App 切到 staging 看到新文案 → 批准 workflow 晋级 `production` → 切回、重启看到;往 staging 发坏包 → 回退到内置并在埋点里看到 `RolledBack`;回滚:JS 仓 revert → 发新版本 → 晋级 → 重启回到旧内容(第 15 点);宿主加一个能力而不加 `hostVersion` → 宿主 CI 失败。 ## M7 · 第二个 App diff --git a/docs/updates.md b/docs/updates.md index fad6d33..fefcfd6 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -41,7 +41,7 @@ pages/**/*.bin | `requires` | `tinyui build` | 页面模块名 → `{ components, capabilities }`:该页用到的带点宿主组件名与 `host.call` 能力名,各自排序;内置组件不列。`publish` 据此核对目标宿主版本(§1.3),客户端不读 | | `hostVersion` | `tinyui bundle` | 发布目标,等于宿主声明值;写入后文件定稿,签名覆盖它的原始字节(§7) | -签名值与 `rollout` 不在 manifest 里,在指针文件 `current.json`(§1.2)。内置包的 manifest 没有 `hostVersion`,也没有指针文件——宿主知道自己的 hostVersion,内置包不参与灰度、不验签。 +签名值与 `rollout` 不在 manifest 里,在指针文件 `current.json`(§1.2)。内置包没有指针文件,不参与灰度、不验签;库不读内置包 manifest 的 `hostVersion`(宿主知道自己的值)。它由 `tinyui pull` 取来时带着这个字段,宿主的发版检查用它确认内置包是给当前宿主版本发布过的(§1.4);本地构建的包没有这个字段。 ### 1.2 `tinyui bundle` @@ -71,21 +71,29 @@ dist/ota////pages/**/*.bin | 输入 | 来源 | |---|---| | 包用到了什么 | `tinyui build` 从每页打包产物里静态找出,写进 manifest 的 `requires`(页 → `{ components, capabilities }`):`host.call` 的能力名、带点的宿主组件名(`ta.Icon`);内置组件不列,随 tinyui 版本走(规则见 build-chain.md §5.1) | -| 目标宿主版本提供了什么 | 宿主快照 `tinyui-host/.txt`(宿主组件、能力名、tinyui 版本)。宿主发 App 版本时由其 CI 上传到热下发服务,每个 (app, hostVersion) 只能写一次,与已随发版冻结的快照一致;`publish` 从服务端读 | +| 目标宿主版本提供了什么 | 宿主快照 `tinyui-host/.txt`(宿主组件、能力名、tinyui 版本)。`hostVersion` 加 1 合入宿主 main 时由宿主 CI 上传到热下发服务,每个 (app, hostVersion) 只能写一次;`publish` 从服务端读 | 不通过即拒绝发布(一个请求都不上传),报出哪一页缺了什么:页面用到的能力或宿主组件不在快照里;或 manifest 的 `tinyui` 不等于快照里的 tinyui 版本(新版 tinyui 的内置组件,旧宿主没有)。目标宿主版本还没有快照(宿主没上传过)也拒绝。 核对的是名字,覆盖不到已有能力的参数形状与行为,那部分仍按 §4.1 的 bump 规则由人判断。绕过 `publish` 直接调发布端点的,目前拦不住;`requires` 进 manifest 并受签名覆盖,日后服务端在 `PUT current.json` 时同样可以核对。快照的上传与读取见 §6.4。 -### 1.4 发 App 时取内置包:`tinyui pull` +### 1.4 内置包:宿主用 `tinyui pull` 刷新 ``` -tinyui pull --channel production --host-version --out <宿主资源目录>/ [--app ] [--root ] +tinyui pull --channel production --host-version --out <宿主资源目录>/ [--app ] [--pkg

--accept-key ] ``` -内置包取服务端上 `production` 当前指向的那一版,而不是本地重新构建一份(M6 拍板第 11 点):线上与 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)。 +内置包是 App 的地板,只要求**兼容**:是给当前 `hostVersion` 发布过的某个版本,引擎、协议、tinyui 版本与宿主一致,用到的宿主东西宿主都有(宿主测试用 `PackageCheck` 核对,§4.1)。它是不是当前的 `production` 不作要求,新旧尽力而为:旧一点,新装用户首启后 `check()` 会补上(M6 拍板第 12 点)。 -宿主仓不跑 Node 时(F-Droid 从源码构建),由 JS 工程在发 App 前跑它并把结果提交进宿主仓。 +`pull` 在宿主仓里跑(宿主的发版冒烟脚本顺手调用),把 `--channel` 当前指向的版本拉进内置包目录: + +- 信任锚是已提交的内置包:包名与验签公钥取 `--out` 里现有的 `manifest.json`。下载的包换了公钥即拒绝,确认过轮换才用 `--accept-key <新公钥>` 放行;JS 仓单方面换钥不会悄悄进入宿主。第一次拉取(目录为空)要显式给 `--pkg` 与 `--accept-key` +- 指针正在灰度(`rollout` < 100)时保持现状:灰度中的版本不能成为每个新装用户的地板 +- 指针指向的版本不新于现有内置包(同一 `hostVersion` 下比 `createdAt`)时保持现状 +- 像设备一样验签、核对 name / version / hostVersion、逐文件核对 sha256,任一步不过就一个文件都不写;换入时先备份旧目录,失败即还原 +- 走公开投递端点,不要 token。输出与 `tinyui build` 产物同布局(`manifest.json`、`runtime/`、`pages/`),manifest 是下载来的原始字节;包里没有 `.js.map`,内置包的栈同热下发的包一样按 `buildId` 离线对映射(build-chain.md §7) + +宿主仓里的是提交的字节,F-Droid 从源码构建时不需要 Node。 ## 2. 投递协议 @@ -101,7 +109,7 @@ tinyui pull --channel production --host-version --out <宿主资源目录>/< - 请求不带 query、不带自定义 header、不带任何设备信息;宿主的 `fetch` 实现可以自行加 header 或按路径分流到不同来源,库不知情 - 同一 `` 目录下的文件永不改内容(改内容必换 version),CDN 因此不会陈旧 - 无更新时客户端只下载指针(约 100 字节);`manifest.json` 与文件只在有新 version 且中签时才取 -- 回滚 = `current.json` 的 `version` 与 `signature` 换回上一版;灰度 = 改 `rollout`;两者都不碰 `/` 下的任何文件 +- 指针只往前走:回滚 = 用旧内容发一个新 version(§6.2);灰度 = 改 `rollout`,不碰 `/` 下的任何文件 - 投递面没有鉴权:包在安装包里本来就能反出来;来源与完整性由签名(§7)保证 ### 2.1 托管与私有化实例 @@ -159,18 +167,21 @@ class Updates( **`hostVersion` 的口径**(作用同 Expo 的 `runtimeVersion`,但只取正整数、数的是宿主的变化;不沿用那个名字,是因为 tinyui 里 runtime 已指运行时模块与引擎):宿主仓持有的递增正整数字符串(`"1"`、`"2"`……;`1.2.0` 这类 App 版本号在 CLI、`Updates`、服务端三处都被拒),语义是"这个值下发布的任何包都能在本宿主上跑";一个宿主一个值,挂在它上面的所有包共用。宿主给页面的东西变了就加 1:宿主组件增删或改 schema;宿主能力增删,或改已有能力的参数与行为;升级 tinyui。只改宿主内部实现、其他原生页面、修崩溃,不加。包的 JS 工程发布时由 `tinyui bundle --host-version` 取这个值,不自行推导。JS 侧取错分两个方向:取成不存在或更新的值,包只是送不到(`releases list` 与客户端事件可见);取成一个仍有设备在用的旧值,旧宿主会照常装上,而页面可能用到它没有的东西——宿主构建的检查拦不住这个方向,由 `publish` 发布前核对(§1.3)。 -漏加的后果是旧宿主收到跑不了的页面(不崩溃的错误不会触发 §4.5 回退),多加的后果是更早的宿主从此收不到更新。漏加只可能发生在宿主仓,由宿主构建拦:宿主快照 `tinyui-host/.txt`(宿主组件 schema、能力名、tinyui 版本)每个版本一份入库,当前宿主与当前版本的快照不符即构建失败;已随发版带出去的版本,其快照冻结不可改。快照覆盖不到已有能力的参数与行为变化,这部分按上面的规则由人判断。为什么是精确匹配而不是 `>=` 范围,见 ADR-006 §2.2。 +漏加的后果是旧宿主收到跑不了的页面(不崩溃的错误不会触发 §4.5 回退),多加的后果是更早的宿主从此收不到更新。漏加只可能发生在宿主仓,由宿主构建拦:宿主快照 `tinyui-host/.txt`(宿主组件 schema、能力名、tinyui 版本)每个版本一份入库,当前宿主与当前版本的快照不符即构建失败;已合入 main 的版本,其快照冻结不可改。快照覆盖不到已有能力的参数与行为变化,这部分按上面的规则由人判断。为什么是精确匹配而不是 `>=` 范围,见 ADR-006 §2.2。 快照由 `HostSnapshot.render(host, hostVersion)` 从宿主的 `TinyUIHost` 生成(所以能力必须在 App 级注册,native-api.md §7),检查写成宿主自己的 host 侧单元测试,照 sample 的 `HostSnapshotTest`: - 当前 `hostVersion` 的快照文件不存在,或与 `render` 结果不同 → 失败,提示加 `hostVersion` -- 加 `-Ptinyui.updateHostSnapshot` 跑同一个测试 → 写入当前结果。这等于人声明"这个版本还没随发版带出去";本地不判断版本是否已发,冻结由服务端兜底:`hosts upload` 同版本不同字节得 409(§6.4),发版 CI 在那里失败 +- 加 `-Ptinyui.updateHostSnapshot` 跑同一个测试 → 写入当前结果。这等于人声明"这个版本还没合入 main";本地不判断,冻结由服务端兜底:`hosts upload` 同版本不同字节得 409(§6.4) +- 宿主 main 的 CI 在每次 push 时跑这个测试并 `hosts upload`(同字节幂等),`hostVersion` 加 1 的提交一进 main 快照即上传、冻结(M6 拍板第 13 点)。上传不等到发 App:发版前要先在新版本下发布包(内置包、staging 验证都要),而发布前要读快照 - 快照文件在 `.gitattributes` 里固定 `eol=lf`:上传的是原始字节,CRLF 的副本会被当成另一份快照而得 409 - `hostVersion` 在宿主里只写一处常量,`Updates(hostVersion = …)` 与这个测试都读它 测试只构造 `TinyUIHost`、不执行任何能力与组件,所以能在 JVM 上跑;能力实现需要的仓库类对象在测试里传假的即可。 -**接入约定:`check()` 在任何 TinyUI 页面挂载之前调用**(App 启动即调)。指针回滚对已装上的用户生效靠的是下一次 `check()` 装上回退版本;页面若先于它崩溃,回滚永远送不到。进程级崩溃(native 段错误)不在 §4.5 的回退范围内,出口就是灰度 + 指针回滚 + 这条约定;挂载标记见 ADR-006 §4.3 推迟项。 +内置包另有一个宿主测试(照 sample 的 `EmbeddedPackageTest`):对每个内置包的 manifest 跑 `PackageCheck.problems(manifest, host)`,要求为空;内置包取自服务端的宿主再断言 `manifest.hostVersion` 等于当前 `hostVersion`(§1.4)。后一条在 `hostVersion` 刚加 1、JS 还没在新版本下发布时必然不过,所以它是发版检查,由宿主的发版脚本与发版 CI 跑,不进日常测试。 + +**接入约定:`check()` 在任何 TinyUI 页面挂载之前调用**(App 启动即调)。回滚(一个新 version)对已装上的用户生效靠的是下一次 `check()` 装上它;页面若先于它崩溃,回滚永远送不到。进程级崩溃(native 段错误)不在 §4.5 的回退范围内,出口就是灰度 + 回滚 + 这条约定;挂载标记见 ADR-006 §4.3 推迟项。 文件与 sha256 用 okio(`FileSystem` + `ByteString.sha256`),是本 artifact 独有的依赖,core 库不引入。ECDSA 验签走平台 API(§7)。 @@ -183,15 +194,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)` / `Reverted(version)` / `Skipped(reason)` / `Failed(stage, cause)`。`UpdateEvent` 是同一组事实加 `Running` 与 `RolledBack`,给宿主打日志与埋点;**所有事件都带 `pkg`**。宿主的分析口径会依赖它,所以发布后字段同样只增不改: +`CheckResult`:`UpToDate` / `Installed(version)` / `Skipped(reason)` / `Failed(stage, cause)`。`UpdateEvent` 是同一组事实加 `Running`、`EmbeddedIncompatible` 与 `RolledBack`,给宿主打日志与埋点;**所有事件都带 `pkg`**。宿主的分析口径会依赖它,所以发布后字段同样只增不改: | 事件 | 何时 | 字段(均含 `pkg`) | |---|---|---| +| `EmbeddedIncompatible` | 构造时,内置包不能在本宿主上跑 | `version`、`mismatch`:`ENGINE` / `PROTOCOL` 的集合(§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` 的集合 | | `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` | @@ -207,9 +218,9 @@ class Updates( | | 下载量对照 | 服务端数到的 `/` 下载次数 vs 客户端上报的 `Installed`,对不上即上报链路或 CDN 有问题 | 投递日志 + `Installed` | 二期 | | 灰度决策 | 灰度命中率 | 中签比例是否贴近 `rollout`,偏离即宿主 `installId` 有问题 | `Skipped(ROLLOUT)` vs `Installed` | MVP | | | 灰度期健康对比 | 灰度人群与内置包人群的回退率、失败率对比,决定推 100% 还是回滚 | `RolledBack`、`Failed` 按 version 分组 | MVP | -| | 拨杆就地操作 | 曲线旁直接改 `rollout` 或回滚指针(§6.2 的 UI 版) | 发布协议 | MVP | +| | 拨杆就地操作 | 曲线旁直接改 `rollout`,或一键触发回滚(发布方 CI 用旧内容重发一个新 version 再晋级;私钥只在发布方,服务端不能自己签,§6.2) | 发布协议 | MVP | | 止血 | 回退率告警 | `RolledBack / Installed` 超阈值即通知(webhook / 邮件) | `RolledBack`、`Installed` | 二期 | -| | 自动止血 | 超阈值自动把 rollout 拨 0 或指针退回上一个好版本,按包开关与阈值 | 同上 + 发布协议 | 二期 | +| | 自动止血 | 超阈值自动把 rollout 拨 0(止住新的安装),并通知发布方回滚,按包开关与阈值 | 同上 + 发布协议 | 二期 | | | 失败原因分布 | `Failed` 按 stage、`Skipped` 按 reason 分布——发错目录、公钥没换、CDN 缓存坏各有各的形状 | `Failed.stage`、`Skipped.reason` | MVP | | 排障 | 回退关联的页面错误 | `RolledBack` 的 kind / page / buildId 聚合,可对回 source map | `RolledBack` | 二期 | | | 兼容性错配 | 各不匹配项各多少台;App 新版发出后旧 hostVersion 还剩多少活跃设备,决定何时停发旧 hostVersion | `Skipped.mismatch` | 二期 | @@ -226,7 +237,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 + ├─ version == embedded.version(内置可用)──────────────────────────▶ UpToDate // 不取 manifest、不看 rollout ├─ hash(installId + ":" + pkg + ":" + version) % 100 ≥ rollout ─────▶ Skipped(rollout) │ ▼ fetch ///manifest.json,拿到原始字节 @@ -234,7 +245,7 @@ fetch //current.json ─解析失败──────── ├─ 解析失败 ────────────────────────────────────────────────────────▶ Failed(manifest) ├─ name ≠ pkg / version ≠ 指针 version / hostVersion ≠ 宿主值 │ / engine ≠ QuickJs.upstreamCommit / protocol ≠ PROTOCOL ─────────▶ Skipped(incompatible) // 发错目录,上报 - ├─ createdAt ≤ embedded.createdAt ─有 installed─删 installed─────────▶ Reverted // 否则 Skipped(older-than-embedded) + ├─ createdAt ≤ embedded.createdAt(内置可用)────────────────────────▶ Skipped(older-than-embedded) │ ▼ 逐文件 fetch ///.bin → /staging//,每个核对 sha256 ├─ 任一失败 ─删 staging──────────────────────────────────────────────▶ Failed(download | integrity) @@ -244,7 +255,7 @@ 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 都不取,每次启动只多一个指针请求。 +指针只往前走(§6.2),所以正常流程里指针不会指向比已装版本更旧的版本;`older-than-embedded` 只出现在 App 升级带来了比线上更新的内置包时。 ### 4.4 启动选择 @@ -260,6 +271,8 @@ manifest 一到手先验签再解析:签名不对的 manifest 里任何字段 构造时对每个包:`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 毫秒。 +内置包的引擎或协议与宿主不符时不抛错(M6 拍板第 14 点):发 `EmbeddedIncompatible`,该内置包只在名义上是地板——它的页面加载即 E6、走宿主的 `error` 槽,不参与上面的 `createdAt` 比较,也不参与 §4.3 的"等于内置版本"与 older-than-embedded;`check()` 照常跑,装上兼容的版本后下次启动恢复。这只是兜底,发版前由宿主测试(§4.1)拦住。 + ### 4.5 失败回退 `UpdatesPage` 挂的页面来自某包的 installed 且报 E2 / E6(`PageHost.failure`)时:该包 `failed += version`,`state.installed = null`,`onEvent(RolledBack)`,随后用该包的 `embedded.page(name)` 重挂同一页面(`TinyUIPage` 的 `remember` 键含 `page`,换 `PageModule` 即重建引擎)。E2 / E6 本身照常经 `PageSink.error` 上报,带 `buildId`。其他包不受影响。 @@ -309,9 +322,11 @@ app token 与发布 token 一样可以签多个、只返回一次明文、服务 | 请求 | 语义 | |---|---| | `GET ////releases` | 已发布的 version 列表:`createdAt`(包的构建时刻,来自 manifest)、`publishedAt`(服务端记下的发布时刻)、各 channel 的指针与 `rollout` | -| `POST /////pointer`,body `{ "version": …, "rollout"?: … }` | 指针指向 (app, pkg, hostVersion) 下任一已发布 version——回滚与跨 channel 晋级是同一个操作;或只改当前指针的 `rollout`。服务端用记录里该 version 的 `signature` 重写 `current.json` | +| `POST /////pointer`,body `{ "version": …, "rollout"?: … }` | 晋级:指针指向 (app, pkg, hostVersion) 下已发布、且比当前更新的 version;或只改当前指针的 `rollout`。服务端用记录里该 version 的 `signature` 重写 `current.json` | + +服务端按 version 保存 release 记录(`createdAt`、`signature`),指针切换不需要重新上传;改 `rollout` 只改指针,签名不受影响(§7)。CLI:`tinyui releases list` / `rollout

` / `promote --to `。 -服务端按 version 保存 release 记录(`createdAt`、`signature`),指针切换不需要重新上传;改 `rollout` 只改指针,签名不受影响(§7)。CLI:`tinyui releases list` / `rollback ` / `rollout

` / `promote --to `。 +**指针只往前走**(M6 拍板第 15 点):发布(`PUT current.json`)与晋级都拒绝把指针指向 `createdAt` 不新于当前指向的版本(409),同一 version 重发、只改灰度不算移动。回滚 = 在 JS 仓 revert、发布一个内容等于旧版的新 version、晋级——它比所有设备上的版本都新,包括内置了坏版本的新装用户,所以回滚一定落得到;设备侧也就不需要"指回旧版本"的分支。 ### 6.3 管理 app 与包 @@ -336,7 +351,7 @@ CLI:`tinyui apps create` / `tinyui apps tokens create|revoke` / `tinyui packag ### 6.4 宿主快照 -宿主快照(§1.3、§4.1)是 `tinyui-host/.txt` 的原始字节:该宿主版本提供的宿主组件、能力名与 tinyui 版本。宿主发 App 版本时由其 CI 上传,`publish` 发布前读取核对。格式是宿主侧生成工具与 CLI 之间的契约: +宿主快照(§1.3、§4.1)是 `tinyui-host/.txt` 的原始字节:该宿主版本提供的宿主组件、能力名与 tinyui 版本。`hostVersion` 加 1 合入宿主 main 时由其 CI 上传(§4.1),`publish` 发布前读取核对。格式是宿主侧生成工具与 CLI 之间的契约: ``` hostVersion 2 @@ -361,10 +376,10 @@ capabilities | 请求 | 凭据 | 语义 | |---|---|---| -| `PUT /apps//hosts/`,body 为快照字节 | `ADMIN_TOKEN` 或本 app 的 app token | 每个 (app, hostVersion) 只写一次:同字节重传返回已存在,不同字节 → 409——已随发版带出的宿主版本,其快照冻结 | +| `PUT /apps//hosts/`,body 为快照字节 | `ADMIN_TOKEN` 或本 app 的 app token | 每个 (app, hostVersion) 只写一次:同字节重传返回已存在,不同字节 → 409——已合入 main 的宿主版本,其快照冻结 | | `GET /apps//hosts/` | 以上两种,或本 app 任一包的发布 token | 原字节;没上传过 → 404,`publish` 据此拒绝发布 | -CLI:`tinyui hosts upload --host-version [--app ]`(宿主 CI 用)。 +CLI:`tinyui hosts upload --host-version [--app ]`(宿主 main 的 CI 用)。 ## 7. 签名 diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 20e99e3..e042dc9 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -19,7 +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 pull --channel --host-version --out [--app ] [--pkg

--accept-key ] tinyui apps create --name [--org ] tinyui apps tokens create tinyui apps tokens revoke --id @@ -29,7 +29,6 @@ const USAGE = `usage: tinyui build [--root

] [--out ] [--qjsc ] tinyui tokens create --channels [--app ] tinyui tokens revoke --id [--app ] tinyui releases list --host-version [--pkg

] [--app ] [--json] - tinyui releases rollback --host-version --channel [--pkg

] [--app ] tinyui releases rollout <0-100> --host-version --channel [--pkg

] [--app ] tinyui releases promote --host-version --to [--pkg

] [--app ] @@ -62,10 +61,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) +pull refresh a host's embedded package to what a channel points at (docs/updates.md §1.4); run in the host repo + --channel the channel to take it from, usually production; never defaulted + --out the embedded package directory: its manifest.json names the package and the key to trust; + replaced as a whole, kept when the channel is mid-rollout or not newer + --pkg only for the first pull into an empty --out + --accept-key trust this key instead of the embedded package's: the first pull, or a checked rotation no token: it reads the public delivery endpoints the publishing and management commands talk to --url, default ${DEFAULT_URL} (or $TINYUI_UPDATES_URL) @@ -218,15 +219,16 @@ 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`); + const pkg = v["pkg"] === undefined ? undefined : requireName("--pkg", v["pkg"] as string); + const acceptKey = v["accept-key"] as string | undefined; + const result = await pull({ url: resolveUrl(v["url"] as string | undefined), app: await appId(v), channel, hostVersion, out, ...(pkg && { pkg }), ...(acceptKey && { acceptKey }) }); + process.stderr.write(result.changed ? `${out} -> ${result.version}\n` : `${out} kept at ${result.version}: ${result.reason}\n`); process.stdout.write(`${result.version}\n`); return 0; }, @@ -298,15 +300,16 @@ async function releases(v: Values, positionals: string[]): Promise { } const target = { app, pkg, hostVersion, channel: "" }; let body: { version?: string; rollout?: number }; - if (action === "rollback" || action === "promote") { - const version = pathSegment(required(positionals[2], `releases ${action}: a version is required`), "a version"); - target.channel = requireName("the channel", required(action === "promote" ? v["to"] : v["channel"], `releases ${action}: ${action === "promote" ? "--to" : "--channel"} is required`)); + if (action === "promote") { + const version = pathSegment(required(positionals[2], "releases promote: a version is required"), "a version"); + target.channel = requireName("the channel", required(v["to"], "releases promote: --to is required")); body = { version }; } else if (action === "rollout") { target.channel = requireName("--channel", required(v["channel"], "releases rollout: --channel is required")); body = { rollout: percentage(required(positionals[2], "releases rollout: a percentage is required"), "releases rollout") }; } else { - throw new Error("releases: expected list, rollback, rollout or promote"); + // a rollback is a new version with the old content: pointers only move forward (docs/updates.md §6.2) + throw new Error("releases: expected list, rollout or promote"); } const pointer = await movePointer(client, target, body); process.stderr.write(`${app}/${pkg}/${hostVersion} ${target.channel} -> ${pointer.version} at rollout ${pointer.rollout}\n`); diff --git a/packages/cli/src/pull.ts b/packages/cli/src/pull.ts index e81604b..da19302 100644 --- a/packages/cli/src/pull.ts +++ b/packages/cli/src/pull.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { mkdir, mkdtemp, rename, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, 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"; @@ -10,55 +10,78 @@ export interface PullOptions { 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/`. */ + /** The host's embedded package directory: its `manifest.json` names the package and the key to trust; replaced as a whole. */ out: string; + /** Only when [out] has no package yet. */ + pkg?: string; + /** A key to trust instead of the embedded package's: the first pull, or a rotation someone checked. */ + acceptKey?: string; /** Injected by the tests; defaults to the global `fetch`. */ fetch?: typeof globalThis.fetch; } export interface PullResult { + /** The embedded version after the pull. */ version: string; - files: string[]; + changed: boolean; + /** Why nothing changed. */ + reason?: string; +} + +interface Manifest { + name: string; + publicKey: string; + version: string; + createdAt: string; + hostVersion?: string; + runtime: string[]; + pages: string[]; + files: Record; + hashes: Record; } /** - * 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. + * Refreshes a host's embedded package to what [PullOptions.channel] points at (docs/updates.md §1.4), verified the + * way a device verifies it against the key the host already embeds. Keeps what is there when the channel is mid-rollout + * or not newer; writes nothing unless all of it checks out. */ export async function pull(options: PullOptions): Promise { + const out = resolve(options.out); + const current = await readManifest(join(out, "manifest.json")); + const pkg = options.pkg ?? current?.name; + if (!pkg) throw new Error(`${out} holds no package yet: pass --pkg and --accept-key for the first pull`); + if (current && current.name !== pkg) throw new Error(`${out} holds package ${current.name}, not ${pkg}`); + const trusted = options.acceptKey ?? current?.publicKey; + if (!trusted) throw new Error(`${out} holds no package yet: pass --accept-key for the first pull`); + const fetchImpl = options.fetch ?? globalThis.fetch; - const base = requireSecureUrl(options.url).replace(/\/+$/, "") + segments(options.app, options.channel, options.pkg, options.hostVersion); + const base = requireSecureUrl(options.url).replace(/\/+$/, "") + segments(options.app, options.channel, 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 pointer = JSON.parse(new TextDecoder().decode(await get("current.json"))) as { version?: unknown; rollout?: 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 keep = (reason: string): PullResult => ({ version: current?.version ?? "", changed: false, reason }); + // a version still rolling out is not yet what every user runs: it must not become the floor of every new install + const rollout = typeof pointer.rollout === "number" ? pointer.rollout : 100; + if (rollout < 100) return keep(`${options.channel} is at ${version} rolling out to ${rollout}%`); + if (current?.version === version) return keep(`already ${version}`); 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 Manifest; + if (!verify(trusted, manifestBytes, pointer.signature) || manifest.publicKey !== trusted) { + throw new Error(`${version} is not signed with the trusted key ${trusted}` + (manifest.publicKey !== trusted ? `; it names ${manifest.publicKey}: after checking the rotation, pass --accept-key ${manifest.publicKey}` : "")); } - 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.name !== pkg) throw new Error(`the manifest is for package ${manifest.name}, not ${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}`); + if (current?.hostVersion === options.hostVersion && manifest.createdAt <= current.createdAt) return keep(`embedded ${current.version} is not older than ${version}`); const files = new Map(); for (const module of [...manifest.runtime, ...manifest.pages]) { @@ -69,9 +92,22 @@ export async function pull(options: PullOptions): Promise { files.set(path, bytes); } files.set("manifest.json", manifestBytes); + await replaceDir(out, files); + return { version, changed: true }; +} - // assembled beside the target and swapped in: a failure at any step leaves the previous package in place - const out = resolve(options.out); +async function readManifest(file: string): Promise { + let text: string; + try { + text = await readFile(file, "utf8"); + } catch { + return null; + } + return JSON.parse(text) as Manifest; +} + +/** Assembled beside [out] and swapped in: a failure at any step leaves the previous package in place. */ +async function replaceDir(out: string, files: Map): Promise { await mkdir(dirname(out), { recursive: true }); const staging = await mkdtemp(`${out}.pulling-`); const backup = `${staging}.previous`; @@ -93,5 +129,4 @@ export async function pull(options: PullOptions): Promise { } // 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/admin.test.ts b/packages/cli/test/admin.test.ts index 7b8b949..3d90692 100644 --- a/packages/cli/test/admin.test.ts +++ b/packages/cli/test/admin.test.ts @@ -49,16 +49,16 @@ describe("management commands", () => { assert.deepEqual(last(), { method: "DELETE", path: "/apps/demo/packages/fixture/tokens/tok_1", auth: "Bearer admin-token", body: null }); }); - it("lists releases and moves the pointer for a rollback, a promotion and a rollout change", async () => { + it("lists releases and moves the pointer for a promotion and a rollout change", async () => { const releases = await listReleases(client, "demo", "fixture", "1"); assert.equal(last().path, "/demo/fixture/1/releases"); assert.equal(releases.versions.length, 2); assert.equal(releases.channels["staging"]?.rollout, 20); const target = { app: "demo", pkg: "fixture", hostVersion: "1" }; - const rolledBack = await movePointer(client, { ...target, channel: "production" }, { version: "20260921T080000Z-aaaaaa" }); + const promoted = await movePointer(client, { ...target, channel: "production" }, { version: "20260921T080000Z-aaaaaa" }); assert.equal(last().path, "/demo/production/fixture/1/pointer"); - assert.equal(rolledBack.version, "20260921T080000Z-aaaaaa"); + assert.equal(promoted.version, "20260921T080000Z-aaaaaa"); await movePointer(client, { ...target, channel: "staging" }, { rollout: 50 }); assert.deepEqual(last().body, { rollout: 50 }); diff --git a/packages/cli/test/pull.test.ts b/packages/cli/test/pull.test.ts index 2761882..798522d 100644 --- a/packages/cli/test/pull.test.ts +++ b/packages/cli/test/pull.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { 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"; @@ -10,17 +10,17 @@ import { fakeDist } from "./helpers.ts"; const pair = generateKeyPair(); const URL = "https://updates.example"; +const OLD = "20260922T090000Z-3f2a1c"; +const NEW = "20260923T090000Z-4b3c2d"; describe("tinyui pull", () => { let tmp: string; + /** `/server/fixture/1`: bundles as the delivery endpoints serve them; the pointer is written per test. */ 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/`; + const url = String(input); if (!url.startsWith(prefix)) return new Response("not found", { status: 404 }); try { return new Response(await readFile(join(hostDir, url.slice(prefix.length)))); @@ -29,54 +29,68 @@ describe("tinyui pull", () => { } }; - const options = (out: string) => ({ url: URL, app: "demo", channel: "production", pkg: "fixture", hostVersion: "1", publicKey: pair.publicKey, out, fetch }); + async function publish(version: string, createdAt: string, key = pair, rollout = 100): Promise { + const signingKey = join(tmp, `${version}.pem`); + await writeFile(signingKey, key.privateKeyPem); + const dist = await fakeDist(join(tmp, version), key.publicKey, (m) => { + m.version = version; + m.createdAt = createdAt; + }); + const result = await bundle({ dist, hostVersion: "1", signingKey, rollout, out: join(tmp, "server") }); + hostDir = result.dir; + } + + const into = (out: string, extra: object = {}) => ({ url: URL, app: "demo", channel: "production", hostVersion: "1", out, fetch, ...extra }); 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 = []; + it("needs the package and its key for the first pull, then trusts what it embedded", async () => { + await publish(OLD, "2026-09-22T09:00:00Z"); const out = join(tmp, "embedded"); - await mkdir(join(out, "pages"), { recursive: true }); - await writeFile(join(out, "pages", "removed.bin"), "stale"); + await assert.rejects(pull(into(out)), /pass --pkg and --accept-key/); - const result = await pull(options(out)); + const first = await pull(into(out, { pkg: "fixture", acceptKey: pair.publicKey })); + assert.deepEqual(first, { version: OLD, changed: true }); + assert.deepEqual((await readdir(out)).sort(), ["manifest.json", "pages", "runtime"]); + assert.deepEqual(await readFile(join(out, "manifest.json")), await readFile(join(hostDir, OLD, "manifest.json")), "the signed bytes, untouched"); + assert.equal((await pull(into(out))).reason, `already ${OLD}`); - assert.equal(result.version, "20260922T090000Z-3f2a1c"); - assert.deepEqual(result.files, ["manifest.json", "pages/home.bin", "pages/orders.bin", "runtime/core.bin", "runtime/native.bin"]); + await publish(NEW, "2026-09-23T09:00:00Z"); + assert.deepEqual(await pull(into(out)), { version: NEW, changed: true }); 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 () => { + it("keeps the embedded package while the channel rolls out, or when it is not newer", async () => { + await publish(NEW, "2026-09-23T09:00:00Z"); const out = join(tmp, "kept"); - await mkdir(out, { recursive: true }); - await writeFile(join(out, "marker"), "untouched"); + await pull(into(out, { pkg: "fixture", acceptKey: pair.publicKey })); - const other = generateKeyPair(); - await assert.rejects(pull({ ...options(out), publicKey: other.publicKey }), /does not verify/); + await publish("20260924T090000Z-5c4d3e", "2026-09-24T09:00:00Z", pair, 50); + assert.deepEqual(await pull(into(out)), { version: NEW, changed: false, reason: "production is at 20260924T090000Z-5c4d3e rolling out to 50%" }); - 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 publish("20260921T090000Z-000000", "2026-09-21T09:00:00Z"); + assert.equal((await pull(into(out))).changed, false, "an older version never replaces the floor"); + }); + + it("refuses a package signed with another key unless that key is accepted, and writes nothing on a bad file", async () => { + const out = join(tmp, "trusted"); + await publish(OLD, "2026-09-22T09:00:00Z"); + await pull(into(out, { pkg: "fixture", acceptKey: pair.publicKey })); - await assert.rejects(pull({ ...options(out), hostVersion: "2" }), /404/); - assert.equal(await readFile(join(out, "marker"), "utf8"), "untouched"); + const rotated = generateKeyPair(); + const ROTATED = "20260925T090000Z-6d5e4f"; + await publish(ROTATED, "2026-09-25T09:00:00Z", rotated); + await assert.rejects(pull(into(out)), (e: Error) => e.message.includes(`pass --accept-key ${rotated.publicKey}`)); + assert.equal(JSON.parse(await readFile(join(out, "manifest.json"), "utf8")).version, OLD); + + const page = join(hostDir, ROTATED, "pages", "home.bin"); + await writeFile(page, "tampered"); + await assert.rejects(pull(into(out, { acceptKey: rotated.publicKey })), /pages\/home\.bin does not match manifest\.hashes/); + assert.equal(JSON.parse(await readFile(join(out, "manifest.json"), "utf8")).version, OLD); }); }); diff --git a/sample/shared/build.gradle.kts b/sample/shared/build.gradle.kts index d5cc484..c29fd07 100644 --- a/sample/shared/build.gradle.kts +++ b/sample/shared/build.gradle.kts @@ -36,12 +36,7 @@ kotlin { } } -// HostSnapshotTest compares against tinyui-host/ here; -Ptinyui.updateHostSnapshot makes it write instead val updateHostSnapshot = providers.gradleProperty("tinyui.updateHostSnapshot") -tasks.withType().configureEach { - inputs.files(layout.projectDirectory.dir("tinyui-host")).withPropertyName("hostSnapshots") - updateHostSnapshot.orNull?.let { systemProperty("tinyui.updateHostSnapshot", "true") } -} // JS 侧:pnpm 编三个包 → tinyui build 把每个示例包的页面与运行时模块编成字节码 → 只把 .bin 与清单打成 Compose 资源 // 两个包演示多包模型(docs/updates.md §0):各自的 JS 工程、密钥、资源子目录 @@ -110,3 +105,14 @@ compose.resources { packageOfResClass = "app.tinyui.sample.res" customDirectory("commonMain", layout.dir(collectTinyUIResources.map { it.destinationDir })) } + +// HostSnapshotTest compares against tinyui-host/ here, -Ptinyui.updateHostSnapshot makes it write instead; +// EmbeddedPackageTest reads the packages this build embeds +tasks.withType().configureEach { + inputs.files(layout.projectDirectory.dir("tinyui-host")).withPropertyName("hostSnapshots") + updateHostSnapshot.orNull?.let { systemProperty("tinyui.updateHostSnapshot", "true") } + dependsOn(collectTinyUIResources) + inputs.dir(tinyUIResourcesRoot).withPropertyName("embeddedPackages") + val embedded = tinyUIResourcesRoot.map { it.dir("files/tinyui").asFile.path } + doFirst { systemProperty("tinyui.embedded", embedded.get()) } +} diff --git a/sample/shared/src/androidHostTest/kotlin/app/tinyui/sample/EmbeddedPackageTest.kt b/sample/shared/src/androidHostTest/kotlin/app/tinyui/sample/EmbeddedPackageTest.kt new file mode 100644 index 0000000..4b4f603 --- /dev/null +++ b/sample/shared/src/androidHostTest/kotlin/app/tinyui/sample/EmbeddedPackageTest.kt @@ -0,0 +1,19 @@ +package app.tinyui.sample + +import app.tinyui.BuildManifest +import app.tinyui.PackageCheck +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals + +/** Every package this App embeds can run on its host (docs/updates.md §4.1). */ +class EmbeddedPackageTest { + @Test + fun embeddedPackagesRunOnThisHost() { + val root = File(System.getProperty("tinyui.embedded")) + for (pkg in listOf("sample", "sample-extra")) { + val manifest = BuildManifest.parse(root.resolve("$pkg/manifest.json").readText()) + assertEquals(emptyList(), PackageCheck.problems(manifest, tinyUIHost), "embedded package $pkg") + } + } +} diff --git a/sample/shared/src/androidHostTest/kotlin/app/tinyui/sample/HostSnapshotTest.kt b/sample/shared/src/androidHostTest/kotlin/app/tinyui/sample/HostSnapshotTest.kt index 6288c75..c1952b4 100644 --- a/sample/shared/src/androidHostTest/kotlin/app/tinyui/sample/HostSnapshotTest.kt +++ b/sample/shared/src/androidHostTest/kotlin/app/tinyui/sample/HostSnapshotTest.kt @@ -17,7 +17,7 @@ class HostSnapshotTest { file.writeText(current) return } - val hint = "if $HOST_VERSION has not shipped yet, rerun with -Ptinyui.updateHostSnapshot; otherwise raise HOST_VERSION and do that" + val hint = "if HOST_VERSION $HOST_VERSION is not on main yet, rerun with -Ptinyui.updateHostSnapshot; otherwise raise HOST_VERSION and do that" assertTrue(file.exists(), "no ${file.path} for HOST_VERSION $HOST_VERSION; $hint") assertEquals(file.readText(), current, "the host no longer matches ${file.path}; $hint") } diff --git a/updates/api/updates.klib.api b/updates/api/updates.klib.api index 6f76040..28a6f2a 100644 --- a/updates/api/updates.klib.api +++ b/updates/api/updates.klib.api @@ -103,19 +103,6 @@ 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] @@ -153,6 +140,25 @@ sealed class app.tinyui.updates/UpdateEvent { // app.tinyui.updates/UpdateEvent| abstract val pkg // app.tinyui.updates/UpdateEvent.pkg|{}pkg[0] abstract fun (): kotlin/String // app.tinyui.updates/UpdateEvent.pkg.|(){}[0] + final class EmbeddedIncompatible : app.tinyui.updates/UpdateEvent { // app.tinyui.updates/UpdateEvent.EmbeddedIncompatible|null[0] + constructor (kotlin/String, kotlin/String, kotlin.collections/Set) // app.tinyui.updates/UpdateEvent.EmbeddedIncompatible.|(kotlin.String;kotlin.String;kotlin.collections.Set){}[0] + + final val mismatch // app.tinyui.updates/UpdateEvent.EmbeddedIncompatible.mismatch|{}mismatch[0] + final fun (): kotlin.collections/Set // app.tinyui.updates/UpdateEvent.EmbeddedIncompatible.mismatch.|(){}[0] + final val pkg // app.tinyui.updates/UpdateEvent.EmbeddedIncompatible.pkg|{}pkg[0] + final fun (): kotlin/String // app.tinyui.updates/UpdateEvent.EmbeddedIncompatible.pkg.|(){}[0] + final val version // app.tinyui.updates/UpdateEvent.EmbeddedIncompatible.version|{}version[0] + final fun (): kotlin/String // app.tinyui.updates/UpdateEvent.EmbeddedIncompatible.version.|(){}[0] + + final fun component1(): kotlin/String // app.tinyui.updates/UpdateEvent.EmbeddedIncompatible.component1|component1(){}[0] + final fun component2(): kotlin/String // app.tinyui.updates/UpdateEvent.EmbeddedIncompatible.component2|component2(){}[0] + final fun component3(): kotlin.collections/Set // app.tinyui.updates/UpdateEvent.EmbeddedIncompatible.component3|component3(){}[0] + final fun copy(kotlin/String = ..., kotlin/String = ..., kotlin.collections/Set = ...): app.tinyui.updates/UpdateEvent.EmbeddedIncompatible // app.tinyui.updates/UpdateEvent.EmbeddedIncompatible.copy|copy(kotlin.String;kotlin.String;kotlin.collections.Set){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // app.tinyui.updates/UpdateEvent.EmbeddedIncompatible.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // app.tinyui.updates/UpdateEvent.EmbeddedIncompatible.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // app.tinyui.updates/UpdateEvent.EmbeddedIncompatible.toString|toString(){}[0] + } + final class Failed : app.tinyui.updates/UpdateEvent { // app.tinyui.updates/UpdateEvent.Failed|null[0] constructor (kotlin/String, kotlin/String?, app.tinyui.updates/FailStage, kotlin/String) // app.tinyui.updates/UpdateEvent.Failed.|(kotlin.String;kotlin.String?;app.tinyui.updates.FailStage;kotlin.String){}[0] @@ -191,22 +197,6 @@ 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] @@ -296,13 +286,12 @@ 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_EmbeddedIncompatible$stableprop // app.tinyui.updates/app_tinyui_updates_UpdateEvent_EmbeddedIncompatible$stableprop|#static{}app_tinyui_updates_UpdateEvent_EmbeddedIncompatible$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] @@ -314,13 +303,12 @@ 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_EmbeddedIncompatible$stableprop_getter(): kotlin/Int // app.tinyui.updates/app_tinyui_updates_UpdateEvent_EmbeddedIncompatible$stableprop_getter|app_tinyui_updates_UpdateEvent_EmbeddedIncompatible$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 19e2930..464ed9d 100644 --- a/updates/src/commonMain/kotlin/app/tinyui/updates/Events.kt +++ b/updates/src/commonMain/kotlin/app/tinyui/updates/Events.kt @@ -14,8 +14,6 @@ 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() } @@ -28,7 +26,8 @@ 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() + /** At construction: the embedded package cannot run on this host (an engine or protocol [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() } @@ -36,7 +35,6 @@ 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 1d49e23..5d1464c 100644 --- a/updates/src/commonMain/kotlin/app/tinyui/updates/Updates.kt +++ b/updates/src/commonMain/kotlin/app/tinyui/updates/Updates.kt @@ -60,10 +60,10 @@ class Updates internal constructor( val byName = LinkedHashMap() for (bundle in packages) { require(bundle.name !in byName) { "two embedded packages are named ${bundle.name}" } - require(bundle.manifest.engine == engine) { "package ${bundle.name} was built for engine ${bundle.manifest.engine}, this host embeds $engine" } byName[bundle.name] = Package(bundle) } this.packages = byName + for (p in byName.values) p.unusable.takeIf { it.isNotEmpty() }?.let { onEvent(UpdateEvent.EmbeddedIncompatible(p.name, p.embedded.manifest.version, it)) } for (p in byName.values) p.selectAtStartup() // App upgrades drop packages: their directories go too fs.listOrNull(dir)?.filter { it.name !in byName }?.forEach { fs.deleteRecursively(it) } @@ -140,6 +140,12 @@ class Updates internal constructor( var installed: String? = null val failed = ArrayList() var current: Bundle = embedded + /** 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) + } + private val embeddedCreatedAt: String? get() = embedded.manifest.createdAt.takeIf { unusable.isEmpty() } fun selectAtStartup() { readState() @@ -162,7 +168,7 @@ class Updates internal constructor( 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.createdAt <= embedded.manifest.createdAt) return null + if (embeddedCreatedAt?.let { manifest.createdAt <= it } == true) return null val files = HashMap() for (module in manifest.runtime + manifest.pages) { val path = manifest.file(module) + ".bin" @@ -189,7 +195,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 == embedded.manifest.version && unusable.isEmpty()) return CheckResult.UpToDate(version) if (version in failed) return CheckResult.Skipped(version, SkipReason.FAILED_BEFORE) if (!inRollout(name, version, pointer.rollout)) return CheckResult.Skipped(version, SkipReason.ROLLOUT) @@ -220,9 +226,7 @@ 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 if (installed != null) revertToEmbedded(version) else CheckResult.Skipped(version, SkipReason.OLDER_THAN_EMBEDDED) - } + if (embeddedCreatedAt?.let { manifest.createdAt <= it } == true) return CheckResult.Skipped(version, SkipReason.OLDER_THAN_EMBEDDED) val stagingRoot = root / STAGING val staging = stagingRoot / version @@ -253,21 +257,6 @@ 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 2151a77..114ace8 100644 --- a/updates/src/commonTest/kotlin/app/tinyui/updates/UpdatesTest.kt +++ b/updates/src/commonTest/kotlin/app/tinyui/updates/UpdatesTest.kt @@ -139,24 +139,13 @@ class UpdatesTest { } @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") + fun anEmbeddedPackageForAnotherEngineDegradesInsteadOfFailingTheHost() = runTest { + val hostEngine = "0".repeat(40) + val u = updates(embedded(), engine = hostEngine) + assertEquals(listOf(UpdateEvent.EmbeddedIncompatible("shop", "v0", setOf(Mismatch.ENGINE)), UpdateEvent.Running("shop", "v0", Source.EMBEDDED)), events) + publish(manifest(createdAt = "2026-09-22T08:00:00Z", engine = hostEngine)) + assertEquals(CheckResult.Installed("v1"), u.check("shop"), "older than the unusable embedded package, still the one that can run") + assertEquals("HOME-v1", pageBytes(updates(embedded(), engine = hostEngine).current("shop"))) } @Test @@ -273,9 +262,8 @@ class UpdatesTest { } @Test - fun refusesPackagesThatCannotRunOnThisHost() { + fun refusesAMisconfiguredHost() { assertFailsWith { updates(embedded(), embedded()) } - assertFailsWith { updates(embedded(), engine = "0".repeat(40)) } for (bad in listOf("../x", "1.2.0", "0", "01", "")) assertFailsWith(bad) { updates(embedded(), hostVersion = bad) } assertFailsWith { updates(embedded()).current("nowhere") } } From 71a8f34ea0300485e644cb8b985d4fb6ec34fc83 Mon Sep 17 00:00:00 2001 From: HarlonWang <81813780@qq.com> Date: Thu, 24 Sep 2026 10:26:39 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E6=84=8F=E8=A7=81?= =?UTF-8?q?=EF=BC=9Apull=20=E9=A6=96=E6=AC=A1=E6=8B=89=E5=8F=96=E9=81=87?= =?UTF-8?q?=E7=81=B0=E5=BA=A6=E5=8D=B3=E6=8A=A5=E9=94=99=E3=80=81"?= =?UTF-8?q?=E5=B7=B2=E6=98=AF=E8=AF=A5=E7=89=88=E6=9C=AC"=E9=A1=BB?= =?UTF-8?q?=E5=AE=BF=E4=B8=BB=E7=89=88=E6=9C=AC=E4=B9=9F=E7=9B=B8=E5=90=8C?= =?UTF-8?q?=EF=BC=9B=E6=96=87=E6=A1=A3=E5=8E=BB=E6=8E=89=E6=9E=84=E9=80=A0?= =?UTF-8?q?=E6=8A=9B=E9=94=99=E4=B8=8E=20releases=20rollback=20=E7=9A=84?= =?UTF-8?q?=E6=97=A7=E8=AF=B4=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/updates-plan.md | 2 +- docs/updates.md | 2 +- packages/cli/src/pull.ts | 7 +++++-- packages/cli/test/pull.test.ts | 1 + 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/updates-plan.md b/docs/updates-plan.md index e265edf..35e01c5 100644 --- a/docs/updates-plan.md +++ b/docs/updates-plan.md @@ -122,7 +122,7 @@ M1 之后 M2 → M3 与 M4 并行;M5.1、M5.2 与 tinyui 0.3.0(npm 三包 + 交付: - **tinyui**:`tinyui build` 静态找出每页用到的能力与宿主组件写进 manifest 的 `requires`,违反 build-chain.md §5.1 即失败;`tinyui publish` 上传前读目标宿主版本的快照核对(updates.md §1.3,快照格式 §6.4);宿主契约快照——`TinyUIHost` 与 `HostSnapshot.render`(拍板第 10 点),sample 带一份 `HostSnapshotTest` 作宿主的写法样板。`tinyui:` 前缀约定写进 app-model.md -- **trendingai-tinyui**:先恢复可构建——依赖从 `link:` 换成 npm 上与 TrendingAI 同版本的 tinyui 三包(0.4.0),加 `tinyui.config.json`(`name: "trendingai"`,公钥),模块名 `pages/subscription` → `trendingai/subscription`;`keys generate`,私钥写进 GitHub secret 与 `HarlonWang/secrets` 的 `tinyui-updates/signing/trendingai/trendingai.pem`;CI:合 main → build → `bundle --host-version <值>` → `publish --channel staging`,`hostVersion` 取 workflow 里的一个变量(抄宿主仓当前值;抄错时由 `publish` 对照服务端的宿主快照拒绝发布,见拍板第 8 点);`production` environment 设人工批准,手动 workflow 跑 `releases promote / rollback / rollout` +- **trendingai-tinyui**:先恢复可构建——依赖从 `link:` 换成 npm 上与 TrendingAI 同版本的 tinyui 三包(0.4.0),加 `tinyui.config.json`(`name: "trendingai"`,公钥),模块名 `pages/subscription` → `trendingai/subscription`;`keys generate`,私钥写进 GitHub secret 与 `HarlonWang/secrets` 的 `tinyui-updates/signing/trendingai/trendingai.pem`;CI:合 main → build → `bundle --host-version <值>` → `publish --channel staging`,`hostVersion` 取 workflow 里的一个变量(抄宿主仓当前值;抄错时由 `publish` 对照服务端的宿主快照拒绝发布,见拍板第 8 点);`production` environment 设人工批准,手动 workflow 跑 `releases promote / rollout`;回滚 = 用旧内容发一个新 version 再 promote(第 15 点) - **服务端代码**:app token 的签发与吊销、各管理端点改为认 app token(updates.md §6.3);宿主快照的上传与读取端点(§6.4,每个 (app, hostVersion) 只写一次);CLI 配套 `apps tokens`、`hosts upload` - **服务端(线上,不可逆,执行前列命令确认)**:`apps create trendingai --name TrendingAI`(`org` 留空);签一枚 app token 给 TrendingAI 宿主 CI 上传快照;`packages create trendingai --app trendingai`(登记公钥);两枚发布 token,CI 用的限 `staging`,production environment 用的限 `production` - **TrendingAI**:依赖升到带 `TinyUIHost` 的 tinyui(0.4.0)并加 `tinyui-updates`;宿主的 TinyUI 入口改用 `Updates(listOf(embedded), hostVersion = "1", dir, installId = getOrCreateInstallId(), fetch = ktor)`,base URL `https://updates.tinyui.app/trendingai/`;设置页隐藏的 channel 开关(持久化,切回即恢复 `production`);订阅页改用 `UpdatesPage`,`PAGE = "trendingai/subscription"`;App 启动即 `check()`(先于任何 TinyUI 页挂载);`onEvent` 接埋点;五个能力搬进 App 级 `CapabilityRegistry`(`ui.snackbar` 经 `PageLocal` 取当前屏的 snackbar),`shared/tinyui-host/1.txt` 与快照测试进 CI,发版时 CI 把快照上传到热下发服务;内置包改为发 App 时从服务端拉当时 `production` 的那一版进 `composeResources/files/tinyui/trendingai/`(`pnpm sync` 只再生成 `HostSchemas.kt`),脚本还是 CLI 子命令做时定 diff --git a/docs/updates.md b/docs/updates.md index fefcfd6..4689f11 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -154,7 +154,7 @@ class LoadedPage(val runtime: RuntimeBundle, val module: PageModule, val sourceM ```kotlin class Updates( - packages: List, // 内置包,各自 manifest.name 即包名;重名、engine 与宿主引擎不符 → 构造抛错 + packages: List, // 内置包,各自 manifest.name 即包名;重名 → 构造抛错;engine / protocol 与宿主不符 → 发 EmbeddedIncompatible 后照常构造(§4.4) val hostVersion: String, dir: Path, // 宿主给的目录,如 Android filesDir/tinyui、iOS Application Support/tinyui;库内按

// 分 installId: String, // 稳定的安装标识;库不生成、不持久化、不上传 diff --git a/packages/cli/src/pull.ts b/packages/cli/src/pull.ts index da19302..ca87688 100644 --- a/packages/cli/src/pull.ts +++ b/packages/cli/src/pull.ts @@ -70,8 +70,11 @@ export async function pull(options: PullOptions): Promise { const keep = (reason: string): PullResult => ({ version: current?.version ?? "", changed: false, reason }); // a version still rolling out is not yet what every user runs: it must not become the floor of every new install const rollout = typeof pointer.rollout === "number" ? pointer.rollout : 100; - if (rollout < 100) return keep(`${options.channel} is at ${version} rolling out to ${rollout}%`); - if (current?.version === version) return keep(`already ${version}`); + if (rollout < 100) { + if (!current) throw new Error(`${options.channel} is at ${version} rolling out to ${rollout}%: a first pull needs a fully rolled out version`); + return keep(`${options.channel} is at ${version} rolling out to ${rollout}%`); + } + if (current?.version === version && current.hostVersion === options.hostVersion) return keep(`already ${version}`); const manifestBytes = await get(`${version}/manifest.json`); const manifest = JSON.parse(new TextDecoder().decode(manifestBytes)) as Manifest; diff --git a/packages/cli/test/pull.test.ts b/packages/cli/test/pull.test.ts index 798522d..f272f15 100644 --- a/packages/cli/test/pull.test.ts +++ b/packages/cli/test/pull.test.ts @@ -72,6 +72,7 @@ describe("tinyui pull", () => { await publish("20260924T090000Z-5c4d3e", "2026-09-24T09:00:00Z", pair, 50); assert.deepEqual(await pull(into(out)), { version: NEW, changed: false, reason: "production is at 20260924T090000Z-5c4d3e rolling out to 50%" }); + await assert.rejects(pull(into(join(tmp, "empty"), { pkg: "fixture", acceptKey: pair.publicKey })), /a first pull needs a fully rolled out version/); await publish("20260921T090000Z-000000", "2026-09-21T09:00:00Z"); assert.equal((await pull(into(out))).changed, false, "an older version never replaces the floor"); From c6c3c83e1116abc2b5290de8139764da05cd42fb Mon Sep 17 00:00:00 2001 From: HarlonWang <81813780@qq.com> Date: Thu, 24 Sep 2026 10:45:31 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E6=84=8F=E8=A7=81?= =?UTF-8?q?=EF=BC=9A=E7=81=B0=E5=BA=A6=E4=B8=AD=E4=B8=94=E7=8E=B0=E6=9C=89?= =?UTF-8?q?=E5=86=85=E7=BD=AE=E5=8C=85=E4=B8=8D=E6=98=AF=E7=BB=99=E5=BD=93?= =?UTF-8?q?=E5=89=8D=E5=AE=BF=E4=B8=BB=E7=89=88=E6=9C=AC=E7=9A=84=EF=BC=8C?= =?UTF-8?q?pull=20=E6=8A=A5=E9=94=99=EF=BC=9B=E8=AE=A1=E5=88=92=E9=87=8C?= =?UTF-8?q?=20publish=20=E6=A0=B8=E5=AF=B9=E7=9A=84=E8=AF=B4=E6=B3=95?= =?UTF-8?q?=E6=94=B9=E5=87=86=E7=A1=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/updates-plan.md | 2 +- packages/cli/src/pull.ts | 4 +++- packages/cli/test/pull.test.ts | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/updates-plan.md b/docs/updates-plan.md index 35e01c5..3a1c090 100644 --- a/docs/updates-plan.md +++ b/docs/updates-plan.md @@ -122,7 +122,7 @@ M1 之后 M2 → M3 与 M4 并行;M5.1、M5.2 与 tinyui 0.3.0(npm 三包 + 交付: - **tinyui**:`tinyui build` 静态找出每页用到的能力与宿主组件写进 manifest 的 `requires`,违反 build-chain.md §5.1 即失败;`tinyui publish` 上传前读目标宿主版本的快照核对(updates.md §1.3,快照格式 §6.4);宿主契约快照——`TinyUIHost` 与 `HostSnapshot.render`(拍板第 10 点),sample 带一份 `HostSnapshotTest` 作宿主的写法样板。`tinyui:` 前缀约定写进 app-model.md -- **trendingai-tinyui**:先恢复可构建——依赖从 `link:` 换成 npm 上与 TrendingAI 同版本的 tinyui 三包(0.4.0),加 `tinyui.config.json`(`name: "trendingai"`,公钥),模块名 `pages/subscription` → `trendingai/subscription`;`keys generate`,私钥写进 GitHub secret 与 `HarlonWang/secrets` 的 `tinyui-updates/signing/trendingai/trendingai.pem`;CI:合 main → build → `bundle --host-version <值>` → `publish --channel staging`,`hostVersion` 取 workflow 里的一个变量(抄宿主仓当前值;抄错时由 `publish` 对照服务端的宿主快照拒绝发布,见拍板第 8 点);`production` environment 设人工批准,手动 workflow 跑 `releases promote / rollout`;回滚 = 用旧内容发一个新 version 再 promote(第 15 点) +- **trendingai-tinyui**:先恢复可构建——依赖从 `link:` 换成 npm 上与 TrendingAI 同版本的 tinyui 三包(0.4.0),加 `tinyui.config.json`(`name: "trendingai"`,公钥),模块名 `pages/subscription` → `trendingai/subscription`;`keys generate`,私钥写进 GitHub secret 与 `HarlonWang/secrets` 的 `tinyui-updates/signing/trendingai/trendingai.pem`;CI:合 main → build → `bundle --host-version <值>` → `publish --channel staging`,`hostVersion` 取 workflow 里的一个变量(抄宿主仓当前值;抄错时 `publish` 对照该版本的服务端快照核对:快照不存在、或包用到的东西快照里没有即拒绝;抄成一个仍然兼容的旧值不会被拒,包只是发给了那个旧宿主版本,见拍板第 8 点);`production` environment 设人工批准,手动 workflow 跑 `releases promote / rollout`;回滚 = 用旧内容发一个新 version 再 promote(第 15 点) - **服务端代码**:app token 的签发与吊销、各管理端点改为认 app token(updates.md §6.3);宿主快照的上传与读取端点(§6.4,每个 (app, hostVersion) 只写一次);CLI 配套 `apps tokens`、`hosts upload` - **服务端(线上,不可逆,执行前列命令确认)**:`apps create trendingai --name TrendingAI`(`org` 留空);签一枚 app token 给 TrendingAI 宿主 CI 上传快照;`packages create trendingai --app trendingai`(登记公钥);两枚发布 token,CI 用的限 `staging`,production environment 用的限 `production` - **TrendingAI**:依赖升到带 `TinyUIHost` 的 tinyui(0.4.0)并加 `tinyui-updates`;宿主的 TinyUI 入口改用 `Updates(listOf(embedded), hostVersion = "1", dir, installId = getOrCreateInstallId(), fetch = ktor)`,base URL `https://updates.tinyui.app/trendingai/`;设置页隐藏的 channel 开关(持久化,切回即恢复 `production`);订阅页改用 `UpdatesPage`,`PAGE = "trendingai/subscription"`;App 启动即 `check()`(先于任何 TinyUI 页挂载);`onEvent` 接埋点;五个能力搬进 App 级 `CapabilityRegistry`(`ui.snackbar` 经 `PageLocal` 取当前屏的 snackbar),`shared/tinyui-host/1.txt` 与快照测试进 CI,发版时 CI 把快照上传到热下发服务;内置包改为发 App 时从服务端拉当时 `production` 的那一版进 `composeResources/files/tinyui/trendingai/`(`pnpm sync` 只再生成 `HostSchemas.kt`),脚本还是 CLI 子命令做时定 diff --git a/packages/cli/src/pull.ts b/packages/cli/src/pull.ts index ca87688..a9f13fc 100644 --- a/packages/cli/src/pull.ts +++ b/packages/cli/src/pull.ts @@ -71,7 +71,9 @@ export async function pull(options: PullOptions): Promise { // a version still rolling out is not yet what every user runs: it must not become the floor of every new install const rollout = typeof pointer.rollout === "number" ? pointer.rollout : 100; if (rollout < 100) { - if (!current) throw new Error(`${options.channel} is at ${version} rolling out to ${rollout}%: a first pull needs a fully rolled out version`); + if (current?.hostVersion !== options.hostVersion) { + throw new Error(`${options.channel} is at ${version} rolling out to ${rollout}%, and ${out} holds nothing for host version ${options.hostVersion} to keep: wait for 100%`); + } return keep(`${options.channel} is at ${version} rolling out to ${rollout}%`); } if (current?.version === version && current.hostVersion === options.hostVersion) return keep(`already ${version}`); diff --git a/packages/cli/test/pull.test.ts b/packages/cli/test/pull.test.ts index f272f15..0135cf7 100644 --- a/packages/cli/test/pull.test.ts +++ b/packages/cli/test/pull.test.ts @@ -72,7 +72,7 @@ describe("tinyui pull", () => { await publish("20260924T090000Z-5c4d3e", "2026-09-24T09:00:00Z", pair, 50); assert.deepEqual(await pull(into(out)), { version: NEW, changed: false, reason: "production is at 20260924T090000Z-5c4d3e rolling out to 50%" }); - await assert.rejects(pull(into(join(tmp, "empty"), { pkg: "fixture", acceptKey: pair.publicKey })), /a first pull needs a fully rolled out version/); + await assert.rejects(pull(into(join(tmp, "empty"), { pkg: "fixture", acceptKey: pair.publicKey })), /holds nothing for host version 1 to keep/); await publish("20260921T090000Z-000000", "2026-09-21T09:00:00Z"); assert.equal((await pull(into(out))).changed, false, "an older version never replaces the floor");