Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions compose/src/commonTest/kotlin/app/tinyui/PageHostTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 } })); }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ Low · Kotlin example keeps an unversioned route

When the tagged-request fixture runs, onTagged still calls /code, and the fake channel only recognizes that route, so the Kotlin test continues to demonstrate a path outside the stated /api/v1/... convention.

Change the fixture URL and its fake-channel matcher to a path such as /api/v1/code.

Prompt for AI agents
In `compose/src/commonTest/kotlin/app/tinyui/PageHostTest.kt` at line 64:

**Kotlin example keeps an unversioned route**

When the tagged-request fixture runs, `onTagged` still calls `/code`, and the fake channel only recognizes that route, so the Kotlin test continues to demonstrate a path outside the stated `/api/v1/...` convention.

Change the fixture URL and its fake-channel matcher to a path such as `/api/v1/code`.

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")]); }
},
Expand Down Expand Up @@ -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()
}
Expand Down
2 changes: 1 addition & 1 deletion docs/app-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,就是为"在返回栈里、此刻没被组合的导航页"设计的):

Expand Down
10 changes: 5 additions & 5 deletions docs/native-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"] // 交给宿主自己的人机验证拦截,标签本身不上网
…
}
}
Expand Down Expand Up @@ -304,15 +304,15 @@ 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`),宿主不参与
- `publicKey` 是 X.509 SubjectPublicKeyInfo 的 DER 的 base64,即 `-----BEGIN PUBLIC KEY-----` 里那段(换行可留);裸 PKCS#1 `RSAPublicKey` 不收。iOS 只认 PKCS#1,框架从 X.509 里取出来再交给系统,两端同一个入参
- `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 加(注册流程触发:密码在第一步就转成密文,明文不离开那一页)
2 changes: 1 addition & 1 deletion docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | 出现第一次真实的轮换诉求,或托管实例接入外部租户 |
Expand Down
2 changes: 1 addition & 1 deletion packages/native/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
Expand Down
4 changes: 2 additions & 2 deletions packages/native/test/native.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading