From 36aaafe92c07a7b13060cc1522e407604a299653 Mon Sep 17 00:00:00 2001 From: HarlonWang <81813780@qq.com> Date: Fri, 25 Sep 2026 17:20:15 +0800 Subject: [PATCH 1/3] =?UTF-8?q?docs(adr-007):=20=E6=A1=86=E6=9E=B6?= =?UTF-8?q?=E8=81=8C=E8=B4=A3=E8=BE=B9=E7=95=8C=E2=80=94=E2=80=94=E8=83=BD?= =?UTF-8?q?=E9=9A=8F=E5=8C=85=E5=8F=98=E7=9A=84=E9=83=BD=E8=BF=9B=E5=8C=85?= =?UTF-8?q?=EF=BC=8C=E5=AE=BF=E4=B8=BB=E5=8F=AA=E7=95=99=E4=B8=8D=E5=8F=AF?= =?UTF-8?q?=E7=BA=A6=E7=9A=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TrendingAI 订阅页 review 引出:页面的五个能力、两个宿主组件、文案 props、 登录与 Pro 态桥接全部压在宿主,改任何一处都要宿主发版。ADR-007 定判据 "改它要不要宿主发版",逐项拍板网络通道、包内 i18n、远程配置、storage、 标准会话、toast / 对话框 / openUrl、Icon / Loading、埋点门面与剩余宿主契约。 - native-api.md 按 ADR-007 重写:TinyUIHost 取代 HostServices,J2 白名单 调整,新增 http 通道、storage、i18n、session、ui、linking、analytics - app-model / components / build-chain / updates / runtime-api 同步: App 级 store 与会话、Icon / Loading / role / selected、包内 i18n 与 http.client 静态识别、manifest 的 i18n 与快照通道段、pageVisible 驱动 - ADR-006 §2.4 的"core 库零 I/O"作废;roadmap 加第 8 条与 D 组推迟项 Co-Authored-By: Claude Opus 5.5 --- docs/README.md | 3 +- docs/adr-006-hot-updates.md | 4 +- docs/adr-007-host-boundary.md | 223 +++++++++++++++++++++++++++++ docs/app-model.md | 14 +- docs/build-chain.md | 20 ++- docs/components.md | 26 +++- docs/native-api.md | 262 ++++++++++++++++++++++++++-------- docs/roadmap.md | 14 +- docs/runtime-api.md | 2 +- docs/updates.md | 26 ++-- 10 files changed, 509 insertions(+), 85 deletions(-) create mode 100644 docs/adr-007-host-boundary.md diff --git a/docs/README.md b/docs/README.md index 43da5c1..abea17c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -45,6 +45,7 @@ JS 桥 Kotlin | [ADR-004](./adr-004-events-and-input-ownership.md) | 事件与输入状态归属 | 已定(2026-09-15) | 事件三分(离散 / 流式输入 / 连续),60 fps 状态留 Kotlin;新增 `x` 命令 op(ref + cmd);文本框 initial prop + 命令 + 事件,不受控回写 | | [ADR-005](./adr-005-engine.md) | JS 引擎选型 | 已定(2026-09-15) | MicroQuickJS → QuickJS(ES2025,经 quickjs-kmp);原生 ESM 必选;不降级 ES5;热下发不在本期(2026-09-18 起见 ADR-006) | | [ADR-006](./adr-006-hot-updates.md) | 热下发 | 已定(2026-09-18,2026-09-19 补服务端与签名、改多包模型,2026-09-24 运行时随宿主) | App 由 N≥1 个包组成,包名进模块名与 URL,各包独立发布回退;整包原子、下次启动生效;运行时随宿主、包只含页面,包的 tinyui 与宿主同 major 且不高于宿主即可跑;宿主 `hostVersion`(宿主对页面承诺的版本,不是 tinyui 版本)为兼容键;内置包是地板,下发页失败即回退并拉黑;`tinyui-updates` 独立 artifact 不做网络 / 调度 / UI;投递两次 GET、发布 PUT;manifest 签名 ECDSA P-256,密钥按包、公钥随包进 manifest;服务端 `tinyui-updates-server` 独立开源仓、CF 为主可私有化,托管 `updates.tinyui.app`;契约见 [updates.md](./updates.md),实施计划见 [updates-plan.md](./updates-plan.md) | +| [ADR-007](./adr-007-host-boundary.md) | 框架职责边界 | 已定(2026-09-25) | 判据"改它要不要宿主发版":能随包变的都进包;框架自己实现网络、存储、包内 i18n、toast / 对话框、打开链接、`Icon` / `Loading`、埋点门面与会话;宿主只提供标准接口的实现(网络通道、语言、会话、埋点出口、链接打开器),`host.call` 与宿主组件兜底;页面按请求选网络通道;删 `HostServices`;core 库放开零 I/O | ## 命名 @@ -111,7 +112,7 @@ tinyui/ | [jsx-transform.md](./jsx-transform.md) | CLI 的 JSX 变换:thunk 包裹规则、编译期报错、source map | 已定(2026-09-16) | | [patch-protocol.md](./patch-protocol.md) | patch 协议 v1:六种 op、id、值、顺序保证、版本、E5 | 已定(2026-09-16) | | [components.md](./components.md) | 内置组件:schema 真值与生成链、公共布局 prop 与 Modifier 顺序、首批八个组件、命令消费、Placeholder | 已定(2026-09-16) | -| [native-api.md](./native-api.md) | `tinyui-native` 与 `HostServices`:J2 白名单、navigation / store / events / http、E3 错误码与阈值 | 已定(2026-09-16) | +| [native-api.md](./native-api.md) | `tinyui-native` 与 `TinyUIHost`:J2 白名单、navigation / store / events / http / storage / i18n / session / ui / linking / analytics、`host.call` 兜底、E3 错误码与阈值 | 已定(2026-09-16;2026-09-25 按 ADR-007 重写) | ## 下一阶段 diff --git a/docs/adr-006-hot-updates.md b/docs/adr-006-hot-updates.md index 1535512..100d683 100644 --- a/docs/adr-006-hot-updates.md +++ b/docs/adr-006-hot-updates.md @@ -1,6 +1,6 @@ # 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 点;2026-09-24 按 M6 拍板第 12–15 点修订 §2.8 回滚与 §4.2 内置包:指针只往前走、回滚是用旧内容发新版本,内置包只求兼容、由宿主 pull 刷新;同日新增 §2.11:运行时随宿主、包只含页面,包与宿主的 tinyui 版本从"相等"放宽为"宿主不低于包",升 tinyui 不再加 `hostVersion`,§2.10"运行时仍放包里"作废);实现进度见 updates-plan.md +- 状态:已定(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 刷新;同日新增 §2.11:运行时随宿主、包只含页面,包与宿主的 tinyui 版本从"相等"放宽为"宿主不低于包",升 tinyui 不再加 `hostVersion`,§2.10"运行时仍放包里"作废;2026-09-25 §2.4 "core 库零 I/O"被 ADR-007 推翻,core 自带 ktor 与 okio,`tinyui-updates` 的边界不变);实现进度见 updates-plan.md - 结论:**App 由 N≥1 个包组成,包是所有权单元与分发单元,一个包是一次 `tinyui build` 的页面产物(运行时随宿主,§2.11),整包原子生效、下次启动切换,各包独立发布、独立回退;包名进模块名(`subscription/home`)与投递 URL,密钥与发布 token 按包发,验签公钥随包进 manifest、以内置包的为信任锚;兼容键是宿主声明的 `hostVersion`,引擎 commit 要求相等、包的 tinyui 版本要求与宿主兼容(同 major 且宿主不低于包),两者只做校验;内置包永远是地板,下发包失败即回退内置并拉黑;库(独立 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 的模块名规则。§2.11 另影响:updates.md §1.1(manifest 去 `runtime`,`tinyui` 改义)、§1.3(publish 判下限)、§4.1(加 1 口径)、§4.3(客户端核对)、§6.4(快照 `tinyui` 行改义);patch-protocol.md §6(K0 版本核对删去);build-chain.md §2(build 不产出 `runtime/`,运行时由库构建);runtime-api.md 写入公开面铁律;roadmap D 组"运行时 ABI 版本"一行删去 @@ -76,7 +76,7 @@ ADR-005 把热下发划出当期,条件是"内核稳定后另立 ADR"。Trendi ### 2.4 库的边界 -库做校验、落盘、选择、回退;不做网络(收宿主的 `fetch(path)`)、不做调度(宿主决定何时 `check()`)、不做 UI(没有"有更新"弹窗)。放独立 artifact `app.tinyui:tinyui-updates`:core 库保持零 I/O、零文件依赖;不需要热下发的宿主直接不依赖它。 +库做校验、落盘、选择、回退;不做网络(收宿主的 `fetch(path)`)、不做调度(宿主决定何时 `check()`)、不做 UI(没有"有更新"弹窗)。放独立 artifact `app.tinyui:tinyui-updates`:core 库保持零 I/O、零文件依赖(2026-09-25 起 core 放开 I/O,见 ADR-007;热下发仍独立成 artifact);不需要热下发的宿主直接不依赖它。 ### 2.5 服务端形态 diff --git a/docs/adr-007-host-boundary.md b/docs/adr-007-host-boundary.md new file mode 100644 index 0000000..d2b0cf7 --- /dev/null +++ b/docs/adr-007-host-boundary.md @@ -0,0 +1,223 @@ +# ADR-007 · 框架职责边界:能随包变的都进包,宿主只留不可约的 + +- 状态:已定(2026-09-25);实现待开,里程碑见 roadmap.md 第 8 条 +- 结论:**判据是"改它要不要宿主发版"——可能独立于原生代码变化的东西(接口地址、请求、文案、远程配置、埋点事件名、业务流程、图标)都进页面包,随热下发改;通用的原生能力由框架自己实现(网络、持久化存储、toast / 对话框、打开链接、图标与加载指示、包内 i18n),不再是等宿主填的空接口;宿主只提供页面拿不到的东西——网络通道(身份在里面)、用户语言、登录会话、埋点出口、链接打开器,全部是框架定义形状的标准接口,挂在 App 级 `TinyUIHost` 上。`host.call` 与宿主组件降为兜底。`HostServices` 删除,core 库放开零 I/O(自带 ktor 与 okio)** +- 契约:[native-api.md](./native-api.md)(`tinyui-native` 全部 API 与宿主接口)、[app-model.md](./app-model.md)(store、会话)、[components.md](./components.md)(`Icon`、`Loading`、`role` / `selected`)、[build-chain.md](./build-chain.md)(包内 `i18n/`、通道名静态识别)、[updates.md](./updates.md)(manifest、快照的通道段、`hostVersion` 口径) +- 影响:ADR-002 §3.1 的 J2 白名单成员改变(删 `config.get`,加 `storage.get` / `storage.set`、`i18n.locale`、`session.get`,`i18n.t` 改由框架实现);ADR-006 §2.4 "core 库零 I/O"作废;native-api.md §7 "下单、登录、埋点不该进框架"作废;2026-09-17 "框架不内置 Icon、加载指示走宿主组件"作废;roadmap D 组"宿主契约 schema 化"的触发前提大半消失 + +## 1. 背景 + +TrendingAI 订阅页(roadmap 第 6 条)是第一个真实页面。它对宿主的依赖: + +| 依赖 | 实际要做的事 | +|---|---| +| `host.call("billing.prices")` | GET 一个不需要登录的公开接口 | +| `host.call("checkout.start")` | 带 token POST 下单 → 打开收银台 → 记下"去过收银台" | +| `host.call("analytics.checkoutStep")` | 上报一个埋点 | +| `host.call("ui.snackbar")` + `PageLocal` | 弹一条轻提示 | +| `host.call("auth.signIn")` | 拉起原生登录 | +| 宿主组件 `ta.Icon`、`ta.Loading` | Material 图标、加载指示 | +| props 里十几条文案 | 多语言字符串 + 远程 `pro_paywall` 覆盖,Kotlin 拼好传入 | +| 每页新建 `InMemoryStore`,两个 `LaunchedEffect` 桥登录态与 Pro 态 | App 级状态 | + +框架其实有 `http`、`i18n.t`、`config.get`、`store`,但 Kotlin 侧都是 `HostServices` 上等宿主实现的接口,默认实现什么都不做,TrendingAI 一个也没接,全部绕到 `host.call`。结果: + +- 页面里多一行字、换一个接口地址、加一个埋点,都要改 props 或能力 → `hostVersion` 加 1 → 发 App,热下发能改的只剩布局 +- 宿主契约的"快照盲区"(能力参数形状、页面 props、store key,updates.md §4.1)几乎全部来自这些本不该在宿主的东西 +- 第二个接入的 App 要把同样的能力再实现一遍 + +## 2. 判据与三层 + +一个东西可能不经原生代码改动而变化,它就属于页面包。据此分三层: + +| 层 | 随什么变 | 负责 | +|---|---|---| +| 页面包 | 热下发 | 接口地址与请求、文案与多语言资源、远程配置的读取与缓存、埋点事件名与字段、业务流程与状态、图标数据 | +| 框架(`app.tinyui:tinyui` + `tinyui-native`) | tinyui 版本(随宿主) | 通用原生能力的**实现**:HTTP 默认通道、按包持久化存储、toast / 对话框、打开链接、`Icon` 渲染、`Loading`、包内 i18n 加载、App 级 store、页面可见性 | +| 宿主 | App 发版 | 页面物理上拿不到的:身份(网络通道里的 token)、用户语言、登录会话与登录流程、埋点出口、链接打开策略;以及真正 App 专属、需要返回值的动作(`host.call`)与必须原生渲染的视图(宿主组件) | + +宿主那一层,除 `host.call` 与宿主组件外,都是框架定义形状的标准接口:宿主只提供实现,不起名字、不定参数,所以不进宿主契约,也不影响 `hostVersion`。 + +框架层的实现原则:**框架提供能力与默认实现,宿主可以接管执行,页面不感知**。网络通道与链接打开器是可接管的两处。 + +## 3. 候选与取舍 + +### 3.1 网络:页面按请求选通道 + +框架自带默认通道(内置 ktor,不带身份);宿主可注册具名通道,接到自己的 HTTP 栈上(token、刷新、公共头都在通道里,页面看不到 token)。难点是哪些请求走宿主通道,用四个场景判:拉价(公开接口)、下单(必须带 token)、请求 `api.github.com`(绝不能带)、后端换域名(新域名上仍须带)。 + +| | 做法 | 请求第三方 | 换域名 | +|---|---|---|---| +| A. 注册了就全走 | 所有请求走宿主通道 | 带上 token,泄露;要堵就得在宿主按域名过滤 | 过滤了就要宿主发版 | +| B. 页面按请求选 | `http.client("app").post(…)`,默认走框架通道 | 不带 | 只改包里的 baseUrl | +| C. 宿主域名白名单 | 白名单内走宿主通道 | 不带 | 要宿主发版 | + +选 B。代价:页面把第三方请求交给 `app` 通道就会发出 token,与原生代码用错 client 同类,靠审查;被篡改的包过不了签名(updates.md §7)。宿主若想更保守,可在自己的通道里再加域名检查,框架不强制。 + +通道挂 `TinyUIHost`,通道名是宿主契约(页面用了宿主没有的通道会失败),进快照(§3.9)。响应语义保持现状(非 2xx 以 `E_HTTP` reject),`HostError` 增加 `status` / `headers` / `body`,页面据此处理 404、409——不改已公开的语义(runtime-api.md §11)。 + +### 3.2 i18n:字典进包,框架加载 + +| | 做法 | 对照 | 问题 | +|---|---|---|---| +| A. 字典在宿主(现状) | 宿主实现 `I18n.translate` | — | 改文案要宿主发版 | +| B. 框架只给语言 | 页面自选 i18n 库 | RN(i18next)、小程序 | 每包重复打库;缺键检查、key 类型各自做 | +| C. 字典进包、框架加载 | 包级 `i18n/.json`,框架读当前语言进内存,`i18n.t` 仍走 J2 | Flutter ARB、Android 资源 | 框架多一件事 | + +选 C。字典经 `Bundle` 的加载器读取(与页面字节码同一来源、同一签名覆盖),只把当前语言放进 Kotlin 内存,J2 查询是纯内存操作(ADR-002 对 J2 的要求)。语言优先取宿主推来的用户语言(App 内语言开关只有宿主知道),缺省用系统语言;宿主推新语言时框架换字典、经 K5 通知活页面,`t()` 绑定重算,页面不重建。回退链 `zh-Hant-TW → zh-Hant → zh → 包的默认语言`,全部缺失显示 key 并经 `PageSink` 上报一次。只支持 `{name}` 插值;`tinyui build` 以默认语言为准校验各语言 key 齐全,并生成 key 的 TS 类型。 + +QuickJS 没有 `Intl`:复数、本地化数字与日期第一批不做,触发时加 J2 `i18n.format`(原生侧实现)。 + +挡住:宿主不能再向页面提供文案;包内页面共用 key 空间(按页面加前缀);`i18n.t` 保持同步,不做按需远程加载——远程文案走 §3.3。 + +### 3.3 远程配置:不是框架概念 + +| | 做法 | 问题 | +|---|---|---| +| A. 宿主转交(`config.get`) | 宿主把缓存的配置给页面 | 来源、字段、缓存都依赖宿主 | +| B. 页面自取自缓存 | `http` 请求 + `storage` 缓存,先旧后新 | 与宿主冷启动的请求重复一次;装机后首次打开没有缓存 | +| C. 框架内置配置服务 | 热下发服务端按包下发配置 | 与 App 自己的后端形成两个真值来源 | + +选 B。删 `config.get` 与宿主 `Config` 接口;`tinyui-native` 提供纯 JS 的 `cached(key, fetcher)`:以 `storage` 同步读到的值为初值,后台请求,成功即更新并写回,失败保留旧值。按语言挑远程文案的逻辑在页面里,远程缺失回退到 `t()`;远程晚到时原地更新,页面不重建。 + +挡住:框架不提供功能开关 / 远程配置服务(C 推迟,触发条件见 §5.3);宿主已有的配置页面要用只能自己请求;首次打开会有一次"默认 → 远程"的文案切换,不能接受的页面先显示加载指示。 + +### 3.4 持久化存储:预载 + 同步读 + +| | 做法 | 对照 | 问题 | +|---|---|---|---| +| A. 纯异步 | `await storage.get(key)` | RN AsyncStorage | 渲染期读要先显示一帧加载 | +| B. 预载 + 同步读、异步写盘 | 包首次使用时整体读进内存,J2 读内存,写入改内存后合并写盘 | 小程序 `getStorageSync`、Web `localStorage`、Flutter shared_preferences | 容量必须设限 | +| C. 并进 `store`,加持久化开关 | `store.set(k, v, { persist })` | — | `store` 跨包共享且宿主会写,所有权与清理说不清 | + +选 B,与 `store` 分开:`store` 是 App 级内存、跨包、宿主可读写;`storage` 落盘、只有本包页面可见、宿主不读。实现为每包一个 JSON 文件(`<宿主给的目录>/storage/<包名>.json`,okio 写临时文件后原子替换)。写盘合并意味着进程被杀可能丢最后几十毫秒的写入,不适合关键数据。每包设总量上限(约 1 MB,实现时按内存与解析耗时定),超出 `set` 抛 `E_QUOTA`。不做 `watch`、不加密、不区分用户(用户相关数据 key 带 `userId` 或在会话变化时自清)、不做迁移(跨版本与回滚的格式兼容归页面:换 key 或容错)。 + +挡住:大数据、文件、数据库(触发见 §5.3);读取保持同步,不改懒加载;包间不经 `storage` 共享;宿主不直接读页面存的东西。 + +### 3.5 共享状态与会话 + +订阅页用到的两个 App 级状态: + +| 状态 | 真值 | 页面能自己拿到吗 | 归属 | +|---|---|---|---| +| 登录态 | 宿主(token 在通道里,登录是原生流程) | 否:看不到 token,别处登录登出也收不到 | 宿主推送 | +| Pro 态 | 服务端 | 能:`app` 通道请求 `/api/me` | 页面自取,可见时刷新 | + +登录态怎么给页面: + +| | 做法 | 问题 | +|---|---|---| +| A. 宿主写 store | 各 App 自定 key | key 与形状各不相同,页面不能跨 App 复用,key 名也是契约 | +| B. 框架标准会话 | `session()` → `{ loggedIn, userId }`,宿主提供 `StateFlow` 与 `signIn` | 框架要固定会话形状 | +| C. 页面自己推断 | 请求"我是谁"接口,可见时重取 | 首屏不确定、前台时感知不到登出、要求后端有该接口 | + +选 B(C 记作备查)。登录动作并入会话:`session.signIn(source)`,登录流程结束时 resolve,替代 `host.call("auth.signIn")`。`userId` 是稳定、非敏感的标识,不含 token;页面在 effect 里观察 `userId` 变化来清理用户数据,不另设登出事件。 + +`store` 从每次挂载的参数改为 App 级框架单例(app-model.md 本就这样设计,实现没跟上);宿主写入改为带类型的 `set` / `bind(key, flow)`;宿主不能再自定义 `Store` 实现。 + +`pageVisible()` 此前在 Kotlin 侧从未被驱动(`PageHost.visible` 没有调用方),"可见时刷新"依赖它:由 `TinyUIPage` 跟随 Lifecycle 的 START / STOP 驱动,前后台切换与被其他页面覆盖都算。 + +挡住:多账号、匿名分级等更复杂的身份模型进不了 `session`,只能走 store;业务状态默认由页面自问服务端并自己决定刷新时机。 + +### 3.6 通用 UI 能力 + +| 能力 | 框架实现 | 宿主可接管 | +|---|---|---| +| toast | `TinyUIPage` 自带 SnackbarHost,画 M3 Snackbar | 否:经 MaterialTheme 继承宿主主题 | +| 对话框 | M3 AlertDialog,命令式纯文本 `ui.alert` / `ui.confirm` | 否 | +| 打开链接 | Android `ACTION_VIEW`、iOS `UIApplication.open` | 是:宿主注册链接打开器,保留自己的策略(如 TrendingAI 的 Custom Tab 开关与 WebView 兜底) | + +对话框取命令式(小程序 `wx.showModal`、RN `Alert.alert`)而不是声明式 ``:第一批只要纯文本;声明式 `Dialog` / `BottomSheet` 推迟。`openUrl` 在链接交出时 resolve,不等用户回来(用 `pageVisible()`);scheme 不限,App 自己的 scheme 回到宿主的深链处理;页面不能指定应用内或外部打开。页面关闭时未结束的 toast 与对话框随之消失,Promise 以 `E_CANCELLED` 失败。 + +挡住:toast 只在页面区域内,跨页提示改为回传结果由上一页弹;对话框样式固定 M3;同一个包在不同宿主里链接打开方式可能不同(有意为之);没有应用内 WebView。 + +### 3.7 内置组件:`Icon`、`Loading` + +| | 做法 | 问题 | +|---|---|---| +| A. 内置 `material-icons-core` + `extended`,按名字查 | 生成"名字 → ImageVector"表 | 查找表让 R8 / iOS 死代码裁剪失效,整库进 App(extended 的 Android AAR 35.7 MB、iOS klib 18.9 MB,本机缓存实测);该库已停在 1.7.3 | +| B. 图标字体 | Material Symbols 字体 | 字体数 MB,裁剪又回到固定集合 | +| C. 图标数据随包,框架只渲染 | 页面 import 图标数据,esbuild 只打进用到的;`Icon` 把 path 渲染成 ImageVector | 只支持单色 path | + +选 C。新 npm 包 `tinyui-icons` 从 Material Symbols(Apache 2.0)生成,一个图标一个具名导出;图标是字符串 `"|"`(prop 不过桥对象,patch-protocol.md §4),页面可手写品牌图标。Kotlin 按字符串缓存解析结果。服务端 key 到图标的映射搬进页面,新 key 发包即可。多色与位图归 Image 管线。 + +`Loading` 用 M3 Expressive 的 `LoadingIndicator`(实验 API,Compose 升级时框架跟进),只做不定进度。 + +同时为可点击容器补无障碍语义:公共 prop `role` 与 `selected`(components.md §2),订阅页的选档卡片由此成为读屏的一个"单选、已选中"节点,与原生版等价。 + +挡住:推翻 2026-09-17 "框架不内置 Icon";`Icon` 不能退化为"Kotlin 只收名字"(那就回到全量打包)。宿主组件机制保留给地图、视频、原生广告这类必须原生渲染的视图。 + +### 3.8 埋点:框架门面 + 宿主出口 + +| | 做法 | 问题 | +|---|---|---| +| A. 每种事件一个宿主能力(现状) | `host.call("analytics.checkoutStep")` | 加埋点要宿主发版 | +| B. 通用 `track` + 宿主出口 | `analytics.track(name, props)`,宿主注册 `AnalyticsSink` 原样转交自己的 SDK | 页面事件失去宿主侧的编译期词汇检查 | +| C. 页面直接请求埋点服务端 | `http` POST | 全局属性、批量、离线队列要在 JS 重写;两条管道对不上 | + +选 B。出口是不可约的(页面事件要与原生事件进同一条流、带同一批全局属性),但事件名与字段由包定义:包内一个 TS 文件集中声明页面事件,页面只经它上报。框架不往 props 里自动加东西,页面身份单独传给出口;没有出口即丢弃。`screen_viewed` 仍归宿主导航。 + +### 3.9 剩余的宿主契约 + +| 机制 | 用途 | +|---|---| +| 框架标准接口(`TinyUIHost` 上) | 网络通道、语言、会话、埋点出口、链接打开器、导航 | +| `events` | 页面单向通知宿主业务事件(如 `trendingai.checkout.opened` → 宿主开始对账计时),宿主没监听页面照常工作 | +| `host.call` | 兜底:App 专属、要返回值、框架不打算标准化的动作;新增前要先论证它不能是标准接口或事件 | +| 宿主组件 | 必须原生渲染的视图 | + +`HostServices` 删除:`navigator` 挪到 `TinyUIHost`,`locals` 改为 `TinyUIPage` 的可选参数(只给 `host.call` 的能力用)。 + +宿主快照收录宿主组件、`host.call` 能力名,**新增网络通道名**;事件 topic、埋点事件名、框架标准接口不进快照。`hostVersion` 的口径随之收窄为这三类的集合及其参数与行为。事件改名后宿主静默收不到,快照拦不住,所以页面到宿主的事件要尽量少。跳原生页的路由名第一批不进快照:`Navigator` 认不出时返回失败,由页面处理。 + +## 4. 决策 + +| 项 | 结论 | +|---|---| +| 判据 | 能独立于原生代码变化的进包;框架实现通用原生能力;宿主只留不可约的,且以框架标准接口表达 | +| 网络 | 框架默认通道(ktor,无身份)+ 宿主具名通道;页面按请求选;通道名进快照;非 2xx 仍 reject,`HostError` 带 `status` / `headers` / `body` | +| i18n | 包级 `i18n/.json`,框架加载、J2 查询、语言由宿主推送、切换不重建、回退链、`{name}` 插值、构建期缺键校验与 key 类型;删宿主 `I18n` | +| 远程配置 | 不是框架概念;删 `config.get` 与 `Config`;`cached()` 工具 | +| 存储 | `storage`,按包隔离、预载同步读、合并写盘、约 1 MB 上限、`E_QUOTA` | +| 会话与状态 | 框架标准会话 `{ loggedIn, userId }` + `signIn`;业务状态页面自取;store App 级单例;`pageVisible()` 由 `TinyUIPage` 驱动 | +| UI 能力 | `ui.toast`、`ui.alert` / `ui.confirm`、`linking.openUrl`(宿主可接管) | +| 组件 | `Icon`(path 数据,`tinyui-icons`)、`Loading`(`LoadingIndicator`)、公共 prop `role` / `selected` | +| 埋点 | `analytics.track` + 宿主 `AnalyticsSink` | +| 宿主契约 | 标准接口 > `events` > `host.call` 兜底 + 宿主组件;删 `HostServices`;快照加通道段 | +| 库的 I/O | core 库放开零 I/O,直接依赖 ktor 与 okio(ADR-006 §2.4 该句作废);`tinyui-updates` 的边界不变(仍不做网络,`fetch` 由宿主给) | + +## 5. 后果与遗留 + +### 5.1 推翻的已定事项 + +| 原结论 | 出处 | 现在 | +|---|---|---| +| J2 白名单四个:`device.info` / `i18n.t` / `config.get` / `store.get`,实现在宿主 | native-api.md §1、ADR-002 §3.1 | 删 `config.get`;`i18n.t` 由框架实现;加 `storage.get` / `storage.set`、`i18n.locale`、`session.get` | +| 下单、登录、埋点是 App 自己的动作,不该进框架 | native-api.md §7 | 请求、打开链接、上报的通用部分归框架;登录并入标准会话;只有业务语义本身留宿主 | +| 宿主给页面的东西分 App 级 `TinyUIHost` 与每次挂载的 `HostServices` | native-api.md 开头 | 只剩 `TinyUIHost`;每次挂载只有 `locals` | +| 框架不内置 Icon,加载指示走宿主组件 | roadmap 第 6 条(2026-09-17) | 内置 `Icon`(path 数据)与 `Loading` | +| core 库零 I/O、零文件依赖 | ADR-006 §2.4 | 放开 | +| 持久化由宿主决定 | app-model.md §3、native-api.md §3 | `store` 仍为内存;持久化由框架的 `storage` 提供 | + +### 5.2 兼容 + +- JS 公开面只增不改(runtime-api.md §11):新增 `storage` / `i18n` / `session` / `ui` / `linking` / `analytics` / `http.client` / `cached` 与 `HostError` 的字段,`http` 已有语义不变,`pageVisible()` 从"从不变化"变为按规定工作。所以随 0.x minor 发出,不需要 1.0 +- Kotlin 侧 API 有破坏(删 `HostServices`、`TinyUIPage` 与 `TinyUIHost` 的参数变化),宿主升级时改接入代码;宿主契约随之变化的按 updates.md §4.1 加 `hostVersion` + +### 5.3 推迟项(记 roadmap D 组) + +| 项 | 触发条件 | +|---|---| +| 复数与本地化数字 / 日期(J2 `i18n.format`) | 第一个需要复数或本地日期的页面 | +| 框架远程配置 / 功能开关服务(§3.3 方案 C,可能是托管实例的付费功能) | 第二个 App 没有自己的配置后端又需要功能开关 | +| 大数据存储(SQLite / 文件 API) | 第一个要缓存大量数据的页面 | +| 声明式 `Dialog` / `BottomSheet` | 第一个需要自定义内容对话框的页面 | +| 剪贴板、分享、震动、选图等通用能力 | 第一个需要的页面;均按"框架默认实现、宿主可接管"加 | +| `openUrl` 的打开方式提示(`mode`) | 页面确实要求应用内或外部打开 | +| 多色 / 位图图标 | 并入 Image 管线 | +| 流式响应 / SSE、上传下载、请求缓存 | 第一个需要的页面 | +| 会话之外的身份模型(多账号等) | 出现这类宿主 | + +### 5.4 验收:TrendingAI 订阅页 + +订阅页按本文重写后,宿主只剩:`app` 通道、用户语言、会话(含 `signIn`)、埋点出口、链接打开器、监听一个 `trendingai.checkout.opened` 事件。`Capabilities.kt` 的五个能力、页面 props、`ta.Icon` / `ta.Loading` 与整条宿主 schema 生成链删除。同时修掉首次接入 review 发现的行为差异:拉价失败一直转圈、顶部多出 8 dp、props 变化整页重建、选档卡片的读屏语义。 diff --git a/docs/app-model.md b/docs/app-model.md index 631b61e..9a57ca1 100644 --- a/docs/app-model.md +++ b/docs/app-model.md @@ -1,6 +1,6 @@ # 应用模型:页面是 JS 的世界,应用是 Kotlin 的世界 -- 状态:已对齐(2026-09-16;2026-09-19 加多包:路由键含包名、§7 跨包约定;2026-09-23 宿主层面的页面标识加 `tinyui:` 前缀,M6 拍板第 7 点);API 面(`tinyui-native` 的 `navigation` / `store` / `events`)归 roadmap C 组与 J2 白名单一起定 +- 状态:已对齐(2026-09-16;2026-09-19 加多包:路由键含包名、§7 跨包约定;2026-09-23 宿主层面的页面标识加 `tinyui:` 前缀,M6 拍板第 7 点;2026-09-25 按 ADR-007:store 为框架持有的 App 级单例、登录态改为框架标准会话、持久化由框架 `storage` 提供);API 面(`tinyui-native` 的 `navigation` / `store` / `events`)归 roadmap C 组与 J2 白名单一起定 - 来源:ADR-002 "跨页共享状态走 Kotlin(K5 推送)"、"多页共享引擎被否:JS 全局正是隔离问题的来源"、`navigation.push` 为 J4;ADR-003 "组件注册表 App 级、不可变" ## 1. 前提 @@ -12,8 +12,8 @@ JS 侧不存在长生命周期的"应用对象":没有 `app.tsx`、没有 `onL | 事务 | 归属 | 机制 | |---|---|---| | 路由 | Kotlin 导航栈 | 页面名 = 模块名 = 路由键,含包名(`subscription/detail`,[build-chain.md](./build-chain.md) §2),跨包跳转与包内跳转写法相同。JS `navigation.push(name, params)` 经 J4 → Kotlin 建页面作用域 → K0 加载 → K1 `__mount(props)`,params 即 props。返回 = pop = `__unmount` = 关引擎。深链由宿主解析成页面名 + 参数走同一条 push。宿主自己的命名空间里(深链、埋点页面名、日志)标识一个 TinyUI 页写 `tinyui:<路由键>`(`tinyui:trendingai/subscription`,与页面里 `import.meta.url` 同形),和原生页区分开;`navigation.push`、路由表与 manifest 里的键仍不带前缀 | -| 应用生命周期 | 宿主事件 | 前后台、内存警告、主题、语言、登录态经 K5 `__emit(topic)` 投给每个订阅了的活页面;栈顶页额外收 K1 `__visible`。页面只有挂载、卸载、可见性三个钩子 | -| 跨页状态 | Kotlin 内存 store | 见第 3 节 | +| 应用生命周期 | 宿主事件 | 前后台、内存警告、主题经 K5 `__emit(topic)` 投给每个订阅了的活页面;语言与登录会话是框架标准接口,各有固定 topic(native-api.md §8、§9);`TinyUIPage` 跟随 Lifecycle 的 START / STOP 发 K1 `__visible`(前后台切换、被其他页面覆盖都算)。页面只有挂载、卸载、可见性三个钩子 | +| 跨页状态 | 框架的 App 级内存 store(`TinyUIHost.store`) | 见第 3 节;登录会话见第 3.1 节 | | 应用级 JS 逻辑 | Kotlin,或无状态工具代码打进各页模块 | 常驻 JS 的出口是"应用级服务 Runtime":一个不挂 UI、随 App 生命周期的引擎,页面经 Kotlin 以 JSON 与它通信,形态同 J3 / K3。v1 不做,触发条件见 roadmap D 组 | **TinyUI 页从宿主看只是一个 Composable**,导航栈里可以混放原生页与 TinyUI 页。v1 不内置路由器:库提供页面 Composable 与 `Navigator` 接口,宿主用自己的导航框架实现,JS 的 `navigation.*` 委托给它;sample 给最简实现。 @@ -32,9 +32,15 @@ JS 侧不存在长生命周期的"应用对象":没有 `app.tsx`、没有 `onL - **向前传参**:props 是 JSON,函数不过桥。"完成后回调我"只能靠回传或事件 - **向后回传**:原语是 K5 topic,结果投给"处在那个栈位置的页面"而不是某个闭包,页面被回收重建后照样收到。`push` 返回 Promise 的写法(J3 + cbId)与栈深回收冲突——被回收页的闭包已不存在;可之后作为糖加在 `tinyui-native`,文档写明回收语义 -- **共享状态**:真值在 Kotlin,原生页与 TinyUI 页共用同一份;内存 key-value 所以 J2 同步读合法;JS 侧包成 signal,K5 到达时更新 signal,绑定了它的 prop 自动重算,与页内响应式同一套。持久化由宿主决定 +- **共享状态**:真值在框架持有的 `TinyUIHost.store`,全 App 一份,原生页与 TinyUI 页共用;内存 key-value 所以 J2 同步读合法;JS 侧包成 signal,K5 到达时更新 signal,绑定了它的 prop 自动重算,与页内响应式同一套。store 不落盘;包自己的持久数据用 `storage`(按包隔离,native-api.md §7) - **事件广播**:与宿主事件同一条总线,网络状态、主题是 Kotlin 往总线发,业务事件是 JS 往总线发,消费方不区分来源。订阅登记到 Kotlin,只推给感兴趣的页面(每次 K5 = 一次线程切换 + 一个事务);订阅随引擎关闭消失 +### 3.1 登录会话与业务状态 + +- **登录态**是宿主的真值(token 在宿主的网络通道里,登录是原生流程),由框架定义标准会话 `{ loggedIn, userId }`:宿主在 `TinyUIHost` 上提供 `StateFlow` 与 `signIn`,框架推给所有活页面(native-api.md §9)。不用 store 的自定 key 表达登录态 +- **业务状态**(是否 Pro、额度等)真值在服务端,页面经身份通道自己请求,在 `pageVisible()` 由 false 变 true 与 `session` 变化时刷新;不由宿主写进 store +- 宿主往 store 写东西即构成宿主依赖,只用于确实要与原生互通、又没有框架标准接口的状态 + ## 4. 投递语义 - **状态会留下,事件会错过**:store 是状态,页面回收重建时从 store 重读;事件是瞬时的,页面不在时发生的事件不补发。业务判断"错过了要不要紧",要紧就写进 store diff --git a/docs/build-chain.md b/docs/build-chain.md index 3272858..c7543de 100644 --- a/docs/build-chain.md +++ b/docs/build-chain.md @@ -1,6 +1,6 @@ # 构建链:TSX → ESM 模块字节码 -- 状态:已完成(2026-09-16,PR #2;2026-09-17 加 §7 错误上报与 source map);2026-09-19 修订模块名规则——加包名前缀、去 `pages/` 段,引入 `tinyui.config.json`([ADR-006](./adr-006-hot-updates.md) §2.10),随热下发 M1 实施,sample 与 TrendingAI 的现有模块名一并改;2026-09-24 按 ADR-006 §2.11 修订:运行时模块改由库构建、随库发布,`tinyui build` 只编页面 +- 状态:已完成(2026-09-16,PR #2;2026-09-17 加 §7 错误上报与 source map);2026-09-19 修订模块名规则——加包名前缀、去 `pages/` 段,引入 `tinyui.config.json`([ADR-006](./adr-006-hot-updates.md) §2.10),随热下发 M1 实施,sample 与 TrendingAI 的现有模块名一并改;2026-09-24 按 ADR-006 §2.11 修订:运行时模块改由库构建、随库发布,`tinyui build` 只编页面;2026-09-25 按 [ADR-007](./adr-007-host-boundary.md) 加包内 i18n 资源(§8)与网络通道名的静态识别(§5.1) - 来源:[ADR-005](./adr-005-engine.md) §4 "语言目标 / 模块" 两行的展开;roadmap A 组"构建链打通" - 范围:`tinyui-cli` 的 `tinyui build`、`compose/` 的 K0 加载序列、sample 的接线与 CI。自动 thunk(roadmap C 组)、页面级配置文件不在本期(`qjsc-kmp` 二进制分发已于 2026-09-23 随热下发 M5.2 做完,见 §5);字节码行号回映射见 §7 @@ -23,7 +23,7 @@ tinyui-native 同上(对它而言 tinyui-core 是 external) | esbuild | TS 类型擦除;JSX → `h()`(`jsxFactory: "h"`,`inject` 一个内存模块自动引入 `h` / `Fragment`,页面不手写);业务内部相对 import 合并进页面模块;`tinyui-core` / `tinyui-native` 在插件 `onResolve` 里标 `external` + `sideEffects: false`,保持裸说明符且未用到时整条 import 被去掉;`format: "esm"`,`target: "esnext"`;输出 source map | 任何语法降级;对运行时模块的解析 | | qjsc-kmp | 源码模块 → 字节码,`-n` 给定模块名,`--strip-source` 保留行号去源码 | 校验模块图(引擎加载时查表) | -**页面名即模块名即路由键**:`/`——包 `subscription` 的 `src/pages/home.tsx` → `subscription/home`,`src/pages/plans/detail.tsx` → `subscription/plans/detail`,无后缀。包名来自工程根的 `tinyui.config.json`(`{ "name", "publicKey", "pages": "src/pages" }`,`name` 匹配 `[a-z0-9-]+`,`publicKey` 见 updates.md §7),是命名空间,所以没有 `pages/` 段;一个 App 挂几个包(ADR-006 §2.10),路由键就靠这个前缀不撞。页面路径的每一段限定 `[A-Za-z0-9._-]+`(`tinyui build` 拒绝其余的,含中文文件名):它随产物路径进热下发的投递 URL,而 percent-encoding 要在 CLI、服务端、Kotlin 客户端三处各对称实现一次,任一处偏差就是客户端收不到更新且只能发 App 修(updates.md §6.1、§7 同一类风险)。页面内容与组件文案不受影响,只有文件名受限;日后若要放开,改 build 一处把模块名与输出路径解耦即可(`files` 本就是映射),协议与另外两端不动。运行时模块名固定 `tinyui-core`、`tinyui-native`,由库构建并随库发布,不进 `tinyui build` 的产物。产物目录 `pages/**/*.bin`、`manifest.json`(页面模块清单、每个模块的产物路径 `files` 与 `buildIds`,包身份 `name` / `publicKey` 与热下发用的 `version` / `createdAt` / `engine` / `tinyui` / `hashes` 见 [updates.md](./updates.md) §1.1;Kotlin 侧路由表的来源,宿主用 `BuildManifest.file(name)` 定位 `.bin` / `.js.map`,见 [app-model.md](./app-model.md))。 +**页面名即模块名即路由键**:`/`——包 `subscription` 的 `src/pages/home.tsx` → `subscription/home`,`src/pages/plans/detail.tsx` → `subscription/plans/detail`,无后缀。包名来自工程根的 `tinyui.config.json`(`{ "name", "publicKey", "pages": "src/pages", "defaultLocale", "i18n": "i18n" }`,后两个见 §8,`name` 匹配 `[a-z0-9-]+`,`publicKey` 见 updates.md §7),是命名空间,所以没有 `pages/` 段;一个 App 挂几个包(ADR-006 §2.10),路由键就靠这个前缀不撞。页面路径的每一段限定 `[A-Za-z0-9._-]+`(`tinyui build` 拒绝其余的,含中文文件名):它随产物路径进热下发的投递 URL,而 percent-encoding 要在 CLI、服务端、Kotlin 客户端三处各对称实现一次,任一处偏差就是客户端收不到更新且只能发 App 修(updates.md §6.1、§7 同一类风险)。页面内容与组件文案不受影响,只有文件名受限;日后若要放开,改 build 一处把模块名与输出路径解耦即可(`files` 本就是映射),协议与另外两端不动。运行时模块名固定 `tinyui-core`、`tinyui-native`,由库构建并随库发布,不进 `tinyui build` 的产物。产物目录 `pages/**/*.bin`、`manifest.json`(页面模块清单、每个模块的产物路径 `files` 与 `buildIds`,包身份 `name` / `publicKey` 与热下发用的 `version` / `createdAt` / `engine` / `tinyui` / `hashes` 见 [updates.md](./updates.md) §1.1;Kotlin 侧路由表的来源,宿主用 `BuildManifest.file(name)` 定位 `.bin` / `.js.map`,见 [app-model.md](./app-model.md))。 业务工程的 tsconfig 用 `jsx: "react-jsx"` + `jsxImportSource: "tinyui-core"` 做类型检查([jsx-transform.md](./jsx-transform.md) §4),而 esbuild 会读到这份 tsconfig 并按它产出 `import { jsx } from "tinyui-core/jsx-runtime"`,即使 `build()` 显式传了 `jsx: "transform"`;引擎里没有这个模块,加载报 `module 'tinyui-core/jsx-runtime' is not registered`(2026-09-16 CI 实测)。页面构建要传 `tsconfigRaw` 覆盖 tsconfig 的 jsx 三项。 @@ -88,11 +88,12 @@ rolldown 是真正的备选(Rollup 同形 API、oxc 转译)。CLI 只包一 ### 5.1 页面对宿主的使用必须能静态识别 -热下发发布前要核对"这个包用到的宿主能力与宿主组件,目标宿主版本都提供"(updates.md §1.3),所以 `tinyui build` 必须能从页面代码里找全这些名字,两条硬规则,违反即构建失败: +热下发发布前要核对"这个包用到的宿主能力、网络通道与宿主组件,目标宿主版本都提供"(updates.md §1.3),所以 `tinyui build` 必须能从页面代码里找全这些名字,三条硬规则,违反即构建失败: - **`host.call` 的第一个参数是字符串字面量**:`host.call("billing.prices")` 可以,`host.call(`analytics.${kind}`)` 或把名字放进变量都不行,要改写成若干个明确的字面量调用。`host` 也只能以 `host.call(…)` 的形式使用,不能赋给别的变量或传出去;整体导入时(`import * as native`)只能写 `native.host.call(…)` 这类按成员的用法 +- **`http.client` 的参数是字符串字面量**:`http.client("app")` 可以,变量或模板串不行;`http` 的其他用法(`http.get` 等,走 `default` 通道)不受限。`http.client` 同样只能按成员调用,不能赋给别的变量或传出去(2026-09-25) - **宿主组件的类型能静态解析**:直接写 `` 这类标签(`tinyui schema` 生成的 `ta` 对象),或字符串字面量类型;条件选择(`cond ? ta.Icon : ta.Loading`)可以。先赋给变量再用(`const C = ta.Icon; `)、来自函数参数、运行时计算的类型都不行;`ta` 这类组件对象不能被改写、删属性或整个传出去 -- 页面里不要再声明名叫 `host`、`h` 的局部变量或参数:解析按名字进行,重名会让所有用法都无法确定 +- 页面里不要再声明名叫 `host`、`http`、`h` 的局部变量或参数:解析按名字进行,重名会让所有用法都无法确定 内置组件不受限(随 tinyui 版本走,发布前核对目标宿主的下限与包的 tinyui 兼容即可(updates.md §1.1))。放宽的出口是在 `tinyui.config.json` 里手工声明额外用到的能力与组件,现在不开,有真实需求再加。 @@ -121,3 +122,14 @@ rolldown 是真正的备选(Rollup 同形 API、oxc 转译)。CLI 只包一 **map 的路径**:thunk pass 的内联 map 以绝对路径作 `source`(相对路径会被 esbuild 再按文件目录解析一次,出现 `src/pages/src/pages/` 的重复,2026-09-17 修),esbuild 输出后 CLI 把 `sources` 改写为相对项目根(`src/pages/todos.tsx`、`../../packages/core/src/host.ts`),运行时与离线符号化都以此显示。 **sample 的接线**:`collectTinyUIResources` 默认连 `.js.map` 一起进资源,`-Ptinyui.maps=false` 不带;`App.kt` 读 manifest,能读到的 map 交给 `SourceMaps`,读不到就按原始栈上报。真实 App 按构建变体决定要不要带 map,库不替宿主决定。 + +## 8. 包内 i18n 资源 + +定于 2026-09-25(ADR-007 §3.2,运行时契约见 native-api.md §8)。 + +- 位置:工程根 `i18n/`(`tinyui.config.json` 的 `i18n` 可改),一个语言一个文件 `.json`,文件名是 BCP 47 标签(`en`、`zh`、`zh-Hant`);内容是扁平对象,key → 字符串,key 限 `[A-Za-z0-9._-]+` +- 默认语言:`tinyui.config.json` 的 `defaultLocale`,必须有对应文件;运行时回退链的终点 +- `tinyui build` 校验,失败即构建失败:每个语言的 key 集合与默认语言相同(缺的、多的都报);同一 key 在各语言里的 `{name}` 占位符集合相同;值都是字符串 +- 产物:文件原样复制到输出目录的 `i18n/`,进 manifest 的 `i18n`(`{ "default": "en", "files": { "en": "i18n/en.json", … } }`)与 `hashes`,随包签名(updates.md §1.1) +- 类型:`tinyui i18n --ts ` 生成对 `tinyui-native` 的模块扩充(每个 key 及其占位符名),`i18n.t` 的 key 与参数因此在编译期检查;与 `tinyui schema` 一样把生成物提交进仓 +- 页面没有用到 i18n 的包可以没有 `i18n/` 目录,此时不写 `defaultLocale` diff --git a/docs/components.md b/docs/components.md index c8c8010..08b93c3 100644 --- a/docs/components.md +++ b/docs/components.md @@ -1,6 +1,6 @@ # 内置组件:schema、布局 prop、首批清单 -- 状态:已定(2026-09-16);M2 的实现依据 +- 状态:已定(2026-09-16);M2 的实现依据;2026-09-25 按 [ADR-007](./adr-007-host-boundary.md) 加 `Icon`、`Loading` 与公共 prop `role` / `selected` - 来源:ADR-003 §3.3 / §3.4(注册表、schema、公共布局 prop)、ADR-004(事件三分、命令、文本框);roadmap C 组 - 相关:[runtime-api.md](./runtime-api.md) §3(`h()` 怎么处理 prop)、[patch-protocol.md](./patch-protocol.md) §4(值类型) @@ -30,7 +30,7 @@ defineComponent("TextField", { }); ``` -prop 类型:`string` / `number` / `boolean` / `dp` / `sp` / `color`(`#RRGGBB` / `#AARRGGBB`,或 §6 的主题 token 名)/ `enumOf([...])` / `size`(数字 dp,或 `"fill"` / `"wrap"`)。选项:`required`、`default`、`initial`(只认创建时的值,之后的写入按 E5 跳过并上报)、`doc`。事件 payload 与命令参数的字段只有 `field.string / number / boolean`,扁平。 +prop 类型:`string` / `number` / `boolean` / `dp` / `sp` / `color`(`#RRGGBB` / `#AARRGGBB`,或 §6 的主题 token 名)/ `enumOf([...])` / `size`(数字 dp,或 `"fill"` / `"wrap"`)/ `icon`(`"|"` 字符串,§3)。选项:`required`、`default`、`initial`(只认创建时的值,之后的写入按 E5 跳过并上报)、`doc`。事件 payload 与命令参数的字段只有 `field.string / number / boolean`,扁平。 ## 2. 公共布局 prop 与 Modifier 顺序 @@ -43,9 +43,18 @@ prop 类型:`string` / `number` / `boolean` / `dp` / `sp` / `color`(`#RRGGBB | 2 | `cornerRadius` | dp | `clip(RoundedCornerShape)` | | 3 | `background` | color | `background(color)` | | 3.5 | `borderWidth` / `borderColor` | dp / color | `border(width, color, 同一个 shape)`;宽 0 即无边框(Compose 的 0.dp 是一像素 hairline,这里不沿用);色缺省 `outline` token(2026-09-17 加) | -| 4 | (有 `onClick` handler 时) | | `clickable { dispatch("onClick") }` | +| 4 | (有 `onClick` handler 时)`role` / `selected` | enum / boolean | 设了 `selected` 为 `selectable(selected, role)`,否则 `clickable(role)`,都 `dispatch("onClick")`(2026-09-25 加 `role` / `selected`) | | 5 | `padding` / `paddingHorizontal` / `paddingVertical` | dp | 一个 `padding(start, top, end, bottom)`:轴向值覆盖该轴,缺的轴用 `padding`(React Native 式级联;2026-09-18 加轴向) | +`role` 取 `button` / `checkbox` / `switch` / `radio` / `tab`,对应 Compose 的 `Role`,只给读屏用;`selected` 让容器成为一个可选中节点,读屏报"已选中 / 未选中"。可点击容器会合并子节点的语义,所以放在它里面的 `RadioButton` 不要再给 `onClick`(给了就是第二个焦点): + +```tsx + + + + +``` + 顺序固定:以后加 prop 只能插进这个序列,不能重排(重排会改变已有页面的视觉)。`padding` 在最里面,所以它是内容内边距;背景、边框和点击区域包含它。 `weight` 需要父作用域:`Row` / `Column` 渲染 children 时按每个 child 的 `weight` 造 `Modifier.weight` 经 CompositionLocal 交给它,child 的 `modifier()` 把它放在最外层;其他容器把这个 local 重置为空,所以写在 `Box` / `LazyColumn` 的直接 child 上不生效、也不报错;`scroll` 开着的 `Column` / `Row` 主轴无界、没有剩余空间可分,其 child 的 `weight` 同样忽略(否则 Compose 会把它压成 0)。 @@ -66,9 +75,20 @@ prop 类型:`string` / `number` / `boolean` / `dp` / `sp` / `color`(`#RRGGBB | `TextField` | `initialText`(initial)、`placeholder`、`singleLine`、`keyboard` | `onChange{text}`、`onCommit{text}`(IME Done 或失焦) | `setText{text}`、`focus`、`blur` | | | `LazyColumn` | `gap` | `onReachEnd`、`onScrollEnd{index}` | `scrollTo{index}` | 是,通常是一个 `` | | `Spacer` | (只有布局 prop) | | | | +| `Icon` | `icon`(必填,`icon` 类型)、`size`(dp,默认 24)、`tint`(color,缺省跟随内容色) | `onClick` | | | +| `Loading` | `size`(dp,默认 24)、`color`(缺省 `primary`) | | | | `RadioButton` 的选中态是普通 prop(2026-09-17 加):它只报点击,选中哪个由 JS 决定——单选组的真值本来就在页面状态里,不属于 ADR-004 的"高频交互状态"。`TextField` 的文本与光标永远在 Kotlin 侧(ADR-004 §3.3):`setText` 会同时触发 `onChange`。`LazyColumn` 的行就是它的 children,虚拟化只在组合层(ADR-003 §3.6)。 +`Icon` 不内置任何图标,图标数据随包(2026-09-25,ADR-007 §3.7)。一个图标是一个字符串 `"|"`:`viewBox` 是四个数(`0 -960 960 960`),`d` 是 SVG path 数据,单色,多个 path 合并成一个 `d`,按 `tint` 着色;用字符串是因为 prop 不过桥对象(patch-protocol.md §4)。schema 里是新的 prop 类型 `icon()`,Kotlin 写入时解析成 `ImageVector` 并按字符串缓存,格式不对按 E5 跳过。页面从 npm 包 `tinyui-icons` 按名 import(由 Material Symbols 生成,一个图标一个具名导出的字符串常量,esbuild 只打进用到的),或自己写品牌图标的字符串。多色图标与位图归 `Image`。 + +```tsx +import { bolt, checkCircle } from "tinyui-icons/material/outlined"; + +``` + +`Loading` 是 M3 Expressive 的 `LoadingIndicator`,不定进度;有进度的进度条等需求。 + **`Image` 推迟**:牵出图片加载管线的选型(coil3 还是宿主提供 loader),M2 用不到,单独一次定。 ## 4. 命令怎么消费 diff --git a/docs/native-api.md b/docs/native-api.md index cbca3ba..e731375 100644 --- a/docs/native-api.md +++ b/docs/native-api.md @@ -1,23 +1,49 @@ -# `tinyui-native` 与宿主服务 +# `tinyui-native` 与宿主接口 -- 状态:已定(2026-09-16);M2 的实现依据 -- 来源:ADR-002 §3.1(J2 白名单、J3 / J4)、[app-model.md](./app-model.md)(路由、store、事件)、ADR-004 §3.1(连续事件阈值) -- 两侧:JS 的 `tinyui-native` 是 `__host_*` 的类型化封装;Kotlin 侧宿主给页面的东西分两层:App 级的 `TinyUIHost`(组件注册表、能力注册表、`PageSink`,启动时建一次)与每次挂载的 `HostServices`(本节以下各接口),都交给 `TinyUIPage` +- 状态:已定(2026-09-16);2026-09-25 按 [ADR-007](./adr-007-host-boundary.md) 重写:框架自己实现网络、存储、i18n、toast / 对话框、打开链接、埋点门面与会话,宿主只提供标准接口的实现;删 `HostServices`、`config.get`、宿主 `I18n` / `Config` / `HttpClient` +- 来源:ADR-002 §3.1(J2 白名单、J3 / J4)、[app-model.md](./app-model.md)(路由、store、会话、事件)、ADR-004 §3.1(连续事件阈值)、ADR-007(职责边界) +- 两侧:JS 的 `tinyui-native` 是 `__host_*` 的类型化封装;Kotlin 侧宿主给页面的东西只有 App 级的 `TinyUIHost`(§1),启动时建一次,交给每个 `TinyUIPage` -## 1. J2 白名单 +## 1. `TinyUIHost` -四个,框架固定,业务不能加;同步返回 JSON 文本,没有就是 `null`: +```kotlin +val host = TinyUIHost( + components = ComponentRegistry().registerBuiltins(), // + 宿主组件 + sink = pageSink, + dataDir = filesDir / "tinyui-data", // storage 落盘目录(§7) + channels = mapOf("app" to KtorChannel(appHttpClient)), // §6 + locale = uiLanguage, // StateFlow,BCP 47;缺省跟系统(§8) + session = SessionSource(state, signIn), // §9 + analytics = AnalyticsSink { name, props, page -> … }, // §12 + links = LinkOpener { url, page -> … }, // §11,缺省 LinkOpener.System + navigator = appNavigator, // §3 + capabilities = CapabilityRegistry().apply { … }, // §13,兜底 +) + +TinyUIPage(module, host, propsJson = "{}", locals = emptyList(), modifier = …) +``` -| 名字 | 宿主实现 | 返回 | -|---|---|---| -| `device.info` | 框架自带(`os`、`osVersion`、`model`)+ `HostServices.deviceInfo` 追加 | 对象 | -| `i18n.t` | `HostServices.i18n`(`I18n.translate(key, argsJson)`) | 字符串 | -| `config.get` | `HostServices.config`(`Config.get(key)`,JSON 文本) | 任意 | -| `store.get` | `HostServices.store` | 任意 | +除 `components`、`sink`、`dataDir` 外都可缺省,缺省行为见各节。标准接口(通道之外)的形状由框架定,不进宿主快照;进快照的只有宿主组件、`capabilities` 的名字与 `channels` 的名字(updates.md §6.4)。`host.store`、`host.events` 是框架建的 App 级实例,宿主经它们与页面交换状态和事件(§4、§5)。 + +`locals` 是这一次挂载交给 `host.call` 能力的对象(§13),只有兜底能力用得到。 + +## 2. J2 白名单 + +框架固定,业务不能加;同步返回 JSON 文本,没有就是 `null`。实现全部在框架内、只读内存: -实现必须在调用线程立即返回、不做 IO(ADR-002 §3.1 场景 3)。 +| 名字 | 返回 | +|---|---| +| `device.info` | 对象:`os`、`osVersion`、`model` | +| `store.get` | 任意(§4) | +| `storage.get` | 任意(§7) | +| `storage.set` | `null`,或错误码 `E_QUOTA`(§7) | +| `i18n.t` | 字符串(§8) | +| `i18n.locale` | 字符串(§8) | +| `session.get` | `{ loggedIn, userId }`(§9) | + +`storage.set` 是白名单里唯一的写:它只改内存、写盘另行合并,所以能在调用线程立即返回;放 J2 是为了让超额在调用处同步报出。 -## 2. `navigation` +## 3. `navigation` ```ts navigation.push("subscription/detail", { id: 42 }); // J4 → Navigator.push(page, paramsJson);页面名含包名 @@ -25,9 +51,9 @@ navigation.pop({ saved: true }); // J4 → Navigator.pop(resultJs navigation.onResult((result, from) => …); // 渲染期调用;K5 topic navigation.result ``` -宿主实现 `Navigator` 接口;把结果送回上一页时,宿主持有该页的 `PageHost`(`TinyUIPage` 的 `onHost` 回调拿到),调 `host.emit("navigation.result", json)`,`json` 形如 `{ "result": …, "from": "subscription/detail" }`。v1 不提供 `push` 返回 Promise 的糖。 +宿主实现 `Navigator` 接口,交给 `TinyUIHost`;把结果送回上一页时,宿主持有该页的 `PageHost`(`TinyUIPage` 的 `onHost` 回调拿到),调 `host.emit("navigation.result", json)`,`json` 形如 `{ "result": …, "from": "subscription/detail" }`。缺省 `Navigator.None`(调用无效果)。跳原生页的路由名由 `Navigator` 解释,认不出时忽略,不进快照。 -## 3. `store` +## 4. `store` ```ts store.get(key) // J2,快照 @@ -35,78 +61,202 @@ store.set(key, value) // J4,value 会被 JSON.stringify const cart = store.watch(key) // 渲染期调用,返回 accessor;K5 topic store: 到达时更新 ``` -真值在 Kotlin `Store`(`get` / `set` / `observe`),默认 `InMemoryStore`;持久化由宿主实现决定。`watch` 会经 J4 `store.subscribe` 登记,只有登记过的页面收到该 key 的变更。 +App 级内存键值,真值在框架的 `host.store`,全 App 一份、跨包共享、进程结束即失。宿主写入用带类型的方法: + +```kotlin +host.store.set("trendingai.flag", true) // 按 kotlinx.serialization 编码 +host.store.bind("trendingai.flag", flow, scope) // 把一个 Flow 持续写入 +``` + +`watch` 经 J4 `store.subscribe` 登记,只有登记过的页面收到该 key 的变更。key 带包名前缀(app-model.md §7)。宿主不能替换 store 的实现;要落盘用 §7。 -## 4. `events` +## 5. `events` ```ts -events.emit("order.paid", { id }) // J4 → EventBus.emit -events.on("network", (p) => …) // 渲染期调用;J4 events.subscribe + K5 +events.emit("trendingai.checkout.opened", { plan }) // J4 → host.events +events.on("network", (p) => …) // 渲染期调用;J4 events.subscribe + K5 ``` -`HostServices.events` 是 App 级 `EventBus`,宿主原生代码用同一个实例 `emit` 网络状态、主题等。订阅随页面 owner 结束,页面关闭时 Kotlin 侧的监听一并注销。 +`host.events` 是 App 级 `EventBus`,宿主原生代码用同一个实例 `emit` 网络状态、主题等,也用它 `subscribe` 页面发来的业务事件。订阅随页面 owner 结束,页面关闭时 Kotlin 侧的监听一并注销。页面发给宿主的事件不进快照:宿主没监听,页面照常工作;改名后宿主静默收不到,这类事件要尽量少。 -## 5. `http` +## 6. `http` ```ts -const { status, body } = await http.get("/todos", { timeout: 5000 }); -await http.post("/todos", { title }, { headers: { "X-Trace": id } }); +const { status, headers, body } = await http.get("https://api.example.com/todos", { timeout: 5000 }); +await http.post(url, { title }, { headers: { "X-Trace": id } }); + +const api = http.client("app"); // 宿主注册的通道 +await api.post(`${BASE}/api/billing/checkout`, { plan }); ``` -J3 `http.request`,参数 `{ method, url, headers?, body?, timeout? }`;宿主实现 `HttpClient.request(HttpRequest): HttpResponse`(suspend,`bodyJson` 为 JSON 文本)。响应 `{ status, body }`,`body` 是解析后的 JSON。宿主抛 `HostException(code, message)` 走 E3,其他异常按 `E_NET`。 +J3 `http.request`,参数 `{ channel, method, url, headers?, body?, timeout? }`;`http.get` / `post` / `request` 走 `channel: "default"`。URL 由页面给全,框架不拼 base。 + +| 通道 | 实现 | 用途 | +|---|---|---| +| `default` | 框架内置 ktor client,不带任何身份 | 公开接口、第三方接口 | +| 宿主注册的名字(`app` 等) | `TinyUIHost.channels`,宿主接自己的 HTTP 栈 | 需要以当前用户身份发出的请求 | + +哪个请求走哪条通道由页面决定:token 只在宿主通道里,页面看不到;把第三方请求交给身份通道就会发出 token,靠审查,被篡改的包过不了签名(ADR-007 §3.1)。 + +```kotlin +fun interface HttpChannel { + suspend fun request(request: HttpRequest): HttpResponse +} +class HttpRequest(val method: String, val url: String, val headers: Map, val body: String?, val timeoutMs: Long?) +class HttpResponse(val status: Int, val headers: Map, val body: String) + +KtorChannel(client: io.ktor.client.HttpClient) // 把宿主已有的 client 包成通道 +``` + +- 请求体:对象按 JSON 编码并设 `Content-Type: application/json`,字符串原样发送 +- 响应体:`Content-Type` 为 JSON 时解析成对象,否则为文本 +- 2xx resolve `{ status, headers, body }`;非 2xx 以 `E_HTTP` reject,`HostError` 带 `status`、`headers`、`body`(解析规则同上) +- 通道名 `default` 保留,宿主不能注册;页面用了宿主没注册的名字 → `E_UNSUPPORTED`。`http.client` 的参数必须是字符串字面量(build-chain.md §5.1),通道名随 `requires` 进 manifest、发布前对照快照核对 `setTimeout` / `clearTimeout` 是 core 提供的全局,经 J3 `timer.schedule` / J4 `timer.cancel`,宿主侧由 `PageHost` 实现,随页面关闭取消。 -## 6. E3 错误码与阈值 +## 7. `storage` -| code | 含义 | -|---|---| -| `E_UNSUPPORTED` | 能力不存在(宿主没实现,或 J3 名字未知) | -| `E_INVALID` | 参数不合法 | -| `E_NET` | 网络层失败,或宿主实现抛了非 `HostException` 的异常 | -| `E_HTTP` | 非 2xx,宿主在 message 里给 status | -| `E_TIMEOUT` | 超时 | -| `E_DENIED` | 权限拒绝 | -| `E_CANCELLED` | 页面关闭或宿主取消 | -| `E_BAD_JSON` | 载荷不是合法 JSON(JS 侧生成) | +```ts +storage.get(key) // J2,同步 +storage.set(key, value) // J2,超额抛 HostError E_QUOTA +storage.remove(key) // J4 +storage.clear() // J4,清本包全部 -阈值(可调,写在代码常量里): +const [paywall] = cached("paywall.v1", () => api.get(`${BASE}/api/app-config`).then((r) => r.body.pro_paywall)); +``` -| 项 | 值 | 位置 | -|---|---|---| -| K 入口超时 | 5 s,超时 interrupt 判 E6 页面失败 | `PageHost.entryTimeoutMs` | -| `onReachEnd` | 最后一行进入可见区时触发一次;列表长度变化后再武装 | `LazyColumnComponent` | -| `onScrollEnd` | 滚动停止时触发,带首个可见行 index | 同上 | -| `onProgress` 类节流 | 1 s | 首批组件没有,写在这里备查 | +- 按包隔离:键空间是 `<包名>`,同一包的页面共享,别的包与宿主不读 +- 包的第一个页面挂载时,框架把 `/storage/<包名>.json`(`dataDir` 不要与 `Updates` 的目录相同,那里按包名分目录) 整体读进内存;读写都在内存,写盘在最后一次写入后合并进行(写临时文件再原子替换)。进程被杀可能丢失最后几十毫秒的写入 +- 每包总量上限(序列化后字节数)由框架常量定,约 1 MB;超出时 `set` 不生效并抛 `E_QUOTA` +- 按设备存,不区分用户:用户相关数据 key 带 `session().userId`,或在其变化时自行清理 +- 不加密,不存敏感信息;框架不做格式迁移,跨版本与回滚的兼容由页面负责(换 key 或读取时容错) + +`cached(key, fetcher)` 是纯 JS 工具(先旧后新):渲染期调用,返回 `[data, { loading, error, refetch }]`,与 `resource` 同形;`data` 初值是 `storage.get(key)`,随后执行 `fetcher`,成功即更新并 `storage.set`,失败保留旧值并置 `error`。 + +## 8. `i18n` + +```ts +i18n.t("subscription.title") +i18n.t("subscription.savings", { percent: "43%" }) +i18n.locale() // accessor,如 "zh" +``` + +- 字典在包里:`i18n/.json`,扁平的 key → 字符串,包的默认语言写在 `tinyui.config.json`(build-chain.md §8) +- 当前语言:`TinyUIHost.locale` 的值,缺省为系统语言;变化时框架加载新字典,经 K5 topic `i18n.locale` 通知活页面,`t()` 与 `locale()` 的绑定重算,页面不重建 +- 查找回退:`zh-Hant-TW → zh-Hant → zh → 默认语言`;全部缺失返回 key 本身,并经 `PageSink` 每个 key 上报一次 +- 插值只有 `{name}`,值按字符串替换;没有复数与数字 / 日期格式化(引擎无 `Intl`) +- key 的 TS 类型由 CLI 生成(build-chain.md §8),写错的 key 编译期报 + +## 9. `session` -## 7. 宿主自定义能力:`host.call` +```ts +session.state() // accessor:{ loggedIn: boolean, userId: string | null } +await session.signIn("paywall") // 拉起宿主的登录流程,流程结束时 resolve(成功与否看 state()) +``` + +```kotlin +class Session(val loggedIn: Boolean, val userId: String?) +class SessionSource(val state: StateFlow, val signIn: suspend (source: String) -> Unit) +``` + +- J2 `session.get` 取初值,K5 topic `session` 推送变化;框架订阅 `state`,推给所有活页面,页面不重建 +- `userId` 是稳定、非敏感的用户标识,不是 token +- 宿主没提供 `session` 时:`state()` 恒为 `{ loggedIn: false, userId: null }`,`signIn` 以 `E_UNSUPPORTED` reject -定于 2026-09-17(TrendingAI 订阅页触发:下单、登录、埋点都是 App 自己的动作,不该进框架)。 +## 10. `ui` ```ts -const { url } = await host.call<{ url: string }>("checkout.start", { plan: "annual" }); +const r = await ui.toast("已复制", { action: "撤销", duration: "short" }) // "action" | "dismissed" +await ui.alert({ title?, message, confirm? }) +const ok = await ui.confirm({ title?, message, confirm?, cancel? }) // boolean ``` -J3,一个名字一个能力。能力在 App 级注册一次,与组件同属宿主契约(2026-09-23 定,M6:契约要能在不跑 App 的情况下枚举出来生成宿主快照,updates.md §4.1): +- toast 由 `TinyUIPage` 自带的 SnackbarHost 显示为 M3 Snackbar,位于页面区域底部;对话框为 M3 AlertDialog,只有纯文本。样式经 `MaterialTheme` 跟随宿主主题 +- 按钮文字缺省用框架内置的文案(随 §8 的当前语言,内置中英文,其余回退英文) +- 页面关闭时未结束的 toast 与对话框随之消失,Promise 以 `E_CANCELLED` reject + +## 11. `linking` + +```ts +await linking.openUrl(url) +``` ```kotlin -val Snackbar = PageLocal() +fun interface LinkOpener { + suspend fun open(url: String, page: PageContext) + companion object { val System: LinkOpener } // Android ACTION_VIEW、iOS UIApplication.open +} +``` + +- 链接交给系统或宿主后 resolve,不等用户回来;回来与否看 `pageVisible()`(runtime-api.md §8) +- URL 不合法 → `E_INVALID`;没有应用能打开 → `E_UNSUPPORTED` +- scheme 不限;宿主提供 `links` 即接管全部打开行为,可在其中对部分 URL 调 `LinkOpener.System` -val host = TinyUIHost(components, sink, CapabilityRegistry().apply { - register("checkout.start") { argsJson, page -> … } - register("ui.snackbar") { argsJson, page -> - val snackbar = page[Snackbar] ?: throw HostException("E_UNSUPPORTED", "no snackbar on ${page.name}") +## 12. `analytics` + +```ts +analytics.track("checkout_step", { step: "plan_selected", plan: "annual" }) // J4,无返回 +``` + +```kotlin +fun interface AnalyticsSink { + fun track(name: String, props: Map, page: PageContext) +} +``` + +- `props` 只允许一层,值为字符串、数字、布尔或 `null`;其他形状在 JS 侧抛 `E_INVALID` +- 框架不往 `props` 里加任何东西,页面身份经 `page` 单独给出;没有 `analytics` 时丢弃,只在 debug 日志打印 +- 事件名与字段由包定义,框架不校验 + +## 13. 宿主自定义能力:`host.call`(兜底) + +```ts +const r = await host.call<{ ok: boolean }>("trendingai.coupon.apply", { code }); +``` + +J3,一个名字一个能力,在 App 级注册一次,与宿主组件同属宿主契约。先用 §3–§12;只有 App 专属、需要返回值、框架不打算标准化的动作才用它(ADR-007 §3.9)。 + +```kotlin +val Coupon = PageLocal() + +TinyUIHost(…, capabilities = CapabilityRegistry().apply { + register("trendingai.coupon.apply") { argsJson, page -> + val vm = page[Coupon] ?: throw HostException("E_UNSUPPORTED", "no coupon on ${page.name}") … } }) -TinyUIPage(module, host, HostServices(locals = listOf(Snackbar provides snackbarHostState))) +TinyUIPage(module, host, locals = listOf(Coupon provides viewModel)) ``` - `HostCapability.call(argsJson, page)`:suspend,收 JS 传的对象(JSON 文本),返回 JSON 文本(`null` 即 `undefined`);抛 `HostException` 走 E3 码,其他异常按 `E_NET`。没注册的名字拒绝 `E_UNSUPPORTED` -- 能力只依赖 App 级对象;依赖某一屏的东西(snackbar、当前屏的 ViewModel)由挂载方经 `HostServices.locals` 以 `PageLocal` 为键传入,能力从 `page[local]` 取,没有就自己决定报什么码。同一名字按屏给不同行为,也在实现里看 `page` 分派 -- 注册表启动时建好即不可变,放进 `TinyUIHost` 后再 `register`(组件同样)直接报错:不能在运行中增删能力(登录后才开放之类,在实现里判断后拒绝)。名字不强制前缀,但不能与框架自己的十个名字(§1–§5)重合,不能含空白,也不能重复注册,都在 `register` 时报错 +- 能力只依赖 App 级对象;依赖某一屏的东西由挂载方经 `locals` 以 `PageLocal` 为键传入,能力从 `page[local]` 取 +- 注册表启动时建好即不可变,放进 `TinyUIHost` 后再 `register` 直接报错。名字不强制前缀,但不能与框架名(§2–§12 用到的 J2 / J3 / J4 名字)重合,不能含空白,也不能重复注册,都在 `register` 时报错 - 挂载清单的 `capabilities` 列出框架名 + 注册表里的全部名字,页面可据此判断宿主是否提供某能力 -不做 J4(火后不理)变体:不等 Promise 就是火后不理,省不下什么。能力的类型声明由宿主 JS 工程自己写一层封装(`ta.checkout.start(plan)`),框架不生成。 +能力的类型声明由宿主的 JS 工程自己封装,框架不生成。 + +## 14. E3 错误码与阈值 + +| code | 含义 | +|---|---| +| `E_UNSUPPORTED` | 能力、通道或标准接口宿主没有提供,或名字未知 | +| `E_INVALID` | 参数不合法 | +| `E_NET` | 网络层失败,或宿主实现抛了非 `HostException` 的异常 | +| `E_HTTP` | 非 2xx;`HostError` 带 `status` / `headers` / `body` | +| `E_TIMEOUT` | 超时 | +| `E_DENIED` | 权限拒绝 | +| `E_CANCELLED` | 页面关闭或宿主取消 | +| `E_QUOTA` | `storage` 超出本包上限 | +| `E_BAD_JSON` | 载荷不是合法 JSON(JS 侧生成) | + +阈值(可调,写在代码常量里): +| 项 | 值 | 位置 | +|---|---|---| +| K 入口超时 | 5 s,超时 interrupt 判 E6 页面失败 | `PageHost.entryTimeoutMs` | +| `onReachEnd` | 最后一行进入可见区时触发一次;列表长度变化后再武装 | `LazyColumnComponent` | +| `onScrollEnd` | 滚动停止时触发,带首个可见行 index | 同上 | +| `onProgress` 类节流 | 1 s | 首批组件没有,写在这里备查 | +| `storage` 每包上限 | 约 1 MB | `Storage` | diff --git a/docs/roadmap.md b/docs/roadmap.md index b04c616..3165b98 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -59,9 +59,17 @@ | 手势组合内置组件 | ADR-004 | 业务需求出现 | | 公钥轮换不中断旧 App:预置备用钥(`publicKeys` 为一组,客户端任一内置钥验过即可,服务端登记一组并可吊销单把,manifest 记签名用的钥) | updates.md §7 | 出现第一次真实的轮换诉求,或托管实例接入外部租户 | | 服务端在 `PUT current.json` 时核对宿主快照:核对逻辑只在服务端一份,另开只读核对端点,CLI 删自己的 `checkHost` 改调它预检 | updates.md §6.1 | 出现 CLI 以外的发布路径(控制台、租户自写工具),或托管实例接入外部租户 | -| 宿主契约 schema 化 + 代码生成:能力参数 / 返回、页面 props 与页面名、store key 用 schema DSL 声明进快照(形状变即快照变、逼 `hostVersion` 加 1),CLI 按目标宿主版本的快照生成 TS 类型,页面编译期核对 | updates.md §4.1 | 宿主与页面由不同的人或团队维护(外部租户、第二个宿主),或首次因快照盲区出线上问题 | +| 宿主契约 schema 化 + 代码生成:`host.call` 能力的参数 / 返回用 schema DSL 声明进快照(形状变即快照变、逼 `hostVersion` 加 1),CLI 按目标宿主版本的快照生成 TS 类型,页面编译期核对。ADR-007 后页面 props 与 store key 不再是宿主契约,盲区只剩少数兜底能力 | updates.md §4.1 | 宿主与页面由不同的人或团队维护(外部租户、第二个宿主),或首次因快照盲区出线上问题 | | 以客户端能力探测替代 `hostVersion`:指针列出多个候选版本及其 `requires`,客户端挑能跑的最新一版,配"能力改参数或语义即换名"约定(运行时 ABI 版本一项已由 ADR-006 §2.11 运行时随宿主取代,2026-09-24) | ADR-006 §2.11 | `hostVersion` 加 1 的频率重新成为负担 | -| `Image` 与图片加载管线(coil3 vs 宿主 loader) | components.md §3 | 出现需要图片的页面 | +| `Image` 与图片加载管线(coil3 vs 宿主 loader);多色 / 位图图标并入这里 | components.md §3 | 出现需要图片的页面 | +| i18n 的复数与本地化数字 / 日期(J2 `i18n.format`,原生侧实现) | ADR-007 §3.2 | 第一个需要复数或本地日期的页面 | +| 框架远程配置 / 功能开关服务(可能是托管实例的付费功能) | ADR-007 §3.3 | 第二个 App 没有自己的配置后端又需要功能开关 | +| 大数据存储(SQLite / 文件 API) | ADR-007 §3.4 | 第一个要缓存大量数据的页面 | +| 声明式 `Dialog` / `BottomSheet` | ADR-007 §3.6 | 第一个需要自定义内容对话框的页面 | +| 剪贴板、分享、震动、选图等通用能力(均按"框架默认实现、宿主可接管") | ADR-007 §3.6 | 第一个需要的页面 | +| `openUrl` 的打开方式提示(`mode`) | ADR-007 §3.6 | 页面确实要求应用内或外部打开 | +| 流式响应 / SSE、上传下载、请求缓存 | ADR-007 §3.1 | 第一个需要的页面 | +| 会话之外的身份模型(多账号等) | ADR-007 §3.5 | 出现这类宿主 | | ~~`weight`~~ / `alignSelf` 等需要父作用域的布局 prop | components.md §2 | `weight`、`border`、`Column` / `Row` 的 `scroll` 已加(2026-09-17,TrendingAI 订阅页触发);`alignSelf` 等需求 | ## 建议顺序与里程碑 @@ -73,4 +81,4 @@ 5. ~~**C 剩余项 + 内置组件集,M2:列表页**——`For` / `LazyColumn` / `TextField` / J3 网络,覆盖所有权、命令、流式输入~~ 已完成(PR #4,2026-09-16):schema 生成链、八个内置组件、`HostServices`、`tinyui-native`;todos 页在 Android 与 iOS 模拟器上跑通输入提交、行增删改、命令滚动、分页 6. **实战接入:TrendingAI 订阅页**(2026-09-17 起,代替多页业务样例)——页面源码在 `~/TrendingProjects/trendingai-tinyui`,产物经 `pnpm sync` 入 TrendingAI;框架缺什么以这一页为准补(第一批:主题 token、weight / border / scroll、`host.call`、`Button` children、`RadioButton`、宿主 include)。已在 Android 模拟器跑通拉价、选档、深浅色(TrendingAI 分支 `feat/tinyui-subscription`);合入 TrendingAI main 的前提——tinyui 0.1.0(Maven + npm 三包 `tinyui-core` / `tinyui-native` / `tinyui-cli`)与 quickjs-kmp 0.1.1——已于 2026-09-18 发出(PR #11);iOS 模拟器 smoke 通过,三条状态验证(结账失败 / 未登录 / 已是 Pro)不做 7. **热下发实现**(ADR-006 定稿后;里程碑与进度见 [updates-plan.md](./updates-plan.md)):CLI(`tinyui.config.json`、模块名加包名、manifest 扩展、`bundle` + 签名、`keys`)→ core 库 `Bundle` → `updates/` 模块(多包 `Updates`)→ `tinyui-updates-server` 仓 MVP(投递 + 发布 + 管理端点,部署 `updates.tinyui.app`)→ CLI `publish` / `apps` / `packages` / `tokens` / `releases` → TrendingAI 接入 → 第二个 App 接入。前置:`qjsc-kmp` npm 平台包 - +8. **框架职责边界**([ADR-007](./adr-007-host-boundary.md),2026-09-25 定):tinyui 一个 PR——`TinyUIHost` 重组、删 `HostServices`、App 级 store 与 `pageVisible()` 驱动、`http` 通道、`storage` 与 `cached`、包内 i18n(CLI 校验与类型)、`ui` / `linking` / `analytics` / `session`、`Icon` 与 `tinyui-icons`、`Loading`、`role` / `selected`、快照通道段,sample 同步改写 → 发 minor → TrendingAI 订阅页按新形态重写(trendingai-tinyui 删 `schema/` 与宿主封装;TrendingAI 删五个能力、props、宿主组件,`hostVersion` 加 1)。验收见 ADR-007 §5.4 diff --git a/docs/runtime-api.md b/docs/runtime-api.md index 8578434..161a570 100644 --- a/docs/runtime-api.md +++ b/docs/runtime-api.md @@ -230,7 +230,7 @@ export default function Home(props: HomeProps): Node { … } - `default` 导出是一个组件函数,K1 `mount` 时在根 owner 里调用一次;返回的节点作为页面根挂到 Kotlin 侧的根容器(id 0) - `props` 是 `navigation.push` 传来的 JSON 对象,静态,不响应式 - 顶层 `await` 只允许等同步就绪的东西(ADR-005),模块求值时仍 pending 由 K0 拒绝 -- `pageVisible()` 是一个 accessor,K1 `visible(bool)` 更新它,可在 effect 里订阅 +- `pageVisible()` 是一个 accessor,K1 `visible(bool)` 更新它,可在 effect 里订阅;Kotlin 侧由 `TinyUIPage` 跟随 Lifecycle 的 START / STOP 发 `visible`(前后台切换、被其他页面覆盖都算,app-model.md §2) - 卸载:根 owner dispose(跑业务的 `onCleanup`),不产出 patch,随后引擎关闭 ## 9. 桥入口(运行时 ↔ Kotlin 的内部契约) diff --git a/docs/updates.md b/docs/updates.md index 55d8c14..b058501 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -1,6 +1,6 @@ # 热下发:包、投递协议、发布协议与客户端 -- 状态:已定(2026-09-18;2026-09-19 加发布协议、签名、服务端形态;2026-09-19 改为多包模型——App 由 N≥1 个包组成,包名进模块名与 URL,公钥进 manifest,`Updates` 以一组包为单位;2026-09-22 修订:签名覆盖不可变 `manifest.json` 的原始字节,指针文件 `current.json` 只含 `version` / `rollout` / `signature`,启动时重算 installed 包的 sha256;2026-09-23 兼容键 `runtimeVersion` 改名 `hostVersion` 并限定为正整数;同日加 §1.3 发布前核对目标宿主版本、§6 的 app token 与宿主快照端点;2026-09-24 按 ADR-006 §2.11 修订:运行时随宿主、包只含页面,manifest 去 `runtime`,`tinyui` 从"等于宿主"改为"与宿主兼容"(同 major 且不高于宿主),宿主快照的 `tinyui` 行改为下限,单纯升 tinyui 不加 `hostVersion`);实现依据,实现待开 +- 状态:已定(2026-09-18;2026-09-19 加发布协议、签名、服务端形态;2026-09-19 改为多包模型——App 由 N≥1 个包组成,包名进模块名与 URL,公钥进 manifest,`Updates` 以一组包为单位;2026-09-22 修订:签名覆盖不可变 `manifest.json` 的原始字节,指针文件 `current.json` 只含 `version` / `rollout` / `signature`,启动时重算 installed 包的 sha256;2026-09-23 兼容键 `runtimeVersion` 改名 `hostVersion` 并限定为正整数;同日加 §1.3 发布前核对目标宿主版本、§6 的 app token 与宿主快照端点;2026-09-24 按 ADR-006 §2.11 修订:运行时随宿主、包只含页面,manifest 去 `runtime`,`tinyui` 从"等于宿主"改为"与宿主兼容"(同 major 且不高于宿主),宿主快照的 `tinyui` 行改为下限,单纯升 tinyui 不加 `hostVersion`;2026-09-25 按 ADR-007:manifest 加 `i18n`、`requires` 与宿主快照加网络通道、`hostVersion` 口径随宿主契约收窄);实现依据,实现待开 - 来源:[ADR-006](./adr-006-hot-updates.md);[build-chain.md](./build-chain.md) §2(manifest、模块名、`tinyui.config.json`)、§7(buildId 与 source map) - 四侧:CLI 产出与发布包(`tinyui build` / `bundle` / `publish`);服务端实现投递与发布两组端点(参考实现 `tinyui-updates-server`,托管实例 `updates.tinyui.app`);Kotlin 的 `Bundle`(core 库)与 `Updates`(`app.tinyui:tinyui-updates`) - 协议规范只在本文一处;服务端仓的一致性测试以本文为准,不复制。**发布后字段与端点只增不改,未知字段透传**——这是两个仓能各自演进的前提 @@ -34,9 +34,10 @@ pages/**/*.bin | `version` | `tinyui build` | 包标识,目录名,只求唯一:`-`,如 `20260918T100212Z-3f2a1c9e04b7`;`--version` 可覆盖 | | `createdAt` | `tinyui build` | ISO 8601 UTC,新旧比较只看它 | | `engine` | `tinyui build` | 字节码文件头里的引擎 commit(40 位 hex,所有 `.bin` 一致,取第一个);客户端要求等于宿主 | -| `hashes` | `tinyui build` | 模块名 → 该模块 `.bin` 的 sha256 hex,键与 `files` 一致 | +| `hashes` | `tinyui build` | 模块名 → 该模块 `.bin` 的 sha256 hex,键与 `files` 一致;另含每个 i18n 文件,键为其相对路径(`i18n/en.json`) | +| `i18n` | `tinyui build` | `{ "default": 默认语言, "files": { 语言: 相对路径 } }`,包没有 i18n 资源时省略(build-chain.md §8) | | `tinyui` | `tinyui build` | JS 工程构建时的 `tinyui-core` 版本,即页面需要的最低运行时(能用哪些内置组件与运行时 API);`publish` 核对目标宿主版本的 tinyui 下限与它兼容(§1.3),客户端核对它与 `TinyUI.version` 兼容(§4.3)。**兼容 = major 相同,且宿主的版本不低于包的**(semver `^`;0.x 之间按版本号全序比较,1.0 之前不允许破坏公开面)。成立的前提是运行时公开面只增不删(runtime-api.md §11);升 major 时宿主加 `hostVersion`(§4.1),旧 major 的包因不兼容被拒收 | -| `requires` | `tinyui build` | 页面模块名 → `{ components, capabilities }`:该页用到的带点宿主组件名与 `host.call` 能力名,各自排序;内置组件不列。`publish` 据此核对目标宿主版本(§1.3),客户端不读 | +| `requires` | `tinyui build` | 页面模块名 → `{ components, capabilities, channels }`:该页用到的带点宿主组件名、`host.call` 能力名与 `http.client` 通道名,各自排序;内置组件与 `default` 通道不列。`publish` 据此核对目标宿主版本(§1.3),客户端不读 | | `hostVersion` | `tinyui bundle` | 发布目标,等于宿主声明值;写入后文件定稿,签名覆盖它的原始字节(§7) | 签名值与 `rollout` 不在 manifest 里,在指针文件 `current.json`(§1.2)。内置包没有指针文件,不参与灰度、不验签;库不读内置包 manifest 的 `hostVersion`(宿主知道自己的值)。它由 `tinyui pull` 取来时带着这个字段,宿主的发版检查用它确认内置包是给当前宿主版本发布过的(§1.4);本地构建的包没有这个字段。 @@ -67,10 +68,10 @@ dist/ota////pages/**/*.bin | 输入 | 来源 | |---|---| -| 包用到了什么 | `tinyui build` 从每页打包产物里静态找出,写进 manifest 的 `requires`(页 → `{ components, capabilities }`):`host.call` 的能力名、带点的宿主组件名(`ta.Icon`);内置组件不列,随 tinyui 版本走(规则见 build-chain.md §5.1) | -| 目标宿主版本提供了什么 | 宿主快照 `tinyui-host/.txt`(宿主组件、能力名、tinyui 下限)。`hostVersion` 加 1 合入宿主 main 时由宿主 CI 上传到热下发服务,每个 (app, hostVersion) 只能写一次;`publish` 从服务端读 | +| 包用到了什么 | `tinyui build` 从每页打包产物里静态找出,写进 manifest 的 `requires`(页 → `{ components, capabilities, channels }`):`host.call` 的能力名、带点的宿主组件名(`ta.Icon`)、`http.client` 的通道名;内置组件与 `default` 通道不列,随 tinyui 版本走(规则见 build-chain.md §5.1) | +| 目标宿主版本提供了什么 | 宿主快照 `tinyui-host/.txt`(宿主组件、能力名、通道名、tinyui 下限)。`hostVersion` 加 1 合入宿主 main 时由宿主 CI 上传到热下发服务,每个 (app, hostVersion) 只能写一次;`publish` 从服务端读 | -不通过即拒绝发布(一个请求都不上传),报出哪一页缺了什么:页面用到的能力或宿主组件不在快照里;或快照里的 tinyui 下限与 manifest 的 `tinyui` 不兼容(§1.1;包更新:页面可能用到新版运行时的东西,这个 `hostVersion` 下最旧的宿主没有,装不上而静默跳过;major 不同:公开面已有破坏)。目标宿主版本还没有快照(宿主没上传过)也拒绝。 +不通过即拒绝发布(一个请求都不上传),报出哪一页缺了什么:页面用到的能力、通道或宿主组件不在快照里;或快照里的 tinyui 下限与 manifest 的 `tinyui` 不兼容(§1.1;包更新:页面可能用到新版运行时的东西,这个 `hostVersion` 下最旧的宿主没有,装不上而静默跳过;major 不同:公开面已有破坏)。目标宿主版本还没有快照(宿主没上传过)也拒绝。 核对的是名字,覆盖不到已有能力的参数形状与行为,那部分仍按 §4.1 的 bump 规则由人判断。绕过 `publish` 直接调发布端点的,目前拦不住;`requires` 进 manifest 并受签名覆盖,日后服务端在 `PUT current.json` 时同样可以核对。快照的上传与读取见 §6.4。 @@ -162,13 +163,13 @@ class Updates( 验签公钥不由宿主传:每个包的信任锚是它内置 manifest 里的 `publicKey`(§1.1、§7)。单包宿主写 `Updates(listOf(embedded), …)`。 -**`hostVersion` 的口径**(作用同 Expo 的 `runtimeVersion`,但只取正整数、数的是宿主的变化;不沿用那个名字,是因为 tinyui 里 runtime 已指运行时模块与引擎):宿主仓持有的递增正整数字符串(`"1"`、`"2"`……;`1.2.0` 这类 App 版本号在 CLI、`Updates`、服务端三处都被拒),语义是"这个值下发布的任何包都能在本宿主上跑";一个宿主一个值,挂在它上面的所有包共用。**它是宿主对页面承诺的版本,不是 tinyui 版本**(ADR-006 §2.11)。宿主给页面的承诺变了就加 1:宿主组件增删或改 schema;宿主能力增删,或改已有能力的参数与行为;抬高 tinyui 下限(页面要用新版运行时的东西,或升到新 major);换引擎(含随 tinyui 升级带来的 quickjs-kmp 变化,build-chain.md §5)。单纯升级 tinyui、只改宿主内部实现、其他原生页面、修崩溃,不加。包的 JS 工程发布时由 `tinyui bundle --host-version` 取这个值,不自行推导。JS 侧取错分两个方向:取成不存在或更新的值,包只是送不到(`releases list` 与客户端事件可见);取成一个仍有设备在用的旧值,旧宿主会照常装上,而页面可能用到它没有的东西——宿主构建的检查拦不住这个方向,由 `publish` 发布前核对(§1.3)。 +**`hostVersion` 的口径**(作用同 Expo 的 `runtimeVersion`,但只取正整数、数的是宿主的变化;不沿用那个名字,是因为 tinyui 里 runtime 已指运行时模块与引擎):宿主仓持有的递增正整数字符串(`"1"`、`"2"`……;`1.2.0` 这类 App 版本号在 CLI、`Updates`、服务端三处都被拒),语义是"这个值下发布的任何包都能在本宿主上跑";一个宿主一个值,挂在它上面的所有包共用。**它是宿主对页面承诺的版本,不是 tinyui 版本**(ADR-006 §2.11)。宿主给页面的承诺变了就加 1:宿主组件增删或改 schema;宿主能力增删,或改已有能力的参数与行为;网络通道增删,或改已有通道带的身份(语言、会话、埋点出口、链接打开器等框架标准接口的形状随 tinyui 走,宿主接没接、怎么实现都不加,ADR-007 §3.9);抬高 tinyui 下限(页面要用新版运行时的东西,或升到新 major);换引擎(含随 tinyui 升级带来的 quickjs-kmp 变化,build-chain.md §5)。单纯升级 tinyui、只改宿主内部实现、其他原生页面、修崩溃,不加。包的 JS 工程发布时由 `tinyui bundle --host-version` 取这个值,不自行推导。JS 侧取错分两个方向:取成不存在或更新的值,包只是送不到(`releases list` 与客户端事件可见);取成一个仍有设备在用的旧值,旧宿主会照常装上,而页面可能用到它没有的东西——宿主构建的检查拦不住这个方向,由 `publish` 发布前核对(§1.3)。 -漏加的后果是旧宿主收到跑不了的页面(不崩溃的错误不会触发 §4.5 回退),多加的后果是更早的宿主从此收不到更新。漏加只可能发生在宿主仓,由宿主构建拦:宿主快照 `tinyui-host/.txt`(宿主组件 schema、能力名、tinyui 下限)每个版本一份入库,当前宿主与当前版本的快照不符即构建失败;已合入 main 的版本,其快照冻结不可改。快照覆盖不到已有能力的参数与行为变化,这部分按上面的规则由人判断。为什么是精确匹配而不是 `>=` 范围,见 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` 结果不同,或当前 tinyui 与快照的下限不兼容(§1.1)→ 失败,提示加 `hostVersion`。`tinyui` 行只判兼容:同 major 内单纯升 tinyui 不会让测试失败,跨 major 会 +- 当前 `hostVersion` 的快照文件不存在,或组件、能力、通道三段与 `render` 结果不同,或当前 tinyui 与快照的下限不兼容(§1.1)→ 失败,提示加 `hostVersion`。`tinyui` 行只判兼容:同 major 内单纯升 tinyui 不会让测试失败,跨 major 会 - 加 `-Ptinyui.updateHostSnapshot` 跑同一个测试 → 写入当前结果,`tinyui` 行即当前 tinyui 版本,成为这个 `hostVersion` 的下限。这等于人声明"这个版本还没合入 main";本地不判断,冻结由服务端兜底:`hosts upload` 同版本不同字节得 409(§6.4) - 宿主 main 的 CI 在每次 push 时跑这个测试并 `hosts upload`(同字节幂等),`hostVersion` 加 1 的提交一进 main 快照即上传、冻结(M6 拍板第 13 点)。上传不等到发 App:发版前要先在新版本下发布包(内置包、staging 验证都要),而发布前要读快照 - 快照文件在 `.gitattributes` 里固定 `eol=lf`:上传的是原始字节,CRLF 的副本会被当成另一份快照而得 409 @@ -350,7 +351,7 @@ CLI:`tinyui apps create` / `tinyui apps tokens create|revoke` / `tinyui packag ### 6.4 宿主快照 -宿主快照(§1.3、§4.1)是 `tinyui-host/.txt` 的原始字节:该宿主版本提供的宿主组件、能力名与 tinyui 下限。第二行的 tinyui 是下限:`hostVersion` 加 1 时写入当时的 tinyui 版本,此后宿主升 tinyui 不改快照(ADR-006 §2.11)。`hostVersion` 加 1 合入宿主 main 时由其 CI 上传(§4.1),`publish` 发布前读取核对。格式是宿主侧生成工具与 CLI 之间的契约: +宿主快照(§1.3、§4.1)是 `tinyui-host/.txt` 的原始字节:该宿主版本提供的宿主组件、能力名、网络通道名与 tinyui 下限。第二行的 tinyui 是下限:`hostVersion` 加 1 时写入当时的 tinyui 版本,此后宿主升 tinyui 不改快照(ADR-006 §2.11)。`hostVersion` 加 1 合入宿主 main 时由其 CI 上传(§4.1),`publish` 发布前读取核对。格式是宿主侧生成工具与 CLI 之间的契约: ``` hostVersion 2 @@ -363,9 +364,12 @@ components capabilities checkout.start coupon.apply + +channels + app ``` -前两行固定;`components` 与 `capabilities` 两段按此顺序各出现一次,没有条目也要写段名(缺段会被读成"什么都不提供",所以一律拒绝);每项缩进两格、一行一项、按名排序,名字补空格对齐。`tinyui hosts upload` 先解析并核对首行的 `hostVersion` 与 `--host-version` 相符再上传——服务端只收第一份。**每行第一个词是名字**,CLI 核对只看名字;其后是给人看、也给宿主侧检查判断 schema 变没变的完整 schema,改哪一处都会让快照不同: +前两行固定;`components`、`capabilities`、`channels` 三段按此顺序各出现一次,没有条目也要写段名(缺段会被读成"什么都不提供",所以一律拒绝)。例外:`channels` 段随 ADR-007 加入,此前冻结的快照没有它,`tinyui` 行低于引入它的 tinyui 版本的快照缺这一段时读作不提供任何通道;每项缩进两格、一行一项、按名排序,名字补空格对齐。`tinyui hosts upload` 先解析并核对首行的 `hostVersion` 与 `--host-version` 相符再上传——服务端只收第一份。**每行第一个词是名字**,CLI 核对只看名字;其后是给人看、也给宿主侧检查判断 schema 变没变的完整 schema,改哪一处都会让快照不同: - 各段以 `; ` 分隔,依次为 props、`events …`、`commands …`、`children`、`layout`,没有的段省略 - prop 按名排序,`名字: 类型`;可选的名字后加 `?`,有默认值加 ` = 默认值`(`string` 的默认值写成 JSON 字符串,带引号与转义,免得换行或 `;` 弄乱一行一项),只在创建时读的加 ` (initial)`;枚举写 `enum(a|b)`,取值排序 From ffbb04e02368cb6d78871c4b6e94fdbfa081dde5 Mon Sep 17 00:00:00 2001 From: HarlonWang <81813780@qq.com> Date: Fri, 25 Sep 2026 17:30:50 +0800 Subject: [PATCH 2/3] =?UTF-8?q?docs(adr-007):=20=E6=8C=89=E5=AE=A1?= =?UTF-8?q?=E6=9F=A5=E8=A1=A5=20i18n=20=E6=96=87=E4=BB=B6=E7=9A=84?= =?UTF-8?q?=E6=8A=95=E9=80=92=E3=80=81=E4=BC=9A=E8=AF=9D=E8=BF=9B=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=E3=80=81=E7=BB=9F=E4=B8=80=20Navigator=20=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - updates.md:bundle 目录、投递路径与下载步骤加 i18n 文件;okio 不再是 tinyui-updates 独有 - 会话缺省时 signIn 必失败,提供与否记作能力 session.signIn 进快照与 requires - navigation.push 是 J4,认不出的路由名由 Navigator 忽略并上报,删去"由页面处理" - storage.set 的 J2 返回错误码、JS 侧据此抛错,两层写清 Co-Authored-By: Claude Opus 5.5 --- docs/adr-007-host-boundary.md | 6 +++--- docs/build-chain.md | 5 +++-- docs/native-api.md | 7 ++++--- docs/updates.md | 10 ++++++---- 4 files changed, 16 insertions(+), 12 deletions(-) diff --git a/docs/adr-007-host-boundary.md b/docs/adr-007-host-boundary.md index d2b0cf7..386201e 100644 --- a/docs/adr-007-host-boundary.md +++ b/docs/adr-007-host-boundary.md @@ -36,7 +36,7 @@ TrendingAI 订阅页(roadmap 第 6 条)是第一个真实页面。它对宿 | 框架(`app.tinyui:tinyui` + `tinyui-native`) | tinyui 版本(随宿主) | 通用原生能力的**实现**:HTTP 默认通道、按包持久化存储、toast / 对话框、打开链接、`Icon` 渲染、`Loading`、包内 i18n 加载、App 级 store、页面可见性 | | 宿主 | App 发版 | 页面物理上拿不到的:身份(网络通道里的 token)、用户语言、登录会话与登录流程、埋点出口、链接打开策略;以及真正 App 专属、需要返回值的动作(`host.call`)与必须原生渲染的视图(宿主组件) | -宿主那一层,除 `host.call` 与宿主组件外,都是框架定义形状的标准接口:宿主只提供实现,不起名字、不定参数,所以不进宿主契约,也不影响 `hostVersion`。 +宿主那一层,除 `host.call` 与宿主组件外,都是框架定义形状的标准接口:宿主只提供实现,不起名字、不定参数。其中网络通道的名字与是否提供会话进宿主快照(§3.9),其余缺省时有可用的默认行为,不进快照、不影响 `hostVersion`。 框架层的实现原则:**框架提供能力与默认实现,宿主可以接管执行,页面不感知**。网络通道与链接打开器是可接管的两处。 @@ -168,7 +168,7 @@ QuickJS 没有 `Intl`:复数、本地化数字与日期第一批不做,触 `HostServices` 删除:`navigator` 挪到 `TinyUIHost`,`locals` 改为 `TinyUIPage` 的可选参数(只给 `host.call` 的能力用)。 -宿主快照收录宿主组件、`host.call` 能力名,**新增网络通道名**;事件 topic、埋点事件名、框架标准接口不进快照。`hostVersion` 的口径随之收窄为这三类的集合及其参数与行为。事件改名后宿主静默收不到,快照拦不住,所以页面到宿主的事件要尽量少。跳原生页的路由名第一批不进快照:`Navigator` 认不出时返回失败,由页面处理。 +宿主快照收录宿主组件、`host.call` 能力名,**新增网络通道名**,以及是否提供会话(记作能力 `session.signIn`:会话缺省时 `signIn` 会失败,必须能在发布前核对);事件 topic、埋点事件名、其余标准接口(缺省时有可用的默认行为)不进快照。`hostVersion` 的口径随之收窄为这几类的集合及其参数与行为。事件改名后宿主静默收不到,快照拦不住,所以页面到宿主的事件要尽量少。跳原生页的路由名第一批不进快照:`navigation.push` 是 J4,页面拿不到结果,`Navigator` 认不出时忽略并经 `PageSink` 上报;需要页面能降级时再议。 ## 4. 决策 @@ -183,7 +183,7 @@ QuickJS 没有 `Intl`:复数、本地化数字与日期第一批不做,触 | UI 能力 | `ui.toast`、`ui.alert` / `ui.confirm`、`linking.openUrl`(宿主可接管) | | 组件 | `Icon`(path 数据,`tinyui-icons`)、`Loading`(`LoadingIndicator`)、公共 prop `role` / `selected` | | 埋点 | `analytics.track` + 宿主 `AnalyticsSink` | -| 宿主契约 | 标准接口 > `events` > `host.call` 兜底 + 宿主组件;删 `HostServices`;快照加通道段 | +| 宿主契约 | 标准接口 > `events` > `host.call` 兜底 + 宿主组件;删 `HostServices`;快照加通道段,是否提供会话记作能力 `session.signIn` | | 库的 I/O | core 库放开零 I/O,直接依赖 ktor 与 okio(ADR-006 §2.4 该句作废);`tinyui-updates` 的边界不变(仍不做网络,`fetch` 由宿主给) | ## 5. 后果与遗留 diff --git a/docs/build-chain.md b/docs/build-chain.md index c7543de..bb38146 100644 --- a/docs/build-chain.md +++ b/docs/build-chain.md @@ -88,12 +88,13 @@ rolldown 是真正的备选(Rollup 同形 API、oxc 转译)。CLI 只包一 ### 5.1 页面对宿主的使用必须能静态识别 -热下发发布前要核对"这个包用到的宿主能力、网络通道与宿主组件,目标宿主版本都提供"(updates.md §1.3),所以 `tinyui build` 必须能从页面代码里找全这些名字,三条硬规则,违反即构建失败: +热下发发布前要核对"这个包用到的宿主能力、网络通道与宿主组件,目标宿主版本都提供"(updates.md §1.3),所以 `tinyui build` 必须能从页面代码里找全这些名字,四条硬规则,违反即构建失败: - **`host.call` 的第一个参数是字符串字面量**:`host.call("billing.prices")` 可以,`host.call(`analytics.${kind}`)` 或把名字放进变量都不行,要改写成若干个明确的字面量调用。`host` 也只能以 `host.call(…)` 的形式使用,不能赋给别的变量或传出去;整体导入时(`import * as native`)只能写 `native.host.call(…)` 这类按成员的用法 +- **`session.signIn` 按成员调用**:`session.signIn(…)` 记作能力 `session.signIn` 进 `requires`;`session` 同样不能赋给别的变量或传出去 - **`http.client` 的参数是字符串字面量**:`http.client("app")` 可以,变量或模板串不行;`http` 的其他用法(`http.get` 等,走 `default` 通道)不受限。`http.client` 同样只能按成员调用,不能赋给别的变量或传出去(2026-09-25) - **宿主组件的类型能静态解析**:直接写 `` 这类标签(`tinyui schema` 生成的 `ta` 对象),或字符串字面量类型;条件选择(`cond ? ta.Icon : ta.Loading`)可以。先赋给变量再用(`const C = ta.Icon; `)、来自函数参数、运行时计算的类型都不行;`ta` 这类组件对象不能被改写、删属性或整个传出去 -- 页面里不要再声明名叫 `host`、`http`、`h` 的局部变量或参数:解析按名字进行,重名会让所有用法都无法确定 +- 页面里不要再声明名叫 `host`、`http`、`session`、`h` 的局部变量或参数:解析按名字进行,重名会让所有用法都无法确定 内置组件不受限(随 tinyui 版本走,发布前核对目标宿主的下限与包的 tinyui 兼容即可(updates.md §1.1))。放宽的出口是在 `tinyui.config.json` 里手工声明额外用到的能力与组件,现在不开,有真实需求再加。 diff --git a/docs/native-api.md b/docs/native-api.md index e731375..7b39e84 100644 --- a/docs/native-api.md +++ b/docs/native-api.md @@ -23,7 +23,7 @@ val host = TinyUIHost( TinyUIPage(module, host, propsJson = "{}", locals = emptyList(), modifier = …) ``` -除 `components`、`sink`、`dataDir` 外都可缺省,缺省行为见各节。标准接口(通道之外)的形状由框架定,不进宿主快照;进快照的只有宿主组件、`capabilities` 的名字与 `channels` 的名字(updates.md §6.4)。`host.store`、`host.events` 是框架建的 App 级实例,宿主经它们与页面交换状态和事件(§4、§5)。 +除 `components`、`sink`、`dataDir` 外都可缺省,缺省行为见各节。标准接口(通道之外)的形状由框架定,不进宿主快照;进快照的只有宿主组件、`capabilities` 的名字、`channels` 的名字,以及是否提供会话(记作能力 `session.signIn`,§9)(updates.md §6.4)。`host.store`、`host.events` 是框架建的 App 级实例,宿主经它们与页面交换状态和事件(§4、§5)。 `locals` 是这一次挂载交给 `host.call` 能力的对象(§13),只有兜底能力用得到。 @@ -36,7 +36,7 @@ TinyUIPage(module, host, propsJson = "{}", locals = emptyList(), modifier = …) | `device.info` | 对象:`os`、`osVersion`、`model` | | `store.get` | 任意(§4) | | `storage.get` | 任意(§7) | -| `storage.set` | `null`,或错误码 `E_QUOTA`(§7) | +| `storage.set` | `null`,或错误码 `"E_QUOTA"`;`tinyui-native` 收到错误码即抛 `HostError`(§7) | | `i18n.t` | 字符串(§8) | | `i18n.locale` | 字符串(§8) | | `session.get` | `{ loggedIn, userId }`(§9) | @@ -51,7 +51,7 @@ navigation.pop({ saved: true }); // J4 → Navigator.pop(resultJs navigation.onResult((result, from) => …); // 渲染期调用;K5 topic navigation.result ``` -宿主实现 `Navigator` 接口,交给 `TinyUIHost`;把结果送回上一页时,宿主持有该页的 `PageHost`(`TinyUIPage` 的 `onHost` 回调拿到),调 `host.emit("navigation.result", json)`,`json` 形如 `{ "result": …, "from": "subscription/detail" }`。缺省 `Navigator.None`(调用无效果)。跳原生页的路由名由 `Navigator` 解释,认不出时忽略,不进快照。 +宿主实现 `Navigator` 接口,交给 `TinyUIHost`;把结果送回上一页时,宿主持有该页的 `PageHost`(`TinyUIPage` 的 `onHost` 回调拿到),调 `host.emit("navigation.result", json)`,`json` 形如 `{ "result": …, "from": "subscription/detail" }`。缺省 `Navigator.None`(调用无效果)。跳原生页的路由名由 `Navigator` 解释,不进快照;`push` 是 J4,页面拿不到结果,认不出的名字由 `Navigator` 忽略并经 `PageSink` 上报。 ## 4. `store` @@ -163,6 +163,7 @@ class SessionSource(val state: StateFlow, val signIn: suspend (source: - J2 `session.get` 取初值,K5 topic `session` 推送变化;框架订阅 `state`,推给所有活页面,页面不重建 - `userId` 是稳定、非敏感的用户标识,不是 token - 宿主没提供 `session` 时:`state()` 恒为 `{ loggedIn: false, userId: null }`,`signIn` 以 `E_UNSUPPORTED` reject +- 提供了 `session` 的宿主,快照的能力段多一行 `session.signIn`;调用了 `session.signIn` 的页面把它记进 `requires`,发布前照能力名核对(updates.md §1.3)。提供或撤掉会话都是宿主契约变化,`hostVersion` 加 1 ## 10. `ui` diff --git a/docs/updates.md b/docs/updates.md index b058501..6559f85 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -54,6 +54,7 @@ tinyui bundle --host-version --signing-key <私钥 PEM> [--rollout dist/ota///current.json 指针:{ "version", "rollout", "signature" } dist/ota////manifest.json build 的 manifest + hostVersion,写定后不再变 dist/ota////pages/**/*.bin +dist/ota////i18n/.json manifest 的 i18n 列出的文件(有才写) ``` `current.json` 只有三个字段:`version` 指向哪个目录;`rollout` 0~100 的整数,缺省 100,服务端可改;`signature` 是对 `/manifest.json` 原始字节的签名(§7)。指针里只有投递策略与签名值,没有任何需要签名保护的字段。 @@ -68,7 +69,7 @@ dist/ota////pages/**/*.bin | 输入 | 来源 | |---|---| -| 包用到了什么 | `tinyui build` 从每页打包产物里静态找出,写进 manifest 的 `requires`(页 → `{ components, capabilities, channels }`):`host.call` 的能力名、带点的宿主组件名(`ta.Icon`)、`http.client` 的通道名;内置组件与 `default` 通道不列,随 tinyui 版本走(规则见 build-chain.md §5.1) | +| 包用到了什么 | `tinyui build` 从每页打包产物里静态找出,写进 manifest 的 `requires`(页 → `{ components, capabilities, channels }`):`host.call` 的能力名与 `session.signIn`(页面调用了它时)、带点的宿主组件名(`ta.Icon`)、`http.client` 的通道名;内置组件与 `default` 通道不列,随 tinyui 版本走(规则见 build-chain.md §5.1) | | 目标宿主版本提供了什么 | 宿主快照 `tinyui-host/.txt`(宿主组件、能力名、通道名、tinyui 下限)。`hostVersion` 加 1 合入宿主 main 时由宿主 CI 上传到热下发服务,每个 (app, hostVersion) 只能写一次;`publish` 从服务端读 | 不通过即拒绝发布(一个请求都不上传),报出哪一页缺了什么:页面用到的能力、通道或宿主组件不在快照里;或快照里的 tinyui 下限与 manifest 的 `tinyui` 不兼容(§1.1;包更新:页面可能用到新版运行时的东西,这个 `hostVersion` 下最旧的宿主没有,装不上而静默跳过;major 不同:公开面已有破坏)。目标宿主版本还没有快照(宿主没上传过)也拒绝。 @@ -102,6 +103,7 @@ tinyui pull --channel production --host-version --out <宿主资源目录>/< | `//current.json` | 可变指针 | `Cache-Control: no-store` | | `///manifest.json` | 不可变,签名覆盖其原始字节 | `Cache-Control: public, max-age=31536000, immutable` | | `///.bin` | 不可变内容 | 同上 | +| `///` | 不可变内容(包内 i18n 资源,build-chain.md §8) | 同上 | - `pkg` 库从内置 manifest 读,`hostVersion` 宿主给;宿主只拼 base,一个 App 不管几个包都是一个 base URL、一个 `fetch` - 请求不带 query、不带自定义 header、不带任何设备信息;宿主的 `fetch` 实现可以自行加 header 或按路径分流到不同来源,库不知情 @@ -163,7 +165,7 @@ class Updates( 验签公钥不由宿主传:每个包的信任锚是它内置 manifest 里的 `publicKey`(§1.1、§7)。单包宿主写 `Updates(listOf(embedded), …)`。 -**`hostVersion` 的口径**(作用同 Expo 的 `runtimeVersion`,但只取正整数、数的是宿主的变化;不沿用那个名字,是因为 tinyui 里 runtime 已指运行时模块与引擎):宿主仓持有的递增正整数字符串(`"1"`、`"2"`……;`1.2.0` 这类 App 版本号在 CLI、`Updates`、服务端三处都被拒),语义是"这个值下发布的任何包都能在本宿主上跑";一个宿主一个值,挂在它上面的所有包共用。**它是宿主对页面承诺的版本,不是 tinyui 版本**(ADR-006 §2.11)。宿主给页面的承诺变了就加 1:宿主组件增删或改 schema;宿主能力增删,或改已有能力的参数与行为;网络通道增删,或改已有通道带的身份(语言、会话、埋点出口、链接打开器等框架标准接口的形状随 tinyui 走,宿主接没接、怎么实现都不加,ADR-007 §3.9);抬高 tinyui 下限(页面要用新版运行时的东西,或升到新 major);换引擎(含随 tinyui 升级带来的 quickjs-kmp 变化,build-chain.md §5)。单纯升级 tinyui、只改宿主内部实现、其他原生页面、修崩溃,不加。包的 JS 工程发布时由 `tinyui bundle --host-version` 取这个值,不自行推导。JS 侧取错分两个方向:取成不存在或更新的值,包只是送不到(`releases list` 与客户端事件可见);取成一个仍有设备在用的旧值,旧宿主会照常装上,而页面可能用到它没有的东西——宿主构建的检查拦不住这个方向,由 `publish` 发布前核对(§1.3)。 +**`hostVersion` 的口径**(作用同 Expo 的 `runtimeVersion`,但只取正整数、数的是宿主的变化;不沿用那个名字,是因为 tinyui 里 runtime 已指运行时模块与引擎):宿主仓持有的递增正整数字符串(`"1"`、`"2"`……;`1.2.0` 这类 App 版本号在 CLI、`Updates`、服务端三处都被拒),语义是"这个值下发布的任何包都能在本宿主上跑";一个宿主一个值,挂在它上面的所有包共用。**它是宿主对页面承诺的版本,不是 tinyui 版本**(ADR-006 §2.11)。宿主给页面的承诺变了就加 1:宿主组件增删或改 schema;宿主能力增删,或改已有能力的参数与行为;网络通道增删,或改已有通道带的身份;提供或撤掉会话(快照里记作能力 `session.signIn`)。语言、埋点出口、链接打开器等框架标准接口缺省时有可用的默认行为,形状随 tinyui 走,宿主接没接、怎么实现都不加(ADR-007 §3.9);抬高 tinyui 下限(页面要用新版运行时的东西,或升到新 major);换引擎(含随 tinyui 升级带来的 quickjs-kmp 变化,build-chain.md §5)。单纯升级 tinyui、只改宿主内部实现、其他原生页面、修崩溃,不加。包的 JS 工程发布时由 `tinyui bundle --host-version` 取这个值,不自行推导。JS 侧取错分两个方向:取成不存在或更新的值,包只是送不到(`releases list` 与客户端事件可见);取成一个仍有设备在用的旧值,旧宿主会照常装上,而页面可能用到它没有的东西——宿主构建的检查拦不住这个方向,由 `publish` 发布前核对(§1.3)。 漏加的后果是旧宿主收到跑不了的页面(不崩溃的错误不会触发 §4.5 回退),多加的后果是更早的宿主从此收不到更新。漏加只可能发生在宿主仓,由宿主构建拦:宿主快照 `tinyui-host/.txt`(宿主组件 schema、能力名、通道名、tinyui 下限)每个版本一份入库,当前宿主与当前版本的快照不符即构建失败;已合入 main 的版本,其快照冻结不可改。快照覆盖不到已有能力的参数与行为变化,这部分按上面的规则由人判断。为什么是精确匹配而不是 `>=` 范围,见 ADR-006 §2.2。 @@ -181,7 +183,7 @@ class Updates( **接入约定:`check()` 在任何 TinyUI 页面挂载之前调用**(App 启动即调)。回滚(一个新 version)对已装上的用户生效靠的是下一次 `check()` 装上它;页面若先于它崩溃,回滚永远送不到。进程级崩溃(native 段错误)不在 §4.5 的回退范围内,出口就是灰度 + 回滚 + 这条约定;挂载标记见 ADR-006 §4.3 推迟项。 -文件与 sha256 用 okio(`FileSystem` + `ByteString.sha256`),是本 artifact 独有的依赖,core 库不引入。ECDSA 验签走平台 API(§7)。 +文件与 sha256 用 okio(`FileSystem` + `ByteString.sha256`);core 库自 ADR-007 起也依赖 okio(`storage`)。ECDSA 验签走平台 API(§7)。 ### 4.2 API 面 @@ -245,7 +247,7 @@ fetch //current.json ─解析失败──────── │ / engine ≠ QuickJs.upstreamCommit / tinyui 与 TinyUI.version 不兼容 ▶ Skipped(incompatible) // 发错目录,上报 ├─ createdAt ≤ embedded.createdAt(内置可用)────────────────────────▶ Skipped(older-than-embedded) │ - ▼ 逐文件 fetch ///.bin → /staging//,每个核对 sha256 + ▼ 逐文件 fetch ///(files 的 .bin 与 i18n.files)→ /staging//,每个核对 sha256 ├─ 任一失败 ─删 staging──────────────────────────────────────────────▶ Failed(download | integrity) ▼ manifest 原始字节写入 staging//manifest.json,staging/ 改名 installed/,state.installed = version,删其他 installed ▼ Installed(version) // 下次启动生效 From 9b178fa059608d9d8a535de5040a8a60647af89d Mon Sep 17 00:00:00 2001 From: HarlonWang <81813780@qq.com> Date: Fri, 25 Sep 2026 17:36:22 +0800 Subject: [PATCH 3/3] =?UTF-8?q?docs(native-api):=20=E5=86=99=E6=98=8E=20se?= =?UTF-8?q?ssion.signIn=20=E6=98=AF=E6=A1=86=E6=9E=B6=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E5=90=8D=EF=BC=8C=E5=BF=AB=E7=85=A7=E9=87=8C=E7=9A=84=E5=90=8C?= =?UTF-8?q?=E5=90=8D=E8=A1=8C=E5=8F=AA=E5=8F=AF=E8=83=BD=E6=9D=A5=E8=87=AA?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/native-api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/native-api.md b/docs/native-api.md index 7b39e84..09e8daf 100644 --- a/docs/native-api.md +++ b/docs/native-api.md @@ -163,7 +163,7 @@ class SessionSource(val state: StateFlow, val signIn: suspend (source: - J2 `session.get` 取初值,K5 topic `session` 推送变化;框架订阅 `state`,推给所有活页面,页面不重建 - `userId` 是稳定、非敏感的用户标识,不是 token - 宿主没提供 `session` 时:`state()` 恒为 `{ loggedIn: false, userId: null }`,`signIn` 以 `E_UNSUPPORTED` reject -- 提供了 `session` 的宿主,快照的能力段多一行 `session.signIn`;调用了 `session.signIn` 的页面把它记进 `requires`,发布前照能力名核对(updates.md §1.3)。提供或撤掉会话都是宿主契约变化,`hostVersion` 加 1 +- 提供了 `session` 的宿主,快照的能力段多一行 `session.signIn`;调用了 `session.signIn` 的页面把它记进 `requires`,发布前照能力名核对(updates.md §1.3)。提供或撤掉会话都是宿主契约变化,`hostVersion` 加 1。`session.signIn` 是框架名,`host.call` 注册同名能力在 `register` 时报错(§13),所以快照里的这一行只可能来自会话 ## 10. `ui`