diff --git a/compose/src/commonTest/kotlin/app/tinyui/PageHostTest.kt b/compose/src/commonTest/kotlin/app/tinyui/PageHostTest.kt index af59273..12f54b8 100644 --- a/compose/src/commonTest/kotlin/app/tinyui/PageHostTest.kt +++ b/compose/src/commonTest/kotlin/app/tinyui/PageHostTest.kt @@ -61,7 +61,7 @@ class PageHostTest { // 2026-01-01 and 2026-07-01 UTC: either side of daylight saving if (event === "onZone") patches.push(["p",1,"text", [__host_query("i18n.timeZone", "{}"), __host_query("i18n.zoneOffset", JSON.stringify({ ms: 1767225600000 })), __host_query("i18n.zoneOffset", JSON.stringify({ ms: 1782864000000 }))].join("|")]); if (event === "onI18nSubscribe") { __host_send("i18n.subscribe", "{}"); patches.push(["p",1,"text","subscribed"]); } - if (event === "onTagged") { __host_call("http.request", 21, JSON.stringify({ channel: "app", method: "POST", url: "/code", body: {}, tags: { captchaScene: "USER_REGISTER" } })); __host_call("http.request", 22, JSON.stringify({ channel: "app", method: "GET", url: "/code", tags: { n: 1 } })); } + if (event === "onTagged") { __host_call("http.request", 21, JSON.stringify({ channel: "app", method: "POST", url: "/code", body: {}, tags: { captchaScene: "signup" } })); __host_call("http.request", 22, JSON.stringify({ channel: "app", method: "GET", url: "/code", tags: { n: 1 } })); } if (event === "onEncrypt") { __host_call("crypto.rsaEncrypt", 23, JSON.stringify({ text: "s3cret", publicKey: payloadJson })); __host_call("crypto.rsaEncrypt", 24, JSON.stringify({ text: "s3cret", publicKey: "bm9wZQ==" })); } if (event === "onSeq") { seq.push(JSON.parse(payloadJson).i); patches.push(["p",1,"text","seq " + seq.length + " " + (seq.every((v, i) => v === i) ? "ordered" : "shuffled")]); } }, @@ -155,7 +155,7 @@ class PageHostTest { host.dispatch(2, "onTagged", "{}") host.await { (host.tree.node(1)!!.props["text"] as String).count { it == ';' } == 1 } val text = host.tree.node(1)!!.props["text"] as String - assertTrue(text.contains("ok 21") && text.contains("USER_REGISTER"), text) + assertTrue(text.contains("ok 21") && text.contains("signup"), text) assertTrue(text.contains("rejected 22") && text.contains("E_INVALID"), text) host.close() } diff --git a/docs/app-model.md b/docs/app-model.md index 716b27e..d4562a5 100644 --- a/docs/app-model.md +++ b/docs/app-model.md @@ -49,7 +49,7 @@ JS 侧不存在长生命周期的"应用对象":没有 `app.tsx`、没有 `onL ## 5. 页面随导航条目存活与返回栈回收 -2026-10-28 定(PingPong 注册流程触发:从第二步返回第一步,填好的邮箱密码不能没了)。**页面实例跟着导航条目活,和原生页一样**:条目在栈里,引擎、节点树、组件里用户输入的东西都在;条目出栈才关。不让业务把表单草稿塞进 `store` 自救——那是业务替框架补课。 +2026-10-28 定(注册流程触发:从第二步返回第一步,填好的邮箱密码不能没了)。**页面实例跟着导航条目活,和原生页一样**:条目在栈里,引擎、节点树、组件里用户输入的东西都在;条目出栈才关。不让业务把表单草稿塞进 `store` 自救——那是业务替框架补课。 机制是 Compose 官方的 `retain`(`androidx.compose.runtime:runtime-retain`,就是为"在返回栈里、此刻没被组合的导航页"设计的): diff --git a/docs/native-api.md b/docs/native-api.md index 1b51f26..6933dc9 100644 --- a/docs/native-api.md +++ b/docs/native-api.md @@ -114,17 +114,17 @@ 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`(解析规则同上) -- `tags`:页面写给宿主通道的备注,**任何通道都不把它发上网**(2026-10-28 加,注册流程触发:页面经通道发的验证码请求要告诉宿主"这个要过极验、场景是哪个")。值只能是字符串,否则 `E_INVALID`;含义由页面与那个宿主的通道约定,框架不定义任何键,`default` 与 `KtorChannel` 忽略它;不进 manifest(不是宿主契约,宿主不认识的键照常发请求)。HTTP 本身没有这个概念,这是客户端库的惯例:OkHttp `Request.tag()`、Retrofit `@Tag`、Ktor `attributes`。`KtorChannel` 暂不把它转成 ktor `attributes`(宿主插件读不到):现有宿主都自写通道,等第一个需要的再加,不破坏接口 +- `tags`:页面写给宿主通道的备注,**任何通道都不把它发上网**(2026-10-28 加,注册流程触发:页面经通道发的验证码请求要告诉宿主"这个要过人机验证、场景是哪个")。值只能是字符串,否则 `E_INVALID`;含义由页面与那个宿主的通道约定,框架不定义任何键,`default` 与 `KtorChannel` 忽略它;不进 manifest(不是宿主契约,宿主不认识的键照常发请求)。HTTP 本身没有这个概念,这是客户端库的惯例:OkHttp `Request.tag()`、Retrofit `@Tag`、Ktor `attributes`。`KtorChannel` 暂不把它转成 ktor `attributes`(宿主插件读不到):现有宿主都自写通道,等第一个需要的再加,不破坏接口 - 通道名 `default` 保留,宿主不能注册;页面用了宿主没注册的名字 → `E_UNSUPPORTED`。`http.client` 的参数必须是字符串字面量(build-chain.md §5.1),通道名随 `requires` 进 manifest、发布前对照快照核对 ```ts -await api.post("/api/app/v1/auth/send-code", { phone }, { tags: { captchaScene: "USER_REGISTER" } }); +await api.post("/api/v1/auth/send-code", { phone }, { tags: { captchaScene: "signup" } }); ``` ```kotlin class AppChannel(…) : HttpChannel { override suspend fun request(request: HttpRequest): HttpResponse { - val scene = request.tags["captchaScene"] // 交给宿主自己的极验拦截,标签本身不上网 + val scene = request.tags["captchaScene"] // 交给宿主自己的人机验证拦截,标签本身不上网 … } } @@ -304,7 +304,7 @@ await ui.toast(i18n.t("copied")) // 要不要提示由页面决定 ```ts const cipher = await crypto.rsaEncrypt(password, { publicKey }) // base64 进,base64 出 -await api.post("/api/app/v1/auth/register", { email, password: cipher }) +await api.post("/api/v1/auth/register", { email, password: cipher }) ``` - J3 `crypto.rsaEncrypt`,参数 `{ text, publicKey }`:RSA 公钥加密,PKCS#1 v1.5 填充,`text` 按 UTF-8 编码;resolve 密文的 base64。框架实现(Android `Cipher("RSA/ECB/PKCS1Padding")`,iOS `SecKeyCreateEncryptedData`),宿主不参与 @@ -312,7 +312,7 @@ await api.post("/api/app/v1/auth/register", { email, password: cipher }) - `text` 最多「密钥字节数 − 11」字节(2048 位密钥 245 字节),超长、公钥读不出或不是 RSA → `E_INVALID`;两端在同一处检查,报错一致 - 填充带随机数:同一输入每次密文都不同,不能拿密文比对是否相等;验证只能用私钥解回来 - 公钥须是完整合法的 X.509(各层无多余字节、带指数),且不少于 1024 位(512 / 768 位已被分解);不合格一律 `E_INVALID`,不交给平台再报别的码 -- 选 PKCS#1 v1.5 是为了兼容现有后端(PingPong 现网密码加密即此)。它的已知风险在解密端:服务端若让人分辨出「填充错误」与其他错误,就可能被 Bleichenbacher 式选择密文攻击;解密接口要对所有失败回同一个响应。后端支持后改用 OAEP(新增参数,不改默认) +- 选 PKCS#1 v1.5 是为了兼容仍在用它的后端。它的已知风险在解密端:服务端若让人分辨出「填充错误」与其他错误,就可能被 Bleichenbacher 式选择密文攻击;解密接口要对所有失败回同一个响应。后端支持后改用 OAEP(新增参数,不改默认) - 为什么是框架能力而不是 `host.call`:加密是通用原生能力,第二个宿主一样要;在 QuickJS 里跑 JS 的 RSA 库既慢,又把加密实现放进了可热下发的包里。公钥由页面经自己的通道取、页内缓存,换钥只改页面 - 只做 RSA 公钥加密一项;OAEP、`sha256`、`hmac`、对称加密等第一个需要的页面出现时再加,参数对象为它们留了位置 - 2026-10-28 加(注册流程触发:密码在第一步就转成密文,明文不离开那一页) diff --git a/docs/roadmap.md b/docs/roadmap.md index 5feb1e1..10c087c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -55,7 +55,7 @@ | 命令回执(`cmd()` 返回 Promise) | ADR-004 | 出现必须知道结果的命令 | | 窗口化 `For` | ADR-003 | 出现万行级列表需求 | | 返回栈深页引擎回收策略 | ADR-002 | 真机内存数据出来后定,可能提前。页面随导航条目存活已完成(2026-10-28,`retain`,[app-model.md](./app-model.md) §5):回收即宿主对最深条目 `clearChild` | -| 页面预载(导航前 `start()` 到挂载完成) | app-model.md §5 | 首屏实测超预算(PingPong 注册首屏 ≤ 300 ms 门槛) | +| 页面预载(导航前 `start()` 到挂载完成) | app-model.md §5 | 首屏实测超出宿主的预算 | | 应用级服务 Runtime(不挂 UI、随 App 生命周期的引擎) | app-model.md | 业务出现不属于任何页面的常驻 JS 逻辑 | | 手势组合内置组件 | ADR-004 | 业务需求出现 | | 公钥轮换不中断旧 App:预置备用钥(`publicKeys` 为一组,客户端任一内置钥验过即可,服务端登记一组并可吊销单把,manifest 记签名用的钥) | updates.md §7 | 出现第一次真实的轮换诉求,或托管实例接入外部租户 | diff --git a/packages/native/src/http.ts b/packages/native/src/http.ts index 3e858ba..f0c14f5 100644 --- a/packages/native/src/http.ts +++ b/packages/native/src/http.ts @@ -6,7 +6,7 @@ export interface HttpOptions { /** ms; rejects with E_TIMEOUT */ timeout?: number; /** - * Notes for the host's channel, never sent: no channel puts them on the wire (`{ captchaScene: "USER_REGISTER" }`). + * Notes for the host's channel, never sent: no channel puts them on the wire (`{ captchaScene: "signup" }`). * What a tag means is between the page and that host's channel; the built-in `default` channel ignores them. */ tags?: Record; diff --git a/packages/native/test/native.test.ts b/packages/native/test/native.test.ts index 4cd90ee..c0c2554 100644 --- a/packages/native/test/native.test.ts +++ b/packages/native/test/native.test.ts @@ -32,8 +32,8 @@ describe("tinyui-native", () => { }); it("tags ride along for the host's channel", () => { - void http.client("app").post("/api/app/v1/code", { phone: "1" }, { tags: { captchaScene: "USER_REGISTER" } }); - assert.deepEqual(bridge.calls.at(-1)!.args, { channel: "app", method: "POST", url: "/api/app/v1/code", body: { phone: "1" }, tags: { captchaScene: "USER_REGISTER" } }); + void http.client("app").post("/api/v1/code", { phone: "1" }, { tags: { captchaScene: "signup" } }); + assert.deepEqual(bridge.calls.at(-1)!.args, { channel: "app", method: "POST", url: "/api/v1/code", body: { phone: "1" }, tags: { captchaScene: "signup" } }); }); it("storage reads and writes through J2; a returned code throws", () => {