diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 390c2628..3428e602 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -57,3 +57,9 @@ jobs: -lpthread \ -o unit_test ./unit_test + + # Golden-vector parity between src/updateFlowCore.ts (reference) and + # the cpp/update_flow_core port. The vectors themselves are kept in + # sync with the TS side by src/__tests__/flowVectors.test.ts (js-test). + - name: Replay flow golden vectors against the C++ port + run: SANITIZE=1 ./scripts/test-update-flow-core.sh diff --git a/NATIVE_CHECKUPDATE_DESIGN.md b/NATIVE_CHECKUPDATE_DESIGN.md index d8d7bb63..4cf13396 100644 --- a/NATIVE_CHECKUPDATE_DESIGN.md +++ b/NATIVE_CHECKUPDATE_DESIGN.md @@ -1,8 +1,15 @@ # 原生检测更新设计:让砖机也能被修好 -> 状态:设计草案,待评审 +> 状态:**实现完成(2026-08-07,e34f390..24f3adf)**——三端编排全部落地: +> 标定层 syncNativeConfig、iOS RCTPushyOrchestrator、Android +> NativeCheckOrchestrator(librnupdate.so 已重编 4 ABI 并过符号/对齐校验)、 +> Harmony NativeCheckOrchestrator.ts + NAPI,§10.3 的 JS 响应缓存复用 +> (getNativeCheckCache,2 分钟 TTL)也已闭环。剩余:e2e 用例(沿用 +> Example/e2etest 基建,验证"坏 bundle 下原生仍拉到修复版"端到端场景)、 +> README/CHANGELOG、发版。 > 取代:`REMOTE_RESET_DESIGN.md`(本地启动熔断方案,已放弃,理由见 §1.2) -> 前置:`BUNDLEHASH_DESIGN.md`(设计定稿、待实施)——协议下沉前必须先定稿协议 +> 前置:bundleHash 迁移 Phase 0/1/2 已上线且 buildTime 永久保留作 fallback 已定稿 +> (2026-08-03)——checkUpdate 的 wire protocol 已稳定,协议前置解除 > 关联:[[native-update-reset-design]] 的 Phase 3 --- @@ -82,9 +89,35 @@ A 直接淘汰。B 是 [[native-update-reset-design]] 里 Phase 3 的原计划 **B 与 C 的差别只有一个:检测逻辑本身出 bug 时,能不能不发新 binary 就修好。** +补充两点评审结论(2026-08-06):其一,B 并不消灭 TS 实现——app JS 侧的交互 +流程仍要用同一套决策逻辑,所以 B 实际是 **TS + C++ 双实现长期同步**(每次协 +议演进都要重编预编译 `.so`),而 C 是同一份 TS 源两处复用,这是 C 相对 B 的 +另一日常优势。其二,C 的独占收益比上表暗示的窄:只要 JS 侧 `checkUpdate` 保 +留为全功能回退(§6 是复用结果、不是拆除),原生检测逻辑出 bug 的最坏情形是 +正常设备仍被 JS 路径救起、只有砖机在窗口期救不了——即退回现状,而非"全网 +瘫"。真正集中风险的是原生**编排**代码(R2),而那部分 B / C 完全相同, +guardian 救不了它。 + +> **最终裁决(2026-08-07):选 B,三端统一 C++,金标向量作为双实现的强制契 +> 约(已实现)。** 推理链:引擎方案的全部价值 = 单一事实来源;Android 宿主 +> Hermes 被符号侦察排除、javascriptengine 因可用性是运行时属性只能当可选优 +> 化——一个必定可用的 C++ 实现无论如何要存在;一旦如此,混合形态(iOS/ +> Harmony 求值 + Android C++)要养两套机制,统一 C++ 的改动次数相同却删掉了 +> 求值器机制整个类别;QuickJS 统一保单源要付 200KB×4 + 三端引擎集成,只换 +> 来"少改一处代码",而那处改动有向量护栏,是机械劳动。 +> "单源"要保的性质是**语义唯一且被机械强制**,由金标向量提供: +> `src/updateFlowCore.ts` 是参照实现(oracle), +> `scripts/generate-flow-vectors.ts` 产出 +> `cpp/update_flow_core/tests/flow_vectors.json`(金标向量集), +> `src/__tests__/flowVectors.test.ts` 钉住 TS 与向量文件一致, +> `scripts/test-update-flow-core.sh`(CI cpp-test job,带 ASan/UBSan)钉住 +> C++ 移植与向量一致。**纪律:语义改动先落 TS → 重新生成向量 → 移植 C++, +> 两侧不绿不发版。** C++ 侧的 `flow_json` 按 JS 语义实现(插入序对象、 +> undefined ≠ null、JS truthiness / 严格相等),移植才能逐字节对齐。 + --- -## 5. Guardian bundle +## 5. Guardian bundle(已否决路线,留档防止重新发明;裁决见 §4) ### 5.1 关键设计约束:它必须是纯函数,不做 IO @@ -93,31 +126,93 @@ A 直接淘汰。B 是 [[native-update-reset-design]] 里 Phase 3 的原计划 正确的边界是:**guardian 只做决策,IO 全部由原生执行。** ``` -原生 ──> guardian.buildCheckRequest(state) ──> { url, headers, body } -原生 <── (发 HTTP,各端现成客户端) +原生 ──> guardian.buildCheckRequest(state) ──> { endpoints, queryUrls, path, headers, body, timeoutMs } +原生 <── (按 endpoints 顺序逐个请求,单个超时即换下一个; + 首个失败后拉 queryUrls 合并远程候选、排除已失败的,继续顺序尝试) 原生 ──> guardian.decide(state, responseText) ──> { action, hash, url, type } | { action: 'none' } 原生 <── (下载 + patch_core 应用 + state_core switchVersion,全是现成的) 原生 ──> guardian.onOutcome(state, result) ──> { nextState } ``` +**endpoint 计划是声明式的**(初稿此处只画了"一问一答",漏掉了 JS 侧真实存在的 +多 endpoint 回退与远程 endpoint 发现):候选排序(随机首选分摊负载 + 配置序回 +退 + 失败排除)是纯策略,由 `orderEndpointCandidates` 给出,随机数由原生作为 +输入注入(guardian 不可自取随机)。**原生侧刻意不实现 JS 交互路径的 hedged +race**(`src/endpoint.ts` 的 250ms 错峰竞速):原生检测跑在冷启动后台、结果下 +次启动才生效,延迟不敏感(§7 R5 本来就要求延迟数秒),顺序回退 + 单请求超时 +就够了——换来的是三端各自的执行引擎退化为一个 for 循环,无定时器、无 abort +协调。两条路径共享同一份候选排序策略,只在并发形态上分叉。 + 这个边界带来的简化是决定性的: -- **不需要事件循环、不需要 Promise、不需要注入 HTTP** —— 只是同步求值一个小 HBC 再调几个函数 +- **不需要事件循环、不需要 Promise、不需要注入 HTTP** —— 只是同步求值一段小 JS 再调几个函数 - **隔离性天然成立** —— guardian 与 app bundle 是两个独立的 parse 单元、独立求值;app bundle 的语法错误跟它毫无关系 -- 灰度分桶(`isInRollout`)、diff→pdiff→full 选择、`expVersion` 解析、URL 拼接这些**已经是纯函数了**(`src/isInRollout.ts`、`src/resolveCheckResult.ts`),可以几乎原样搬过去 +- 灰度分桶、diff→pdiff→full 选择、`expVersion` 解析、URL 拼接、请求体构造、 + endpoint 排序**已全部抽为纯函数并在 JS 侧原地使用**(`src/updateFlowCore.ts`: + `buildCheckRequestBody` / `resolveCheckResult` / `decideDownload` / + `isInRollout` / `joinUrls` / `orderEndpointCandidates`,import 闭包为纯, + 可在裸引擎中求值),guardian 直接复用同一份源码 ### 5.2 运行时 -在后台线程创建一个 `hermes::makeHermesRuntime()`,求值 guardian 的 HBC,调用导出的函数,用完销毁。全同步,毫秒级。 - -放在 `cpp/` 里与 `patch_core` / `state_core` 同级,三端共享同一份 C++;各端只提供 HTTP 客户端(Android OkHttp、iOS NSURLSession、Harmony `@ohos.net.http` —— 都已在用)和文件路径。 - -**主要风险**:直接链接 libhermes 的 C++ API 在 RN 各版本间会变,可能出现符号/ABI 兼容问题。Android 的 `librnupdate.so` 是预编译产物(4 个 ABI),链上 Hermes 后与宿主 RN 的 Hermes 版本耦合。**这是 C 相对 B 的主要代价,需要先做一个链接可行性验证再决定**。 - -备选:不用 Hermes,用一个极小的嵌入式 JS 引擎(QuickJS 约 200KB)。彻底解耦宿主 RN,代价是包体增加与另一套字节码工具链。 +在后台线程创建一个 JS 运行时,求值 guardian 源码,调用导出的函数,用完销毁。全同步,毫秒级。 + +**已定稿(2026-08-06):guardian 以纯文本 JS 源码分发与求值,不用 HBC。** +Hermes 字节码格式不跨版本稳定——若分发 HBC,服务端覆盖包必须按宿主 Hermes +版本分桶,基线包也与构建期 hermesc 版本绑死,复杂度远超收益。源码求值走的 +是慢速路径,但 guardian 每次冷启动只求值一次、代码量千行级,毫秒级完全可接 +受。代价:宿主 libhermes 若编译时裁掉了源码编译器则不可用——这归入下面的链 +接可行性验证。 + +**源码可移植性已验证(2026-08-07)**:`src/updateFlowCore.ts` 经 +`bun build --format=cjs` 打成 6.4KB 单文件纯文本 JS(零依赖、零 require), +在三个裸引擎下对同一驱动脚本的输出**逐字节一致**且全部正确——macOS 系统 +`jsc`(与 iOS JavaScriptCore 同源)、Hermes VM(RN 0.73 配套版本,源码直接 +求值无需 HBC)、Node V8。函数调用边界(JSON 进出)即 §5.1 的三步接口,工作 +正常。进一步用 ObjC 写了最小原生宿主实测通过:`JSContext` 求值源码 → +`callWithArguments` 调 `decideDownload` → 拿回决策 JSON——40 行代码、仅链 +系统 Foundation + JavaScriptCore 两个框架,就是 iOS 编排器的最终形态。语法下限:产物含 `?.`/`??`/对象展开 → Hermes ≥0.7(RN 0.64+)、iOS +JSC ≥13.4;如需更老目标,guardian 构建加 es2015 降级即可。 + +**纯文本定稿的推论——三端不必用同一个引擎**:源码是共享物,引擎只是求值 +器,选型可以按端就地取材: + +| 端 | 引擎 | 风险 | +|---|---|---| +| iOS | 系统 JavaScriptCore 框架 | **零**——系统框架,零链接、零包体、零版本耦合,上面已实测同源引擎 | +| Harmony | 系统 JSVM-API(V8,API 11+,RNOH 本就要求 5.0+) | 低——系统能力,待落地时确认 API 细节 | +| Android | 开放问题:androidx.javascriptengine(系统 WebView 引擎、沙箱进程、异步)/ QuickJS(~200KB×4 ABI)/ ~~链接宿主 Hermes~~ | **剩余的全部 spike 范围**(Hermes 路线已被侦察排除,见下) | + +**Android 链接宿主 Hermes 已排除(2026-08-07 对 hermes-android +250829098.0.10 / RN 0.85 预编译产物的符号侦察)**:prefab 里虽然带了 +`hermes_abi/hermes_abi.h`(稳定 C ABI 头),但其入口 `get_hermes_abi_vtable` +**并未从 `libhermesvm.so` 导出**——只导出了 C++ 的 +`facebook::hermes::makeHermesRuntime(RuntimeConfig const&)`,而走 C++ 路线 +要求调用方与宿主版本的 `RuntimeConfig` 布局、libc++ ABI 精确匹配,且 JSI 符 +号导出为零(消费方须自行编译与宿主一致版本的 jsi.cpp)。对跨 RN 版本分发的 +预编译 `librnupdate.so`,这是不可控的版本矩阵;老 RN 的 `libhermes.so` 连库 +名和导出面都不同。若未来官方开始导出稳定 C ABI,此路线可复活。当前 Android +实际候选就两个:javascriptengine(零包体、依赖 WebView provider、沙箱进程异 +步——对冷启动后台任务可接受,需真机验证)与 QuickJS(确定可行、包体代价)。 + +原生编排层(HTTP、下载、状态)仍放 `cpp/` 与 `patch_core` / `state_core` 同级三端共享;求值器作为注入接口由各端实现,与 HTTP 客户端同一地位。 + +**剩余风险收窄为 Android 单点**:直接链接 libhermes 的 C++ API 在 RN 各版本间会变,且 `librnupdate.so` 是预编译产物(4 个 ABI)。若三个 Android 候选都不干净,**仅 Android 一端退回方案 B(决策逻辑 C++ 重写)也是可接受的混合形态**——三端源码一致性只在 iOS/Harmony 保持,Android 以 updateFlowCore 为参照实现并靠共享测试向量对齐。 ### 5.3 分发与覆盖 +> **裁决(2026-08-07):远程覆盖通道不做,guardian 只带随 binary 打包的基线。** +> 覆盖通道买的保险是"决策纯函数出 bug 时不发 binary 就能修",但决策层是全 +> 链路最可测的部分(纯函数、单测、与 JS 侧同一份源码),且已有两条兜底: +> JS 侧 checkUpdate 全功能回退(决策 bug 最坏退回现状,不是新增灾难)、服 +> 务端塑造响应/重绑版本本身就是一条远程修复通道(决策层消费的是服务端数 +> 据)。代价却是全系统最敏感的安全面——启动最早期执行、有权决定装什么版本 +> 的服务端下发代码——加上 §5.4/§5.5 的全部工程量,而覆盖机制自身是原生代 +> 码,它出 bug 同样无法远程修。保费高于风险敞口。 +> 未来若决策层 bug 真在现场咬人,优先评估**搭现有热更通道便车**(ppk 附带 +> guardian.js,复用既有 hash 校验与下发权限),不自建通道。§5.4/§5.5 保留 +> 作为那时的设计输入;"救砖"能力来自 §8 第 4 步的原生编排,不受本裁决影响。 + | | 来源 | 作用 | |---|---|---| | 基线 | 随 binary 打包(asset / rawfile) | 永远存在,已随发版验证过 | @@ -174,13 +269,28 @@ app 侧的 `client.ts` / `UpdateProvider` 仍然负责**交互**:更新提示 ## 8. 分期 -1. **bundleHash 迁移**(`BUNDLEHASH_DESIGN.md`,已定稿)—— 协议下沉的前置,否则返工 -2. **纯函数抽取** —— 把 `buildCheckRequest` / `decide` 从 `client.ts` 剥成无 IO 的纯函数,先在 JS 侧原地使用并补测试。这一步 B / C 都需要,且不依赖运行时选型,**可以立刻开始** -3. **运行时选型** —— Hermes 链接可行性验证;失败则退回方案 B(C++ 纯函数) -4. **原生编排** —— 三端 HTTP + 调用纯函数 + 复用现有下载/patch/state -5. **guardian 分发通道**(仅方案 C)—— 打包、下发、§5.4 回滚保护 - -第 2 步是关键:**它是 B 和 C 的公共前置**,做完之后再决定选型也不迟,而且它本身就能让现有 JS 实现更可测。 +1. ~~**bundleHash 迁移**~~ **已解除**(2026-08-03)—— Phase 0/1/2 已上线、 + 判定开关双端开启、buildTime 永久保留作 fallback 定稿,checkUpdate 的 + wire protocol 已稳定;剩余的 Phase 3(SyncBinaryVersion 迁移)是客户端 + 本地状态变更,不动协议,与本方案只需在落地顺序上错开(都动原生启动路 + 径与状态 schema),不再构成前置 +2. ~~**纯函数抽取**~~ **已完成**(2026-08-07)—— `src/updateFlowCore.ts`: + `buildCheckRequestBody` / `resolveCheckResult` / `decideDownload` / + `isInRollout` / `joinUrls` / `orderEndpointCandidates`,无 IO、无 + react-native 依赖、无模块级状态(身份/随机数均参数注入),import 闭包 + 为纯;client.ts / provider.tsx / endpoint.ts 已原地改用,单测覆盖 +3. ~~**运行时选型**~~ **已关闭:改判方案 B**(2026-08-07,裁决与推理链见 + §4)。`cpp/update_flow_core` 已实现(flow_json + 七个决策函数的 1:1 移 + 植),金标向量集在本机与 CI(ASan/UBSan)全过 +4. **原生编排** —— 三端 HTTP + 调用 `update_flow_core` + 复用现有下载/ + patch/state;endpoint 执行引擎为顺序回退(§5.1),不移植 hedged race。 + Android 侧 `update_flow_core` 进 `librnupdate.so`(协议演进从此绑定 + `.so` 重编,走 build-android-so.sh + CI + verify-android-so.js 的 16KB + 对齐断言) +5. ~~**guardian 分发通道**~~ **已裁决不做**(2026-08-07,见 §5.3)——随 + §4 改判 B,整个 guardian 路线关闭;砖机救援能力在第 4 步,不受影响 + +剩余工作只有第 4 步:原生编排。决策层已就位(TS 参照 + C++ 移植 + 向量契约),编排层是纯 IO 胶水。 ## 9. 已写代码的处置 @@ -190,3 +300,123 @@ app 侧的 `client.ts` / `UpdateProvider` 仍然负责**交互**:更新提示 - `autoReset` 遥测事件 —— 无 reset 动作后失去意义,撤回 `unconfirmedBoots` / 前台闸门 / `markBootHealthy` / `PROVIDER_REQUIRED` / `CONTENT_APPEARED` 全部撤回。 + +--- + +## 10. 原生编排设计(§8 第 4 步) + +### 10.1 标定(provisioning)——设计到此才暴露的缺口 + +appKey、server endpoints、更新策略今天只活在 JS 的 `ClientOptions` 里,而原 +生检测跑在冷启动、任何 JS 之前。解决:**JS 是唯一配置源,原生只消费落盘副 +本。** 每次 `setOptions`(含构造)后 JS 调新的原生方法 `syncNativeConfig`, +持久化: + +```text +{ appKey, packageVersion, endpoints: server.main, queryUrls, + afterDownload: 'none' | 'setNeedUpdate', + disabled?: boolean, rnu, rn } +``` + +- `afterDownload` 由 updateStrategy 折算:`silentAndLater` / `silentAndNow` + → `setNeedUpdate`(原生的作用面本来就是"下次启动");alert 类策略 → 只下 + 载不激活,弹窗与确认永远归 JS(§6) +- **无配置 → 原生静默不跑**。首次安装首启、或从未升级到新 JS 的老接入,天 + 然回到现状,零行为变化——这就是灰度开关,不需要另设开关 +- 刻意不做 Info.plist / AndroidManifest 注入:配置双源必然漂移 +- 砖机场景自洽:设备能被坏热更砖掉,说明它至少健康跑过一次 JS,配置早已落盘 + +### 10.2 流程 + +冷启动 + 延迟 5s(R5),后台线程,每次冷启动至多一轮: + +```text +读 config(无则退出)+ 原生 state(currentVersion / rolledBackVersion / + packageVersion / buildTime / uuid / supportedDiffVersion / bundleHash 缓存) +→ BuildCheckRequestBody(bundleHash 同步读缓存,缺省省略字段) +→ OrderEndpointCandidates(endpoints, 原生随机数) +→ 顺序请求,单个 connect/read 超时 10s、whole-call 15s、整轮 HTTP 最多 + 8 次;全失败 → 拉 queryUrls + (任一成功即用)合并新候选,排除已失败的,再顺序一轮;仍失败 → 本轮放弃 +→ HandleCheckResponse(响应原文, identity, isDev=false)(已实现,含 info 透出) +→ action=download:按 attempts 顺序走现有下载器(diff→pdiff→full, + testUrls 语义由原生逐个尝试实现);diff/pdiff 共享 600s 绝对 deadline, + full 另有 600s 救砖预算;同 hash 完整版本在任务真正开跑时再次跳过,失败 + 清理不得删除已有 `.pushy-complete` 安装;成功 → setLocalHashInfo(info 的 + name/description/metaInfo) → 按 afterDownload 决定是否 setNeedUpdate +→ 原生处理完成后,将响应原文 + **响应到达时刻** + 请求/配置指纹落盘 + (§10.3;下载耗时不得让旧响应获得新的时间戳) +``` + +### 10.3 与 JS 的去重(§6 的落地形态) + +首版**原生只写缓存**:响应原文 + 响应到达时间戳 + 请求/配置指纹落到固定文件。紧 +随其后的 JS 小改动:`checkUpdate` 先读该缓存,时间戳新鲜(暂定 2 分钟) +则直接复用不发请求。改造前的过渡期是双检查——多一次网络请求,服务端有 +缓存,无害。 + +### 10.4 失败策略 + +- 整轮静默失败:无重试风暴、无退避状态机,下次冷启动天然重试 +- 下载/patch 失败不拉黑版本、不计数(本地熔断的教训:多记会毁好版本); + 每次启动至多重试一轮,行为有界 +- 不引入任何新的回退/降级路径;apk 过期(expired)响应原生不处理,留给 JS UI + +### 10.5 安全面 + +与 JS 路径同一协议、同一 HTTPS endpoints、同一下载器 hash 校验,无新增 +面。`flow_json` 解析网络数据已做深度上限 + 畸形输入回归(ASan/UBSan)。 + +### 10.6 分平台落地顺序 + +iOS(NSURLSession,下载/patch/state 全现成,纯增量)→ Android(OkHttp + +librnupdate.so 进 update_flow_core,绑一次 .so 重编)→ Harmony。e2e 用例 +沿用 Example/e2etest 既有基建,验证"坏 bundle 下原生仍能拉到修复版"的端到 +端场景。 + +### 10.7 forceBoot——策略的按版本远程覆盖(救砖的最后一环,2026-08-08 实现) + +§10.1 的 `afterDownload` 折算暴露了一个洞:alert 类策略(默认策略)下原生只 +下载不激活,而砖机的 JS 永远不会跑——修复版躺在磁盘上永不生效,救援在它存 +在的理由上失效。 + +解法是把激活决策做成**客户端默认 + 服务端按版本覆盖**:版本 `config` 增加 +`forceBoot: true`(控制台按版本勾选,语义是"强制以该版本启动",不是 UX 层 +面的"静默")。激活谓词收敛为纯层的 `shouldActivateAfterDownload(info, +afterDownload)`:本地 silent 策略 或 响应标记 forceBoot 即激活。 +`HandleCheckResponse` 增参 `afterDownload` 并在 download 决策中直接给出 +`activate` 布尔——三端编排器各自只读这一个字段,零判断逻辑。 + +刻意的语义边界: +- **仅作用于原生**。JS 侧交互策略不感知不受影响——健康设备该弹窗还弹窗, + 用户点"取消"只是"这次不切",下次冷启动仍会进入标记版本(原生分不出砖机 + 与健康设备,这正是显式标记版本想要的触达)。 +- **本机 `rolledBack` 黑名单赢过 forceBoot**(守卫在谓词之前):本机有崩溃 + 证据的版本不会被重装,开发者应重绑到别的版本。 +- **first_time 崩溃保护对强制版本依然生效**:强制启动的版本若也是坏的, + 下次启动照常回滚,不存在"强制进入坏版本且无法回头"。 + +**服务端存储位置的裁决(2026-08-09,推翻初稿):forceBoot 存 `bindings.config`, +不存 `versions.config`。** 两个 config 的意图沿革必须记清,防止再犯: + +- **`versions.config` 属旧灰度设计,已弃用且在被主动清洗**。旧设计把 + rollout 存在版本上(`config.rollout[packageVersion]`);新设计把 rollout + 搬到 `bindings.rollout` 列,客户端协议里的 `config.rollout` 形状由服务端 + **从绑定数据合成**。绑定事务里的 `removePackageRolloutConfig` 每次重绑都 + 会从 versions.config 清掉对应的 legacy rollout 键——决策层对 + versions.config 刻意不读,响应里的 config 一律合成,这是现行铁律。 +- **初稿曾把 forceBoot 放进 versions.config 并让绑定路径透传它,已否决**: + 透传会把未被清洗的 legacy rollout 连带泄漏回响应、让双 config 源复活、 + 与清洗机制逆行。相应改动在四个仓库均已回退。 +- **`bindings.config` 是新设计预留的"这次投放"配置位**(upsert API 全线 + 打通、快照本就 select 它),forceBoot 正是投放属性:救砖 = 把包重绑到正 + 常版本这一动作。存绑定还带来正确的生命周期——重绑即替换绑定,救援结束 + 后标记自动消失,不会像挂在版本上那样永久残留;粒度也收敛到单个 + packageVersion。 +- **客户端协议不变**:客户端仍读响应里 `info.config.forceBoot`,它从哪合成 + 客户端不感知。 + +已实施(四仓库,本地提交待推送):pushy-server / cresc-server 决策层从 +`binding.config.forceBoot` 合成进下发 config(灰度与全量两分支),绑定列表 +接口补 select config;pushy-admin / cresc-admin 发布菜单加"全量+强制启动 +(救砖)",已绑定行显示⚡标记 + 切换项(重发同绑定翻转标记)。 diff --git a/NATIVE_CHECK_FOLLOWUPS.md b/NATIVE_CHECK_FOLLOWUPS.md new file mode 100644 index 00000000..3524e811 --- /dev/null +++ b/NATIVE_CHECK_FOLLOWUPS.md @@ -0,0 +1,365 @@ +# 原生冷启动检测:遗留改进项 + +> 来源:`agent/harden-native-check-update` 分支两轮评审 +> (f679e11 初评 10 项 → 181952a 修 4 缓解 1;181952a 复评又出 10 项, +> 其中 6 项为修复自身引入,已并入下文)。 +> 本文最初只记录开放项。2026-08-10 已完成代码项复核与修复;下文保留 +> 原始问题描述用于追溯,处理结论以紧随其后的状态表为准。发版动作仍保留在 +> 文末清单中,不因代码合入而自动视为完成。 + +## 2026-08-10 处理结论 + +| 项目 | 结论 | 落地方式 | +|---|---|---| +| P1 Android/Harmony 重复下载 | 已修复 | 任务真正开始时复查 `.pushy-complete` + bundle;失败清理检测到完整安装时不删目录。iOS 注册表同时升级为按 hash + artifact type 合流/排队 | +| P2 缓存 `ts` 锚点 | 已修复 | 三端在 check 响应到达时捕获时间,下载、补丁和激活结束后仍写该时间 | +| P2 下载轮次 deadline | 已修复 | 三端统一为 diff/pdiff 共享 600s、full 独享 600s;发起前检查绝对 deadline,下载任务在真正开跑时按剩余预算设置 whole-call timeout | +| P2 JS 配置回退竞态 | 已修复 | 在途写入期间仍记录与 `synced` 相同的最新期望值;B 完成后会继续把 A 写回;删除同步 throw 路径的无效递归 | +| P2 iOS 合流语义 | 已修复 | 同类型下载共享结果并向所有 join 者广播进度;不同类型按 hash 排队,避免把 diff 的失败错误归给 full | +| P3 `.pushy-complete` 迁移 | 接受一次性重下 | 不把仅有 bundle 的旧目录推断为完整安装,避免把半解压目录误标为成功;10.51.0 发布说明仍需明确这一流量成本 | +| P3 状态解析异常不调度 | 已修复 | 三端启动解析改为 finally 调度;正常路径保留最终 rollback 快照,异常路径使用空快照 | +| P3 无 hash 死弹窗 | 已修复 | Provider 将无 hash 的 update 降级为开发者日志/遥测与 `upToDate`,不再展示不可执行的确认按钮 | +| P3 `noArtifact` 静默 | 已修复 | 保持终端用户无感,同时恢复 `errorUpdate` 报告并携带目标 hash | +| P4 bundleHash 窗口 | 接受并记录 | `readNativeCheckCache` 已注明首启预取未完成时有意 cache miss,不为 hash 阻塞检查 | +| P4 缓存冗余解析 | 已修复 | 直接传入现成 `fetchBody`/native config 对象,只解析缓存中的字符串一侧 | +| P4 endpoint 斜杠重复 | 已修复 | 三端首轮请求也先查规范化后的 `tried` 集合,重复项不计入 8 次上限 | +| P4 Harmony 整请求超时 | 已修复 | check HTTP 增加 15s whole-call cap;更新下载增加绝对 deadline 并在超时后销毁请求 | + +代码验证基线:JS 完整回归 173 项、Biome/TypeScript/Harmony strict 类型检查、 +77 项 flow core ASan/UBSan、29 项 patch core、Harmony debug HAR、Android +Release Java 编译、iOS Release simulator 静态库构建均通过。 + +--- + +## 2026-08-10 第三轮评审(283dfd4)开放项 + +上表 13 项的修复经复评确认全部属实;以下为修复自身引入/暴露的新开放项 +(详情与逐条修法见评审面板)。 + +复核后处理结论: + +| 项 | 结论 | 落地方式 | +|---|---|---| +| 1 iOS joiner 预算 | 已修复 | 注册表记录 owner 的单调时钟 deadline;预算更长的 waiter 观察当前进度但 deferred,owner 成功时由完成标记立即命中,失败时以自己的完整预算重启 | +| 2 Harmony 外层时限 | 已修复 | `performAttempts` 用绝对单调 deadline 包住排队、HTTP、解压和 hpatch 的完整 Promise;底层串行任务即使晚结束也不再阻止编排器落响应缓存 | +| 3 壁钟 deadline | 已修复并修正文档结论 | iOS 改用 `systemUptime`,Harmony 改用 `systemDateTime.getUptime`;full 预算进入 full 阶段才创建,因此原文“增量阶段校时会同时耗尽尚未创建的 full 预算”不成立 | +| 4 Android full 判定 | 已修复 | 与分发逻辑及 iOS/Harmony 一致,非 diff/pdiff 统一视为 full;上游当前只生成三种合法类型,此项属于防御性收口 | +| 5 坏发布遥测膨胀 | 已修复 | 缺 hash 与 noArtifact 共用按 appKey/reason/hash 的进程内去重,保留一次服务端可见的坏发布信号 | +| 6 有 hash 无产物弹窗 | 已修复 | Provider 在展示/静默下载前复用 `decideDownload`,noArtifact 降级为 `upToDate` 与一次开发者遥测 | +| 7 deferred UX/deadline | 已修复 | deferred waiter 订阅当前同 hash 进度;旧 deadline 在重新注册前校验,过期的编排器请求不会成为 owner 或结算后来的 JS 请求 | +| 8 owner-only 进度事件 | 不采纳 | 支持路径在 JS 已按 hash 维持单一原生监听;常见 join 是无监听器的冷启动 engine + JS bridge。限制为 owner 发事件会在 engine 先成为 owner 时丢失 JS 进度 | +| 9 iOS 完成判定重复 | 已修复 | 抽取 `PushyHasCompletedVersionAtPath`,预检与冷启动编排器共用同一 bundle+marker 判定 | + +**P2(三端时限模型的二阶问题,建议一并收口)** +1. **iOS 合流者继承 owner 剩余预算**:JS 同 hash 同类型合流到编排器下载时, + 共享会话带的是编排器所剩阶段预算(可能只剩几十秒),其超时会结算全部 + 合流者——修改前 JS 独立下载固定 600s 的不变量对合流路径失效。 +2. **Harmony await 无外层时限**:统一预算只管住 HTTP 流;解压/hpatch 卡死 + 或串行链被长任务占用时 `await context.downloadX` 永不返回,救援轮次挂满 + 进程生命周期,响应缓存也不落盘。修法:performAttempts 层对每个 await 包 + Promise.race 绝对 deadline。 +3. **壁钟 vs 单调钟**:iOS/Harmony 的 deadline 锚壁钟(epoch/Date.now), + Android 锚 nanoTime——首次联网触发 NTP 前跳(恰是救援检测时刻)会让 + iOS/Harmony 全部预算(含 full 保底)瞬时过期。修法:iOS 用 + systemUptime/mach 时基,Harmony 用相对定时器。 + +**P3** +4. **Android 按字面 "full" 判保底预算**,分发 else 分支却把任意非 diff/pdiff + 类型当 full 下载;iOS/Harmony 用排除法——三端对"谁享有 full 保底"不再 + 一致,Android 改排除法即齐。 +5. **noArtifact 遥测重复膨胀**:映射到 download_fail 聚合且每次 + downloadUpdate 调用都上报(静默策略下每个检查周期一条),需按 hash + 会话内去重或换不入聚合的事件类型。 +6. **有 hash 无产物的死弹窗变体**:provider 只降级了无 hash 的 update; + 有 hash 但无任何产物 URL 的坏发布仍弹确认框、确认后静默。 +7. **iOS deferred 的 UX 与陈旧 deadline**:异类型 deferred 请求无进度、 + 延迟开始(UI 冻结);重启时携带编排器的旧 deadlineAt,排队期间过期则 + 瞬时失败并连带结算 JS 合流者。JS 发起的请求不应携带编排器 deadline。 + +**P4** +8. **iOS 合流者进度块重复发事件**(N 合流者 = N 倍 RN 事件,事件发送应只由 + owner 承担)。 +9. **完成判定(bundle+marker)在 iOS 两处手写**,抽 PushyHasCompletedVersion + helper 防漂移。 + + +--- + +## P1 — Android/Harmony 重复下载竞态,失败路径可删除已安装版本 + +**现状**:iOS 在 f679e11 引入了进程级在途下载注册表 +(`PushyRegisterDownload`/`PushyFinishDownload`),同一 hash 的并发下载合流。 +Android 与 Harmony 只有任务串行化,没有按 hash 去重,也不保护已完成目录。 + +**风险**:JS 自动下载版本 H 进行中(单线程 executor 排队),原生检测 5 秒后 +`hasCompletedVersion(H)` 为 false,再排入第二个 H 的下载任务。JS 任务完成、 +写入 `.pushy-complete` 并 `setNeedUpdate(H)` 后,排队的重复任务重新下载 H +且中途失败(断网)时,`cleanUpAfterFailure` 删除 `rootDir/H`(含 +`index.bundlejs` 与标记)。下次冷启动解析到 H 但 bundle 缺失 → 回滚—— +**已成功安装的更新被静默丢失**。 + +**建议修法**(三选一,或组合): +1. 移植 iOS 的按 hash 在途注册表(Android 用 + `ConcurrentHashMap>`,Harmony 用模块级 Map); +2. 更小的止血:`cleanUpAfterFailure` 里若目标目录已有 + `.pushy-complete` 标记则不删(失败的是重复下载,不是这份安装); +3. DownloadTask 开跑前再查一次 `hasCompletedVersion(hash)`,已完成即直接 + 走成功回调。 +方案 2+3 组合改动最小且互补;方案 1 语义最完整(还能省一次重复下载)。 + +--- + +## P2 — 响应缓存的 `ts` 锚在持久化时刻,而非响应到达时刻 + +**现状**:`persistResponseCache` 在原生下载/激活全部结束后才落盘,`ts` 取 +当时时间。181952a 给 Android 加了整轮 600s deadline,把最坏偏移压到 +~12 分钟,但锚点本身未改;iOS 仍是逐 URL 600s、Harmony 依赖下载任务自身 +超时,无整轮上限。 + +**风险**:版本发布几分钟后被运营撤下/回滚时,一个"响应早已过时但 ts 很新" +的缓存会让 JS 在 120s 窗口内把已撤回的响应当新鲜结果复用,继续下载/激活 +已撤回版本。 + +**建议修法**:响应到达时捕获 `responseAtSeconds`,作为参数传入 +`persistResponseCache` 写进 `ts`(三端各一行改动,JS 读侧无需变)。 + +**复评补充(181952a 引入/暴露)——下载轮次时限应作为一个整体设计统一三端**: +1. 整轮 600s deadline 只落了 Android;iOS 仍逐 URL 600s(上界 + attempts×urls×600s,可达 90 分钟),Harmony 完全没有(慢滴 CDN 在每个 + 60 秒窗口内返回至少一个字节即可绕过下载任务的不活动看门狗,轮次可跑数小时); +2. Android 的 deadline 在下一个下载**已发起后**才检查——超时轮次仍会 + 多启动一个孤儿下载,浪费带宽且可能与 JS 重试并发写版本文件 + (修法:发起前先查); +3. 整轮预算会被 diff/pdiff 的失败耗尽,挤压救砖最后手段 full 的时间 + (原先 full 独享 600s)——建议 full 单独保底额度。 + +--- + +## P2 — JS 配置同步的回退竞态(181952a 引入) + +**现状**:`syncNativeConfig` 的去重把当前配置与**最后一次已完成**的写入 +(`syncedNativeConfigJson`)比较。 + +**风险**:已同步 A → setOptions 改为 B(写入在途)→ B 完成前又回退为 A: +回退因等于 `synced`(仍是 A)被直接丢弃,随后 B 完成、`synced=B`、pending +为空——原生 KV 永久持有错误的策略/endpoints(如 app 已切回 alert 却按 +silent 激活),直到未来某次不同值的 setOptions 才被纠正。 + +**建议修法**(改动极小):去重时把在途/待写值一并纳入比较,或写入完成回调里 +与"最新期望值"复核不符则重新入队。顺手删掉 `flushNativeConfig` 同步抛 +catch 里的死代码递归"重试"(pending 已清空,必然早退,误导维护者)。 + +--- + +## P2 — iOS 合流下载的两个语义代价 + +**现状**:JS 的下载调用合流到冷启动引擎的在途下载后: +1. **收不到进度事件**——事件只由持有下载的实例发出,引擎实例 + `hasListeners=NO`,用户看到进度条冻在 0; +2. **继承异类 attempt 的失败**——引擎的 diff 尝试 404 时,JS 本可成功的 + full 下载调用被判失败(JS 策略链会继续下一策略+重试回退,最终多能成功, + 但单次调用的失败归因是错的)。 + +**建议修法**:注册表回调列表旁存进度回调,引擎下载的 NSURLSession 进度 +透传给所有 join 者(解决 1);join 时携带请求类型,类型不同不合流、改为 +排队串行(解决 2,代价是偶发的一次串行等待)。若认为 JS 策略链的自愈已 +足够,可显式拍板只修 1。 + +--- + +## P3 — `.pushy-complete` 无迁移,存量已下载版本升级后重下一次 + +**现状**:旧版本库下载的版本目录没有标记文件;升级到新库后 +`hasCompletedVersion` 判 false,原生检测整包重下一遍(下载完成后写标记, +自愈,每台受影响设备一次)。 + +**风险**:一次性流量成本,仅影响"已下载未激活"(alert 类策略)的存量设备。 + +**建议**:二选一显式拍板——(a) 接受成本,在 CHANGELOG 注明;(b) 加迁移: +首次运行时对"有 `index.bundlejs` 且 mtime 早于本次库安装"的目录补写标记 +(无法区分半解压目录,有误判风险,故 (a) 可能更稳)。 + +--- + +## P3 — 启动状态解析抛异常时,救援检测永不调度 + +**现状**:f679e11 把 `schedule` 从 `getBundleUrl`/`+bundleURL` 顶部移到 +各出口(为携带回滚快照),但若 `runStateCore`/回滚循环自身抛异常,所有 +出口都到不了。 + +**风险**:持久化状态损坏(恰是救援机制存在的场景)时,救援检测在后续所有 +启动中都不运行。 + +**建议修法**:状态解析段包 try/finally,finally 里以"空快照"调度 +(rolledBackVersion 传 null 即可,宁可少守卫不可不调度——与"判定失败不 +计数"同一取舍方向);三端同改。 + +--- + +## P3 — 无 hash 的灰度条目从"静默忽略"变成"死弹窗"(181952a 语义变更的副作用) + +**现状**:`resolveCheckResult` 的非空 hash 守卫移除了旧的 +`undefined === undefined` 等价:服务端误配出无 hash 的灰度条目时,内置包 +设备(currentVersion 为空)从静默 upToDate 变为返回 `update:true` 且无 +hash——已被再生成的金标向量固化。 + +**风险**:alert 策略下每次检查都弹"发现新版本",点确认后 `decideDownload` +因 `!hash` 判 noUpdate——死弹窗,按钮无效,每次检查重现。 + +**建议修法**:若新语义有意(暴露服务端错配),在 provider 层把无 hash 的 +update 降级为日志 + 遥测而非弹窗;若无意,恢复"无 hash 条目不视为可更新"。 +与下一条(noArtifact)同属"坏发布该以遥测暴露而非 UI 弹窗/静默"。 + +--- + +## P3 — `noArtifact` 分支静默返回,丢失 errorUpdate 遥测(181952a 引入) + +**现状**:decideDownload 新增的 noArtifact 拒绝让 `downloadUpdate` 无日志、 +无错误事件、无遥测地返回;旧的空 attempts 路径会上报 +`{type:'errorUpdate'}`。 + +**风险**:服务端发出 update:true 有 hash 但无任何产物 URL 的坏发布,从 +"控制台可见"退化为"客户端静默无事发生",坏发布隐形。 + +**建议修法**:noArtifact 分支恢复 report({type:'errorUpdate', ...}),保持 +用户无感但平台可见。 + +--- + +## P4 — bundleHash 未就绪窗口的请求指纹失配(记录即可) + +**现状**:JS 预取 bundleHash 未 settle 时构造的请求体缺 `bundleHash` 键, +与原生缓存的请求(原生同步计算,总带该键)键集不同 → 指纹不命中,走网络。 +窗口极窄(通常仅首启,且该时刻缓存多半尚不存在);`overridePackageVersion` +的失配已在 181952a 通过配置携带 `packageVersion` 修复。 + +**建议**:在 `readNativeCheckCache` 注释里写明这是已知且有意的失配面, +不做代码改动。 + +--- + +## P4 — `readNativeCheckCache` 冗余解析(cleanup) + +**现状**:调用方刚 stringify 的请求体被立即 parse 回来做结构比较,外加对 +当前 config JSON 的一次重复解析——每次 release 路径 checkUpdate 的固定 +开销。 + +**建议**:把现成的 `fetchBody` 对象传入,只 parse `entry.request` 一侧。 + +--- + +## P4 — 若干小项(复评新增) + +- **斜杠变体 endpoint 重复请求**:尾斜杠归一化发生在 C++ 去重之后,且首轮 + 循环只 add 不查 `tried`——`https://u.example.com` 与 `…com/` 会对同一 + URL POST 两次,还白占 8 次 HTTP 上限中的两次。修法:归一化提前进纯层 + (`orderEndpointCandidates` 前),或首轮也查 `tried`。 +- **Harmony 检查请求缺整请求封顶**:只有 connect/read 超时,没有 Android + `callTimeout(15s)` / iOS 超时取消的对应物,慢滴响应可拖长每次尝试。 + +--- + +## 2026-08-11 第四轮评审(60f5fd2..5f5d0cb)开放项 + +上一轮 9 项闭环全部属实(第 8 项"owner-only 进度事件"维护者明确不采纳,理由 +成立)。以下为仍开放项;**前三条建议合入前处理**,其余可随小版本。 + +### 合并阻断 —— 已于 2026-08-11 修复(见下方"修复说明") + +#### 原始问题描述 + +1. **原生检测无视 `checkStrategy`,并能撞销 `resetToPackagedBundle`** + (CI 已红)。`getNativeConfig` 只用 `updateStrategy` 折算 `afterDownload`, + 从不读 `checkStrategy`;三端也没有 reset↔检测 的任何联动(无 generation、 + 无取消,reset 也不清 `nativeCheckResp`)。e2e app 明写 `checkStrategy: null` + 却仍被原生自动下载+激活;`resetToPackagedBundle` 可被在飞的检测撤销。 + **证据**:e2e-ios 在 `0f4651e` 与 `5f5d0cb` 两次运行均挂在 `beforeEach` + 的 `bundleLabel: BINARY_BASE`,且两次挂的是不同用例(竞态签名);失败态 + `currentHash: e2e-full-v1` 恰是"reset 后从零检查会拿到的第一个版本"; + master 上 e2e-ios 为绿 → 本分支引入的回归。本分支新增的 + `hasCompletedVersion` 快路径(版本已落盘则跳过下载直接 switchVersion) + 把竞态窗口从"下载完"压到"5 秒后瞬间",是这轮才炸的原因。 + **修法**:`afterDownload` 计算纳入 `checkStrategy`(为 null 时降为 + `'none'`——只下载不激活,`forceBoot` 仍可救砖);`resetToPackagedBundle` + bump 进程级 generation,编排器在 `switchVersion`/落缓存前比对,变了就 + 放弃,并清掉响应缓存。 + +2. **iOS 同类型 join 分支实际不可达**。`deadlineUptime > ownerDeadline` 是 + 严格大于,而 JS 发起的请求总晚于 owner 的计算时刻,因此永远走 deferred: + P1"一个 hash 共享一次下载"的意图失效,退化为串行重下;CDN 黑洞时用户 + 弹窗可转近 20 分钟(改前是合流后一次失败并回退下一候选 URL)。修法:比较 + 加容忍阈值,或 JS 发起的请求不参与 deadline 比较。 + +3. **Harmony 用 `TimeType.STARTUP`**(计深度睡眠),而 iOS `systemUptime` / + Android `nanoTime` 睡眠时停走——"三端统一单调钟"不成立。锁屏休眠数分钟 + 即让预算过期、救援中止,同网络的另两端能续传完成。一行改 + `TimeType.ACTIVE`。 + +#### 修复说明(2026-08-11) + +1. **checkStrategy + reset 竞态**:`getNativeConfig` 的 `afterDownload` 现在 + 要求 `checkStrategy != null` —— 关掉自动检查的应用不会再被塞一次它没要 + 过的版本切换;检测本身照跑(救砖能力不变),`forceBoot` 仍可激活。另加 + **reset 代数守卫**:`resetToPackagedBundle` 递增进程级计数并清掉响应缓存, + 编排器在开跑前采样、在激活与落缓存前比对,不一致即整轮丢弃。三端同构 + (iOS `std::atomic`、Android `AtomicLong`、Harmony 静态计数)。 +2. **iOS join 分支不可达**:比较从"绝对 deadline 严格大于"改为"剩余预算", + 仅当 owner 剩余不足新来者的一半时才 defer —— 正常情况(JS 晚几秒发起) + 恢复合流,只有真正濒临耗尽的 owner 才让位。 +3. **Harmony 单调钟**:`TimeType.STARTUP` → `TimeType.ACTIVE`,与 iOS + `systemUptime` / Android `nanoTime` 的"睡眠时停走"语义对齐。 + +**补强(2026-08-11,CodeRabbit 复核后)**:初版守卫是 compare-and-act,且 +`hash_` 元信息写在守卫之前——reset 若落在"比对通过"与"写入"之间仍 +可被覆盖。现改为**一次原子提交**:版本元信息 + 激活 + 响应缓存合并为 +`commitNativeCheckResult(expectedGeneration, ...)`,内部先复核代数再落全部 +写入;`resetToPackagedBundle` 与之互斥并**先失效代数再清状态**。互斥手段按 +端选取:iOS 复用既有 `PushyWithStateLock`(为此把 switchVersion 拆出无锁核 +`PushySwitchVersionLocked`,避免不可重入死锁)、Android 用共享 +`commitLock`、Harmony 是 ArkTS 单线程且提交内无 await,天然原子(已注释说 +明)。三处落点(元信息/激活/缓存)与三个调用点(nothing-to-do、未下载、成功) +全部走同一入口。 + +验证:JS 178 项(新增 2 项 checkStrategy 折算用例)、Biome/tsc/DevEco +strict、77 金标向量 + ASan/UBSan、`.so` 符号与 16KB 对齐、iOS clang +(DEBUG=0/1)、Android javac(main+oldarch)、OHOS 工具链语法。本轮不触 +`cpp/`,无需重生成向量或重编 `.so`。 + +### 后续小版本 + +4. Harmony 超时只放弃编排器的 `await`,卡死的 DownloadTask 仍占 `taskChain`, + 后续 attempt 只是排队并空烧自己的新鲜预算(有效 full 产物 + 完整预算都在 + 却仍救不回)。修法:超时真正取消底层任务,或救援轮次用独立链。 +5. iOS/Harmony 的 `switchVersion` 仍可激活半完成安装(Android 本轮加了无标记 + 拒绝守卫,三端 parity 分歧)。 +6. iOS deferred 异类型 waiter 订阅了 owner 的进度流,收到的是另一种产物的 + 字节数(进度条先到 100% 再回 0%)。修法:只向同类型合流者广播。 +7. 缺 hash 守卫新加的 `!info.expired` 让 `{expired:true, update:true}` 且无 + hash 的畸形响应既不降级也不上报,却仍把 `update:true` 发给业务侧。 +8. `getNativeConfigJson() ?? '{"disabled":true}'` 会把瞬时为空的 + `appKey`/`server.main` 变成持久 disabled 写入,覆盖上一份可用配置并长期 + 关掉救砖能力。修法:仅在确实不可用(web/旧原生)时写 disabled。 +9. `noArtifact` 上报位置从 `client.downloadUpdate`(实际尝试下载)移到 + `provider.checkUpdate`(只要检查就发),映射到服务端 `download_fail` 聚合 + 后会把一次坏发布放大成全量设备级的健康下降。 +10. 两处 cleanup:Android `switchVersion` 重写了同文件已有的 + `hasCompletedVersion` 谓词;provider 为一个是非问题重跑完整 + `decideDownload`(分配 URL 计划后丢弃)。 + +### 累计 + +四轮共 40 条发现:27 修复、2 显式接受、1 不采纳、10 开放(其中 3 条建议合入 +前处理)。 + +--- + +## 发版清单(非代码缺陷,勿遗漏) + +- [ ] e2e:坏 bundle → 原生拉修复版 → 下次启动复活的端到端用例 + (沿用 Example/e2etest 基建;harmony 走 hdc/uitest 链路) +- [ ] README / README-CN / CHANGELOG:原生冷启动检测、forceBoot、 + 绑定依赖硬校验(RN 版本一致、rnu 不降级) +- [ ] 客户端 10.51.0 发版(admin 的 forceBoot 门槛与之对齐) +- [ ] 服务端已推送未激活:pushy 主机 `install-npm-release.sh` + + `service-cli.sh restart all`(验 serverVersion);cresc 跑 + `update-cloudrun.sh`;两个 admin 走各自 CI diff --git a/android/jni/Android.mk b/android/jni/Android.mk index a80698ee..830d356e 100644 --- a/android/jni/Android.mk +++ b/android/jni/Android.mk @@ -10,7 +10,8 @@ LOCAL_C_INCLUDES := \ $(LOCAL_PATH)/HDiffPatch \ $(LOCAL_PATH)/HDiffPatch/libHDiffPatch/HPatch \ $(LOCAL_PATH)/lzma/C \ - $(LOCAL_PATH)/../../cpp/patch_core + $(LOCAL_PATH)/../../cpp/patch_core \ + $(LOCAL_PATH)/../../cpp/update_flow_core Hdp_Files := \ hpatch.c \ @@ -28,6 +29,9 @@ LOCAL_SRC_FILES := \ ../../cpp/patch_core/patch_core_android.cpp \ ../../cpp/patch_core/state_core.cpp \ ../../cpp/patch_core/update_core_android.cpp \ + ../../cpp/update_flow_core/flow_json.cpp \ + ../../cpp/update_flow_core/update_flow_core.cpp \ + ../../cpp/update_flow_core/update_flow_jni.cpp \ $(Hdp_Files) include $(BUILD_SHARED_LIBRARY) diff --git a/android/lib/arm64-v8a/librnupdate.so b/android/lib/arm64-v8a/librnupdate.so index 4c758693..a05184e5 100755 Binary files a/android/lib/arm64-v8a/librnupdate.so and b/android/lib/arm64-v8a/librnupdate.so differ diff --git a/android/lib/armeabi-v7a/librnupdate.so b/android/lib/armeabi-v7a/librnupdate.so index 853c95c6..cf8f5086 100755 Binary files a/android/lib/armeabi-v7a/librnupdate.so and b/android/lib/armeabi-v7a/librnupdate.so differ diff --git a/android/lib/x86/librnupdate.so b/android/lib/x86/librnupdate.so index 457d312a..34c1288e 100755 Binary files a/android/lib/x86/librnupdate.so and b/android/lib/x86/librnupdate.so differ diff --git a/android/lib/x86_64/librnupdate.so b/android/lib/x86_64/librnupdate.so index 6ddf2149..acac7414 100755 Binary files a/android/lib/x86_64/librnupdate.so and b/android/lib/x86_64/librnupdate.so differ diff --git a/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java b/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java index f5d3dbb7..2c14b9ed 100644 --- a/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +++ b/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java @@ -107,14 +107,29 @@ private void downloadFile() throws IOException { this.hash = params.hash; String url = params.url; File writePath = params.targetFile; - UpdateFileUtils.ensureParentDirectory(writePath); Request request = new Request.Builder().url(url).build(); + OkHttpClient requestClient = HTTP_CLIENT; + if (params.deadlineNanos > 0) { + long remainingNanos = params.deadlineNanos - System.nanoTime(); + if (remainingNanos <= 0) { + throw new IOException("Download deadline expired before start"); + } + long remainingMillis = Math.max( + 1L, + java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(remainingNanos) + ); + requestClient = HTTP_CLIENT.newBuilder() + .callTimeout(remainingMillis, java.util.concurrent.TimeUnit.MILLISECONDS) + .build(); + } + + UpdateFileUtils.ensureParentDirectory(writePath); if (writePath.exists() && !writePath.delete()) { throw new IOException("Failed to replace existing file: " + writePath); } - try (Response response = HTTP_CLIENT.newCall(request).execute()) { + try (Response response = requestClient.newCall(request).execute()) { if (!response.isSuccessful()) { throw new IOException("Server error: " + response.code() + " " + response.message()); } @@ -418,32 +433,53 @@ private boolean isPatchTask(int taskType) { || taskType == DownloadTaskParams.TASK_TYPE_PATCH_FROM_PPK; } + private boolean hasCompletedPatchDirectory() { + return params.unzipDirectory != null + && new File(params.unzipDirectory, "index.bundlejs").isFile() + && new File( + params.unzipDirectory, + UpdateContext.VERSION_COMPLETE_FILE + ).isFile(); + } + @Override public void run() { int taskType = params.type; + final boolean alreadyCompleted = isPatchTask(taskType) + && hasCompletedPatchDirectory(); try { - switch (taskType) { - case DownloadTaskParams.TASK_TYPE_PATCH_FULL: - doFullPatch(); - break; - case DownloadTaskParams.TASK_TYPE_PATCH_FROM_APK: - doPatchFromApk(); - break; - case DownloadTaskParams.TASK_TYPE_PATCH_FROM_PPK: - doPatchFromPpk(); - break; - case DownloadTaskParams.TASK_TYPE_CLEANUP: - doCleanUp(); - break; - case DownloadTaskParams.TASK_TYPE_PLAIN_DOWNLOAD: - downloadFile(); - break; - default: - break; + if (alreadyCompleted) { + Log.i(UpdateContext.TAG, + "download task: version " + params.hash + " already completed"); + } else { + switch (taskType) { + case DownloadTaskParams.TASK_TYPE_PATCH_FULL: + doFullPatch(); + break; + case DownloadTaskParams.TASK_TYPE_PATCH_FROM_APK: + doPatchFromApk(); + break; + case DownloadTaskParams.TASK_TYPE_PATCH_FROM_PPK: + doPatchFromPpk(); + break; + case DownloadTaskParams.TASK_TYPE_CLEANUP: + doCleanUp(); + break; + case DownloadTaskParams.TASK_TYPE_PLAIN_DOWNLOAD: + downloadFile(); + break; + default: + break; + } } } catch (Throwable error) { Log.e(UpdateContext.TAG, "download task failed", error); - cleanUpAfterFailure(taskType); + // A duplicate task must never delete a version completed by an + // earlier queued task. The marker + bundle pair is the ownership + // handoff: once present, this failure did not create that install. + if (!hasCompletedPatchDirectory()) { + cleanUpAfterFailure(taskType); + } if (params.listener != null) { // A patch task that failed after its artifact was fully @@ -462,6 +498,25 @@ && isPatchTask(taskType) return; } + if (isPatchTask(taskType) && !alreadyCompleted) { + try { + File marker = new File( + params.unzipDirectory, + UpdateContext.VERSION_COMPLETE_FILE + ); + if (!marker.createNewFile() && !marker.isFile()) { + throw new IOException("Failed to mark completed update: " + marker); + } + } catch (Throwable error) { + Log.e(UpdateContext.TAG, "failed to mark completed update", error); + cleanUpAfterFailure(taskType); + if (params.listener != null) { + params.listener.onDownloadFailed(error); + } + return; + } + } + // The task itself succeeded. Run the completion callback outside the // try/catch above so an exception thrown by the callback (e.g. a // FileProvider misconfiguration during installApk) is not mistaken for diff --git a/android/src/main/java/cn/reactnative/modules/update/DownloadTaskParams.java b/android/src/main/java/cn/reactnative/modules/update/DownloadTaskParams.java index 136761db..c741f7bc 100644 --- a/android/src/main/java/cn/reactnative/modules/update/DownloadTaskParams.java +++ b/android/src/main/java/cn/reactnative/modules/update/DownloadTaskParams.java @@ -20,6 +20,9 @@ class DownloadTaskParams { String originHash; // TASK_TYPE_CLEANUP only: entries younger than this survive; 0 = delete all int maxAgeDays = 3; + // Absolute System.nanoTime deadline for orchestrated cold-start downloads; + // 0 keeps the normal public API's 10-minute per-call timeout. + long deadlineNanos; File targetFile; File unzipDirectory; File originDirectory; diff --git a/android/src/main/java/cn/reactnative/modules/update/NativeCheckOrchestrator.java b/android/src/main/java/cn/reactnative/modules/update/NativeCheckOrchestrator.java new file mode 100644 index 00000000..6ab4e38e --- /dev/null +++ b/android/src/main/java/cn/reactnative/modules/update/NativeCheckOrchestrator.java @@ -0,0 +1,455 @@ +package cn.reactnative.modules.update; + +import android.os.Build; +import android.util.Log; +import java.util.HashSet; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +/** + * Native cold-start update check (NATIVE_CHECKUPDATE_DESIGN §10): once per + * process, a few seconds after getBundleUrl, entirely independent of the app + * bundle — this is what lets a device bricked by a bad hot update pull the + * fixed version on the next launch. All decisions come from + * cpp/update_flow_core via NativeUpdateFlow; this class is IO glue only. + * Failures are silent and bounded: one round per launch, no retry storms, no + * version blacklisting. + */ +final class NativeCheckOrchestrator { + static final String KEY_CONFIG = "nativeConfig"; + // Raw response cache for the JS side to reuse (§10.3), scoped to the + // exact logical request and native config that produced it. + static final String KEY_RESP_CACHE = "nativeCheckResp"; + private static final int MAX_CHECK_HTTP_ATTEMPTS = 8; + private static final long DOWNLOAD_PHASE_TIMEOUT_SECONDS = 600; + + private static final AtomicBoolean scheduled = new AtomicBoolean(false); + + private NativeCheckOrchestrator() { + } + + static void schedule(final UpdateContext context, final String launchRolledBackVersion) { + if (UpdateContext.DEBUG) { + return; + } + if (!scheduled.compareAndSet(false, true)) { + return; + } + Thread thread = new Thread(new Runnable() { + @Override + public void run() { + try { + // Keep the check away from the cold-start critical path + // (§7 R5) — its result targets the NEXT launch anyway. + Thread.sleep(5000); + runOnce(context, launchRolledBackVersion); + } catch (Throwable e) { + // The rescue path must never take the app down with it. + Log.w(UpdateContext.TAG, "native check failed: " + e); + } + } + }, "pushy-native-check"); + thread.setPriority(Thread.MIN_PRIORITY + 1); + thread.setDaemon(true); + thread.start(); + } + + private static void runOnce( + UpdateContext context, + String launchRolledBackVersion + ) throws JSONException { + // Sampled before any IO: a reset landing while this round runs must + // win over the round's decision. + final long resetGeneration = UpdateContext.getResetGeneration(); + String configJson = context.getKv(KEY_CONFIG); + if (configJson == null || configJson.isEmpty()) { + // No persisted config (old integration / first ever launch): the + // native check silently does not run — this is the rollout gate. + return; + } + JSONObject config; + try { + config = new JSONObject(configJson); + } catch (JSONException e) { + return; + } + if (config.optBoolean("disabled", false)) { + return; + } + String appKey = config.optString("appKey", ""); + if (appKey.isEmpty()) { + return; + } + String packageVersion = config.optString( + "packageVersion", context.getPackageVersion()); + if (packageVersion.isEmpty()) { + packageVersion = context.getPackageVersion(); + } + + String currentVersion = context.getCurrentVersion(); + // Snapshot captured on the launch path before getConstants consumes + // the one-shot rollback marker. Reading SharedPreferences here, five + // seconds later, would lose the guard and could forceBoot the version + // that this very launch just rolled back. + String rolledBackVersion = launchRolledBackVersion; + String uuid = context.getKv("uuid"); + if (uuid == null) { + uuid = ""; + } + + JSONObject identity = new JSONObject(); + identity.put("packageVersion", packageVersion); + identity.put( + "currentVersion", + currentVersion == null ? JSONObject.NULL : currentVersion + ); + identity.put("uuid", uuid); + if (rolledBackVersion != null) { + identity.put("rolledBackVersion", rolledBackVersion); + } + + JSONObject cInfo = new JSONObject(); + cInfo.put("rnu", config.optString("rnu", "")); + cInfo.put("rn", config.optString("rn", "")); + // React Native's Platform.Version is the Android SDK integer; use the + // same value so this request can be fingerprinted against the JS one. + cInfo.put("os", "android " + Build.VERSION.SDK_INT); + cInfo.put("uuid", uuid); + + JSONObject input = new JSONObject(); + input.put("packageVersion", packageVersion); + input.put( + "currentVersion", + currentVersion == null ? JSONObject.NULL : currentVersion + ); + input.put("buildTime", context.getBuildTime()); + input.put("cInfo", cInfo); + input.put("supportedDiffVersion", NativeUpdateCore.supportedDiffVersion()); + input.put("bundleHash", context.computeBundleHash()); + + String body = NativeUpdateFlow.buildCheckRequestBody(input.toString()); + if (body == null) { + return; + } + + String responseText = runCheckRequest(config, appKey, body); + if (responseText == null) { + Log.i(UpdateContext.TAG, + "native check: no endpoint reachable, giving up until next launch"); + return; + } + // Cache freshness is anchored to when the server response arrived, + // not to when a potentially long download/patch/activation finished. + final long responseAtSeconds = System.currentTimeMillis() / 1000; + + String decisionJson = NativeUpdateFlow.handleCheckResponse( + responseText, identity.toString(), config.optString("afterDownload", "")); + if (decisionJson == null) { + return; + } + JSONObject decision = new JSONObject(decisionJson); + if (!"download".equals(decision.optString("action"))) { + context.commitNativeCheckResult( + resetGeneration, null, null, false, + buildResponseCacheJson(configJson, body, responseText, responseAtSeconds)); + Log.i(UpdateContext.TAG, + "native check: nothing to do (" + decision.optString("reason") + ")"); + return; + } + String hash = decision.optString("hash", ""); + if (!UpdateContext.isSafePathComponent(hash)) { + return; + } + + boolean downloaded = context.hasCompletedVersion(hash); + if (!downloaded) { + downloaded = performAttempts( + context, decision.optJSONArray("attempts"), hash, currentVersion); + } + if (!downloaded) { + // The native attempt has finished, so JS may safely reuse the + // response and retry through its own strategy chain. + context.commitNativeCheckResult( + resetGeneration, null, null, false, + buildResponseCacheJson(configJson, body, responseText, responseAtSeconds)); + return; + } + + // Version info (mirroring the JS side's setLocalHashInfo), the + // activation and the response cache all land in one atomic commit — + // see UpdateContext.commitNativeCheckResult. + String hashInfoJson = null; + JSONObject info = decision.optJSONObject("info"); + if (info != null) { + JSONObject hashInfo = new JSONObject(); + for (String key : new String[] {"name", "description", "metaInfo"}) { + Object value = info.opt(key); + if (value instanceof String) { + hashInfo.put(key, value); + } + } + hashInfoJson = hashInfo.toString(); + } + // Silent strategies or a server-marked forceBoot version (per-version + // remote override — the brick rescue) activate for the next launch; + // otherwise activation stays with the JS side. + boolean activate = decision.optBoolean("activate", false); + boolean committed; + try { + committed = context.commitNativeCheckResult( + resetGeneration, + hash, + hashInfoJson, + activate, + buildResponseCacheJson(configJson, body, responseText, responseAtSeconds)); + } catch (Exception e) { + Log.w(UpdateContext.TAG, "native check: commit failed: " + e); + return; + } + if (!committed) { + Log.i(UpdateContext.TAG, "native check: reset during round, dropping result"); + } else if (activate) { + Log.i(UpdateContext.TAG, + "native check: downloaded " + hash + " and set for next launch"); + } else { + Log.i(UpdateContext.TAG, + "native check: downloaded " + hash + ", activation left to JS"); + } + } + + private static String buildResponseCacheJson( + String configJson, + String requestBody, + String responseText, + long responseAtSeconds + ) throws JSONException { + JSONObject cacheEntry = new JSONObject(); + cacheEntry.put("ts", responseAtSeconds); + cacheEntry.put("body", responseText); + cacheEntry.put("request", requestBody); + cacheEntry.put("config", configJson); + return cacheEntry.toString(); + } + + private static final OkHttpClient httpClient = new OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .callTimeout(15, TimeUnit.SECONDS) + .build(); + + private static String httpRequest(String url, String postBody) { + try { + Request.Builder builder = + new Request.Builder().url(url).header("Accept", "application/json"); + if (postBody != null) { + builder.post(RequestBody.create( + postBody, MediaType.parse("application/json; charset=utf-8"))); + } + try (Response response = httpClient.newCall(builder.build()).execute()) { + if (!response.isSuccessful() || response.body() == null) { + return null; + } + return response.body().string(); + } + } catch (Exception e) { + return null; + } + } + + private static boolean isValidCheckResponse(String responseText) { + if (responseText == null) { + return false; + } + try { + new JSONObject(responseText); + return true; + } catch (JSONException e) { + return false; + } + } + + private static String normalizeEndpointBase(String base) { + while (base.endsWith("/")) { + base = base.substring(0, base.length() - 1); + } + return base; + } + + /** + * Sequential fallback over the ordered candidates (§5.1): one request at + * a time with its own timeout; after the configured round fails, + * queryUrls discovery merges remote candidates (excluding the + * already-tried) for one more round. No hedged race on purpose — this + * path is latency-insensitive. + */ + private static String runCheckRequest(JSONObject config, String appKey, String body) { + JSONArray endpoints = config.optJSONArray("endpoints"); + String orderedJson = NativeUpdateFlow.orderEndpointCandidates( + endpoints == null ? "[]" : endpoints.toString(), Math.random()); + JSONArray ordered; + try { + ordered = orderedJson == null ? new JSONArray() : new JSONArray(orderedJson); + } catch (JSONException e) { + return null; + } + HashSet tried = new HashSet<>(); + int httpAttempts = 0; + for (int i = 0; i < ordered.length(); i++) { + String base = normalizeEndpointBase(ordered.optString(i, "")); + if (base.isEmpty() || !tried.add(base)) { + continue; + } + if (httpAttempts++ >= MAX_CHECK_HTTP_ATTEMPTS) { + return null; + } + String response = httpRequest(base + "/checkUpdate/" + appKey, body); + if (isValidCheckResponse(response)) { + return response; + } + } + JSONArray queryUrls = config.optJSONArray("queryUrls"); + if (queryUrls == null) { + return null; + } + for (int i = 0; i < queryUrls.length(); i++) { + String listUrl = queryUrls.optString(i, ""); + if (listUrl.isEmpty()) { + continue; + } + if (httpAttempts++ >= MAX_CHECK_HTTP_ATTEMPTS) { + return null; + } + String listText = httpRequest(listUrl, null); + if (listText == null) { + continue; + } + JSONArray remote; + try { + remote = new JSONArray(listText); + } catch (JSONException e) { + continue; + } + for (int j = 0; j < remote.length(); j++) { + String base = normalizeEndpointBase(remote.optString(j, "")); + if (base.isEmpty() || tried.contains(base)) { + continue; + } + if (httpAttempts++ >= MAX_CHECK_HTTP_ATTEMPTS) { + return null; + } + tried.add(base); + String response = httpRequest(base + "/checkUpdate/" + appKey, body); + if (isValidCheckResponse(response)) { + return response; + } + } + // One successfully fetched remote list is enough. + break; + } + return null; + } + + private static boolean performAttempts( + UpdateContext context, JSONArray attempts, String hash, String originHash + ) { + if (attempts == null) { + return false; + } + final long incrementalDeadlineNanos = System.nanoTime() + + TimeUnit.SECONDS.toNanos(DOWNLOAD_PHASE_TIMEOUT_SECONDS); + long fullDeadlineNanos = 0; + for (int i = 0; i < attempts.length(); i++) { + JSONObject attempt = attempts.optJSONObject(i); + if (attempt == null) { + continue; + } + String type = attempt.optString("type"); + if ("diff".equals(type) && (originHash == null || originHash.isEmpty())) { + // diff patches from the running version; none is running. + continue; + } + final boolean isFullAttempt = !"diff".equals(type) && !"pdiff".equals(type); + if (isFullAttempt && fullDeadlineNanos == 0) { + // Incremental failures must not consume the last-resort full + // download's budget. Each phase gets one bounded 10min window. + fullDeadlineNanos = System.nanoTime() + + TimeUnit.SECONDS.toNanos(DOWNLOAD_PHASE_TIMEOUT_SECONDS); + } + final long deadlineNanos = isFullAttempt + ? fullDeadlineNanos : incrementalDeadlineNanos; + JSONArray urls = attempt.optJSONArray("urls"); + if (urls == null) { + continue; + } + for (int j = 0; j < urls.length(); j++) { + String url = urls.optString(j, ""); + if (url.isEmpty()) { + continue; + } + // Check before enqueueing: once the phase budget is gone we + // must not launch an orphan download that outlives the round. + long remainingNanos = deadlineNanos - System.nanoTime(); + if (remainingNanos <= 0) { + if (isFullAttempt) { + return false; + } + break; + } + final CountDownLatch latch = new CountDownLatch(1); + final AtomicBoolean succeeded = new AtomicBoolean(false); + final String attemptType = type; + UpdateContext.DownloadFileListener listener = + new UpdateContext.DownloadFileListener() { + @Override + public void onDownloadCompleted(DownloadTaskParams params) { + succeeded.set(true); + latch.countDown(); + } + + @Override + public void onDownloadFailed(Throwable error) { + Log.i(UpdateContext.TAG, "native check: " + attemptType + + " attempt failed: " + error); + latch.countDown(); + } + }; + if ("diff".equals(type)) { + context.downloadPatchFromPpk( + url, hash, originHash, listener, deadlineNanos); + } else if ("pdiff".equals(type)) { + context.downloadPatchFromApk( + url, hash, listener, deadlineNanos); + } else { + context.downloadFullUpdate( + url, hash, listener, deadlineNanos); + } + try { + if (!latch.await(remainingNanos, TimeUnit.NANOSECONDS)) { + Log.w(UpdateContext.TAG, + "native check: download phase timed out during " + type); + if (isFullAttempt) { + return false; + } + break; + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + if (succeeded.get()) { + return true; + } + } + } + return false; + } +} diff --git a/android/src/main/java/cn/reactnative/modules/update/NativeUpdateFlow.java b/android/src/main/java/cn/reactnative/modules/update/NativeUpdateFlow.java new file mode 100644 index 00000000..4d835f28 --- /dev/null +++ b/android/src/main/java/cn/reactnative/modules/update/NativeUpdateFlow.java @@ -0,0 +1,23 @@ +package cn.reactnative.modules.update; + +/** + * JNI bindings to the shared update-flow decision layer + * (cpp/update_flow_core). String-in/string-out JSON on purpose — it matches + * the decision layer's own boundary and keeps this surface trivially stable. + * A null return means the input did not parse; callers skip the check round. + */ +final class NativeUpdateFlow { + static { + NativeUpdateCore.ensureLoaded(); + } + + private NativeUpdateFlow() { + } + + static native String buildCheckRequestBody(String inputJson); + + static native String orderEndpointCandidates(String endpointsJson, double randomSample); + + static native String handleCheckResponse( + String responseText, String identityJson, String afterDownload); +} diff --git a/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java b/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java index edffa45f..338e5a90 100644 --- a/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +++ b/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java @@ -47,6 +47,17 @@ public class UpdateContext { private static final int STATE_OP_CLEAR_ROLLBACK_MARK = 5; private static final int STATE_OP_RESOLVE_LAUNCH = 6; private static final String KEY_FIRST_LOAD_MARKED = "firstLoadMarked"; + static final String VERSION_COMPLETE_FILE = ".pushy-complete"; + // Bumped by resetToPackagedBundle. The cold-start check runs for minutes + // and may already hold a decision when the app resets to the packaged + // bundle; the orchestrator samples this counter and abandons activation + // (and its response cache) when the value moved, so an in-flight rescue + // can never resurrect the version the app just reset away from. + private static final java.util.concurrent.atomic.AtomicLong resetGeneration = + new java.util.concurrent.atomic.AtomicLong(0); + // Held by resetToPackagedBundle and by the cold-start check's commit, so + // the generation check and the writes it guards are one atomic step. + private static final Object commitLock = new Object(); // Singleton instance private static volatile UpdateContext sInstance; @@ -159,7 +170,9 @@ public void run() { }); } - private String computeBundleHash() { + // Package-private: also the native cold-start check's request input + // (NativeCheckOrchestrator). Blocking — call off the main thread. + String computeBundleHash() { String cachePrefix = getPackageVersion() + "|" + getPackageLastUpdateTime() + "|"; String cached = sp.getString(KEY_BUNDLE_HASH_CACHE, null); if (cached != null && cached.startsWith(cachePrefix)) { @@ -238,6 +251,12 @@ public interface DownloadFileListener { } public void downloadFullUpdate(String url, String hash, DownloadFileListener listener) { + downloadFullUpdate(url, hash, listener, 0); + } + + void downloadFullUpdate( + String url, String hash, DownloadFileListener listener, long deadlineNanos + ) { if (rejectUnsafeComponent(hash, listener)) { return; } @@ -246,6 +265,7 @@ public void downloadFullUpdate(String url, String hash, DownloadFileListener lis params.url = url; params.hash = hash; params.listener = listener; + params.deadlineNanos = deadlineNanos; params.targetFile = new File(rootDir, hash + ".ppk"); params.unzipDirectory = new File(rootDir, hash); enqueue(params); @@ -273,6 +293,12 @@ public void downloadFile(String url, String hash, String fileName, DownloadFileL } public void downloadPatchFromApk(String url, String hash, DownloadFileListener listener) { + downloadPatchFromApk(url, hash, listener, 0); + } + + void downloadPatchFromApk( + String url, String hash, DownloadFileListener listener, long deadlineNanos + ) { if (rejectUnsafeComponent(hash, listener)) { return; } @@ -281,12 +307,23 @@ public void downloadPatchFromApk(String url, String hash, DownloadFileListener l params.url = url; params.hash = hash; params.listener = listener; + params.deadlineNanos = deadlineNanos; params.targetFile = new File(rootDir, hash + ".apk.patch"); params.unzipDirectory = new File(rootDir, hash); enqueue(params); } public void downloadPatchFromPpk(String url, String hash, String originHash, DownloadFileListener listener) { + downloadPatchFromPpk(url, hash, originHash, listener, 0); + } + + void downloadPatchFromPpk( + String url, + String hash, + String originHash, + DownloadFileListener listener, + long deadlineNanos + ) { if (rejectUnsafeComponent(hash, listener) || rejectUnsafeComponent(originHash, listener)) { return; } @@ -296,6 +333,7 @@ public void downloadPatchFromPpk(String url, String hash, String originHash, Dow params.hash = hash; params.originHash = originHash; params.listener = listener; + params.deadlineNanos = deadlineNanos; params.targetFile = new File(rootDir, originHash + "-" + hash + ".ppk.patch"); params.unzipDirectory = new File(rootDir, hash); params.originDirectory = new File(rootDir, originHash); @@ -348,10 +386,21 @@ public void switchVersion(String hash) { if (!isSafePathComponent(hash)) { throw new IllegalArgumentException("Invalid hash: " + hash); } - if (!new File(rootDir, hash+"/index.bundlejs").exists()) { + File versionDir = new File(rootDir, hash); + File bundleFile = new File(versionDir, "index.bundlejs"); + if (!bundleFile.isFile()) { throw new IllegalStateException("Bundle version " + hash + " not found."); } StateCoreResult currentState = getStateSnapshot(); + boolean isLegacyActivatedVersion = hash.equals(currentState.currentVersion) + || hash.equals(currentState.lastVersion); + if (!new File(versionDir, VERSION_COMPLETE_FILE).isFile() + && !isLegacyActivatedVersion) { + // Versions activated before completion markers were introduced are + // explicitly grandfathered through current/last state. An arbitrary + // markerless directory may be a crash-left partial install. + throw new IllegalStateException("Bundle version " + hash + " is incomplete."); + } StateCoreResult nextState = runStateCore( STATE_OP_SWITCH_VERSION, currentState, @@ -445,6 +494,16 @@ public void clearFirstTime() { * for gray release bucketing and must not change on reset. */ public void resetToPackagedBundle() { + synchronized (commitLock) { + resetToPackagedBundleLocked(); + } + } + + private void resetToPackagedBundleLocked() { + // Invalidate any in-flight cold-start round before clearing state: a + // round committing under the same lock afterwards sees the new + // generation and drops its result. + resetGeneration.incrementAndGet(); StateCoreResult resetState = new StateCoreResult(); resetState.packageVersion = getPackageVersion(); resetState.buildTime = getBuildTime(); @@ -459,6 +518,8 @@ public void resetToPackagedBundle() { } persistEditor(editor, "reset to packaged bundle"); ignoreRollback = false; + // editor.clear() above already dropped the cached check response; it + // still advertised the version this reset removed. Log.i(TAG, "Reset to packaged bundle"); DownloadTaskParams params = new DownloadTaskParams(); @@ -530,55 +591,112 @@ public String getBundleUrl() { public String getBundleUrl(String defaultAssetsUrl) { isUsingBundleUrl = true; - StateCoreResult currentState = getStateSnapshot(); - StateCoreResult launchState = runStateCore( - STATE_OP_RESOLVE_LAUNCH, - currentState, - null, - ignoreRollback, - true - ); - if (launchState.didRollback) { - // The crash-protection rollback: the new version never called - // markSuccess. Keep this visible in release logs. - Log.e(TAG, "Version " + currentState.currentVersion - + " was not marked as successful, rolling back to " - + launchState.currentVersion); - } - if (launchState.didRollback || launchState.consumedFirstTime) { - SharedPreferences.Editor editor = sp.edit(); - applyState(editor, launchState); + String nativeCheckRolledBackVersion = null; + try { + StateCoreResult currentState = getStateSnapshot(); + StateCoreResult launchState = runStateCore( + STATE_OP_RESOLVE_LAUNCH, + currentState, + null, + ignoreRollback, + true + ); + nativeCheckRolledBackVersion = launchState.rolledBackVersion; + if (launchState.didRollback) { + // The crash-protection rollback: the new version never called + // markSuccess. Keep this visible in release logs. + Log.e(TAG, "Version " + currentState.currentVersion + + " was not marked as successful, rolling back to " + + launchState.currentVersion); + } + if (launchState.didRollback || launchState.consumedFirstTime) { + SharedPreferences.Editor editor = sp.edit(); + applyState(editor, launchState); + if (launchState.consumedFirstTime) { + editor.putBoolean(KEY_FIRST_LOAD_MARKED, true); + } + persistEditor(editor, "resolve launch"); + } if (launchState.consumedFirstTime) { - editor.putBoolean(KEY_FIRST_LOAD_MARKED, true); + // bundleURL may be resolved multiple times in one process. + ignoreRollback = true; + } + + String currentVersion = launchState.loadVersion; + if (currentVersion == null) { + return defaultAssetsUrl; + } + + // Guard the rollback chain against cycles: a corrupted state returning + // an already-visited version would otherwise spin this loop forever on + // the main thread. + java.util.HashSet visitedVersions = new java.util.HashSet<>(); + while (currentVersion != null && visitedVersions.add(currentVersion)) { + File bundleFile = new File(rootDir, currentVersion+"/index.bundlejs"); + if (!bundleFile.exists()) { + Log.e(TAG, "Bundle version " + currentVersion + " not found."); + currentVersion = this.rollBack(); + nativeCheckRolledBackVersion = rolledBackVersion(); + continue; + } + launchVersion = currentVersion; + nativeCheckRolledBackVersion = rolledBackVersion(); + return bundleFile.toString(); } - persistEditor(editor, "resolve launch"); - } - if (launchState.consumedFirstTime) { - // bundleURL may be resolved multiple times in one process. - ignoreRollback = true; - } - String currentVersion = launchState.loadVersion; - if (currentVersion == null) { + nativeCheckRolledBackVersion = rolledBackVersion(); return defaultAssetsUrl; + } finally { + // Even corrupted state or a state-core exception must not disable + // the next-launch rescue check. A null snapshot simply omits the + // rollback guard for this exceptional launch. + NativeCheckOrchestrator.schedule(this, nativeCheckRolledBackVersion); } + } - // Guard the rollback chain against cycles: a corrupted state returning - // an already-visited version would otherwise spin this loop forever on - // the main thread. - java.util.HashSet visitedVersions = new java.util.HashSet<>(); - while (currentVersion != null && visitedVersions.add(currentVersion)) { - File bundleFile = new File(rootDir, currentVersion+"/index.bundlejs"); - if (!bundleFile.exists()) { - Log.e(TAG, "Bundle version " + currentVersion + " not found."); - currentVersion = this.rollBack(); - continue; + /** Sampled/compared by the native check orchestrator; see resetGeneration. */ + static long getResetGeneration() { + return resetGeneration.get(); + } + + /** + * Commit everything a cold-start round persists — version info, the + * activation, the response cache — under one lock that first re-checks the + * reset generation. resetToPackagedBundle takes the same lock, so there is + * no compare-and-act window: either the whole round lands, or the reset + * wins and none of it does. Returns whether the round was committed. + */ + boolean commitNativeCheckResult( + long expectedGeneration, + String hash, + String hashInfoJson, + boolean activate, + String responseCacheJson + ) { + synchronized (commitLock) { + if (resetGeneration.get() != expectedGeneration) { + return false; + } + if (hash != null && hashInfoJson != null) { + setKv("hash_" + hash, hashInfoJson); + } + if (activate && hash != null) { + switchVersion(hash); } - launchVersion = currentVersion; - return bundleFile.toString(); + if (responseCacheJson != null) { + setKv(NativeCheckOrchestrator.KEY_RESP_CACHE, responseCacheJson); + } + return true; } + } - return defaultAssetsUrl; + boolean hasCompletedVersion(String hash) { + if (!isSafePathComponent(hash)) { + return false; + } + File versionDir = new File(rootDir, hash); + return new File(versionDir, "index.bundlejs").isFile() + && new File(versionDir, VERSION_COMPLETE_FILE).isFile(); } private String rollBack() { diff --git a/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java b/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java index ccfbea6b..5bd0849c 100644 --- a/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java +++ b/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java @@ -224,10 +224,54 @@ public void run() { }); } + /** + * Raw response cached by the native cold-start check, for the JS side to + * reuse instead of re-checking (§10.3). Empty string when absent; never + * rejects. + */ + public static void getNativeCheckCache( + final UpdateContext updateContext, + final Promise promise + ) { + String cached = updateContext.getKv(NativeCheckOrchestrator.KEY_RESP_CACHE); + promise.resolve(cached == null ? "" : cached); + } + private static void setUuidInternal(UpdateContext updateContext, String uuid) { updateContext.setKv("uuid", uuid); } + /** + * Provisioning for the native cold-start update check + * (NATIVE_CHECKUPDATE_DESIGN §10.1): the raw JSON persists as-is and is + * parsed on read by the orchestrator; absent config = check disabled. + * Validated at write time — a corrupt config would otherwise silently + * disable the native check forever with no signal. + */ + public static void syncNativeConfig( + final UpdateContext updateContext, + final String config, + final Promise promise + ) { + if (config == null || config.isEmpty()) { + promise.reject(ErrorCodes.INVALID_OPTIONS, "config must be a JSON object string"); + return; + } + try { + new JSONObject(config); + } catch (JSONException e) { + promise.reject(ErrorCodes.INVALID_OPTIONS, "config must be a JSON object string", e); + return; + } + StateSerialRunner.run(promise, ErrorCodes.FILE_OPERATION_FAILED, "syncNativeConfig", new StateSerialRunner.Operation() { + @Override + public void run() { + updateContext.setKv(NativeCheckOrchestrator.KEY_CONFIG, config); + promise.resolve(true); + } + }); + } + public static void setUuid( final UpdateContext updateContext, final String uuid, diff --git a/android/src/newarch/cn/reactnative/modules/update/UpdateModule.java b/android/src/newarch/cn/reactnative/modules/update/UpdateModule.java index 703a444c..e25d31df 100644 --- a/android/src/newarch/cn/reactnative/modules/update/UpdateModule.java +++ b/android/src/newarch/cn/reactnative/modules/update/UpdateModule.java @@ -88,6 +88,16 @@ public void setUuid(String uuid, Promise promise) { UpdateModuleImpl.setUuid(updateContext, uuid, promise); } + @Override + public void syncNativeConfig(String config, Promise promise) { + UpdateModuleImpl.syncNativeConfig(updateContext, config, promise); + } + + @Override + public void getNativeCheckCache(Promise promise) { + UpdateModuleImpl.getNativeCheckCache(updateContext, promise); + } + @Override public void setLocalHashInfo(String hash, String info, Promise promise) { UpdateModuleImpl.setLocalHashInfo(updateContext, hash, info, promise); diff --git a/android/src/oldarch/cn/reactnative/modules/update/UpdateModule.java b/android/src/oldarch/cn/reactnative/modules/update/UpdateModule.java index e3bc8068..710bdb6a 100644 --- a/android/src/oldarch/cn/reactnative/modules/update/UpdateModule.java +++ b/android/src/oldarch/cn/reactnative/modules/update/UpdateModule.java @@ -90,6 +90,16 @@ public void setUuid(String uuid) { UpdateModuleImpl.setUuid(updateContext, uuid); } + @ReactMethod + public void syncNativeConfig(String config, Promise promise) { + UpdateModuleImpl.syncNativeConfig(updateContext, config, promise); + } + + @ReactMethod + public void getNativeCheckCache(Promise promise) { + UpdateModuleImpl.getNativeCheckCache(updateContext, promise); + } + @ReactMethod public void setLocalHashInfo(String hash, String info) { UpdateModuleImpl.setLocalHashInfo(updateContext, hash, info); diff --git a/cpp/update_flow_core/flow_json.cpp b/cpp/update_flow_core/flow_json.cpp new file mode 100644 index 00000000..471a2d7c --- /dev/null +++ b/cpp/update_flow_core/flow_json.cpp @@ -0,0 +1,522 @@ +#include "flow_json.h" + +#include +#include +#include + +namespace flowjson { + +namespace { +const Value kUndefined; +} // namespace + +bool Value::Truthy() const { + switch (kind_) { + case Kind::Undefined: + case Kind::Null: + return false; + case Kind::Bool: + return bool_; + case Kind::Number: + return number_ != 0 && !std::isnan(number_); + case Kind::String: + return !string_.empty(); + case Kind::Array: + case Kind::Object: + return true; + } + return false; +} + +const Value& Value::At(size_t i) const { + if (kind_ != Kind::Array || i >= elements_.size()) { + return kUndefined; + } + return elements_[i]; +} + +const Value& Value::Get(const std::string& key) const { + if (kind_ == Kind::Object) { + for (const auto& member : members_) { + if (member.first == key) { + return member.second; + } + } + } + return kUndefined; +} + +void Value::Set(const std::string& key, Value v) { + for (auto& member : members_) { + if (member.first == key) { + member.second = std::move(v); + return; + } + } + members_.emplace_back(key, std::move(v)); +} + +void Value::Remove(const std::string& key) { + for (auto it = members_.begin(); it != members_.end(); ++it) { + if (it->first == key) { + members_.erase(it); + return; + } + } +} + +bool Value::StrictEquals(const Value& a, const Value& b) { + if (a.kind_ != b.kind_) { + return false; + } + switch (a.kind_) { + case Kind::Undefined: + case Kind::Null: + return true; + case Kind::Bool: + return a.bool_ == b.bool_; + case Kind::Number: + return a.number_ == b.number_; + case Kind::String: + return a.string_ == b.string_; + case Kind::Array: + case Kind::Object: + return false; + } + return false; +} + +namespace { + +void AppendEscaped(const std::string& s, std::string* out) { + out->push_back('"'); + for (unsigned char c : s) { + switch (c) { + case '"': + out->append("\\\""); + break; + case '\\': + out->append("\\\\"); + break; + case '\b': + out->append("\\b"); + break; + case '\f': + out->append("\\f"); + break; + case '\n': + out->append("\\n"); + break; + case '\r': + out->append("\\r"); + break; + case '\t': + out->append("\\t"); + break; + default: + if (c < 0x20) { + char buf[8]; + std::snprintf(buf, sizeof(buf), "\\u%04x", c); + out->append(buf); + } else { + out->push_back(static_cast(c)); + } + } + } + out->push_back('"'); +} + +void AppendNumber(double n, std::string* out) { + // The decision layer only ever emits integral numbers (hashes, counters, + // rollout percentages); print them the way JSON.stringify does. The %.17g + // fallback exists so an unexpected fractional value surfaces as a vector + // mismatch instead of silent truncation. + if (std::isfinite(n) && n == std::floor(n) && std::fabs(n) <= 9007199254740992.0) { + char buf[32]; + std::snprintf(buf, sizeof(buf), "%lld", static_cast(n)); + out->append(buf); + } else { + char buf[40]; + std::snprintf(buf, sizeof(buf), "%.17g", n); + out->append(buf); + } +} + +void AppendValue(const Value& v, std::string* out) { + switch (v.kind()) { + case Value::Kind::Undefined: // only reachable inside arrays + case Value::Kind::Null: + out->append("null"); + break; + case Value::Kind::Bool: + out->append(v.AsBool() ? "true" : "false"); + break; + case Value::Kind::Number: + AppendNumber(v.AsNumber(), out); + break; + case Value::Kind::String: + AppendEscaped(v.AsString(), out); + break; + case Value::Kind::Array: { + out->push_back('['); + bool first = true; + for (const auto& element : v.elements()) { + if (!first) { + out->push_back(','); + } + first = false; + AppendValue(element, out); + } + out->push_back(']'); + break; + } + case Value::Kind::Object: { + out->push_back('{'); + bool first = true; + for (const auto& member : v.members()) { + if (member.second.IsUndefined()) { + continue; // JSON.stringify drops undefined-valued members + } + if (!first) { + out->push_back(','); + } + first = false; + AppendEscaped(member.first, out); + out->push_back(':'); + AppendValue(member.second, out); + } + out->push_back('}'); + break; + } + } +} + +} // namespace + +std::string Stringify(const Value& v) { + if (v.IsUndefined()) { + return "undefined"; + } + std::string out; + AppendValue(v, &out); + return out; +} + +namespace { + +// The parser also consumes server checkUpdate responses, so hostile input +// must fail cleanly: nesting is capped to keep recursive descent off the +// stack limit. Real responses nest ~4 levels. +constexpr int kMaxDepth = 64; + +class Parser { + public: + Parser(const std::string& text) : text_(text) {} + + Value Run(bool* ok) { + Value v = ParseValue(); + SkipWs(); + *ok = ok_ && pos_ == text_.size(); + return *ok ? v : Value::Undefined(); + } + + private: + void SkipWs() { + while (pos_ < text_.size()) { + char c = text_[pos_]; + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') { + pos_++; + } else { + break; + } + } + } + + bool Consume(char expected) { + if (pos_ < text_.size() && text_[pos_] == expected) { + pos_++; + return true; + } + ok_ = false; + return false; + } + + bool ConsumeLiteral(const char* literal) { + size_t len = 0; + while (literal[len]) { + len++; + } + if (text_.compare(pos_, len, literal) == 0) { + pos_ += len; + return true; + } + ok_ = false; + return false; + } + + Value ParseValue() { + SkipWs(); + if (pos_ >= text_.size()) { + ok_ = false; + return Value::Undefined(); + } + char c = text_[pos_]; + switch (c) { + case '{': + case '[': { + if (depth_ >= kMaxDepth) { + ok_ = false; + return Value::Undefined(); + } + depth_++; + Value v = c == '{' ? ParseObject() : ParseArray(); + depth_--; + return v; + } + case '"': + return Value::String(ParseString()); + case 't': + ConsumeLiteral("true"); + return Value::Bool(true); + case 'f': + ConsumeLiteral("false"); + return Value::Bool(false); + case 'n': + ConsumeLiteral("null"); + return Value::Null(); + default: + return ParseNumber(); + } + } + + Value ParseObject() { + Value obj = Value::Object(); + Consume('{'); + SkipWs(); + if (pos_ < text_.size() && text_[pos_] == '}') { + pos_++; + return obj; + } + while (ok_) { + SkipWs(); + std::string key = ParseString(); + SkipWs(); + Consume(':'); + obj.Set(key, ParseValue()); + SkipWs(); + if (pos_ < text_.size() && text_[pos_] == ',') { + pos_++; + continue; + } + Consume('}'); + break; + } + return obj; + } + + Value ParseArray() { + Value arr = Value::Array(); + Consume('['); + SkipWs(); + if (pos_ < text_.size() && text_[pos_] == ']') { + pos_++; + return arr; + } + while (ok_) { + arr.Push(ParseValue()); + SkipWs(); + if (pos_ < text_.size() && text_[pos_] == ',') { + pos_++; + continue; + } + Consume(']'); + break; + } + return arr; + } + + std::string ParseString() { + std::string out; + if (!Consume('"')) { + return out; + } + while (pos_ < text_.size()) { + char c = text_[pos_++]; + if (c == '"') { + return out; + } + if (c != '\\') { + out.push_back(c); + continue; + } + if (pos_ >= text_.size()) { + break; + } + char esc = text_[pos_++]; + switch (esc) { + case '"': + case '\\': + case '/': + out.push_back(esc); + break; + case 'b': + out.push_back('\b'); + break; + case 'f': + out.push_back('\f'); + break; + case 'n': + out.push_back('\n'); + break; + case 'r': + out.push_back('\r'); + break; + case 't': + out.push_back('\t'); + break; + case 'u': { + unsigned code = ParseHex4(); + // BMP code point to UTF-8 (surrogate pairs are combined). + if (code >= 0xd800 && code <= 0xdbff && + text_.compare(pos_, 2, "\\u") == 0) { + pos_ += 2; + unsigned low = ParseHex4(); + if (low >= 0xdc00 && low <= 0xdfff) { + code = 0x10000 + ((code - 0xd800) << 10) + (low - 0xdc00); + } else { + ok_ = false; + } + } + AppendUtf8(code, &out); + break; + } + default: + ok_ = false; + return out; + } + } + ok_ = false; + return out; + } + + unsigned ParseHex4() { + unsigned code = 0; + for (int i = 0; i < 4; i++) { + if (pos_ >= text_.size()) { + ok_ = false; + return 0; + } + char c = text_[pos_++]; + code <<= 4; + if (c >= '0' && c <= '9') { + code |= static_cast(c - '0'); + } else if (c >= 'a' && c <= 'f') { + code |= static_cast(c - 'a' + 10); + } else if (c >= 'A' && c <= 'F') { + code |= static_cast(c - 'A' + 10); + } else { + ok_ = false; + return 0; + } + } + return code; + } + + static void AppendUtf8(unsigned code, std::string* out) { + if (code < 0x80) { + out->push_back(static_cast(code)); + } else if (code < 0x800) { + out->push_back(static_cast(0xc0 | (code >> 6))); + out->push_back(static_cast(0x80 | (code & 0x3f))); + } else if (code < 0x10000) { + out->push_back(static_cast(0xe0 | (code >> 12))); + out->push_back(static_cast(0x80 | ((code >> 6) & 0x3f))); + out->push_back(static_cast(0x80 | (code & 0x3f))); + } else { + out->push_back(static_cast(0xf0 | (code >> 18))); + out->push_back(static_cast(0x80 | ((code >> 12) & 0x3f))); + out->push_back(static_cast(0x80 | ((code >> 6) & 0x3f))); + out->push_back(static_cast(0x80 | (code & 0x3f))); + } + } + + Value ParseNumber() { + size_t start = pos_; + if (pos_ < text_.size() && text_[pos_] == '-') { + pos_++; + } + + if (pos_ >= text_.size()) { + ok_ = false; + return Value::Undefined(); + } + if (text_[pos_] == '0') { + pos_++; + // RFC 8259 forbids leading zeroes (except the number zero itself). + if (pos_ < text_.size() && text_[pos_] >= '0' && text_[pos_] <= '9') { + ok_ = false; + return Value::Undefined(); + } + } else if (text_[pos_] >= '1' && text_[pos_] <= '9') { + while (pos_ < text_.size() && text_[pos_] >= '0' && + text_[pos_] <= '9') { + pos_++; + } + } else { + ok_ = false; + return Value::Undefined(); + } + + if (pos_ < text_.size() && text_[pos_] == '.') { + pos_++; + if (pos_ >= text_.size() || text_[pos_] < '0' || text_[pos_] > '9') { + ok_ = false; + return Value::Undefined(); + } + while (pos_ < text_.size() && text_[pos_] >= '0' && + text_[pos_] <= '9') { + pos_++; + } + } + + if (pos_ < text_.size() && + (text_[pos_] == 'e' || text_[pos_] == 'E')) { + pos_++; + if (pos_ < text_.size() && + (text_[pos_] == '+' || text_[pos_] == '-')) { + pos_++; + } + if (pos_ >= text_.size() || text_[pos_] < '0' || text_[pos_] > '9') { + ok_ = false; + return Value::Undefined(); + } + while (pos_ < text_.size() && text_[pos_] >= '0' && + text_[pos_] <= '9') { + pos_++; + } + } + + char* end = nullptr; + std::string token = text_.substr(start, pos_ - start); + double n = std::strtod(token.c_str(), &end); + if (end == nullptr || *end != '\0') { + ok_ = false; + return Value::Undefined(); + } + return Value::Number(n); + } + + const std::string& text_; + size_t pos_ = 0; + int depth_ = 0; + bool ok_ = true; +}; + +} // namespace + +Value Parse(const std::string& text, bool* ok) { + Parser parser(text); + return parser.Run(ok); +} + +} // namespace flowjson diff --git a/cpp/update_flow_core/flow_json.h b/cpp/update_flow_core/flow_json.h new file mode 100644 index 00000000..09a5ccba --- /dev/null +++ b/cpp/update_flow_core/flow_json.h @@ -0,0 +1,103 @@ +#pragma once + +#include +#include +#include +#include + +// Minimal JSON document model for the update-flow decision layer. +// +// Deliberately mirrors JavaScript object semantics where the ported decision +// logic depends on them, because the golden vectors are generated by the TS +// reference implementation and compared as serialized strings: +// - object members keep insertion order, and overwriting an existing key +// keeps its original position (JS spread/assignment semantics); +// - Kind::Undefined is distinct from Kind::Null: undefined-valued members +// are skipped by Stringify (like JSON.stringify), null is emitted; +// - Truthy() implements JS truthiness (undefined/null/false/0/NaN/'' are +// falsy; empty arrays and objects are truthy); +// - StrictEquals() implements === for primitives only (never for +// arrays/objects — reference equality cannot hold across a parse). +namespace flowjson { + +class Value; +using Members = std::vector>; +using Elements = std::vector; + +class Value { + public: + enum class Kind { Undefined, Null, Bool, Number, String, Array, Object }; + + Value() = default; + static Value Undefined() { return Value(); } + static Value Null() { return Value(Kind::Null); } + static Value Bool(bool b) { + Value v(Kind::Bool); + v.bool_ = b; + return v; + } + static Value Number(double n) { + Value v(Kind::Number); + v.number_ = n; + return v; + } + static Value String(std::string s) { + Value v(Kind::String); + v.string_ = std::move(s); + return v; + } + static Value Array() { return Value(Kind::Array); } + static Value Object() { return Value(Kind::Object); } + + Kind kind() const { return kind_; } + bool IsUndefined() const { return kind_ == Kind::Undefined; } + bool IsArray() const { return kind_ == Kind::Array; } + bool IsObject() const { return kind_ == Kind::Object; } + bool IsNumber() const { return kind_ == Kind::Number; } + bool IsString() const { return kind_ == Kind::String; } + + bool AsBool() const { return bool_; } + double AsNumber() const { return number_; } + const std::string& AsString() const { return string_; } + + bool Truthy() const; + + // Array access. + const Elements& elements() const { return elements_; } + void Push(Value v) { elements_.push_back(std::move(v)); } + size_t Size() const { return elements_.size(); } + const Value& At(size_t i) const; + + // Object access. Get returns Undefined for a missing key; Set overwrites + // in place (keeping the key's position) or appends. + const Members& members() const { return members_; } + const Value& Get(const std::string& key) const; + void Set(const std::string& key, Value v); + void Remove(const std::string& key); + + static bool StrictEquals(const Value& a, const Value& b); + + private: + explicit Value(Kind kind) : kind_(kind) {} + + Kind kind_ = Kind::Undefined; + bool bool_ = false; + double number_ = 0; + std::string string_; + Elements elements_; + Members members_; +}; + +// JSON.stringify-compatible for the value shapes the decision layer produces +// (undefined members skipped, undefined array elements become null, integral +// numbers within the double-safe range print without a decimal point). A +// top-level Undefined prints as "undefined" so two undefined results compare +// equal. +std::string Stringify(const Value& v); + +// Strict JSON parser (the vectors file and checkUpdate responses). Returns +// Undefined and sets *ok to false on malformed input; nesting beyond 64 +// levels is rejected so hostile server data cannot exhaust the stack. +Value Parse(const std::string& text, bool* ok); + +} // namespace flowjson diff --git a/cpp/update_flow_core/tests/flow_vectors.json b/cpp/update_flow_core/tests/flow_vectors.json new file mode 100644 index 00000000..2290f932 --- /dev/null +++ b/cpp/update_flow_core/tests/flow_vectors.json @@ -0,0 +1,1401 @@ +{ + "generated_by": "scripts/generate-flow-vectors.ts — do not edit by hand", + "cases": [ + { + "fn": "murmurhash3_32_gc", + "args": [ + "" + ], + "expected": 0 + }, + { + "fn": "murmurhash3_32_gc", + "args": [ + "hello" + ], + "expected": 613153351 + }, + { + "fn": "murmurhash3_32_gc", + "args": [ + "test" + ], + "expected": 3127628307 + }, + { + "fn": "murmurhash3_32_gc", + "args": [ + "Hello, world!" + ], + "expected": 3224780355 + }, + { + "fn": "murmurhash3_32_gc", + "args": [ + "The quick brown fox jumps over the lazy dog" + ], + "expected": 776992547 + }, + { + "fn": "murmurhash3_32_gc", + "args": [ + "test1" + ], + "expected": 374203662 + }, + { + "fn": "murmurhash3_32_gc", + "args": [ + "test2" + ], + "expected": 3155333867 + }, + { + "fn": "murmurhash3_32_gc", + "args": [ + "test3" + ], + "expected": 4144626353 + }, + { + "fn": "murmurhash3_32_gc", + "args": [ + "123e4567-e89b-12d3-a456-426614174000" + ], + "expected": 439536286 + }, + { + "fn": "murmurhash3_32_gc", + "args": [ + "123e4567-e89b-12d3-a456-426614174001" + ], + "expected": 3316680088 + }, + { + "fn": "murmurhash3_32_gc", + "args": [ + "a" + ], + "expected": 1009084850 + }, + { + "fn": "murmurhash3_32_gc", + "args": [ + "ab" + ], + "expected": 2613040991 + }, + { + "fn": "murmurhash3_32_gc", + "args": [ + "abc" + ], + "expected": 3017643002 + }, + { + "fn": "murmurhash3_32_gc", + "args": [ + "abcd" + ], + "expected": 1139631978 + }, + { + "fn": "isInRollout", + "args": [ + 63, + "test1" + ], + "expected": true + }, + { + "fn": "isInRollout", + "args": [ + 62, + "test1" + ], + "expected": false + }, + { + "fn": "isInRollout", + "args": [ + 61, + "test1" + ], + "expected": false + }, + { + "fn": "isInRollout", + "args": [ + 0, + "test1" + ], + "expected": false + }, + { + "fn": "isInRollout", + "args": [ + 100, + "test1" + ], + "expected": true + }, + { + "fn": "isInRollout", + "args": [ + -1, + "test3" + ], + "expected": false + }, + { + "fn": "isInRollout", + "args": [ + 54, + "test3" + ], + "expected": true + }, + { + "fn": "isInRollout", + "args": [ + 53, + "test3" + ], + "expected": false + }, + { + "fn": "joinUrls", + "args": [ + [ + "example.com" + ] + ] + }, + { + "fn": "joinUrls", + "args": [ + [ + "example.com" + ], + "" + ] + }, + { + "fn": "joinUrls", + "args": [ + [], + "file.txt" + ], + "expected": [] + }, + { + "fn": "joinUrls", + "args": [ + [ + "example.com", + "test.org" + ], + "file.txt" + ], + "expected": [ + "https://example.com/file.txt", + "https://test.org/file.txt" + ] + }, + { + "fn": "joinUrls", + "args": [ + [ + "example.com///", + "http://example.com///" + ], + "file.txt" + ], + "expected": [ + "https://example.com/file.txt", + "http://example.com/file.txt" + ] + }, + { + "fn": "joinUrls", + "args": [ + [ + "ftp://example.com", + "myapp://some/path" + ], + "file.txt" + ], + "expected": [ + "ftp://example.com/file.txt", + "myapp://some/path/file.txt" + ] + }, + { + "fn": "joinUrls", + "args": [ + [ + "192.168.1.1:8080", + "10.0.0.1:3000/api" + ], + "file.txt" + ], + "expected": [ + "https://192.168.1.1:8080/file.txt", + "https://10.0.0.1:3000/api/file.txt" + ] + }, + { + "fn": "joinUrls", + "args": [ + [ + "" + ], + "file.txt" + ], + "expected": [ + "https:///file.txt" + ] + }, + { + "fn": "joinUrls", + "args": [ + [ + "HTTPS://Upper.example.com" + ], + "file.txt" + ], + "expected": [ + "HTTPS://Upper.example.com/file.txt" + ] + }, + { + "fn": "joinUrls", + "args": [ + [ + "a:b://weird" + ], + "file.txt" + ], + "expected": [ + "https://a:b://weird/file.txt" + ] + }, + { + "fn": "orderEndpointCandidates", + "args": [ + [ + "a", + "b", + "c" + ], + 0 + ], + "expected": [ + "a", + "b", + "c" + ] + }, + { + "fn": "orderEndpointCandidates", + "args": [ + [ + "a", + "b", + "c" + ], + 0.34 + ], + "expected": [ + "b", + "a", + "c" + ] + }, + { + "fn": "orderEndpointCandidates", + "args": [ + [ + "a", + "b", + "c" + ], + 0.5 + ], + "expected": [ + "b", + "a", + "c" + ] + }, + { + "fn": "orderEndpointCandidates", + "args": [ + [ + "a", + "b", + "c" + ], + 0.99 + ], + "expected": [ + "c", + "a", + "b" + ] + }, + { + "fn": "orderEndpointCandidates", + "args": [ + [ + "a", + "b", + "c" + ], + 1 + ], + "expected": [ + "c", + "a", + "b" + ] + }, + { + "fn": "orderEndpointCandidates", + "args": [ + [ + "a", + null, + "a", + "", + "b" + ], + 0.6 + ], + "expected": [ + "b", + "a" + ] + }, + { + "fn": "orderEndpointCandidates", + "args": [ + [], + 0.5 + ], + "expected": [] + }, + { + "fn": "orderEndpointCandidates", + "args": [ + [ + "a" + ], + 0.5 + ], + "expected": [ + "a" + ] + }, + { + "fn": "buildCheckRequestBody", + "args": [ + { + "packageVersion": "2.3.4", + "currentVersion": "abcdef1234", + "buildTime": "1719999999", + "cInfo": { + "rnu": "10.50.0", + "rn": "0.85.2", + "os": "ios 17.5", + "uuid": "u-1" + } + } + ], + "expected": { + "packageVersion": "2.3.4", + "hash": "abcdef1234", + "buildTime": "1719999999", + "cInfo": { + "rnu": "10.50.0", + "rn": "0.85.2", + "os": "ios 17.5", + "uuid": "u-1" + } + } + }, + { + "fn": "buildCheckRequestBody", + "args": [ + { + "packageVersion": "2.3.4", + "currentVersion": "abcdef1234", + "buildTime": "1719999999", + "cInfo": { + "rnu": "10.50.0", + "rn": "0.85.2", + "os": "ios 17.5", + "uuid": "u-1" + }, + "supportedDiffVersion": 2, + "bundleHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ], + "expected": { + "packageVersion": "2.3.4", + "hash": "abcdef1234", + "buildTime": "1719999999", + "cInfo": { + "rnu": "10.50.0", + "rn": "0.85.2", + "os": "ios 17.5", + "uuid": "u-1" + }, + "diffV": 2, + "bundleHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + }, + { + "fn": "buildCheckRequestBody", + "args": [ + { + "packageVersion": "2.3.4", + "currentVersion": "", + "buildTime": "1719999999", + "cInfo": { + "rnu": "10.50.0", + "rn": "0.85.2", + "os": "ios 17.5", + "uuid": "u-1" + }, + "supportedDiffVersion": 0, + "bundleHash": "" + } + ], + "expected": { + "packageVersion": "2.3.4", + "hash": "", + "buildTime": "1719999999", + "cInfo": { + "rnu": "10.50.0", + "rn": "0.85.2", + "os": "ios 17.5", + "uuid": "u-1" + } + } + }, + { + "fn": "buildCheckRequestBody", + "args": [ + { + "packageVersion": "2.3.4", + "currentVersion": "abcdef1234", + "buildTime": "1719999999", + "cInfo": { + "rnu": "10.50.0", + "rn": "0.85.2", + "os": "ios 17.5", + "uuid": "u-1" + }, + "extra": { + "toHash": "debug-hash", + "hash": "override-hash" + } + } + ], + "expected": { + "packageVersion": "2.3.4", + "hash": "override-hash", + "buildTime": "1719999999", + "cInfo": { + "rnu": "10.50.0", + "rn": "0.85.2", + "os": "ios 17.5", + "uuid": "u-1" + }, + "toHash": "debug-hash" + } + }, + { + "fn": "buildCheckRequestBody", + "args": [ + { + "packageVersion": "2.3.4", + "currentVersion": "abcdef1234", + "buildTime": "1719999999", + "cInfo": { + "rnu": "10.50.0", + "rn": "0.85.2", + "os": "ios 17.5", + "uuid": "u-1" + }, + "isDev": true, + "extra": { + "buildTime": "injected" + } + } + ], + "expected": { + "packageVersion": "2.3.4", + "hash": "abcdef1234", + "cInfo": { + "rnu": "10.50.0", + "rn": "0.85.2", + "os": "ios 17.5", + "uuid": "u-1" + } + } + }, + { + "fn": "buildCheckRequestBody", + "args": [ + { + "packageVersion": "2.3.4", + "buildTime": "1719999999", + "cInfo": { + "rnu": "10.50.0", + "rn": "0.85.2", + "os": "ios 17.5", + "uuid": "u-1" + } + } + ], + "expected": { + "packageVersion": "2.3.4", + "buildTime": "1719999999", + "cInfo": { + "rnu": "10.50.0", + "rn": "0.85.2", + "os": "ios 17.5", + "uuid": "u-1" + } + } + }, + { + "fn": "resolveCheckResult", + "args": [ + { + "update": true, + "hash": "root-hash", + "name": "root", + "description": "rd", + "metaInfo": "rm", + "diff": "a.hdiff", + "pdiff": "b.phdiff", + "full": "c.ppk", + "paths": [ + "cdn.example.com" + ], + "expVersion": { + "name": "gray", + "hash": "gray-hash", + "description": "d", + "metaInfo": "m", + "config": { + "rollout": { + "2.3.4": 63 + } + } + } + }, + { + "packageVersion": "2.3.4", + "currentVersion": "current-hash", + "uuid": "test1" + } + ], + "expected": { + "update": true, + "name": "gray", + "hash": "gray-hash", + "description": "d", + "metaInfo": "m", + "config": { + "rollout": { + "2.3.4": 63 + } + }, + "paths": [ + "cdn.example.com" + ] + } + }, + { + "fn": "resolveCheckResult", + "args": [ + { + "update": true, + "hash": "root-hash", + "name": "root", + "description": "rd", + "metaInfo": "rm", + "diff": "a.hdiff", + "pdiff": "b.phdiff", + "full": "c.ppk", + "paths": [ + "cdn.example.com" + ], + "expVersion": { + "name": "gray", + "hash": "gray-hash", + "description": "d", + "metaInfo": "m", + "config": { + "rollout": { + "2.3.4": 62 + } + } + } + }, + { + "packageVersion": "2.3.4", + "currentVersion": "current-hash", + "uuid": "test1" + } + ], + "expected": { + "update": true, + "hash": "root-hash", + "name": "root", + "description": "rd", + "metaInfo": "rm", + "diff": "a.hdiff", + "pdiff": "b.phdiff", + "full": "c.ppk", + "paths": [ + "cdn.example.com" + ] + } + }, + { + "fn": "resolveCheckResult", + "args": [ + { + "update": true, + "hash": "root-hash", + "name": "root", + "description": "rd", + "metaInfo": "rm", + "diff": "a.hdiff", + "pdiff": "b.phdiff", + "full": "c.ppk", + "paths": [ + "cdn.example.com" + ], + "expVersion": { + "name": "gray", + "hash": "current-hash", + "description": "d", + "metaInfo": "m", + "config": { + "rollout": { + "2.3.4": 100 + } + } + } + }, + { + "packageVersion": "2.3.4", + "currentVersion": "current-hash", + "uuid": "test1" + } + ], + "expected": { + "upToDate": true + } + }, + { + "fn": "resolveCheckResult", + "args": [ + { + "update": true, + "hash": "root-hash", + "name": "root", + "description": "rd", + "metaInfo": "rm", + "diff": "a.hdiff", + "pdiff": "b.phdiff", + "full": "c.ppk", + "paths": [ + "cdn.example.com" + ] + }, + { + "packageVersion": "2.3.4", + "currentVersion": "current-hash", + "uuid": "test1" + } + ], + "expected": { + "update": true, + "hash": "root-hash", + "name": "root", + "description": "rd", + "metaInfo": "rm", + "diff": "a.hdiff", + "pdiff": "b.phdiff", + "full": "c.ppk", + "paths": [ + "cdn.example.com" + ] + } + }, + { + "fn": "resolveCheckResult", + "args": [ + { + "update": true, + "hash": "current-hash", + "name": "root", + "description": "rd", + "metaInfo": "rm", + "diff": "a.hdiff", + "pdiff": "b.phdiff", + "full": "c.ppk", + "paths": [ + "cdn.example.com" + ] + }, + { + "packageVersion": "2.3.4", + "currentVersion": "current-hash", + "uuid": "test1" + } + ], + "expected": { + "upToDate": true + } + }, + { + "fn": "resolveCheckResult", + "args": [ + { + "upToDate": true + }, + { + "packageVersion": "2.3.4", + "currentVersion": "current-hash", + "uuid": "test1" + } + ], + "expected": { + "upToDate": true + } + }, + { + "fn": "resolveCheckResult", + "args": [ + { + "update": false, + "hash": "x" + }, + { + "packageVersion": "2.3.4", + "currentVersion": "current-hash", + "uuid": "test1" + } + ], + "expected": { + "update": false, + "hash": "x" + } + }, + { + "fn": "resolveCheckResult", + "args": [ + { + "update": true, + "hash": "root-hash", + "name": "root", + "description": "rd", + "metaInfo": "rm", + "diff": "a.hdiff", + "pdiff": "b.phdiff", + "full": "c.ppk", + "paths": [ + "cdn.example.com" + ], + "expVersion": { + "name": "gray", + "hash": "gray-hash", + "description": "d", + "metaInfo": "m", + "config": { + "rollout": { + "other": 100 + } + } + } + }, + { + "packageVersion": "2.3.4", + "currentVersion": "current-hash", + "uuid": "test1" + } + ], + "expected": { + "update": true, + "hash": "root-hash", + "name": "root", + "description": "rd", + "metaInfo": "rm", + "diff": "a.hdiff", + "pdiff": "b.phdiff", + "full": "c.ppk", + "paths": [ + "cdn.example.com" + ] + } + }, + { + "fn": "resolveCheckResult", + "args": [ + { + "update": true, + "hash": "root-hash", + "expVersion": { + "name": "gray", + "hash": "gray-hash", + "description": "d", + "metaInfo": "m", + "config": { + "rollout": { + "2.3.4": 63 + } + } + } + }, + { + "packageVersion": "2.3.4", + "currentVersion": "current-hash", + "uuid": "test1" + } + ], + "expected": { + "update": true, + "name": "gray", + "hash": "gray-hash", + "description": "d", + "metaInfo": "m", + "config": { + "rollout": { + "2.3.4": 63 + } + } + } + }, + { + "fn": "resolveCheckResult", + "args": [ + { + "update": true, + "hash": "root-hash", + "expVersion": { + "name": "gray", + "description": "d", + "metaInfo": "m", + "config": { + "rollout": { + "2.3.4": 63 + } + } + } + }, + { + "packageVersion": "2.3.4", + "uuid": "test1" + } + ], + "expected": { + "update": true, + "name": "gray", + "description": "d", + "metaInfo": "m", + "config": { + "rollout": { + "2.3.4": 63 + } + } + } + }, + { + "fn": "decideDownload", + "args": [ + { + "upToDate": true + }, + { + "currentVersion": "current-hash", + "rolledBackVersion": "bad-hash" + }, + false + ], + "expected": { + "action": "none", + "reason": "noUpdate" + } + }, + { + "fn": "decideDownload", + "args": [ + { + "update": true + }, + { + "currentVersion": "current-hash", + "rolledBackVersion": "bad-hash" + }, + false + ], + "expected": { + "action": "none", + "reason": "noUpdate" + } + }, + { + "fn": "decideDownload", + "args": [ + { + "update": true, + "hash": "current-hash", + "diff": "cur-next.hdiff", + "pdiff": "pkg-next.phdiff", + "full": "next.ppk", + "paths": [ + "cdn.example.com", + "https://mirror.example.com/base/" + ] + }, + { + "currentVersion": "current-hash", + "rolledBackVersion": "bad-hash" + }, + false + ], + "expected": { + "action": "none", + "reason": "alreadyCurrent" + } + }, + { + "fn": "decideDownload", + "args": [ + { + "update": true, + "hash": "bad-hash", + "diff": "cur-next.hdiff", + "pdiff": "pkg-next.phdiff", + "full": "next.ppk", + "paths": [ + "cdn.example.com", + "https://mirror.example.com/base/" + ] + }, + { + "currentVersion": "current-hash", + "rolledBackVersion": "bad-hash" + }, + false + ], + "expected": { + "action": "none", + "reason": "rolledBack" + } + }, + { + "fn": "decideDownload", + "args": [ + { + "update": true, + "hash": "bad-hash", + "diff": "cur-next.hdiff", + "pdiff": "pkg-next.phdiff", + "full": "next.ppk", + "paths": [ + "cdn.example.com", + "https://mirror.example.com/base/" + ] + }, + { + "currentVersion": "current-hash" + }, + false + ], + "expected": { + "action": "download", + "hash": "bad-hash", + "attempts": [ + { + "type": "diff", + "urls": [ + "https://cdn.example.com/cur-next.hdiff", + "https://mirror.example.com/base/cur-next.hdiff" + ] + }, + { + "type": "pdiff", + "urls": [ + "https://cdn.example.com/pkg-next.phdiff", + "https://mirror.example.com/base/pkg-next.phdiff" + ] + }, + { + "type": "full", + "urls": [ + "https://cdn.example.com/next.ppk", + "https://mirror.example.com/base/next.ppk" + ] + } + ], + "devNoop": false + } + }, + { + "fn": "decideDownload", + "args": [ + { + "update": true, + "hash": "next-hash", + "diff": "cur-next.hdiff", + "pdiff": "pkg-next.phdiff", + "full": "next.ppk", + "paths": [ + "cdn.example.com", + "https://mirror.example.com/base/" + ] + }, + { + "currentVersion": "current-hash", + "rolledBackVersion": "bad-hash" + }, + false + ], + "expected": { + "action": "download", + "hash": "next-hash", + "attempts": [ + { + "type": "diff", + "urls": [ + "https://cdn.example.com/cur-next.hdiff", + "https://mirror.example.com/base/cur-next.hdiff" + ] + }, + { + "type": "pdiff", + "urls": [ + "https://cdn.example.com/pkg-next.phdiff", + "https://mirror.example.com/base/pkg-next.phdiff" + ] + }, + { + "type": "full", + "urls": [ + "https://cdn.example.com/next.ppk", + "https://mirror.example.com/base/next.ppk" + ] + } + ], + "devNoop": false + } + }, + { + "fn": "decideDownload", + "args": [ + { + "update": true, + "hash": "next-hash", + "pdiff": "pkg-next.phdiff", + "full": "next.ppk", + "paths": [ + "cdn.example.com", + "https://mirror.example.com/base/" + ] + }, + { + "currentVersion": "current-hash", + "rolledBackVersion": "bad-hash" + }, + false + ], + "expected": { + "action": "download", + "hash": "next-hash", + "attempts": [ + { + "type": "pdiff", + "urls": [ + "https://cdn.example.com/pkg-next.phdiff", + "https://mirror.example.com/base/pkg-next.phdiff" + ] + }, + { + "type": "full", + "urls": [ + "https://cdn.example.com/next.ppk", + "https://mirror.example.com/base/next.ppk" + ] + } + ], + "devNoop": false + } + }, + { + "fn": "decideDownload", + "args": [ + { + "update": true, + "hash": "next-hash", + "diff": "cur-next.hdiff", + "full": "next.ppk", + "paths": [ + "cdn.example.com", + "https://mirror.example.com/base/" + ] + }, + { + "currentVersion": "current-hash", + "rolledBackVersion": "bad-hash" + }, + false + ], + "expected": { + "action": "download", + "hash": "next-hash", + "attempts": [ + { + "type": "diff", + "urls": [ + "https://cdn.example.com/cur-next.hdiff", + "https://mirror.example.com/base/cur-next.hdiff" + ] + }, + { + "type": "full", + "urls": [ + "https://cdn.example.com/next.ppk", + "https://mirror.example.com/base/next.ppk" + ] + } + ], + "devNoop": false + } + }, + { + "fn": "decideDownload", + "args": [ + { + "update": true, + "hash": "next-hash", + "paths": [ + "cdn.example.com", + "https://mirror.example.com/base/" + ] + }, + { + "currentVersion": "current-hash", + "rolledBackVersion": "bad-hash" + }, + false + ], + "expected": { + "action": "none", + "reason": "noArtifact" + } + }, + { + "fn": "decideDownload", + "args": [ + { + "update": true, + "hash": "next-hash", + "diff": "cur-next.hdiff", + "pdiff": "pkg-next.phdiff", + "full": "next.ppk", + "paths": [] + }, + { + "currentVersion": "current-hash", + "rolledBackVersion": "bad-hash" + }, + false + ], + "expected": { + "action": "none", + "reason": "noArtifact" + } + }, + { + "fn": "decideDownload", + "args": [ + { + "update": true, + "hash": "next-hash", + "full": "next.ppk" + }, + { + "currentVersion": "current-hash", + "rolledBackVersion": "bad-hash" + }, + false + ], + "expected": { + "action": "none", + "reason": "noArtifact" + } + }, + { + "fn": "decideDownload", + "args": [ + { + "update": true, + "hash": "next-hash", + "diff": "cur-next.hdiff", + "pdiff": "pkg-next.phdiff", + "full": "next.ppk", + "paths": [ + "cdn.example.com", + "https://mirror.example.com/base/" + ] + }, + { + "currentVersion": "current-hash", + "rolledBackVersion": "bad-hash" + }, + true + ], + "expected": { + "action": "download", + "hash": "next-hash", + "attempts": [ + { + "type": "full", + "urls": [ + "https://cdn.example.com/next.ppk", + "https://mirror.example.com/base/next.ppk" + ] + } + ], + "devNoop": false + } + }, + { + "fn": "decideDownload", + "args": [ + { + "update": true, + "hash": "next-hash", + "diff": "cur-next.hdiff", + "pdiff": "pkg-next.phdiff", + "paths": [ + "cdn.example.com", + "https://mirror.example.com/base/" + ] + }, + { + "currentVersion": "current-hash", + "rolledBackVersion": "bad-hash" + }, + true + ], + "expected": { + "action": "download", + "hash": "next-hash", + "attempts": [], + "devNoop": true + } + }, + { + "fn": "shouldActivateAfterDownload", + "args": [ + { + "hash": "x" + }, + "setNeedUpdate" + ], + "expected": true + }, + { + "fn": "shouldActivateAfterDownload", + "args": [ + { + "hash": "x" + }, + "none" + ], + "expected": false + }, + { + "fn": "shouldActivateAfterDownload", + "args": [ + { + "hash": "x", + "config": { + "forceBoot": true + } + }, + "none" + ], + "expected": true + }, + { + "fn": "shouldActivateAfterDownload", + "args": [ + { + "hash": "x", + "config": { + "forceBoot": false + } + }, + "none" + ], + "expected": false + }, + { + "fn": "shouldActivateAfterDownload", + "args": [ + { + "hash": "x", + "config": { + "forceBoot": 1 + } + }, + "none" + ], + "expected": true + }, + { + "fn": "shouldActivateAfterDownload", + "args": [ + { + "hash": "x", + "config": {} + }, + "none" + ], + "expected": false + }, + { + "fn": "shouldActivateAfterDownload", + "args": [ + { + "hash": "x", + "config": { + "forceBoot": true + } + } + ], + "expected": true + }, + { + "fn": "shouldActivateAfterDownload", + "args": [ + { + "upToDate": true + }, + "none" + ], + "expected": false + } + ] +} diff --git a/cpp/update_flow_core/tests/update_flow_core_test.cpp b/cpp/update_flow_core/tests/update_flow_core_test.cpp new file mode 100644 index 00000000..cebab9a9 --- /dev/null +++ b/cpp/update_flow_core/tests/update_flow_core_test.cpp @@ -0,0 +1,317 @@ +// Replays the golden vectors generated by scripts/generate-flow-vectors.ts +// against the C++ port and compares canonical serializations. A mismatch +// means the two implementations of the decision layer disagree — fix the C++ +// side (the TS side is the reference) or regenerate stale vectors. +#include +#include +#include +#include + +#include "../flow_json.h" +#include "../update_flow_core.h" + +using flowjson::Parse; +using flowjson::Stringify; +using flowjson::Value; + +namespace { + +Value Dispatch(const std::string& fn, const Value& args, bool* known) { + *known = true; + if (fn == "murmurhash3_32_gc") { + return Value::Number(updateflow::Murmur3_32(args.At(0).AsString())); + } + if (fn == "isInRollout") { + return Value::Bool( + updateflow::IsInRollout(args.At(0).AsNumber(), args.At(1).AsString())); + } + if (fn == "joinUrls") { + return updateflow::JoinUrls(args.At(0), args.At(1)); + } + if (fn == "orderEndpointCandidates") { + return updateflow::OrderEndpointCandidates(args.At(0), + args.At(1).AsNumber()); + } + if (fn == "buildCheckRequestBody") { + return updateflow::BuildCheckRequestBody(args.At(0)); + } + if (fn == "resolveCheckResult") { + return updateflow::ResolveCheckResult(args.At(0), args.At(1)); + } + if (fn == "decideDownload") { + return updateflow::DecideDownload(args.At(0), args.At(1), + args.At(2).Truthy()); + } + if (fn == "shouldActivateAfterDownload") { + return Value::Bool(updateflow::ShouldActivateAfterDownload( + args.At(0), args.At(1).AsString())); + } + *known = false; + return Value::Undefined(); +} + +// The parser consumes network data (checkUpdate responses); malformed and +// hostile inputs must set ok=false without crashing — this suite runs under +// ASan+UBSan in CI. Valid-but-tricky inputs pin the JS JSON.parse semantics +// the port relies on. +int RunParserRobustness() { + int failures = 0; + + const char* malformed[] = { + "", + "{", + "[1,2", + "\"abc", + "{\"a\":}", + "{\"a\" 1}", + "{\"a\":1,}", + "[1,]", + "tru", + "nul", + "falsy", + "1x", + "1 2", + "- 1", + "01", + "-01", + "1.", + "1e", + "1e+", + "\"\\q\"", + "\"\\u12\"", + "\"\\ud800\\u0041\"", // high surrogate followed by a non-low escape + "{\"a\":1}garbage", + "[}", + "{]", + }; + for (const char* input : malformed) { + bool ok = true; + Parse(input, &ok); + if (ok) { + std::fprintf(stderr, "robustness: accepted malformed input: %s\n", input); + failures++; + } + } + + // Nesting: 64 levels parse, 65 are rejected (stack-depth cap). + for (int depth : {64, 65}) { + std::string nested; + for (int i = 0; i < depth; i++) { + nested.push_back('['); + } + for (int i = 0; i < depth; i++) { + nested.push_back(']'); + } + bool ok = false; + Parse(nested, &ok); + bool expected = depth <= 64; + if (ok != expected) { + std::fprintf(stderr, "robustness: depth %d parsed=%d, expected %d\n", + depth, ok, expected); + failures++; + } + } + // A deeply nested bomb must fail cleanly, not overflow the stack. + { + std::string bomb(100000, '['); + bool ok = true; + Parse(bomb, &ok); + if (ok) { + std::fprintf(stderr, "robustness: accepted nesting bomb\n"); + failures++; + } + } + + // Valid-but-tricky inputs: assert the parsed value via canonical stringify. + const struct { + const char* input; + const char* expected; + } tricky[] = { + // Duplicate keys: first position, last value (JSON.parse semantics). + {"{\"a\":1,\"b\":2,\"a\":3}", "{\"a\":3,\"b\":2}"}, + {"\"\\u00e9\"", "\"\xc3\xa9\""}, // BMP escape -> UTF-8 + {"\"\\ud83d\\ude00\"", "\"\xf0\x9f\x98\x80\""}, // surrogate pair + {" { \"a\" : [ 1 , true , null ] } ", "{\"a\":[1,true,null]}"}, + {"-0.5", "-0.5"}, + {"1e2", "100"}, + }; + for (const auto& t : tricky) { + bool ok = false; + Value v = Parse(t.input, &ok); + std::string actual = Stringify(v); + if (!ok || actual != t.expected) { + std::fprintf(stderr, + "robustness: %s -> ok=%d %s (expected %s)\n", t.input, ok, + actual.c_str(), t.expected); + failures++; + } + } + + return failures; +} + +// HandleCheckResponse is pure composition (no decision logic of its own), so +// it is tested directly here instead of via TS-generated vectors: response +// text in, canonical decision out. +int RunHandleCheckResponse() { + int failures = 0; + Value identity = Value::Object(); + identity.Set("packageVersion", Value::String("2.3.4")); + identity.Set("currentVersion", Value::String("cur")); + identity.Set("uuid", Value::String("test1")); // bucket 62 + identity.Set("rolledBackVersion", Value::String("bad")); + + const struct { + const char* name; + const char* response; + const char* afterDownload; + const char* expected; + } cases[] = { + {"malformed", "not json{", "none", + "{\"action\":\"none\",\"reason\":\"invalidResponse\"}"}, + {"non-object", "[1,2]", "none", + "{\"action\":\"none\",\"reason\":\"invalidResponse\"}"}, + {"upToDate", "{\"upToDate\":true}", "none", + "{\"action\":\"none\",\"reason\":\"noUpdate\",\"info\":{\"upToDate\":" + "true}}"}, + {"expired", + "{\"expired\":true,\"downloadUrl\":\"https://x/app.apk\"}", "none", + "{\"action\":\"none\",\"reason\":\"noUpdate\",\"info\":{\"expired\":" + "true,\"downloadUrl\":\"https://x/app.apk\"}}"}, + {"alreadyCurrent", + "{\"update\":true,\"hash\":\"cur\",\"full\":\"cur.ppk\"," + "\"paths\":[\"cdn.x.com\"]}", + "none", + "{\"action\":\"none\",\"reason\":\"noUpdate\",\"info\":{\"upToDate\":" + "true}}"}, + {"download", + "{\"update\":true,\"hash\":\"h2\",\"full\":\"h2.ppk\"," + "\"paths\":[\"cdn.x.com\"],\"name\":\"v2\"}", + "none", + "{\"action\":\"download\",\"hash\":\"h2\",\"attempts\":[{\"type\":" + "\"full\",\"urls\":[\"https://cdn.x.com/h2.ppk\"]}],\"devNoop\":false," + "\"activate\":false," + "\"info\":{\"update\":true,\"hash\":\"h2\",\"full\":\"h2.ppk\"," + "\"paths\":[\"cdn.x.com\"],\"name\":\"v2\"}}"}, + {"download-silent-strategy", + "{\"update\":true,\"hash\":\"h2\",\"full\":\"h2.ppk\"," + "\"paths\":[\"cdn.x.com\"]}", + "setNeedUpdate", + "{\"action\":\"download\",\"hash\":\"h2\",\"attempts\":[{\"type\":" + "\"full\",\"urls\":[\"https://cdn.x.com/h2.ppk\"]}],\"devNoop\":false," + "\"activate\":true," + "\"info\":{\"update\":true,\"hash\":\"h2\",\"full\":\"h2.ppk\"," + "\"paths\":[\"cdn.x.com\"]}}"}, + {"download-forceBoot", + "{\"update\":true,\"hash\":\"h2\",\"full\":\"h2.ppk\"," + "\"paths\":[\"cdn.x.com\"],\"config\":{\"forceBoot\":true}}", + "none", + "{\"action\":\"download\",\"hash\":\"h2\",\"attempts\":[{\"type\":" + "\"full\",\"urls\":[\"https://cdn.x.com/h2.ppk\"]}],\"devNoop\":false," + "\"activate\":true," + "\"info\":{\"update\":true,\"hash\":\"h2\",\"full\":\"h2.ppk\"," + "\"paths\":[\"cdn.x.com\"],\"config\":{\"forceBoot\":true}}}"}, + {"rollout-in-forceBoot", + "{\"update\":true,\"hash\":\"root\",\"full\":\"root.ppk\"," + "\"paths\":[\"cdn.x.com\"],\"expVersion\":{\"name\":\"g\",\"hash\":" + "\"gray\",\"full\":\"gray.ppk\",\"config\":{\"rollout\":{\"2.3.4\":" + "63},\"forceBoot\":true}}}", + "none", + "{\"action\":\"download\",\"hash\":\"gray\",\"attempts\":[{\"type\":" + "\"full\",\"urls\":[\"https://cdn.x.com/gray.ppk\"]}],\"devNoop\":" + "false,\"activate\":true," + "\"info\":{\"update\":true,\"name\":\"g\",\"hash\":\"gray\"," + "\"full\":\"gray.ppk\",\"config\":{\"rollout\":{\"2.3.4\":63}," + "\"forceBoot\":true},\"paths\":[\"cdn.x.com\"]}}"}, + {"rollout-out", + "{\"update\":true,\"hash\":\"root\",\"full\":\"root.ppk\"," + "\"paths\":[\"cdn.x.com\"],\"expVersion\":{\"name\":\"g\",\"hash\":" + "\"gray\",\"full\":\"gray.ppk\",\"config\":{\"rollout\":{\"2.3.4\":" + "62}}}}", + "none", + "{\"action\":\"download\",\"hash\":\"root\",\"attempts\":[{\"type\":" + "\"full\",\"urls\":[\"https://cdn.x.com/root.ppk\"]}],\"devNoop\":" + "false,\"activate\":false,\"info\":{\"update\":true,\"hash\":\"root\"," + "\"full\":\"root.ppk\",\"paths\":[\"cdn.x.com\"]}}"}, + {"rolledBack-wins-over-forceBoot", + "{\"update\":true,\"hash\":\"bad\",\"full\":\"bad.ppk\"," + "\"paths\":[\"cdn.x.com\"],\"config\":{\"forceBoot\":true}}", + "none", + "{\"action\":\"none\",\"reason\":\"rolledBack\",\"info\":{\"update\":" + "true,\"hash\":\"bad\",\"full\":\"bad.ppk\",\"paths\":[\"cdn.x.com\"]," + "\"config\":{\"forceBoot\":true}}}"}, + }; + for (const auto& c : cases) { + Value decision = updateflow::HandleCheckResponse(c.response, identity, + false, c.afterDownload); + std::string actual = Stringify(decision); + if (actual != c.expected) { + std::fprintf(stderr, + "handleCheckResponse %s MISMATCH\n expected: %s\n" + " actual: %s\n", + c.name, c.expected, actual.c_str()); + failures++; + } + } + return failures; +} + +} // namespace + +int main(int argc, char* argv[]) { + if (argc < 2) { + std::fprintf(stderr, "usage: %s \n", argv[0]); + return 2; + } + std::ifstream file(argv[1]); + if (!file) { + std::fprintf(stderr, "cannot open %s\n", argv[1]); + return 2; + } + std::stringstream buffer; + buffer << file.rdbuf(); + + bool ok = false; + Value doc = Parse(buffer.str(), &ok); + if (!ok) { + std::fprintf(stderr, "failed to parse %s\n", argv[1]); + return 2; + } + + const Value& cases = doc.Get("cases"); + if (!cases.IsArray() || cases.Size() == 0) { + std::fprintf(stderr, "%s must contain a non-empty cases array\n", argv[1]); + return 2; + } + int failures = RunParserRobustness() + RunHandleCheckResponse(); + for (size_t i = 0; i < cases.Size(); i++) { + const Value& testCase = cases.At(i); + const std::string& fn = testCase.Get("fn").AsString(); + bool known = false; + Value actual = Dispatch(fn, testCase.Get("args"), &known); + if (!known) { + std::fprintf(stderr, "case %zu: unknown fn %s\n", i, fn.c_str()); + failures++; + continue; + } + std::string actualStr = Stringify(actual); + std::string expectedStr = Stringify(testCase.Get("expected")); + if (actualStr != expectedStr) { + std::fprintf(stderr, + "case %zu (%s) MISMATCH\n args: %s\n expected: %s\n" + " actual: %s\n", + i, fn.c_str(), Stringify(testCase.Get("args")).c_str(), + expectedStr.c_str(), actualStr.c_str()); + failures++; + } + } + + if (failures) { + std::fprintf(stderr, "%d failures (%zu vectors + parser robustness)\n", + failures, cases.Size()); + return 1; + } + std::printf("all %zu flow vectors + parser robustness passed\n", + cases.Size()); + return 0; +} diff --git a/cpp/update_flow_core/update_flow_core.cpp b/cpp/update_flow_core/update_flow_core.cpp new file mode 100644 index 00000000..3bfb1f0d --- /dev/null +++ b/cpp/update_flow_core/update_flow_core.cpp @@ -0,0 +1,301 @@ +#include "update_flow_core.h" + +#include + +namespace updateflow { + +using flowjson::Value; + +uint32_t Murmur3_32(const std::string& key, uint32_t seed) { + // The TS reference emulates 32-bit multiplication with 16-bit halves; + // native uint32_t arithmetic is exactly (x * y) mod 2^32, so the halved + // dance collapses to plain multiplies. Bytes are read as charCodeAt & 0xff, + // which is identical for the ASCII inputs this layer handles (uuids, keys). + const uint32_t c1 = 0xcc9e2d51; + const uint32_t c2 = 0x1b873593; + const size_t len = key.size(); + const size_t nblocks = len / 4; + uint32_t h1 = seed; + + const unsigned char* data = + reinterpret_cast(key.data()); + for (size_t i = 0; i < nblocks; i++) { + uint32_t k1 = static_cast(data[i * 4]) | + (static_cast(data[i * 4 + 1]) << 8) | + (static_cast(data[i * 4 + 2]) << 16) | + (static_cast(data[i * 4 + 3]) << 24); + k1 *= c1; + k1 = (k1 << 15) | (k1 >> 17); + k1 *= c2; + h1 ^= k1; + h1 = (h1 << 13) | (h1 >> 19); + h1 = h1 * 5 + 0xe6546b64; + } + + uint32_t k1 = 0; + const unsigned char* tail = data + nblocks * 4; + switch (len & 3) { + case 3: + k1 ^= static_cast(tail[2]) << 16; + [[fallthrough]]; + case 2: + k1 ^= static_cast(tail[1]) << 8; + [[fallthrough]]; + case 1: + k1 ^= tail[0]; + k1 *= c1; + k1 = (k1 << 15) | (k1 >> 17); + k1 *= c2; + h1 ^= k1; + } + + h1 ^= static_cast(len); + h1 ^= h1 >> 16; + h1 *= 0x85ebca6b; + h1 ^= h1 >> 13; + h1 *= 0xc2b2ae35; + h1 ^= h1 >> 16; + return h1; +} + +bool IsInRollout(double rollout, const std::string& uuid) { + return static_cast(Murmur3_32(uuid) % 100) < rollout; +} + +namespace { + +// ^[a-z][a-z0-9+.-]*:\/\/ (case-insensitive) +bool HasExplicitScheme(const std::string& s) { + size_t i = 0; + auto isAlpha = [](char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); + }; + if (i >= s.size() || !isAlpha(s[i])) { + return false; + } + i++; + while (i < s.size()) { + char c = s[i]; + if (isAlpha(c) || (c >= '0' && c <= '9') || c == '+' || c == '.' || + c == '-') { + i++; + } else { + break; + } + } + return s.compare(i, 3, "://") == 0; +} + +} // namespace + +Value JoinUrls(const Value& paths, const Value& fileName) { + if (!fileName.Truthy()) { + return Value::Undefined(); + } + Value urls = Value::Array(); + for (const auto& path : paths.elements()) { + std::string normalized = path.AsString(); + while (!normalized.empty() && normalized.back() == '/') { + normalized.pop_back(); + } + std::string base = HasExplicitScheme(normalized) + ? normalized + : "https://" + normalized; + urls.Push(Value::String(base + "/" + fileName.AsString())); + } + return urls; +} + +Value OrderEndpointCandidates(const Value& endpoints, double randomSample) { + Value deduped = Value::Array(); + for (const auto& endpoint : endpoints.elements()) { + if (!endpoint.Truthy()) { + continue; + } + bool seen = false; + for (const auto& kept : deduped.elements()) { + if (kept.AsString() == endpoint.AsString()) { + seen = true; + break; + } + } + if (!seen) { + deduped.Push(endpoint); + } + } + const size_t n = deduped.Size(); + if (n < 2) { + return deduped; + } + const double idx = std::isfinite(randomSample) + ? std::floor(randomSample * static_cast(n)) + : 0; + // Validate in floating-point space before converting to size_t: converting + // NaN, infinity, or an out-of-range value is undefined behavior in C++. + const size_t first = idx <= 0 ? 0 + : (idx >= static_cast(n - 1) + ? n - 1 + : static_cast(idx)); + Value ordered = Value::Array(); + ordered.Push(deduped.At(first)); + for (size_t i = 0; i < n; i++) { + if (i != first) { + ordered.Push(deduped.At(i)); + } + } + return ordered; +} + +Value BuildCheckRequestBody(const Value& input) { + Value body = Value::Object(); + body.Set("packageVersion", input.Get("packageVersion")); + body.Set("hash", input.Get("currentVersion")); + body.Set("buildTime", input.Get("buildTime")); + body.Set("cInfo", input.Get("cInfo")); + const Value& diffV = input.Get("supportedDiffVersion"); + if (diffV.Truthy()) { + body.Set("diffV", diffV); + } + const Value& bundleHash = input.Get("bundleHash"); + if (bundleHash.Truthy()) { + body.Set("bundleHash", bundleHash); + } + const Value& extra = input.Get("extra"); + if (extra.IsObject()) { + for (const auto& member : extra.members()) { + body.Set(member.first, member.second); + } + } + if (input.Get("isDev").Truthy()) { + body.Remove("buildTime"); + } + return body; +} + +Value ResolveCheckResult(const Value& rootInfo, const Value& identity) { + Value rootResult = Value::Object(); + for (const auto& member : rootInfo.members()) { + if (member.first != "expVersion") { + rootResult.Set(member.first, member.second); + } + } + const Value& expVersion = rootInfo.Get("expVersion"); + const Value& currentVersion = identity.Get("currentVersion"); + // expVersion?.config?.rollout?.[identity.packageVersion] — Get on a + // non-object returns Undefined, mirroring optional chaining. + const Value& rollout = expVersion.Get("config").Get("rollout").Get( + identity.Get("packageVersion").AsString()); + if (rootResult.Get("update").Truthy() && expVersion.Truthy() && + rollout.IsNumber()) { + if (IsInRollout(rollout.AsNumber(), identity.Get("uuid").AsString())) { + const Value& expHash = expVersion.Get("hash"); + if (expHash.IsString() && !expHash.AsString().empty() && + Value::StrictEquals(expHash, currentVersion)) { + Value upToDate = Value::Object(); + upToDate.Set("upToDate", Value::Bool(true)); + return upToDate; + } + Value info = Value::Object(); + info.Set("update", Value::Bool(true)); + for (const auto& member : expVersion.members()) { + info.Set(member.first, member.second); + } + if (rootResult.Get("paths").Truthy()) { + info.Set("paths", rootResult.Get("paths")); + } + return info; + } + } + const Value& rootHash = rootResult.Get("hash"); + if (rootResult.Get("update").Truthy() && rootHash.IsString() && + !rootHash.AsString().empty() && + Value::StrictEquals(rootHash, currentVersion)) { + Value upToDate = Value::Object(); + upToDate.Set("upToDate", Value::Bool(true)); + return upToDate; + } + return rootResult; +} + +namespace { + +Value DeclineDownload(const char* reason) { + Value none = Value::Object(); + none.Set("action", Value::String("none")); + none.Set("reason", Value::String(reason)); + return none; +} + +} // namespace + +Value DecideDownload(const Value& info, const Value& identity, bool isDev) { + const Value& hash = info.Get("hash"); + Value paths = info.Get("paths"); + if (paths.IsUndefined()) { + paths = Value::Array(); // const { paths = [] } — undefined only + } + if (!info.Get("update").Truthy() || !hash.Truthy()) { + return DeclineDownload("noUpdate"); + } + if (Value::StrictEquals(hash, identity.Get("currentVersion"))) { + return DeclineDownload("alreadyCurrent"); + } + const Value& rolledBack = identity.Get("rolledBackVersion"); + if (rolledBack.Truthy() && Value::StrictEquals(hash, rolledBack)) { + return DeclineDownload("rolledBack"); + } + Value attempts = Value::Array(); + auto pushAttempt = [&attempts](const char* type, const Value& urls) { + if (urls.IsArray() && urls.Size() > 0) { + Value attempt = Value::Object(); + attempt.Set("type", Value::String(type)); + attempt.Set("urls", urls); + attempts.Push(std::move(attempt)); + } + }; + if (!isDev) { + pushAttempt("diff", JoinUrls(paths, info.Get("diff"))); + pushAttempt("pdiff", JoinUrls(paths, info.Get("pdiff"))); + } + Value fullUrls = JoinUrls(paths, info.Get("full")); + pushAttempt("full", fullUrls); + + const bool devNoop = + isDev && !(fullUrls.IsArray() && fullUrls.Size() > 0); + if (attempts.Size() == 0 && !devNoop) { + return DeclineDownload("noArtifact"); + } + + Value decision = Value::Object(); + decision.Set("action", Value::String("download")); + decision.Set("hash", hash); + decision.Set("attempts", std::move(attempts)); + decision.Set("devNoop", Value::Bool(devNoop)); + return decision; +} + +bool ShouldActivateAfterDownload(const Value& info, + const std::string& afterDownload) { + return afterDownload == "setNeedUpdate" || + info.Get("config").Get("forceBoot").Truthy(); +} + +Value HandleCheckResponse(const std::string& responseText, + const Value& identity, bool isDev, + const std::string& afterDownload) { + bool ok = false; + Value root = flowjson::Parse(responseText, &ok); + if (!ok || !root.IsObject()) { + return DeclineDownload("invalidResponse"); + } + Value resolved = ResolveCheckResult(root, identity); + Value decision = DecideDownload(resolved, identity, isDev); + if (decision.Get("action").AsString() == "download") { + decision.Set("activate", Value::Bool(ShouldActivateAfterDownload( + resolved, afterDownload))); + } + decision.Set("info", std::move(resolved)); + return decision; +} + +} // namespace updateflow diff --git a/cpp/update_flow_core/update_flow_core.h b/cpp/update_flow_core/update_flow_core.h new file mode 100644 index 00000000..f2dd44d3 --- /dev/null +++ b/cpp/update_flow_core/update_flow_core.h @@ -0,0 +1,77 @@ +#pragma once + +#include +#include + +#include "flow_json.h" + +// C++ port of the update-flow decision layer. +// +// src/updateFlowCore.ts is the REFERENCE implementation: every function here +// mirrors its TS counterpart 1:1, including object key order and JS +// truthiness/strict-equality semantics, because parity is enforced by golden +// vectors (tests/flow_vectors.json, generated from the TS side). Any semantic +// change lands in the TS file first, regenerates the vectors, then gets +// ported here — never the other direction. +// +// Like the TS side this layer is pure: no IO, no time, no randomness — the +// random sample, identity and parsed JSON all arrive as parameters. The +// orchestrators (per-platform HTTP/download/state glue) own all effects. +namespace updateflow { + +uint32_t Murmur3_32(const std::string& key, uint32_t seed = 0); + +// murmur(uuid) % 100 < rollout — the gray-release bucketing predicate. +bool IsInRollout(double rollout, const std::string& uuid); + +// paths × fileName -> candidate URL array; Undefined when fileName is falsy +// (mirrors joinUrls returning undefined without a file name). +flowjson::Value JoinUrls(const flowjson::Value& paths, + const flowjson::Value& fileName); + +// Dedupe + move the sampled pick to the front, rest in configured order. +// randomSample ∈ [0, 1) is injected by the caller. +flowjson::Value OrderEndpointCandidates(const flowjson::Value& endpoints, + double randomSample); + +// input: { packageVersion, currentVersion, buildTime, cInfo, +// supportedDiffVersion?, bundleHash?, isDev?, extra? } +flowjson::Value BuildCheckRequestBody(const flowjson::Value& input); + +// identity: { packageVersion, currentVersion?, uuid } +flowjson::Value ResolveCheckResult(const flowjson::Value& rootInfo, + const flowjson::Value& identity); + +// identity: { currentVersion?, rolledBackVersion? } +// -> { action: 'none', reason } | { action: 'download', hash, attempts, +// devNoop } +flowjson::Value DecideDownload(const flowjson::Value& info, + const flowjson::Value& identity, bool isDev); + +// Whether the orchestrator should activate a downloaded version for the +// next launch: the client's silent strategies opt in locally +// (afterDownload == "setNeedUpdate"), or the server marks the version +// config.forceBoot — the per-version remote override that closes the +// brick-rescue gap for alert-strategy apps (a bricked device never runs +// JS, so activation cannot wait for it). Native-only; the device-local +// rolledBackVersion guard in DecideDownload still wins, and the activated +// version keeps the first_time crash-protection rollback. +bool ShouldActivateAfterDownload(const flowjson::Value& info, + const std::string& afterDownload); + +// Composes Parse → ResolveCheckResult → DecideDownload: one call from the +// raw checkUpdate response text to a download decision, so the platform +// orchestrators contain no decision logic at all. identity is the union of +// both composed functions' needs: { packageVersion, currentVersion?, uuid, +// rolledBackVersion? }. afterDownload is the client's persisted activation +// policy; a download decision carries `activate` (ShouldActivateAfterDownload +// folded over it and the version's forceBoot) plus `info` — the resolved +// check result — so orchestrators can persist name/description/metaInfo +// alongside a downloaded version (the JS side's setLocalHashInfo). +// Malformed JSON yields { action: 'none', reason: 'invalidResponse' }. +flowjson::Value HandleCheckResponse(const std::string& responseText, + const flowjson::Value& identity, + bool isDev, + const std::string& afterDownload); + +} // namespace updateflow diff --git a/cpp/update_flow_core/update_flow_jni.cpp b/cpp/update_flow_core/update_flow_jni.cpp new file mode 100644 index 00000000..f6397774 --- /dev/null +++ b/cpp/update_flow_core/update_flow_jni.cpp @@ -0,0 +1,121 @@ +// JNI surface of the update-flow decision layer for the Android orchestrator +// (NativeCheckOrchestrator.java). Pure string-in/string-out: every payload is +// JSON, matching the decision layer's own boundary. A null return means +// "input did not parse" and the caller skips the check round. +#include + +#include +#include +#include +#include + +#include "../patch_core/jni_util.h" +#include "flow_json.h" +#include "update_flow_core.h" + +namespace { + +jstring ToJString(JNIEnv* env, const std::string& value) { + // flow_json emits standard UTF-8, while NewStringUTF expects JNI's modified + // UTF-8 and corrupts supplementary code points. Decode explicitly and pass + // UTF-16 code units to NewString. Accept encoded surrogate code points too: + // strings originating in Java may have entered through modified UTF-8. + std::vector utf16; + utf16.reserve(value.size()); + size_t i = 0; + while (i < value.size()) { + const unsigned char first = static_cast(value[i]); + uint32_t codePoint = 0xfffd; + size_t width = 1; + if (first < 0x80) { + codePoint = first; + } else if (first >= 0xc2 && first <= 0xdf && i + 1 < value.size()) { + const unsigned char b1 = static_cast(value[i + 1]); + if ((b1 & 0xc0) == 0x80) { + codePoint = ((first & 0x1f) << 6) | (b1 & 0x3f); + width = 2; + } + } else if (first == 0xc0 && i + 1 < value.size() && + static_cast(value[i + 1]) == 0x80) { + codePoint = 0; // modified UTF-8 encoding of U+0000 + width = 2; + } else if (first >= 0xe0 && first <= 0xef && i + 2 < value.size()) { + const unsigned char b1 = static_cast(value[i + 1]); + const unsigned char b2 = static_cast(value[i + 2]); + if ((b1 & 0xc0) == 0x80 && (b2 & 0xc0) == 0x80 && + !(first == 0xe0 && b1 < 0xa0)) { + codePoint = ((first & 0x0f) << 12) | ((b1 & 0x3f) << 6) | + (b2 & 0x3f); + width = 3; + } + } else if (first >= 0xf0 && first <= 0xf4 && i + 3 < value.size()) { + const unsigned char b1 = static_cast(value[i + 1]); + const unsigned char b2 = static_cast(value[i + 2]); + const unsigned char b3 = static_cast(value[i + 3]); + if ((b1 & 0xc0) == 0x80 && (b2 & 0xc0) == 0x80 && + (b3 & 0xc0) == 0x80 && !(first == 0xf0 && b1 < 0x90) && + !(first == 0xf4 && b1 > 0x8f)) { + codePoint = ((first & 0x07) << 18) | ((b1 & 0x3f) << 12) | + ((b2 & 0x3f) << 6) | (b3 & 0x3f); + width = 4; + } + } + i += width; + if (codePoint <= 0xffff) { + utf16.push_back(static_cast(codePoint)); + } else { + codePoint -= 0x10000; + utf16.push_back(static_cast(0xd800 + (codePoint >> 10))); + utf16.push_back(static_cast(0xdc00 + (codePoint & 0x3ff))); + } + } + if (utf16.size() > static_cast(std::numeric_limits::max())) { + return nullptr; + } + return env->NewString(utf16.empty() ? nullptr : utf16.data(), + static_cast(utf16.size())); +} + +} // namespace + +extern "C" JNIEXPORT jstring JNICALL +Java_cn_reactnative_modules_update_NativeUpdateFlow_buildCheckRequestBody( + JNIEnv* env, jclass, jstring inputJson) { + bool ok = false; + flowjson::Value input = flowjson::Parse( + pushy::jni_util::JStringToString(env, inputJson), &ok); + if (!ok || !input.IsObject()) { + return nullptr; + } + return ToJString( + env, flowjson::Stringify(updateflow::BuildCheckRequestBody(input))); +} + +extern "C" JNIEXPORT jstring JNICALL +Java_cn_reactnative_modules_update_NativeUpdateFlow_orderEndpointCandidates( + JNIEnv* env, jclass, jstring endpointsJson, jdouble randomSample) { + bool ok = false; + flowjson::Value endpoints = flowjson::Parse( + pushy::jni_util::JStringToString(env, endpointsJson), &ok); + if (!ok || !endpoints.IsArray()) { + return nullptr; + } + return ToJString(env, flowjson::Stringify(updateflow::OrderEndpointCandidates( + endpoints, randomSample))); +} + +extern "C" JNIEXPORT jstring JNICALL +Java_cn_reactnative_modules_update_NativeUpdateFlow_handleCheckResponse( + JNIEnv* env, jclass, jstring responseText, jstring identityJson, + jstring afterDownload) { + bool ok = false; + flowjson::Value identity = flowjson::Parse( + pushy::jni_util::JStringToString(env, identityJson), &ok); + if (!ok || !identity.IsObject()) { + return nullptr; + } + return ToJString(env, flowjson::Stringify(updateflow::HandleCheckResponse( + pushy::jni_util::JStringToString(env, responseText), + identity, false, + pushy::jni_util::JStringToString(env, afterDownload)))); +} diff --git a/harmony/pushy/src/main/cpp/CMakeLists.txt b/harmony/pushy/src/main/cpp/CMakeLists.txt index 3465b95c..528b3a08 100644 --- a/harmony/pushy/src/main/cpp/CMakeLists.txt +++ b/harmony/pushy/src/main/cpp/CMakeLists.txt @@ -17,6 +17,10 @@ set(PATCH_CORE_DIR ${REPO_ROOT}/cpp/patch_core) if(NOT EXISTS ${PATCH_CORE_DIR}/patch_core.cpp) set(PATCH_CORE_DIR ${STAGED_NATIVE_DIR}/patch_core) endif() +set(UPDATE_FLOW_CORE_DIR ${REPO_ROOT}/cpp/update_flow_core) +if(NOT EXISTS ${UPDATE_FLOW_CORE_DIR}/update_flow_core.cpp) + set(UPDATE_FLOW_CORE_DIR ${STAGED_NATIVE_DIR}/update_flow_core) +endif() # Always compile from source. A prebuilt-import branch used to live here, but # it had no symbol verification: a stale libs//librnupdate.so missing a @@ -32,6 +36,8 @@ set(HDP_SOURCES ${PATCH_CORE_DIR}/hbc_transform_wire.cpp ${PATCH_CORE_DIR}/patch_core.cpp ${PATCH_CORE_DIR}/state_core.cpp + ${UPDATE_FLOW_CORE_DIR}/flow_json.cpp + ${UPDATE_FLOW_CORE_DIR}/update_flow_core.cpp ${ANDROID_JNI_DIR}/hpatch.c ${HDIFFPATCH_DIR}/libHDiffPatch/HPatch/patch.c ${HDIFFPATCH_DIR}/file_for_patch.c @@ -46,6 +52,7 @@ add_library(rnupdate SHARED target_include_directories(rnupdate PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} ${PATCH_CORE_DIR} + ${UPDATE_FLOW_CORE_DIR} ${ANDROID_JNI_DIR} ${HDIFFPATCH_DIR} ${HDIFFPATCH_DIR}/libHDiffPatch/HPatch diff --git a/harmony/pushy/src/main/cpp/pushy.cpp b/harmony/pushy/src/main/cpp/pushy.cpp index 617361f3..7cf4660d 100644 --- a/harmony/pushy/src/main/cpp/pushy.cpp +++ b/harmony/pushy/src/main/cpp/pushy.cpp @@ -1,4 +1,7 @@ #include + +#include "flow_json.h" +#include "update_flow_core.h" #include #include @@ -992,6 +995,102 @@ static napi_value GetSupportedDiffVersion(napi_env env, napi_callback_info) { return result; } +// ---- update-flow decision layer (NATIVE_CHECKUPDATE_DESIGN §10) ---- +// String-in/string-out JSON, matching the decision layer's own boundary. +// Returning undefined (nullptr without a pending exception) means "input did +// not parse"; the ArkTS orchestrator skips the check round. + +static napi_value MakeUtf8String(napi_env env, const std::string& value) { + napi_value result = nullptr; + if (napi_create_string_utf8(env, value.c_str(), value.size(), &result) != + napi_ok) { + ThrowError(env, "Failed to create string"); + return nullptr; + } + return result; +} + +static napi_value FlowBuildCheckRequestBody(napi_env env, + napi_callback_info info) { + size_t argc = 1; + napi_value args[1] = {nullptr}; + if (!GetArgCount(env, info, &argc, args) || argc < 1) { + ThrowError(env, "buildCheckRequestBody: missing input argument"); + return nullptr; + } + bool ok = false; + std::string input_json = GetString(env, args[0], &ok); + if (!ok) { + return nullptr; + } + bool parsed = false; + flowjson::Value input = flowjson::Parse(input_json, &parsed); + if (!parsed || !input.IsObject()) { + return nullptr; + } + return MakeUtf8String( + env, flowjson::Stringify(updateflow::BuildCheckRequestBody(input))); +} + +static napi_value FlowOrderEndpointCandidates(napi_env env, + napi_callback_info info) { + size_t argc = 2; + napi_value args[2] = {nullptr, nullptr}; + if (!GetArgCount(env, info, &argc, args) || argc < 2) { + ThrowError(env, "orderEndpointCandidates: missing arguments"); + return nullptr; + } + bool ok = false; + std::string endpoints_json = GetString(env, args[0], &ok); + if (!ok) { + return nullptr; + } + double sample = 0; + if (napi_get_value_double(env, args[1], &sample) != napi_ok) { + ThrowError(env, "orderEndpointCandidates: expected number sample"); + return nullptr; + } + bool parsed = false; + flowjson::Value endpoints = flowjson::Parse(endpoints_json, &parsed); + if (!parsed || !endpoints.IsArray()) { + return nullptr; + } + return MakeUtf8String(env, + flowjson::Stringify(updateflow::OrderEndpointCandidates( + endpoints, sample))); +} + +static napi_value FlowHandleCheckResponse(napi_env env, + napi_callback_info info) { + size_t argc = 3; + napi_value args[3] = {nullptr, nullptr, nullptr}; + if (!GetArgCount(env, info, &argc, args) || argc < 3) { + ThrowError(env, "handleCheckResponse: missing arguments"); + return nullptr; + } + bool ok = false; + std::string response_text = GetString(env, args[0], &ok); + if (!ok) { + return nullptr; + } + std::string identity_json = GetString(env, args[1], &ok); + if (!ok) { + return nullptr; + } + std::string after_download = GetString(env, args[2], &ok); + if (!ok) { + return nullptr; + } + bool parsed = false; + flowjson::Value identity = flowjson::Parse(identity_json, &parsed); + if (!parsed || !identity.IsObject()) { + return nullptr; + } + return MakeUtf8String(env, + flowjson::Stringify(updateflow::HandleCheckResponse( + response_text, identity, false, after_download))); +} + napi_value Init(napi_env env, napi_value exports) { if (!ExportFunction(env, exports, "syncStateWithBinaryVersion", SyncStateWithBinaryVersion) || !ExportFunction(env, exports, "runStateCore", RunStateCore) || @@ -1001,7 +1100,10 @@ napi_value Init(napi_env env, napi_value exports) { !ExportFunction(env, exports, "cleanupOldEntries", CleanupOldEntries) || !ExportFunction(env, exports, "sha256Hex", Sha256Hex) || !ExportFunction(env, exports, "crc32", Crc32) || - !ExportFunction(env, exports, "getSupportedDiffVersion", GetSupportedDiffVersion)) { + !ExportFunction(env, exports, "getSupportedDiffVersion", GetSupportedDiffVersion) || + !ExportFunction(env, exports, "buildCheckRequestBody", FlowBuildCheckRequestBody) || + !ExportFunction(env, exports, "orderEndpointCandidates", FlowOrderEndpointCandidates) || + !ExportFunction(env, exports, "handleCheckResponse", FlowHandleCheckResponse)) { return nullptr; } return exports; diff --git a/harmony/pushy/src/main/ets/DownloadTask.ts b/harmony/pushy/src/main/ets/DownloadTask.ts index 6ea5360b..016501de 100644 --- a/harmony/pushy/src/main/ets/DownloadTask.ts +++ b/harmony/pushy/src/main/ets/DownloadTask.ts @@ -11,6 +11,9 @@ import NativePatchCore, { ARCHIVE_PATCH_TYPE_FROM_PPK, CopyGroupResult, } from './NativePatchCore'; +import { monotonicNowMs } from './MonotonicClock'; + +export const VERSION_COMPLETE_FILE_NAME = '.pushy-complete'; export interface PatchManifestArrays { copyFroms: string[]; @@ -95,6 +98,7 @@ const DIFF_MANIFEST_ENTRY = '__diff.json'; const HARMONY_BUNDLE_PATCH_ENTRY = 'bundle.harmony.js.patch'; const TEMP_ORIGIN_BUNDLE_ENTRY = '.origin.bundle.harmony.js'; const FILE_COPY_BUFFER_SIZE = 64 * 1024; +const DOWNLOAD_CALL_TIMEOUT_MS = 10 * 60 * 1000; export class DownloadTask { private context: common.Context; @@ -343,6 +347,12 @@ export class DownloadTask { let writeQueue = Promise.resolve(); let lastReportedPercentage = -1; let lastReportedBytes = 0; + const deadlineUptimeMs = params.deadlineUptimeMs > 0 + ? params.deadlineUptimeMs + : monotonicNowMs() + DOWNLOAD_CALL_TIMEOUT_MS; + if (deadlineUptimeMs <= monotonicNowMs()) { + throw Error('Download deadline expired before start'); + } // Emit at most one progress event per whole-percent change (or per 256KB // when the total is unknown), and only from the dataReceive handler, so the @@ -374,6 +384,7 @@ export class DownloadTask { // Promise (and the JS caller) forever. const INACTIVITY_TIMEOUT_MS = 60000; let watchdogTimer: number | null = null; + let deadlineTimer: number | null = null; let inactivityReject: ((error: Error) => void) | null = null; const clearWatchdog = () => { if (watchdogTimer !== null) { @@ -482,14 +493,22 @@ export class DownloadTask { }, ); - const responseCode = await httpRequest.requestInStream(params.url, { - method: http.RequestMethod.GET, - readTimeout: 60000, - connectTimeout: 60000, - header: { - 'Content-Type': 'application/octet-stream', - }, + const deadlinePromise = new Promise((_, reject) => { + deadlineTimer = setTimeout(() => { + reject(Error('Download exceeded its whole-call deadline')); + }, Math.max(1, deadlineUptimeMs - monotonicNowMs())); }); + const responseCode = await Promise.race([ + httpRequest.requestInStream(params.url, { + method: http.RequestMethod.GET, + readTimeout: 60000, + connectTimeout: 60000, + header: { + 'Content-Type': 'application/octet-stream', + }, + }), + deadlinePromise, + ]); if (responseCode > 299) { throw Error(`Server error: ${responseCode}`); } @@ -499,7 +518,7 @@ export class DownloadTask { // 时 reject(unhandled rejection),且 promise 一经 reject 无法复活—— // 即使随后数据正常流入,race 也必然以 "Download stalled" 失败。 refreshWatchdog(); - await Promise.race([dataEndPromise, inactivityPromise]); + await Promise.race([dataEndPromise, inactivityPromise, deadlinePromise]); const stats = await fileIo.stat(params.targetFile); const fileSize = stats.size; if (contentLength > 0 && fileSize !== contentLength) { @@ -512,6 +531,9 @@ export class DownloadTask { throw error; } finally { clearWatchdog(); + if (deadlineTimer !== null) { + clearTimeout(deadlineTimer); + } try { await closeWriter(); } catch (closeError) { @@ -798,6 +820,10 @@ export class DownloadTask { } public async execute(params: DownloadTaskParams): Promise { + const isPatchTask = + params.type === DownloadTaskParams.TASK_TYPE_PATCH_FULL || + params.type === DownloadTaskParams.TASK_TYPE_PATCH_FROM_APP || + params.type === DownloadTaskParams.TASK_TYPE_PATCH_FROM_PPK; try { switch (params.type) { case DownloadTaskParams.TASK_TYPE_PATCH_FULL: @@ -818,13 +844,26 @@ export class DownloadTask { default: throw Error(`Unknown task type: ${params.type}`); } + if (isPatchTask) { + await this.writeFileContent( + `${params.unzipDirectory}/${VERSION_COMPLETE_FILE_NAME}`, + new Uint8Array(0), + ); + } } catch (error: any) { console.error('Task execution failed:', error.message); if (params.type !== DownloadTaskParams.TASK_TYPE_CLEANUP) { try { if (params.type === DownloadTaskParams.TASK_TYPE_PLAIN_DOWNLOAD) { await fileIo.unlink(params.targetFile); - } else { + } else if ( + !fileIo.accessSync( + `${params.unzipDirectory}/${VERSION_COMPLETE_FILE_NAME}`, + ) || + !fileIo.accessSync(`${params.unzipDirectory}/bundle.harmony.js`) + ) { + // Never let a failed duplicate task remove an install already + // handed off by an earlier task via marker + bundle. await this.removeDirectory(params.unzipDirectory); } } catch (cleanupError: any) { diff --git a/harmony/pushy/src/main/ets/DownloadTaskParams.ts b/harmony/pushy/src/main/ets/DownloadTaskParams.ts index 1f302564..ff612466 100644 --- a/harmony/pushy/src/main/ets/DownloadTaskParams.ts +++ b/harmony/pushy/src/main/ets/DownloadTaskParams.ts @@ -16,4 +16,7 @@ export class DownloadTaskParams { targetFile: string = ''; // 目标文件路径 unzipDirectory: string = ''; // 解压目录路径 originDirectory: string = ''; // 原始文件目录路径 + // Native cold-start orchestrator's absolute monotonic-uptime deadline. Zero + // uses the normal public download API's 10-minute whole-call cap. + deadlineUptimeMs: number = 0; } diff --git a/harmony/pushy/src/main/ets/MonotonicClock.ts b/harmony/pushy/src/main/ets/MonotonicClock.ts new file mode 100644 index 00000000..243958f0 --- /dev/null +++ b/harmony/pushy/src/main/ets/MonotonicClock.ts @@ -0,0 +1,13 @@ +import { systemDateTime } from '@kit.BasicServicesKit'; + +// Absolute deadlines must not use Date.now(): automatic time synchronization +// can move the wall clock while the cold-start rescue round is running. +// +// TimeType.ACTIVE (not STARTUP) is the semantic match for the other two +// platforms: iOS NSProcessInfo.systemUptime and Android System.nanoTime both +// stop during deep sleep, so a device that sleeps mid-download resumes with +// its budget intact. STARTUP keeps counting through sleep and would abort a +// rescue that the same network completes on iOS/Android. +export function monotonicNowMs(): number { + return systemDateTime.getUptime(systemDateTime.TimeType.ACTIVE); +} diff --git a/harmony/pushy/src/main/ets/NativeCheckOrchestrator.ts b/harmony/pushy/src/main/ets/NativeCheckOrchestrator.ts new file mode 100644 index 00000000..63170018 --- /dev/null +++ b/harmony/pushy/src/main/ets/NativeCheckOrchestrator.ts @@ -0,0 +1,500 @@ +import http from '@ohos.net.http'; +import deviceInfo from '@ohos.deviceInfo'; +import logger from './Logger'; +import NativePatchCore from './NativePatchCore'; +import type { UpdateContext } from './UpdateContext'; +import { isSafePathComponent } from './PathUtils'; +import { monotonicNowMs } from './MonotonicClock'; + +// 原生冷启动检测(NATIVE_CHECKUPDATE_DESIGN §10):每进程一次,getBundleUrl +// 后延迟数秒运行,完全不依赖 app bundle——坏热更把 JS 砸挂后,下次启动仍能 +// 拉到修复版。决策全部来自 cpp/update_flow_core(经 NativePatchCore 的 +// NAPI 面),本文件只是 IO 胶水。失败静默且有界:每次启动至多一轮,无重试 +// 风暴,不拉黑版本。 +// 鸿蒙的 debug 门控由触发点天然承担:dev 走 MetroJSBundleProvider, +// PushyFileJSBundleProvider.getBundleUrl 不会被调用。 +const TAG = 'NativeCheck'; +export const KEY_CONFIG = 'nativeConfig'; +// 供 JS 侧复用的原始响应缓存(§10.3),同时记录请求与配置指纹以限定命中范围。 +export const KEY_RESP_CACHE = 'nativeCheckResp'; +const REQUEST_TIMEOUT_MS = 10000; +const REQUEST_CALL_TIMEOUT_MS = 15000; +const MAX_CHECK_HTTP_ATTEMPTS = 8; +const START_DELAY_MS = 5000; +const DOWNLOAD_PHASE_TIMEOUT_MS = 10 * 60 * 1000; +const DOWNLOAD_TYPE_DIFF = 'diff'; +const DOWNLOAD_TYPE_PDIFF = 'pdiff'; + +interface NativeConfig { + appKey?: string; + packageVersion?: string; + endpoints?: string[]; + queryUrls?: string[]; + afterDownload?: string; + rnu?: string; + rn?: string; + disabled?: boolean; +} + +interface FlowIdentity { + packageVersion: string; + currentVersion?: string; + uuid: string; + rolledBackVersion?: string; +} + +interface FlowCInfo { + rnu: string; + rn: string; + os: string; + uuid: string; +} + +interface FlowCheckInput { + packageVersion: string; + currentVersion?: string; + buildTime: string; + cInfo: FlowCInfo; + supportedDiffVersion: number; + bundleHash: string; +} + +interface DecisionAttempt { + type?: string; + urls?: string[]; +} + +interface DecisionInfo { + name?: string; + description?: string; + metaInfo?: string; +} + +interface Decision { + action?: string; + reason?: string; + hash?: string; + attempts?: DecisionAttempt[]; + activate?: boolean; + info?: DecisionInfo; +} + +interface RespCacheEntry { + ts: number; + body: string; + request: string; + config: string; +} + +let scheduled = false; + +export function scheduleNativeCheck( + context: UpdateContext, + launchRolledBackVersion: string, +): void { + if (scheduled) { + return; + } + scheduled = true; + // 结果本来就是"下次启动生效",延迟几秒让开冷启动关键路径(§7 R5)。 + setTimeout(() => { + runOnce(context, launchRolledBackVersion).catch((e: Object) => { + // 救援路径自身绝不能把应用拖垮。 + logger.error(TAG, `native check failed: ${e}`); + }); + }, START_DELAY_MS); +} + +async function runOnce( + context: UpdateContext, + launchRolledBackVersion: string, +): Promise { + // 在任何 IO 之前采样:resetToPackagedBundle 会递增它,本轮运行期间发生的 + // reset 必须赢过本轮的决策。 + const resetGeneration = context.getResetGeneration(); + const configJson = context.getKv(KEY_CONFIG); + if (!configJson) { + // 无落盘配置(老接入/首启):静默不跑——这就是灰度开关。 + return; + } + let config: NativeConfig; + try { + config = JSON.parse(configJson) as NativeConfig; + } catch (e) { + return; + } + if (config.disabled) { + return; + } + const appKey = config.appKey ?? ''; + if (!appKey) { + return; + } + + const currentVersion = context.getCurrentVersion(); + // getConstants consumes the persisted rollback marker during startup; use + // the launch-path snapshot captured before that happens. + const rolledBackVersion = launchRolledBackVersion; + const uuid = context.getKv('uuid') ?? ''; + const packageVersion = config.packageVersion || context.getPackageVersion(); + + const identity: FlowIdentity = { + packageVersion, + currentVersion, + uuid, + }; + if (rolledBackVersion) { + identity.rolledBackVersion = rolledBackVersion; + } + + const cInfo: FlowCInfo = { + rnu: config.rnu ?? '', + rn: config.rn ?? '', + // RNOH's Platform.Version is osFullName; use the same value so JS can + // reuse the response cache produced by this native request. + os: `harmony ${deviceInfo.osFullName}`, + uuid, + }; + + const input: FlowCheckInput = { + packageVersion: identity.packageVersion, + currentVersion, + buildTime: context.getBuildTime(), + cInfo, + supportedDiffVersion: NativePatchCore.getSupportedDiffVersion(), + bundleHash: await context.getBundleHash(), + }; + const body = NativePatchCore.buildCheckRequestBody(JSON.stringify(input)); + if (!body) { + return; + } + + const responseText = await runCheckRequest(config, appKey, body); + if (!responseText) { + logger.debug(TAG, 'no endpoint reachable, giving up until next launch'); + return; + } + // Anchor cache freshness to response arrival, before download/patch work. + const responseAtSeconds = Math.floor(Date.now() / 1000); + + const decisionJson = NativePatchCore.handleCheckResponse( + responseText, + JSON.stringify(identity), + config.afterDownload ?? '', + ); + if (!decisionJson) { + return; + } + const decision = JSON.parse(decisionJson) as Decision; + if (decision.action !== 'download') { + context.commitNativeCheckResult( + resetGeneration, + '', + '', + false, + buildResponseCacheJson(configJson, body, responseText, responseAtSeconds), + ); + logger.debug(TAG, `nothing to do (${decision.reason ?? ''})`); + return; + } + const hash = decision.hash ?? ''; + if (!isSafePathComponent(hash)) { + return; + } + + let downloaded = context.hasDownloadedVersion(hash); + if (!downloaded) { + downloaded = await performAttempts( + context, + decision.attempts ?? [], + hash, + currentVersion, + ); + } + if (!downloaded) { + context.commitNativeCheckResult( + resetGeneration, + '', + '', + false, + buildResponseCacheJson(configJson, body, responseText, responseAtSeconds), + ); + return; + } + + // 与 JS 侧下载成功后的 setLocalHashInfo 对齐,持久化版本元信息。 + const info = decision.info; + let hashInfoJson = ''; + if (info) { + const hashInfo: DecisionInfo = {}; + if (typeof info.name === 'string') { + hashInfo.name = info.name; + } + if (typeof info.description === 'string') { + hashInfo.description = info.description; + } + if (typeof info.metaInfo === 'string') { + hashInfo.metaInfo = info.metaInfo; + } + hashInfoJson = JSON.stringify(hashInfo); + } + + // 版本元信息、激活与响应缓存一次性原子提交(见 commitNativeCheckResult); + // 缓存只在原生文件/状态工作结束后公开,避免 JS 观察到响应后并发下载。 + // 静默策略、或服务端按版本标记的 forceBoot(远程覆盖,救砖指令)才激活。 + const activate = decision.activate === true; + let committed = false; + try { + committed = context.commitNativeCheckResult( + resetGeneration, + hash, + hashInfoJson, + activate, + buildResponseCacheJson(configJson, body, responseText, responseAtSeconds), + ); + } catch (e) { + logger.error(TAG, `commit failed: ${e}`); + return; + } + if (!committed) { + logger.debug(TAG, 'reset during round, dropping result'); + } else if (activate) { + logger.debug(TAG, `downloaded ${hash} and set for next launch`); + } else { + logger.debug(TAG, `downloaded ${hash}, activation left to JS`); + } +} + +function buildResponseCacheJson( + configJson: string, + requestBody: string, + responseText: string, + responseAtSeconds: number, +): string { + const cacheEntry: RespCacheEntry = { + ts: responseAtSeconds, + body: responseText, + request: requestBody, + config: configJson, + }; + return JSON.stringify(cacheEntry); +} + +function isValidCheckResponse(responseText: string | undefined): boolean { + if (responseText === undefined) { + return false; + } + try { + const parsed = JSON.parse(responseText) as Object | null; + return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed); + } catch (e) { + return false; + } +} + +async function httpRequest( + url: string, + postBody?: string, +): Promise { + const client = http.createHttp(); + let callTimer: number | null = null; + try { + const header: Record = { Accept: 'application/json' }; + if (postBody !== undefined) { + header['Content-Type'] = 'application/json'; + } + const requestPromise = client.request(url, { + method: postBody !== undefined + ? http.RequestMethod.POST + : http.RequestMethod.GET, + header, + extraData: postBody, + connectTimeout: REQUEST_TIMEOUT_MS, + readTimeout: REQUEST_TIMEOUT_MS, + expectDataType: http.HttpDataType.STRING, + }); + const timeoutPromise = new Promise((_, reject) => { + callTimer = setTimeout(() => { + reject(Error(`HTTP request exceeded ${REQUEST_CALL_TIMEOUT_MS}ms`)); + }, REQUEST_CALL_TIMEOUT_MS); + }); + const response = await Promise.race([requestPromise, timeoutPromise]); + if ( + response.responseCode >= 200 && + response.responseCode < 300 && + typeof response.result === 'string' + ) { + return response.result; + } + return undefined; + } catch (e) { + return undefined; + } finally { + if (callTimer !== null) { + clearTimeout(callTimer); + } + client.destroy(); + } +} + +// 顺序回退(§5.1):按纯层给出的候选序逐个请求,单请求超时;整轮失败后经 +// queryUrls 发现远程候选(排除已试过的)再来一轮。刻意不做 hedged race—— +// 该路径对延迟不敏感。 +async function runCheckRequest( + config: NativeConfig, + appKey: string, + body: string, +): Promise { + const orderedJson = NativePatchCore.orderEndpointCandidates( + JSON.stringify(config.endpoints ?? []), + Math.random(), + ); + if (!orderedJson) { + return undefined; + } + const ordered = JSON.parse(orderedJson) as string[]; + const tried = new Set(); + let httpAttempts = 0; + for (const rawBase of ordered) { + const base = normalizeEndpointBase(rawBase); + if (!base || tried.has(base)) { + continue; + } + if (httpAttempts++ >= MAX_CHECK_HTTP_ATTEMPTS) { + return undefined; + } + tried.add(base); + const response = await httpRequest(`${base}/checkUpdate/${appKey}`, body); + if (isValidCheckResponse(response)) { + return response; + } + } + for (const listUrl of config.queryUrls ?? []) { + if (!listUrl) { + continue; + } + if (httpAttempts++ >= MAX_CHECK_HTTP_ATTEMPTS) { + return undefined; + } + const listText = await httpRequest(listUrl); + if (listText === undefined) { + continue; + } + let remote: string[]; + try { + const parsed = JSON.parse(listText) as Object; + if (!Array.isArray(parsed)) { + continue; + } + remote = parsed as string[]; + } catch (e) { + continue; + } + for (const rawBase of remote) { + if (typeof rawBase !== 'string') { + continue; + } + const base = normalizeEndpointBase(rawBase); + if (!base || tried.has(base)) { + continue; + } + if (httpAttempts++ >= MAX_CHECK_HTTP_ATTEMPTS) { + return undefined; + } + tried.add(base); + const response = await httpRequest(`${base}/checkUpdate/${appKey}`, body); + if (isValidCheckResponse(response)) { + return response; + } + } + // 拉到一份可解析的远程列表就够了。 + break; + } + return undefined; +} + +function normalizeEndpointBase(base: string): string { + return base.replace(/\/+$/, ''); +} + +async function runWithinDeadline( + start: () => Promise, + deadlineUptimeMs: number, +): Promise { + const remainingMs = deadlineUptimeMs - monotonicNowMs(); + if (remainingMs <= 0) { + throw Error('Download phase deadline expired before start'); + } + let deadlineTimer = 0; + const deadlinePromise = new Promise((_, reject) => { + deadlineTimer = setTimeout(() => { + reject(Error('Download phase deadline exceeded')); + }, remainingMs); + }); + try { + // This bounds queueing, HTTP, decompression and native hpatch work from + // the orchestrator's perspective. The serialized task may still finish + // later, but it can no longer prevent the response cache from settling. + await Promise.race([start(), deadlinePromise]); + } finally { + clearTimeout(deadlineTimer); + } +} + +async function performAttempts( + context: UpdateContext, + attempts: DecisionAttempt[], + hash: string, + originHash: string, +): Promise { + const incrementalDeadline = monotonicNowMs() + DOWNLOAD_PHASE_TIMEOUT_MS; + let fullDeadline = 0; + for (const attempt of attempts) { + const type = attempt.type ?? ''; + if (type === DOWNLOAD_TYPE_DIFF && !originHash) { + // diff 以当前运行版本为源;没有运行中的热更版本就跳过。 + continue; + } + const isFullAttempt = + type !== DOWNLOAD_TYPE_DIFF && type !== DOWNLOAD_TYPE_PDIFF; + if (isFullAttempt && fullDeadline === 0) { + // Preserve a full 10min rescue budget even when diff/pdiff exhausted + // their own phase window. + fullDeadline = monotonicNowMs() + DOWNLOAD_PHASE_TIMEOUT_MS; + } + const deadline = isFullAttempt ? fullDeadline : incrementalDeadline; + for (const url of attempt.urls ?? []) { + if (!url) { + continue; + } + if (monotonicNowMs() >= deadline) { + if (isFullAttempt) { + return false; + } + break; + } + try { + if (type === DOWNLOAD_TYPE_DIFF) { + await runWithinDeadline( + () => context.downloadPatchFromPpk(url, hash, originHash, deadline), + deadline, + ); + } else if (type === DOWNLOAD_TYPE_PDIFF) { + await runWithinDeadline( + () => context.downloadPatchFromPackage(url, hash, deadline), + deadline, + ); + } else { + await runWithinDeadline( + () => context.downloadFullUpdate(url, hash, deadline), + deadline, + ); + } + return true; + } catch (e) { + logger.debug(TAG, `${type} attempt failed: ${e}`); + } + } + } + return false; +} diff --git a/harmony/pushy/src/main/ets/NativePatchCore.ts b/harmony/pushy/src/main/ets/NativePatchCore.ts index a40a4a70..93a838ba 100644 --- a/harmony/pushy/src/main/ets/NativePatchCore.ts +++ b/harmony/pushy/src/main/ets/NativePatchCore.ts @@ -86,6 +86,20 @@ interface NativePatchCoreBindings { crc32(data: Uint8Array | ArrayBuffer): number; /** 原生 patch 内核可消费的 diff 轨道版本(2 = hdiffv2 轨道) */ getSupportedDiffVersion(): number; + + // 更新流程决策层(cpp/update_flow_core,NATIVE_CHECKUPDATE_DESIGN §10): + // JSON 字符串进出,与决策层自身的边界一致。返回 undefined = 输入未通过 + // 解析,编排器跳过本轮检测。 + buildCheckRequestBody(inputJson: string): string | undefined; + orderEndpointCandidates( + endpointsJson: string, + randomSample: number, + ): string | undefined; + handleCheckResponse( + responseText: string, + identityJson: string, + afterDownload: string, + ): string | undefined; } export default NativeUpdateCore as unknown as NativePatchCoreBindings; diff --git a/harmony/pushy/src/main/ets/PathUtils.ts b/harmony/pushy/src/main/ets/PathUtils.ts new file mode 100644 index 00000000..383f1d81 --- /dev/null +++ b/harmony/pushy/src/main/ets/PathUtils.ts @@ -0,0 +1,21 @@ +// Server-controlled identifiers are used as children of the update root. Keep +// validation in a dependency-free module so startup orchestration and storage +// code can share it without creating an import cycle. +export function isSafePathComponent(name: string): boolean { + return ( + typeof name === 'string' && + name.length > 0 && + name !== '.' && + name !== '..' && + !name.includes('/') && + !name.includes('\\') && + !name.includes('\0') + ); +} + +export function assertSafePathComponent(name: string): string { + if (!isSafePathComponent(name)) { + throw Error(`Invalid path component: ${name}`); + } + return name; +} diff --git a/harmony/pushy/src/main/ets/PushyTurboModule.ts b/harmony/pushy/src/main/ets/PushyTurboModule.ts index ef076b13..f9a91691 100644 --- a/harmony/pushy/src/main/ets/PushyTurboModule.ts +++ b/harmony/pushy/src/main/ets/PushyTurboModule.ts @@ -8,6 +8,10 @@ import logger from './Logger'; import NativePatchCore from './NativePatchCore'; import { UpdateContext } from './UpdateContext'; import { EventHub } from './EventHub'; +import { + KEY_CONFIG, + KEY_RESP_CACHE, +} from './NativeCheckOrchestrator'; const TAG = 'PushyTurboModule'; @@ -186,6 +190,24 @@ export class PushyTurboModule extends UITurboModule { this.context.setKv('uuid', uuid); } + // Provisioning for the native cold-start update check + // (NATIVE_CHECKUPDATE_DESIGN §10.1): the raw JSON persists as-is, parsed on + // read by the orchestrator; absent config = check disabled. Validated at + // write time — a corrupt config would otherwise silently disable the + // native check forever with no signal. + async syncNativeConfig(config: string): Promise { + logger.debug(TAG, ',call syncNativeConfig'); + JSON.parse(config); + this.context.setKv(KEY_CONFIG, config); + } + + // 原生冷启动检测落盘的原始响应缓存,JS 侧新鲜期内直接复用免二次请求 + // (NATIVE_CHECKUPDATE_DESIGN §10.3)。缺省空串,永不 reject。 + async getNativeCheckCache(): Promise { + logger.debug(TAG, ',call getNativeCheckCache'); + return this.context.getKv(KEY_RESP_CACHE) ?? ''; + } + async reloadUpdate(options: { hash: string }): Promise { logger.debug(TAG, ',call reloadUpdate'); const hash = this.requireHash(options.hash, 'reloadUpdate'); diff --git a/harmony/pushy/src/main/ets/UpdateContext.ts b/harmony/pushy/src/main/ets/UpdateContext.ts index f8dc392b..5727e93c 100644 --- a/harmony/pushy/src/main/ets/UpdateContext.ts +++ b/harmony/pushy/src/main/ets/UpdateContext.ts @@ -1,11 +1,18 @@ import preferences from '@ohos.data.preferences'; import fileIo from '@ohos.file.fs'; -import { DownloadTask } from './DownloadTask'; +import { + DownloadTask, + VERSION_COMPLETE_FILE_NAME, +} from './DownloadTask'; import common from '@ohos.app.ability.common'; import { DownloadTaskParams } from './DownloadTaskParams'; import { bundleManager } from '@kit.AbilityKit'; import { util } from '@kit.ArkTS'; import logger from './Logger'; +import { + KEY_RESP_CACHE, + scheduleNativeCheck, +} from './NativeCheckOrchestrator'; import NativePatchCore, { STATE_OP_CLEAR_FIRST_TIME, STATE_OP_CLEAR_ROLLBACK_MARK, @@ -15,32 +22,14 @@ import NativePatchCore, { STATE_OP_SWITCH_VERSION, StateCoreResult, } from './NativePatchCore'; +import { assertSafePathComponent } from './PathUtils'; + +export { isSafePathComponent } from './PathUtils'; type FlushablePreferences = preferences.Preferences & { flushSync?: () => void; }; -// 服务端下发的 hash/originHash/fileName 会拼进 rootDir 作为子路径;凡是可能 -// 逃出 rootDir 的值(路径分隔符、".."、".")必须在触碰文件系统前拒绝。 -export function isSafePathComponent(name: string): boolean { - return ( - typeof name === 'string' && - name.length > 0 && - name !== '.' && - name !== '..' && - !name.includes('/') && - !name.includes('\\') && - !name.includes('\0') - ); -} - -function assertSafePathComponent(name: string): string { - if (!isSafePathComponent(name)) { - throw Error(`Invalid path component: ${name}`); - } - return name; -} - export class UpdateContext { private context: common.UIAbilityContext; private rootDir: string; @@ -52,6 +41,10 @@ export class UpdateContext { // resetToPackagedBundle 不能删它的目录:热更包内的图片等资源是运行时按需 // 读盘的,静默(不重启)reset 若删掉会导致后续所有未加载过的资源失败。 private static launchVersion: string = ''; + // 由 resetToPackagedBundle 递增。原生冷启动检测可能跑数分钟并已握有决策, + // 期间发生的 reset 必须赢:编排器采样该值,发现变化即放弃激活与响应缓存, + // 在飞的救援不会把刚被重置掉的版本装回去。 + private static resetGeneration: number = 0; private static cachedPackageVersion: string = ''; private static cachedBuildTime: string = ''; // 单例:确保 bundle provider 与 TurboModule 共用同一份 preferences 内存状态, @@ -338,6 +331,15 @@ export class UpdateContext { private async executeTask(params: DownloadTaskParams): Promise { await this.enqueueSerialTask(() => { + const isPatchTask = + params.type === DownloadTaskParams.TASK_TYPE_PATCH_FULL || + params.type === DownloadTaskParams.TASK_TYPE_PATCH_FROM_APP || + params.type === DownloadTaskParams.TASK_TYPE_PATCH_FROM_PPK; + // Re-check only when this queued job actually starts. A JS download may + // have completed while the cold-start duplicate waited behind it. + if (isPatchTask && this.hasDownloadedVersion(params.hash)) { + return Promise.resolve(); + } const downloadTask = new DownloadTask(this.context); return downloadTask.execute(params); }); @@ -451,7 +453,16 @@ export class UpdateContext { console.error('Failed to clear hash info on reset:', e); } } + // 先让在飞的原生检测轮次失效,再清理状态:随后在同一(单线程)执行序里 + // 提交的轮次会看到新代数并整轮丢弃。 + UpdateContext.resetGeneration += 1; this.persistState(resetState, { clearFirstLoadMarker: true }); + // 缓存里的响应仍在宣告本次 reset 刚删掉的版本,一并丢弃,避免 JS 侧复用。 + try { + this.preferences.deleteSync(KEY_RESP_CACHE); + } catch (e: any) { + console.error('Failed to clear native check cache on reset:', e); + } UpdateContext.ignoreRollback = false; // maxAgeDays=0:删除下载目录内容,仅保留当前运行版本的目录(残留目录由 @@ -469,7 +480,44 @@ export class UpdateContext { this.trace('resetToPackagedBundle:after'); } - public async downloadFullUpdate(url: string, hash: string): Promise { + /** 供原生检测编排器采样/比对的 reset 代数(见 resetGeneration 注释)。 */ + public getResetGeneration(): number { + return UpdateContext.resetGeneration; + } + + /** + * 一次性提交原生检测轮次的全部持久化结果(版本元信息、激活、响应缓存): + * 先复核 reset 代数,再落所有写入。ArkTS 单线程 + 本方法内无 await,因此 + * 校验与写入之间不存在可插入 reset 的窗口(iOS/Android 用锁达到同一效果)。 + * 返回是否提交成功。 + */ + public commitNativeCheckResult( + expectedGeneration: number, + hash: string, + hashInfoJson: string, + activate: boolean, + responseCacheJson: string, + ): boolean { + if (UpdateContext.resetGeneration !== expectedGeneration) { + return false; + } + if (hash && hashInfoJson) { + this.setKv(`hash_${hash}`, hashInfoJson); + } + if (activate && hash) { + this.switchVersion(hash); + } + if (responseCacheJson) { + this.setKv(KEY_RESP_CACHE, responseCacheJson); + } + return true; + } + + public async downloadFullUpdate( + url: string, + hash: string, + deadlineUptimeMs: number = 0, + ): Promise { try { const params = this.createTaskParams( DownloadTaskParams.TASK_TYPE_PATCH_FULL, @@ -478,6 +526,7 @@ export class UpdateContext { ); params.targetFile = `${this.rootDir}/${hash}.ppk`; params.unzipDirectory = `${this.rootDir}/${hash}`; + params.deadlineUptimeMs = deadlineUptimeMs; await this.executeTask(params); } catch (e) { console.error('Failed to download full update:', e); @@ -503,6 +552,7 @@ export class UpdateContext { url: string, hash: string, originHash: string, + deadlineUptimeMs: number = 0, ): Promise { const params = this.createTaskParams( DownloadTaskParams.TASK_TYPE_PATCH_FROM_PPK, @@ -513,12 +563,14 @@ export class UpdateContext { params.targetFile = `${this.rootDir}/${originHash}_${hash}.ppk.patch`; params.unzipDirectory = `${this.rootDir}/${hash}`; params.originDirectory = `${this.rootDir}/${params.originHash}`; + params.deadlineUptimeMs = deadlineUptimeMs; await this.executeTask(params); } public async downloadPatchFromPackage( url: string, hash: string, + deadlineUptimeMs: number = 0, ): Promise { try { const params = this.createTaskParams( @@ -528,6 +580,7 @@ export class UpdateContext { ); params.targetFile = `${this.rootDir}/${hash}.app.patch`; params.unzipDirectory = `${this.rootDir}/${hash}`; + params.deadlineUptimeMs = deadlineUptimeMs; return await this.executeTask(params); } catch (e) { console.error('Failed to download package patch:', e); @@ -535,6 +588,20 @@ export class UpdateContext { } } + // 原生冷启动检测(NativeCheckOrchestrator)用来跳过已就绪版本的重复下载 + // ——alert 类策略下版本已下载但未激活,若不判在这里会每次冷启动重下一遍。 + public hasDownloadedVersion(hash: string): boolean { + try { + const safeHash = assertSafePathComponent(hash); + return fileIo.accessSync(this.getBundlePath(safeHash)) + && fileIo.accessSync( + `${this.rootDir}/${safeHash}/${VERSION_COMPLETE_FILE_NAME}`, + ); + } catch (e) { + return false; + } + } + public switchVersion(hash: string): void { try { const bundlePath = this.getBundlePath(assertSafePathComponent(hash)); @@ -565,58 +632,70 @@ export class UpdateContext { public getBundleUrl() { UpdateContext.isUsingBundleUrl = true; this.trace('getBundleUrl:enter'); - const stateBeforeLaunch = this.getStateSnapshot(); - const launchState = NativePatchCore.runStateCore( - STATE_OP_RESOLVE_LAUNCH, - stateBeforeLaunch, - '', - UpdateContext.ignoreRollback, - true, - ); - if (launchState.didRollback) { - // The crash-protection rollback: the new version never called - // markSuccess. Keep this visible in release logs. - console.error( - `Version ${stateBeforeLaunch.currentVersion} was not marked as successful,` + - ` rolled back to ${launchState.currentVersion}`, + let nativeCheckRolledBackVersion = ''; + try { + const stateBeforeLaunch = this.getStateSnapshot(); + const launchState = NativePatchCore.runStateCore( + STATE_OP_RESOLVE_LAUNCH, + stateBeforeLaunch, + '', + UpdateContext.ignoreRollback, + true, + ); + nativeCheckRolledBackVersion = launchState.rolledBackVersion || ''; + if (launchState.didRollback) { + // The crash-protection rollback: the new version never called + // markSuccess. Keep this visible in release logs. + console.error( + `Version ${stateBeforeLaunch.currentVersion} was not marked as successful,` + + ` rolled back to ${launchState.currentVersion}`, + ); + } + if (launchState.didRollback || launchState.consumedFirstTime) { + this.persistState(launchState, { + markFirstLoadMarker: launchState.consumedFirstTime, + }); + } + if (launchState.consumedFirstTime) { + UpdateContext.ignoreRollback = true; + } + this.trace( + `getBundleUrl:load=${launchState.loadVersion}` + + ` consumed=${launchState.consumedFirstTime}` + + ` rollback=${launchState.didRollback}`, ); - } - if (launchState.didRollback || launchState.consumedFirstTime) { - this.persistState(launchState, { - markFirstLoadMarker: launchState.consumedFirstTime, - }); - } - if (launchState.consumedFirstTime) { - UpdateContext.ignoreRollback = true; - } - this.trace( - `getBundleUrl:load=${launchState.loadVersion}` + - ` consumed=${launchState.consumedFirstTime}` + - ` rollback=${launchState.didRollback}`, - ); - let version = launchState.loadVersion || ''; - // Guard the rollback chain against cycles: a corrupted state returning an - // already-visited version would otherwise spin this loop forever during - // startup (Android has the same guard). - const visitedVersions = new Set(); - while (version && !visitedVersions.has(version)) { - visitedVersions.add(version); - const bundleFile = this.getBundlePath(version); - try { - if (!fileIo.accessSync(bundleFile)) { - console.error(`Bundle version ${version} not found.`); + let version = launchState.loadVersion || ''; + // Guard the rollback chain against cycles: a corrupted state returning an + // already-visited version would otherwise spin this loop forever during + // startup (Android has the same guard). + const visitedVersions = new Set(); + while (version && !visitedVersions.has(version)) { + visitedVersions.add(version); + const bundleFile = this.getBundlePath(version); + try { + if (!fileIo.accessSync(bundleFile)) { + console.error(`Bundle version ${version} not found.`); + version = this.rollBack(); + nativeCheckRolledBackVersion = this.rolledBackVersion(); + continue; + } + UpdateContext.launchVersion = version; + nativeCheckRolledBackVersion = this.rolledBackVersion(); + return bundleFile; + } catch (e) { + console.error('Failed to access bundle file:', e); version = this.rollBack(); - continue; + nativeCheckRolledBackVersion = this.rolledBackVersion(); } - UpdateContext.launchVersion = version; - return bundleFile; - } catch (e) { - console.error('Failed to access bundle file:', e); - version = this.rollBack(); } + nativeCheckRolledBackVersion = this.rolledBackVersion(); + return ''; + } finally { + // State corruption is exactly when the native rescue check is needed; + // schedule even if state parsing/rollback throws before a normal exit. + scheduleNativeCheck(this, nativeCheckRolledBackVersion); } - return ''; } public getCurrentVersion(): string { diff --git a/ios/RCTPushy/RCTPushy.mm b/ios/RCTPushy/RCTPushy.mm index 9ed485e7..2bf76f68 100644 --- a/ios/RCTPushy/RCTPushy.mm +++ b/ios/RCTPushy/RCTPushy.mm @@ -7,6 +7,10 @@ #include "../../cpp/patch_core/error_codes.h" #include "../../cpp/patch_core/patch_core.h" #include "../../cpp/patch_core/state_core.h" +#include "../../cpp/update_flow_core/flow_json.h" +#include "../../cpp/update_flow_core/update_flow_core.h" + +#import #if __has_include("RCTReloadCommand.h") #import "RCTReloadCommand.h" @@ -37,12 +41,19 @@ // installed binary (packageVersion + embedded bundle size + mtime). Recomputed // only when the key changes, i.e. once per install. static NSString *const keyBundleHashCache = @"REACTNATIVECN_PUSHY_BUNDLEHASH_KEY"; +// Raw JSON persisted by JS (syncNativeConfig) for the native cold-start +// update check; parsed on read by the orchestrator. Absent = check disabled. +static NSString *const keyNativeConfig = @"REACTNATIVECN_PUSHY_NATIVE_CONFIG_KEY"; +// Raw response cache written by the native cold-start check for the JS side +// to reuse (§10.3), scoped to the request and config that produced it. +static NSString *const keyNativeCheckCache = @"REACTNATIVECN_PUSHY_NATIVE_CHECK_RESP_KEY"; static NSString *const PushyErrorDomain = @"cn.reactnative.pushy"; // file def static NSString * const BUNDLE_FILE_NAME = @"index.bundlejs"; static NSString * const SOURCE_PATCH_NAME = @"__diff.json"; static NSString * const BUNDLE_PATCH_NAME = @"index.bundlejs.patch"; +static NSString * const VERSION_COMPLETE_FILE_NAME = @".pushy-complete"; // error def — messages are human-readable; the stable cross-platform codes // live in cpp/patch_core/error_codes.h and travel in PushyErrorCodeKey. @@ -60,6 +71,26 @@ static NSString * const PARAM_PROGRESS_RECEIVED = @"received"; static NSString * const PARAM_PROGRESS_TOTAL = @"total"; +static NSTimeInterval PushyMonotonicNow(void) { + return [NSProcessInfo processInfo].systemUptime; +} + +static BOOL PushyHasCompletedVersionAtPath(NSString *versionDir) { + NSString *bundlePath = [versionDir stringByAppendingPathComponent:BUNDLE_FILE_NAME]; + NSString *markerPath = [versionDir stringByAppendingPathComponent:VERSION_COMPLETE_FILE_NAME]; + return [[NSFileManager defaultManager] fileExistsAtPath:bundlePath] + && [[NSFileManager defaultManager] fileExistsAtPath:markerPath]; +} + +static NSError *PushyDownloadDeadlineExpiredError(void) { + return [NSError errorWithDomain:PushyErrorDomain + code:-1 + userInfo:@{ + NSLocalizedDescriptionKey: @"download deadline expired before start", + PushyErrorCodeKey: PushyCode(pushy::error_codes::kDownloadFailed), + }]; +} + typedef NS_ENUM(NSInteger, PushyType) { PushyTypeFullDownload = 1, @@ -69,6 +100,12 @@ typedef NS_ENUM(NSInteger, PushyType) { }; static std::atomic ignoreRollback{false}; +// Bumped by resetToPackagedBundle. The cold-start check runs for minutes and +// may already hold a decision when the app resets to the packaged bundle; it +// samples this counter and abandons activation (and its response cache) when +// the value moved, so an in-flight rescue can never resurrect the version the +// app just reset away from. +static std::atomic pushyResetGeneration{0}; // The version whose bundle this process actually loaded (resolved in // +bundleURL). resetToPackagedBundle must not delete its directory: update // assets (images/fonts) are read from it on demand at runtime, so wiping it @@ -76,6 +113,126 @@ typedef NS_ENUM(NSInteger, PushyType) { // has not loaded yet. Guarded by the state lock. static NSString *pushyLaunchVersion = nil; +// JS and the bridge-free cold-start engine use different RCTPushy instances, +// but they must still share one download per target hash. Without this +// process-wide registry two NSURLSessionDownloadTasks race over the same +// archive path and the later unzip can delete the first task's valid output. +static NSMutableDictionary *PushyInFlightDownloads(void) { + static NSMutableDictionary *downloads; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + downloads = [NSMutableDictionary dictionary]; + }); + return downloads; +} + +typedef NS_ENUM(NSInteger, PushyDownloadRegistration) { + PushyDownloadRegistrationOwner, + PushyDownloadRegistrationJoined, + PushyDownloadRegistrationDeferred, +}; + +static PushyDownloadRegistration PushyRegisterDownload( + NSString *hash, + NSInteger type, + NSTimeInterval deadlineUptime, + void (^callback)(NSError *), + void (^progress)(long long, long long), + void (^deferredStart)(void) +) { + NSMutableDictionary *downloads = PushyInFlightDownloads(); + @synchronized (downloads) { + NSMutableDictionary *entry = downloads[hash]; + if (entry != nil) { + if ([entry[@"type"] integerValue] == type) { + NSTimeInterval ownerDeadline = [entry[@"deadlineUptime"] doubleValue]; + // A caller with substantially more time must not inherit an + // owner's nearly-exhausted timeout: it observes the current + // transfer and restarts after the owner settles (the + // completion-marker preflight makes a successful owner free). + // The comparison is on remaining budget, not on the absolute + // deadline: a JS caller always computes now+600 a few seconds + // after the owner did, so a strict `>` would defer every + // second caller and turn the shared download back into + // serialized re-downloads. Only a genuinely starved owner + // (less than half the newcomer's budget left) defers. + const NSTimeInterval now = PushyMonotonicNow(); + if (2 * (ownerDeadline - now) < (deadlineUptime - now)) { + if (progress != nil) { + [entry[@"progress"] addObject:[progress copy]]; + } + [entry[@"deferred"] addObject:[deferredStart copy]]; + return PushyDownloadRegistrationDeferred; + } + [entry[@"callbacks"] addObject:[callback copy]]; + if (progress != nil) { + [entry[@"progress"] addObject:[progress copy]]; + } + return PushyDownloadRegistrationJoined; + } + // A diff failure must not settle a joined full request. Queue the + // different artifact type behind the owner; once restarted it + // registers normally (and re-checks the completion marker). + if (progress != nil) { + // The artifact type differs, but it still installs the same + // target hash. Keep the waiting JS UI moving while its own + // transfer is queued behind the current owner. + [entry[@"progress"] addObject:[progress copy]]; + } + [entry[@"deferred"] addObject:[deferredStart copy]]; + return PushyDownloadRegistrationDeferred; + } + NSMutableArray *progressHandlers = [NSMutableArray array]; + if (progress != nil) { + [progressHandlers addObject:[progress copy]]; + } + downloads[hash] = [@{ + @"type": @(type), + @"deadlineUptime": @(deadlineUptime), + @"callbacks": [NSMutableArray arrayWithObject:[callback copy]], + @"progress": progressHandlers, + @"deferred": [NSMutableArray array], + } mutableCopy]; + return PushyDownloadRegistrationOwner; + } +} + +static void PushyReportDownloadProgress( + NSString *hash, long long received, long long total +) { + NSMutableDictionary *downloads = PushyInFlightDownloads(); + NSArray *handlers = nil; + @synchronized (downloads) { + handlers = [downloads[hash][@"progress"] copy]; + } + for (id value in handlers) { + void (^handler)(long long, long long) = + (void (^)(long long, long long))value; + handler(received, total); + } +} + +static void PushyFinishDownload(NSString *hash, NSError *error) { + NSMutableDictionary *downloads = PushyInFlightDownloads(); + NSArray *callbacks = nil; + NSArray *deferred = nil; + @synchronized (downloads) { + callbacks = [downloads[hash][@"callbacks"] copy]; + deferred = [downloads[hash][@"deferred"] copy]; + [downloads removeObjectForKey:hash]; + } + // Establish the next (different-type) owner before waking the completed + // owner's callbacks; otherwise its strategy loop could race the waiter. + for (id value in deferred) { + void (^start)(void) = (void (^)(void))value; + start(); + } + for (id value in callbacks) { + void (^callback)(NSError *) = (void (^)(NSError *))value; + callback(error); + } +} + // Serializes every read-modify-write of the persisted update state. The state // machine itself is a pure function (state_core), but callers run on different // threads (main thread bundleURL, module method queue, _fileQueue), so the @@ -85,8 +242,11 @@ typedef NS_ENUM(NSInteger, PushyType) { static void PushyWithStateLock(void (NS_NOESCAPE ^block)(void)) { os_unfair_lock_lock(&pushyStateLock); - block(); - os_unfair_lock_unlock(&pushyStateLock); + @try { + block(); + } @finally { + os_unfair_lock_unlock(&pushyStateLock); + } } static std::string PushyToStdString(NSString *value) { @@ -255,6 +415,21 @@ static void PushyApplyStateToDefaults(NSUserDefaults *defaults, const pushy::sta PushyFromStdString(state.rolled_back_version)); } +// Version switch without acquiring the state lock: the caller must already +// hold it. Lets the cold-start check commit its whole result (version info, +// switch, response cache) inside one lock acquisition, so resetToPackagedBundle +// can never interleave between the generation check and the writes. +static void PushySwitchVersionLocked(NSString *hash) { + NSUserDefaults *defaults = PushyDefaults(); + pushy::state::State next = pushy::state::SwitchVersion( + PushyStateFromDefaults(defaults), + PushyToStdString(hash) + ); + PushyApplyStateToDefaults(defaults, next); + // Re-enable first-load consumption and rollback checks for the newly selected bundle. + ignoreRollback = false; +} + @interface RCTPushy () - (void)downloadUpdate:(PushyType)type options:(NSDictionary *)options @@ -284,8 +459,62 @@ + (void)excludeFromBackup:(NSString *)path; - (void)unzipFileAtPath:(NSString *)path toDestination:(NSString *)destination completionHandler:(void (^)(NSError *error))completionHandler; ++ (NSString *)downloadDir; ++ (NSURL *)binaryBundleURL; ++ (NSString *)packageVersion; ++ (NSString *)buildTime; @end +// Native cold-start update check (NATIVE_CHECKUPDATE_DESIGN §10): runs once +// per process, a few seconds after launch, entirely independent of the app +// bundle — this is what lets a bricked hot update be replaced on the next +// launch. Decisions come from cpp/update_flow_core; this class is IO glue. +@interface RCTPushyOrchestrator : NSObject ++ (void)scheduleFromColdStart:(NSString *)launchRolledBackVersion; ++ (void)runOnce:(NSString *)launchRolledBackVersion; ++ (BOOL)commitRoundWithGeneration:(uint64_t)generation + hashInfo:(NSDictionary *)hashInfoEntry + activate:(NSString *)hashToActivate + responseText:(NSString *)responseText + request:(NSString *)requestBody + config:(NSString *)configJson + responseAt:(long long)responseAtSeconds; +@end + +// Shared by the getBundleHash RCT method and the native cold-start check. +// Returns @"" when unknown; blocking (sha256 of the embedded bundle on first +// call per install, cached afterwards) — call off the main thread. +static NSString *PushyBundleHashSync(void) { + NSString *path = [[RCTPushy binaryBundleURL] path]; + if (path == nil) { + return @""; + } + NSDictionary *attributes = + [[NSFileManager defaultManager] attributesOfItemAtPath:path error:nil]; + if (attributes == nil) { + return @""; + } + NSString *cacheKey = [NSString stringWithFormat:@"%@|%llu|%.0f", + [RCTPushy packageVersion], + attributes.fileSize, + [attributes.fileModificationDate timeIntervalSince1970]]; + + NSUserDefaults *defaults = PushyDefaults(); + NSString *cached = [defaults stringForKey:keyBundleHashCache]; + NSString *cachedPrefix = [cacheKey stringByAppendingString:@"|"]; + if ([cached hasPrefix:cachedPrefix]) { + return [cached substringFromIndex:cachedPrefix.length]; + } + + NSString *hash = PushyFromStdString( + pushy::digest::Sha256File(PushyToStdString(path))) ?: @""; + if (hash.length > 0) { + [defaults setObject:[cachedPrefix stringByAppendingString:hash] + forKey:keyBundleHashCache]; + } + return hash; +} + @implementation RCTPushy { dispatch_queue_t _fileQueue; bool hasListeners; @@ -296,8 +525,10 @@ @implementation RCTPushy { + (NSURL *)bundleURL { __block NSURL *resolvedURL = nil; - PushyWithStateLock(^{ - NSUserDefaults *defaults = PushyDefaults(); + __block NSString *launchRolledBackVersion = nil; + @try { + PushyWithStateLock(^{ + NSUserDefaults *defaults = PushyDefaults(); NSString *curPackageVersion = [RCTPushy packageVersion]; NSString *curBuildTime = [RCTPushy buildTime]; @@ -351,7 +582,7 @@ + (NSURL *)bundleURL if ([[NSFileManager defaultManager] fileExistsAtPath:bundlePath isDirectory:NULL]) { pushyLaunchVersion = loadVersion; resolvedURL = [NSURL fileURLWithPath:bundlePath]; - return; + break; } else { RCTLogError(@"RCTPushy -- bundle version %@ not found, rolling back", loadVersion); state = pushy::state::Rollback(state); @@ -360,9 +591,18 @@ + (NSURL *)bundleURL } } } - }); - - return resolvedURL ?: [RCTPushy binaryBundleURL]; + // Capture before constantsToExport consumes this one-shot marker. + // The delayed native check must never forceBoot the version that this + // launch just rolled back. + launchRolledBackVersion = PushyFromStdString(state.rolled_back_version); + }); + return resolvedURL ?: [RCTPushy binaryBundleURL]; + } @finally { + // State corruption is exactly when the rescue path matters most. If + // resolution throws before a snapshot exists, nil safely omits only + // this launch's rollback guard instead of disabling the check. + [RCTPushyOrchestrator scheduleFromColdStart:launchRolledBackVersion]; + } } + (NSString *) rollback { @@ -452,6 +692,36 @@ - (instancetype)init resolve(@true); } +RCT_EXPORT_METHOD(syncNativeConfig:(NSString *)config + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ + // Provisioning for the native cold-start check (NATIVE_CHECKUPDATE_DESIGN + // §10.1). Validate at write time: a corrupt config would otherwise + // silently disable the native check forever with no signal. + if (PushyStringIsBlank(config)) { + PushyRejectError(reject, PushyErrorWithCode(pushy::error_codes::kInvalidOptions, ERROR_OPTIONS)); + return; + } + NSData *data = [config dataUsingEncoding:NSUTF8StringEncoding]; + NSError *error = nil; + id object = data == nil ? nil : [NSJSONSerialization JSONObjectWithData:data options:0 error:&error]; + if (![object isKindOfClass:[NSDictionary class]]) { + PushyRejectError(reject, PushyErrorWithCode( + pushy::error_codes::kInvalidOptions, + error != nil ? error.localizedDescription : ERROR_OPTIONS)); + return; + } + [PushyDefaults() setObject:config forKey:keyNativeConfig]; + resolve(@true); +} + +RCT_EXPORT_METHOD(getNativeCheckCache:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ + resolve([PushyDefaults() stringForKey:keyNativeCheckCache] ?: @""); +} + RCT_EXPORT_METHOD(setLocalHashInfo:(NSString *)hash value:(NSString *)value resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) @@ -565,37 +835,7 @@ - (instancetype)init resolve(@""); #else dispatch_async(_fileQueue, ^{ - NSString *path = [[RCTPushy binaryBundleURL] path]; - if (path == nil) { - resolve(@""); - return; - } - NSDictionary *attributes = - [[NSFileManager defaultManager] attributesOfItemAtPath:path error:nil]; - if (attributes == nil) { - resolve(@""); - return; - } - NSString *cacheKey = [NSString stringWithFormat:@"%@|%llu|%.0f", - [RCTPushy packageVersion], - attributes.fileSize, - [attributes.fileModificationDate timeIntervalSince1970]]; - - NSUserDefaults *defaults = PushyDefaults(); - NSString *cached = [defaults stringForKey:keyBundleHashCache]; - NSString *cachedPrefix = [cacheKey stringByAppendingString:@"|"]; - if ([cached hasPrefix:cachedPrefix]) { - resolve([cached substringFromIndex:cachedPrefix.length]); - return; - } - - NSString *hash = PushyFromStdString( - pushy::digest::Sha256File(PushyToStdString(path))) ?: @""; - if (hash.length > 0) { - [defaults setObject:[cachedPrefix stringByAppendingString:hash] - forKey:keyBundleHashCache]; - } - resolve(hash); + resolve(PushyBundleHashSync()); }); #endif } @@ -633,6 +873,10 @@ - (instancetype)init // for gray release bucketing and must not change on reset. __block NSString *keepVersion = nil; PushyWithStateLock(^{ + // Invalidate any in-flight cold-start round before clearing state, so + // a round that commits under this same lock afterwards always sees the + // new generation and drops its result. + pushyResetGeneration.fetch_add(1); NSUserDefaults *defaults = PushyDefaults(); keepVersion = pushyLaunchVersion; @@ -652,6 +896,9 @@ - (instancetype)init } [defaults removeObjectForKey:keyFirstLoadMarked]; [defaults removeObjectForKey:KeyPackageUpdatedMarked]; + // A cached response still advertises the version this reset just + // removed; dropping it stops the JS side from reusing that answer. + [defaults removeObjectForKey:keyNativeCheckCache]; ignoreRollback = false; }); @@ -744,31 +991,85 @@ - (void)performUpdate:(PushyType)type options:(NSDictionary *)options callback:( return; } + NSString *unzipDir = [dir stringByAppendingPathComponent:hash]; + if (PushyHasCompletedVersionAtPath(unzipDir)) { + callback(nil); + return; + } + + NSTimeInterval deadlineUptime = PushyMonotonicNow() + 600; + NSNumber *configuredDeadline = options[@"deadlineUptime"]; + if ([configuredDeadline isKindOfClass:[NSNumber class]]) { + deadlineUptime = configuredDeadline.doubleValue; + } + if (deadlineUptime <= PushyMonotonicNow()) { + callback(PushyDownloadDeadlineExpiredError()); + return; + } + + void (^progress)(long long, long long) = ^(long long receivedBytes, long long totalBytes) { + if (self->hasListeners) { + [self sendEventWithName:EVENT_PROGRESS_DOWNLOAD body:@{ + PARAM_PROGRESS_HASH:hash, + PARAM_PROGRESS_RECEIVED:@(receivedBytes), + PARAM_PROGRESS_TOTAL:@(totalBytes), + }]; + } + }; + void (^deferredStart)(void) = ^{ + [self performUpdate:type options:options callback:callback]; + }; + PushyDownloadRegistration registration = PushyRegisterDownload( + hash, type, deadlineUptime, callback, progress, deferredStart); + if (registration != PushyDownloadRegistrationOwner) { + RCTLogInfo( + @"RCTPushy -- %@ in-flight download for %@", + registration == PushyDownloadRegistrationJoined ? @"join" : @"defer", + hash); + return; + } + NSString *zipFilePath = [dir stringByAppendingPathComponent:[NSString stringWithFormat:@"%@%@",hash, [self zipExtension:type]]]; // On failure, remove the partial version directory like Android/Harmony // do: a half-unzipped/half-patched dir leaks disk and could later be // mistaken for a complete version. hash is validated non-blank above, so // this can never resolve to the download root itself. - NSString *unzipDir = [dir stringByAppendingPathComponent:hash]; void (^completion)(NSError *) = ^(NSError *error) { - if (error != nil) { - dispatch_async(self->_fileQueue, ^{ + // Settle every JS/native waiter only after cleanup or the atomic + // completion marker write has run on the process-wide file queue. + dispatch_async(self->_fileQueue, ^{ + NSError *finalError = error; + if (finalError == nil) { + NSString *marker = [unzipDir stringByAppendingPathComponent:VERSION_COMPLETE_FILE_NAME]; + NSError *markerError = nil; + BOOL marked = [[NSData data] writeToFile:marker + options:NSDataWritingAtomic + error:&markerError]; + if (!marked) { + finalError = markerError ?: PushyErrorWithCode( + pushy::error_codes::kFileOperationFailed, + @"failed to mark completed update"); + } + } + if (finalError != nil) { [[NSFileManager defaultManager] removeItemAtPath:unzipDir error:nil]; - }); - } - callback(error); + } + PushyFinishDownload(hash, finalError); + }); }; RCTLogInfo(@"RCTPushy -- download file %@", updateUrl); - [RCTPushyDownloader download:updateUrl savePath:zipFilePath progressHandler:^(long long receivedBytes, long long totalBytes) { - if (self->hasListeners) { - [self sendEventWithName:EVENT_PROGRESS_DOWNLOAD body:@{ - PARAM_PROGRESS_HASH:hash, - PARAM_PROGRESS_RECEIVED:[NSNumber numberWithLongLong:receivedBytes], - PARAM_PROGRESS_TOTAL:[NSNumber numberWithLongLong:totalBytes] - }]; - } + NSTimeInterval timeoutSeconds = deadlineUptime - PushyMonotonicNow(); + if (timeoutSeconds <= 0) { + completion(PushyDownloadDeadlineExpiredError()); + return; + } + [RCTPushyDownloader download:updateUrl + savePath:zipFilePath + timeoutInterval:timeoutSeconds + progressHandler:^(long long receivedBytes, long long totalBytes) { + PushyReportDownloadProgress(hash, receivedBytes, totalBytes); } completionHandler:^(NSString *path, NSError *error) { if (error != nil) { completion(error); @@ -934,14 +1235,7 @@ - (BOOL)switchVersion:(NSString *)hash error:(NSError **)error } PushyWithStateLock(^{ - NSUserDefaults *defaults = PushyDefaults(); - pushy::state::State next = pushy::state::SwitchVersion( - PushyStateFromDefaults(defaults), - PushyToStdString(hash) - ); - PushyApplyStateToDefaults(defaults, next); - // Re-enable first-load consumption and rollback checks for the newly selected bundle. - ignoreRollback = false; + PushySwitchVersionLocked(hash); }); return YES; } @@ -1110,3 +1404,427 @@ + (NSString *)buildTime #endif @end + +#pragma mark - native cold-start check orchestration + +// Blocking JSON HTTP round-trip on the orchestrator's utility thread. Returns +// the response body on 2xx, nil on any failure. The semaphore timeout is a +// backstop over the request's own timeoutInterval. +static NSString *PushyHttpRequest(NSString *urlString, NSString *method, + NSString *body, NSTimeInterval timeout) { + NSURL *url = [NSURL URLWithString:urlString]; + if (url == nil) { + return nil; + } + NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url]; + request.HTTPMethod = method; + request.timeoutInterval = timeout; + [request setValue:@"application/json" forHTTPHeaderField:@"Accept"]; + if (body != nil) { + [request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"]; + request.HTTPBody = [body dataUsingEncoding:NSUTF8StringEncoding]; + } + dispatch_semaphore_t sem = dispatch_semaphore_create(0); + __block NSString *result = nil; + NSURLSessionDataTask *task = [[NSURLSession sharedSession] dataTaskWithRequest:request + completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + NSHTTPURLResponse *httpResponse = + [response isKindOfClass:[NSHTTPURLResponse class]] + ? (NSHTTPURLResponse *)response + : nil; + NSInteger status = httpResponse.statusCode; + if (error == nil && httpResponse != nil && status >= 200 && + status < 300 && data != nil) { + result = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; + } + dispatch_semaphore_signal(sem); + }]; + [task resume]; + if (dispatch_semaphore_wait( + sem, dispatch_time(DISPATCH_TIME_NOW, + (int64_t)((timeout + 5) * NSEC_PER_SEC))) != 0) { + [task cancel]; + return nil; + } + return result; +} + +static NSString *PushyNormalizeEndpointBase(NSString *base) { + while ([base hasSuffix:@"/"]) { + base = [base substringToIndex:base.length - 1]; + } + return base; +} + +static BOOL PushyIsValidCheckResponse(NSString *responseText) { + if (responseText == nil) { + return NO; + } + bool ok = false; + flowjson::Value parsed = flowjson::Parse(PushyToStdString(responseText), &ok); + return ok && parsed.IsObject(); +} + +@implementation RCTPushyOrchestrator + ++ (void)scheduleFromColdStart:(NSString *)launchRolledBackVersion { +#if !DEBUG + // Once per process; a few seconds of delay keeps the check away from the + // cold-start critical path (§7 R5) — its result targets the NEXT launch. + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5 * NSEC_PER_SEC)), + dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{ + @try { + [self runOnce:launchRolledBackVersion]; + } @catch (NSException *exception) { + // The rescue path must never take the app down with it. + RCTLogWarn(@"RCTPushy -- native check crashed: %@", exception.reason); + } + }); + }); +#endif +} + +// A bare module instance drives the existing download/patch pipeline without +// a bridge: the file queue is process-global and progress events are gated on +// hasListeners (never set without a bridge). ++ (RCTPushy *)engine { + static RCTPushy *engine; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + engine = [[RCTPushy alloc] init]; + }); + return engine; +} + ++ (void)runOnce:(NSString *)launchRolledBackVersion { + // Sampled before any IO: resetToPackagedBundle bumps it, and a reset that + // lands while this round is running must win over the round's decision. + const uint64_t resetGeneration = pushyResetGeneration.load(); + NSUserDefaults *defaults = PushyDefaults(); + NSString *configJson = [defaults stringForKey:keyNativeConfig]; + if (configJson.length == 0) { + // No persisted config (old integration / first ever launch): the + // native check silently does not run — this is the rollout gate. + return; + } + bool ok = false; + flowjson::Value config = flowjson::Parse(PushyToStdString(configJson), &ok); + if (!ok || !config.IsObject() || config.Get("disabled").Truthy()) { + return; + } + NSString *appKey = PushyFromStdString(config.Get("appKey").AsString()); + if (appKey.length == 0) { + return; + } + NSString *packageVersion = + PushyFromStdString(config.Get("packageVersion").AsString()); + if (packageVersion.length == 0) { + packageVersion = [RCTPushy packageVersion]; + } + + __block NSString *currentVersion = nil; + PushyWithStateLock(^{ + pushy::state::State state = PushyStateFromDefaults(PushyDefaults()); + currentVersion = PushyFromStdString(state.current_version); + }); + NSString *rolledBackVersion = launchRolledBackVersion; + NSString *uuid = [defaults stringForKey:keyUuid] ?: @""; + + flowjson::Value identity = flowjson::Value::Object(); + identity.Set("packageVersion", + flowjson::Value::String(PushyToStdString(packageVersion))); + if (currentVersion != nil) { + identity.Set("currentVersion", + flowjson::Value::String(PushyToStdString(currentVersion))); + } + identity.Set("uuid", flowjson::Value::String(PushyToStdString(uuid))); + if (rolledBackVersion != nil) { + identity.Set("rolledBackVersion", + flowjson::Value::String(PushyToStdString(rolledBackVersion))); + } + + flowjson::Value cInfo = flowjson::Value::Object(); + cInfo.Set("rnu", config.Get("rnu")); + cInfo.Set("rn", config.Get("rn")); + cInfo.Set("os", flowjson::Value::String(PushyToStdString([NSString + stringWithFormat:@"ios %@", [[UIDevice currentDevice] systemVersion]]))); + cInfo.Set("uuid", flowjson::Value::String(PushyToStdString(uuid))); + + flowjson::Value input = flowjson::Value::Object(); + input.Set("packageVersion", identity.Get("packageVersion")); + if (currentVersion != nil) { + input.Set("currentVersion", identity.Get("currentVersion")); + } + input.Set("buildTime", + flowjson::Value::String(PushyToStdString([RCTPushy buildTime]))); + input.Set("cInfo", cInfo); + input.Set("supportedDiffVersion", + flowjson::Value::Number(pushy::hbc::kSupportedDiffVersion)); + input.Set("bundleHash", + flowjson::Value::String(PushyToStdString(PushyBundleHashSync()))); + + std::string bodyJson = + flowjson::Stringify(updateflow::BuildCheckRequestBody(input)); + NSString *body = [NSString stringWithUTF8String:bodyJson.c_str()]; + if (body == nil) { + RCTLogWarn(@"RCTPushy -- native check: request body is not valid UTF-8"); + return; + } + + NSString *responseText = [self runCheckRequest:config appKey:appKey body:body]; + if (responseText == nil) { + RCTLogInfo(@"RCTPushy -- native check: no endpoint reachable, giving up until next launch"); + return; + } + long long responseAtSeconds = (long long)[[NSDate date] timeIntervalSince1970]; + + flowjson::Value decision = updateflow::HandleCheckResponse( + PushyToStdString(responseText), identity, false, + config.Get("afterDownload").AsString()); + if (decision.Get("action").AsString() != "download") { + [self commitRoundWithGeneration:resetGeneration + hashInfo:nil + activate:nil + responseText:responseText + request:body + config:configJson + responseAt:responseAtSeconds]; + RCTLogInfo(@"RCTPushy -- native check: nothing to do (%s)", + decision.Get("reason").AsString().c_str()); + return; + } + NSString *hash = PushyFromStdString(decision.Get("hash").AsString()); + if (!PushyIsSafePathComponent(hash)) { + return; + } + + NSString *versionDir = [[RCTPushy downloadDir] stringByAppendingPathComponent:hash]; + BOOL downloaded = PushyHasCompletedVersionAtPath(versionDir); + if (!downloaded) { + downloaded = [self performAttempts:decision.Get("attempts") + hash:hash + originHash:currentVersion]; + } + if (!downloaded) { + [self commitRoundWithGeneration:resetGeneration + hashInfo:nil + activate:nil + responseText:responseText + request:body + config:configJson + responseAt:responseAtSeconds]; + return; + } + + // Persist name/description/metaInfo alongside the version, mirroring the + // JS side's setLocalHashInfo after a successful download. + const flowjson::Value &info = decision.Get("info"); + NSMutableDictionary *versionInfo = [NSMutableDictionary dictionary]; + for (const char *key : {"name", "description", "metaInfo"}) { + if (info.Get(key).IsString()) { + versionInfo[@(key)] = PushyFromStdString(info.Get(key).AsString()) ?: @""; + } + } + // Silent strategies or a server-marked forceBoot version (per-version + // remote override — the brick rescue) activate for the next launch; + // otherwise activation stays with the JS side (§6/§10.1). + BOOL activate = decision.Get("activate").Truthy(); + BOOL committed = [self commitRoundWithGeneration:resetGeneration + hashInfo:@{@"hash": hash, @"info": versionInfo} + activate:activate ? hash : nil + responseText:responseText + request:body + config:configJson + responseAt:responseAtSeconds]; + if (!committed) { + RCTLogInfo(@"RCTPushy -- native check: reset during round, dropping result"); + } else if (activate) { + RCTLogInfo(@"RCTPushy -- native check: downloaded %@ and set for next launch", hash); + } else { + RCTLogInfo(@"RCTPushy -- native check: downloaded %@, activation left to JS", hash); + } +} + +// Everything a round persists — version info, the activation, the response +// cache — is written inside ONE state-lock acquisition that first re-checks the +// reset generation. resetToPackagedBundle bumps that generation under the same +// lock, so there is no compare-and-act window: either the whole round commits, +// or the reset wins and none of it does. ++ (BOOL)commitRoundWithGeneration:(uint64_t)generation + hashInfo:(NSDictionary *)hashInfoEntry + activate:(NSString *)hashToActivate + responseText:(NSString *)responseText + request:(NSString *)requestBody + config:(NSString *)configJson + responseAt:(long long)responseAtSeconds { + NSDictionary *cacheEntry = @{ + @"ts": @(responseAtSeconds), + @"body": responseText, + @"request": requestBody, + @"config": configJson, + }; + NSData *cacheData = [NSJSONSerialization dataWithJSONObject:cacheEntry options:0 error:nil]; + __block BOOL committed = NO; + PushyWithStateLock(^{ + if (pushyResetGeneration.load() != generation) { + return; + } + NSUserDefaults *defaults = PushyDefaults(); + if (hashInfoEntry != nil) { + NSData *infoData = [NSJSONSerialization dataWithJSONObject:hashInfoEntry[@"info"] + options:0 + error:nil]; + if (infoData != nil) { + [defaults setObject:[[NSString alloc] initWithData:infoData encoding:NSUTF8StringEncoding] + forKey:PushyHashInfoKey(hashInfoEntry[@"hash"])]; + } + } + if (hashToActivate != nil) { + PushySwitchVersionLocked(hashToActivate); + } + if (cacheData != nil) { + [defaults setObject:[[NSString alloc] initWithData:cacheData encoding:NSUTF8StringEncoding] + forKey:keyNativeCheckCache]; + } + committed = YES; + }); + return committed; +} + +// Sequential fallback over the ordered candidates (§5.1): one request at a +// time with its own timeout; after the configured round fails, queryUrls +// discovery merges remote candidates (excluding the already-tried) for one +// more round. No hedged race on purpose — this path is latency-insensitive. ++ (NSString *)runCheckRequest:(const flowjson::Value &)config + appKey:(NSString *)appKey + body:(NSString *)body { + double sample = arc4random() / 4294967296.0; + flowjson::Value ordered = + updateflow::OrderEndpointCandidates(config.Get("endpoints"), sample); + NSMutableSet *tried = [NSMutableSet set]; + const NSUInteger maxHttpAttempts = 8; + NSUInteger httpAttempts = 0; + for (const auto &endpoint : ordered.elements()) { + NSString *base = PushyNormalizeEndpointBase( + PushyFromStdString(endpoint.AsString())); + if (base.length == 0 || [tried containsObject:base]) { + continue; + } + if (httpAttempts++ >= maxHttpAttempts) { + return nil; + } + [tried addObject:base]; + NSString *response = PushyHttpRequest( + [NSString stringWithFormat:@"%@/checkUpdate/%@", base, appKey], + @"POST", body, 10); + if (PushyIsValidCheckResponse(response)) { + return response; + } + } + for (const auto &queryUrl : config.Get("queryUrls").elements()) { + NSString *listUrl = PushyFromStdString(queryUrl.AsString()); + if (listUrl == nil) { + continue; + } + if (httpAttempts++ >= maxHttpAttempts) { + return nil; + } + NSString *listText = PushyHttpRequest(listUrl, @"GET", nil, 10); + if (listText == nil) { + continue; + } + bool ok = false; + flowjson::Value remote = flowjson::Parse(PushyToStdString(listText), &ok); + if (!ok || !remote.IsArray()) { + continue; + } + for (const auto &endpoint : remote.elements()) { + NSString *base = PushyNormalizeEndpointBase( + PushyFromStdString(endpoint.AsString())); + if (base.length == 0 || [tried containsObject:base]) { + continue; + } + if (httpAttempts++ >= maxHttpAttempts) { + return nil; + } + [tried addObject:base]; + NSString *response = PushyHttpRequest( + [NSString stringWithFormat:@"%@/checkUpdate/%@", base, appKey], + @"POST", body, 10); + if (PushyIsValidCheckResponse(response)) { + return response; + } + } + break; // one successfully fetched remote list is enough + } + return nil; +} + ++ (BOOL)performAttempts:(const flowjson::Value &)attempts + hash:(NSString *)hash + originHash:(NSString *)originHash { + RCTPushy *engine = [self engine]; + NSTimeInterval incrementalDeadline = PushyMonotonicNow() + 600; + NSTimeInterval fullDeadline = 0; + for (const auto &attempt : attempts.elements()) { + const std::string &type = attempt.Get("type").AsString(); + PushyType pushyType = type == "diff" ? PushyTypePatchFromPpk + : type == "pdiff" ? PushyTypePatchFromPackage + : PushyTypeFullDownload; + if (pushyType == PushyTypePatchFromPpk && originHash.length == 0) { + continue; // diff patches from the running version; none running + } + BOOL isFullAttempt = pushyType == PushyTypeFullDownload; + if (isFullAttempt && fullDeadline == 0) { + // diff/pdiff cannot starve the last-resort full download. + fullDeadline = PushyMonotonicNow() + 600; + } + NSTimeInterval deadline = isFullAttempt ? fullDeadline : incrementalDeadline; + for (const auto &urlValue : attempt.Get("urls").elements()) { + NSString *url = PushyFromStdString(urlValue.AsString()); + if (url == nil) { + continue; + } + NSTimeInterval remaining = deadline - PushyMonotonicNow(); + if (remaining <= 0) { + if (isFullAttempt) { + return NO; + } + break; + } + NSMutableDictionary *options = + [@{ + @"updateUrl": url, + @"hash": hash, + @"deadlineUptime": @(deadline), + } mutableCopy]; + if (pushyType == PushyTypePatchFromPpk) { + options[@"originHash"] = originHash; + } + dispatch_semaphore_t sem = dispatch_semaphore_create(0); + __block NSError *resultError = nil; + [engine performUpdate:pushyType options:options callback:^(NSError *error) { + resultError = error; + dispatch_semaphore_signal(sem); + }]; + if (dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, + (int64_t)(remaining * NSEC_PER_SEC))) != 0) { + RCTLogWarn(@"RCTPushy -- native check: %s attempt timed out", type.c_str()); + if (isFullAttempt) { + return NO; + } + break; + } + if (resultError == nil) { + return YES; + } + RCTLogInfo(@"RCTPushy -- native check: %s attempt failed: %@", + type.c_str(), resultError.localizedDescription); + } + } + return NO; +} + +@end diff --git a/ios/RCTPushy/RCTPushyDownloader.h b/ios/RCTPushy/RCTPushyDownloader.h index 6089e7c6..8a97a415 100644 --- a/ios/RCTPushy/RCTPushyDownloader.h +++ b/ios/RCTPushy/RCTPushyDownloader.h @@ -3,6 +3,7 @@ @interface RCTPushyDownloader : NSObject + (void)download:(NSString *)downloadPath savePath:(NSString *)savePath + timeoutInterval:(NSTimeInterval)timeoutInterval progressHandler:(void (^)(long long, long long))progressHandler completionHandler:(void (^)(NSString *path, NSError *error))completionHandler; diff --git a/ios/RCTPushy/RCTPushyDownloader.mm b/ios/RCTPushy/RCTPushyDownloader.mm index 8b5de4c0..0234fd7c 100644 --- a/ios/RCTPushy/RCTPushyDownloader.mm +++ b/ios/RCTPushy/RCTPushyDownloader.mm @@ -17,6 +17,7 @@ @interface RCTPushyDownloader() @implementation RCTPushyDownloader + (void)download:(NSString *)downloadPath savePath:(NSString *)savePath +timeoutInterval:(NSTimeInterval)timeoutInterval progressHandler:(void (^)(long long receivedBytes, long long totalBytes))progressHandler completionHandler:(void (^)(NSString *path, NSError *error))completionHandler { @@ -28,10 +29,10 @@ + (void)download:(NSString *)downloadPath savePath:(NSString *)savePath downloader.completionHandler = completionHandler; downloader.savePath = savePath; - [downloader startDownload:downloadPath]; + [downloader startDownload:downloadPath timeoutInterval:timeoutInterval]; } -- (void)startDownload:(NSString *)path +- (void)startDownload:(NSString *)path timeoutInterval:(NSTimeInterval)timeoutInterval { NSURL *url = [NSURL URLWithString:path]; if (url == nil) { @@ -50,7 +51,7 @@ - (void)startDownload:(NSString *)path // Android's 10min callTimeout — 300s made a 30MB full package on a slow // (<100KB/s) network fail on iOS while succeeding on Android. sessionConfig.timeoutIntervalForRequest = 30; - sessionConfig.timeoutIntervalForResource = 600; + sessionConfig.timeoutIntervalForResource = MAX(1, timeoutInterval); self.session = [NSURLSession sessionWithConfiguration:sessionConfig delegate:self delegateQueue:nil]; diff --git a/package.json b/package.json index cda5ff6d..8ebe60a8 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "submodule": "git submodule update --init --recursive", "test": "bun test src/__tests__", "test:patch-core": "./scripts/test-patch-core.sh", + "test:flow-core": "./scripts/test-update-flow-core.sh", + "generate:flow-vectors": "bun scripts/generate-flow-vectors.ts", "build:harmony-har": "node scripts/build-harmony-har.js", "build:harmony-e2e": "bash Example/e2etest/scripts/build-harmony-e2e.sh", "build:so": "bun submodule && bash scripts/build-android-so.sh", diff --git a/react-native-update.podspec b/react-native-update.podspec index 46b704a4..9432c0de 100644 --- a/react-native-update.podspec +++ b/react-native-update.podspec @@ -110,6 +110,8 @@ Pod::Spec.new do |s| s.subspec 'RCTPushy' do |ss| ss.source_files = ['ios/RCTPushy/*.{h,m,mm}', + 'cpp/update_flow_core/flow_json.{h,cpp}', + 'cpp/update_flow_core/update_flow_core.{h,cpp}', 'cpp/patch_core/archive_patch_core.{h,cpp}', 'cpp/patch_core/digest.{h,cpp}', 'cpp/patch_core/hbc_transform.{h,cpp}', diff --git a/scripts/build-harmony-har.js b/scripts/build-harmony-har.js index 06ddf70b..28f4fc9d 100644 --- a/scripts/build-harmony-har.js +++ b/scripts/build-harmony-har.js @@ -7,6 +7,7 @@ const { spawnSync } = require('child_process'); const projectRoot = path.resolve(__dirname, '..'); const androidJniDir = path.join(projectRoot, 'android', 'jni'); const patchCoreDir = path.join(projectRoot, 'cpp', 'patch_core'); +const updateFlowCoreDir = path.join(projectRoot, 'cpp', 'update_flow_core'); const harmonyModuleDir = path.join(projectRoot, 'harmony', 'pushy'); const harmonyBuildDir = path.join(harmonyModuleDir, 'build'); const harmonyNativeStageDir = path.join( @@ -215,6 +216,12 @@ function syncHarmonyNativeSources() { path.join(patchCoreDir, 'patch_core.cpp'), )}`, ); + ensureFileExists( + path.join(updateFlowCoreDir, 'update_flow_core.cpp'), + `Missing shared update flow core source: ${relativeToProject( + path.join(updateFlowCoreDir, 'update_flow_core.cpp'), + )}`, + ); fs.rmSync(harmonyNativeStageDir, { recursive: true, force: true }); fs.mkdirSync(path.join(harmonyNativeStageJniDir, 'lzma'), { @@ -238,6 +245,10 @@ function syncHarmonyNativeSources() { path.join(harmonyNativeStageJniDir, 'lzma', 'C'), ); copyPath(patchCoreDir, harmonyNativeStagePatchCoreDir); + copyPath( + updateFlowCoreDir, + path.join(harmonyNativeStageDir, 'update_flow_core'), + ); } function cleanupHarmonyNativeSources() { diff --git a/scripts/generate-flow-vectors.ts b/scripts/generate-flow-vectors.ts new file mode 100644 index 00000000..9fea934f --- /dev/null +++ b/scripts/generate-flow-vectors.ts @@ -0,0 +1,294 @@ +// Golden-vector generator for the update-flow decision layer. +// +// src/updateFlowCore.ts is the reference implementation; this script runs it +// over a dense input matrix and writes the results to +// cpp/update_flow_core/tests/flow_vectors.json. The C++ port replays the same +// vectors in CI (scripts/test-update-flow-core.sh), and +// src/__tests__/flowVectors.test.ts fails whenever the TS implementation and +// the committed file disagree — so every semantic change must regenerate the +// vectors (bun scripts/generate-flow-vectors.ts) and keep both sides green. +// +// JSON cannot express `undefined`: an undefined return value is encoded by +// omitting `expected`, and undefined-valued object members disappear on +// serialization — the C++ side mirrors both (Kind::Undefined members are +// skipped by stringify). + +import { fileURLToPath } from 'node:url'; +import { + buildCheckRequestBody, + decideDownload, + isInRollout, + joinUrls, + murmurhash3_32_gc, + orderEndpointCandidates, + resolveCheckResult, + shouldActivateAfterDownload, +} from '../src/updateFlowCore'; + +const impls: Record any> = { + murmurhash3_32_gc, + isInRollout, + joinUrls, + orderEndpointCandidates, + buildCheckRequestBody, + resolveCheckResult, + decideDownload, + shouldActivateAfterDownload, +}; + +export interface FlowVector { + fn: string; + args: any[]; + expected?: any; +} + +export const buildVectors = (): FlowVector[] => { + const cases: FlowVector[] = []; + const add = (fn: string, ...args: any[]) => { + cases.push({ fn, args, expected: impls[fn](...args) }); + }; + + // murmurhash3_32_gc — canonical reference vectors + bucketing inputs + for (const key of [ + '', + 'hello', + 'test', + 'Hello, world!', + 'The quick brown fox jumps over the lazy dog', + 'test1', + 'test2', + 'test3', + '123e4567-e89b-12d3-a456-426614174000', + '123e4567-e89b-12d3-a456-426614174001', + 'a', + 'ab', + 'abc', + 'abcd', // every remainder-length path through the tail switch + ]) { + add('murmurhash3_32_gc', key); + } + + // isInRollout — boundaries around murmur('test1') % 100 === 62 + add('isInRollout', 63, 'test1'); + add('isInRollout', 62, 'test1'); + add('isInRollout', 61, 'test1'); + add('isInRollout', 0, 'test1'); + add('isInRollout', 100, 'test1'); + add('isInRollout', -1, 'test3'); + add('isInRollout', 54, 'test3'); + add('isInRollout', 53, 'test3'); + + // joinUrls + add('joinUrls', ['example.com']); // no fileName -> undefined + add('joinUrls', ['example.com'], ''); // falsy fileName -> undefined + add('joinUrls', [], 'file.txt'); + add('joinUrls', ['example.com', 'test.org'], 'file.txt'); + add('joinUrls', ['example.com///', 'http://example.com///'], 'file.txt'); + add('joinUrls', ['ftp://example.com', 'myapp://some/path'], 'file.txt'); + add('joinUrls', ['192.168.1.1:8080', '10.0.0.1:3000/api'], 'file.txt'); + add('joinUrls', [''], 'file.txt'); + add('joinUrls', ['HTTPS://Upper.example.com'], 'file.txt'); + add('joinUrls', ['a:b://weird'], 'file.txt'); // scheme regex must not match + + // orderEndpointCandidates + add('orderEndpointCandidates', ['a', 'b', 'c'], 0); + add('orderEndpointCandidates', ['a', 'b', 'c'], 0.34); + add('orderEndpointCandidates', ['a', 'b', 'c'], 0.5); + add('orderEndpointCandidates', ['a', 'b', 'c'], 0.99); + add('orderEndpointCandidates', ['a', 'b', 'c'], 1); // clamps to last + add('orderEndpointCandidates', ['a', null, 'a', '', 'b'], 0.6); + add('orderEndpointCandidates', [], 0.5); + add('orderEndpointCandidates', ['a'], 0.5); + + // buildCheckRequestBody + const cInfo = { rnu: '10.50.0', rn: '0.85.2', os: 'ios 17.5', uuid: 'u-1' }; + add('buildCheckRequestBody', { + packageVersion: '2.3.4', + currentVersion: 'abcdef1234', + buildTime: '1719999999', + cInfo, + }); + add('buildCheckRequestBody', { + packageVersion: '2.3.4', + currentVersion: 'abcdef1234', + buildTime: '1719999999', + cInfo, + supportedDiffVersion: 2, + bundleHash: 'a'.repeat(64), + }); + add('buildCheckRequestBody', { + packageVersion: '2.3.4', + currentVersion: '', + buildTime: '1719999999', + cInfo, + supportedDiffVersion: 0, + bundleHash: '', + }); + // extra overrides keep the original key position (JS spread semantics) + add('buildCheckRequestBody', { + packageVersion: '2.3.4', + currentVersion: 'abcdef1234', + buildTime: '1719999999', + cInfo, + extra: { toHash: 'debug-hash', hash: 'override-hash' }, + }); + // dev drops buildTime even when extra re-injects it + add('buildCheckRequestBody', { + packageVersion: '2.3.4', + currentVersion: 'abcdef1234', + buildTime: '1719999999', + cInfo, + isDev: true, + extra: { buildTime: 'injected' }, + }); + // missing currentVersion: `hash` becomes undefined and vanishes on stringify + add('buildCheckRequestBody', { + packageVersion: '2.3.4', + buildTime: '1719999999', + cInfo, + }); + + // resolveCheckResult + const identity = { + packageVersion: '2.3.4', + currentVersion: 'current-hash', + uuid: 'test1', // bucket 62 + }; + const gray = (rollout: number, hash = 'gray-hash') => ({ + name: 'gray', + hash, + description: 'd', + metaInfo: 'm', + config: { rollout: { '2.3.4': rollout } }, + }); + const root = { + update: true, + hash: 'root-hash', + name: 'root', + description: 'rd', + metaInfo: 'rm', + diff: 'a.hdiff', + pdiff: 'b.phdiff', + full: 'c.ppk', + paths: ['cdn.example.com'], + }; + add('resolveCheckResult', { ...root, expVersion: gray(63) }, identity); + add('resolveCheckResult', { ...root, expVersion: gray(62) }, identity); + add( + 'resolveCheckResult', + { ...root, expVersion: gray(100, 'current-hash') }, + identity + ); + add('resolveCheckResult', { ...root }, identity); + add('resolveCheckResult', { ...root, hash: 'current-hash' }, identity); + add('resolveCheckResult', { upToDate: true }, identity); + add('resolveCheckResult', { update: false, hash: 'x' }, identity); + // rollout keyed by another packageVersion is ignored + add( + 'resolveCheckResult', + { + ...root, + expVersion: { ...gray(100), config: { rollout: { other: 100 } } }, + }, + identity + ); + // in-rollout target without root paths: nothing inherited + add( + 'resolveCheckResult', + { update: true, hash: 'root-hash', expVersion: gray(63) }, + identity + ); + // strict-equality edge: both hashes undefined -> upToDate + add( + 'resolveCheckResult', + { + update: true, + hash: 'root-hash', + expVersion: { ...gray(63), hash: undefined }, + }, + { packageVersion: '2.3.4', uuid: 'test1' } + ); + + // decideDownload + const dlIdentity = { + currentVersion: 'current-hash', + rolledBackVersion: 'bad-hash', + }; + const dlInfo = { + update: true, + hash: 'next-hash', + diff: 'cur-next.hdiff', + pdiff: 'pkg-next.phdiff', + full: 'next.ppk', + paths: ['cdn.example.com', 'https://mirror.example.com/base/'], + }; + add('decideDownload', { upToDate: true }, dlIdentity, false); + add('decideDownload', { update: true }, dlIdentity, false); + add('decideDownload', { ...dlInfo, hash: 'current-hash' }, dlIdentity, false); + add('decideDownload', { ...dlInfo, hash: 'bad-hash' }, dlIdentity, false); + // no rolledBackVersion recorded: same hash must NOT be declined + add( + 'decideDownload', + { ...dlInfo, hash: 'bad-hash' }, + { currentVersion: 'current-hash' }, + false + ); + add('decideDownload', dlInfo, dlIdentity, false); + add('decideDownload', { ...dlInfo, diff: undefined }, dlIdentity, false); + add('decideDownload', { ...dlInfo, pdiff: undefined }, dlIdentity, false); + add( + 'decideDownload', + { ...dlInfo, diff: undefined, pdiff: undefined, full: undefined }, + dlIdentity, + false + ); + add('decideDownload', { ...dlInfo, paths: [] }, dlIdentity, false); + add( + 'decideDownload', + { update: true, hash: 'next-hash', full: 'next.ppk' }, + dlIdentity, + false + ); // paths defaulted to [] + add('decideDownload', dlInfo, dlIdentity, true); // dev: full only + add('decideDownload', { ...dlInfo, full: undefined }, dlIdentity, true); // devNoop + + // shouldActivateAfterDownload — silent strategies opt in locally, the + // server's per-version forceBoot overrides remotely (JS truthiness) + add('shouldActivateAfterDownload', { hash: 'x' }, 'setNeedUpdate'); + add('shouldActivateAfterDownload', { hash: 'x' }, 'none'); + add( + 'shouldActivateAfterDownload', + { hash: 'x', config: { forceBoot: true } }, + 'none' + ); + add( + 'shouldActivateAfterDownload', + { hash: 'x', config: { forceBoot: false } }, + 'none' + ); + add( + 'shouldActivateAfterDownload', + { hash: 'x', config: { forceBoot: 1 } }, + 'none' + ); + add('shouldActivateAfterDownload', { hash: 'x', config: {} }, 'none'); + add('shouldActivateAfterDownload', { + hash: 'x', + config: { forceBoot: true }, + }); + add('shouldActivateAfterDownload', { upToDate: true }, 'none'); + + return cases; +}; + +if (import.meta.main) { + const outPath = fileURLToPath( + new URL('../cpp/update_flow_core/tests/flow_vectors.json', import.meta.url) + ); + const doc = { + generated_by: 'scripts/generate-flow-vectors.ts — do not edit by hand', + cases: buildVectors(), + }; + await Bun.write(outPath, `${JSON.stringify(doc, null, 2)}\n`); + console.log(`wrote ${doc.cases.length} vectors to ${outPath}`); +} diff --git a/scripts/test-update-flow-core.sh b/scripts/test-update-flow-core.sh new file mode 100755 index 00000000..30187cef --- /dev/null +++ b/scripts/test-update-flow-core.sh @@ -0,0 +1,32 @@ +#!/bin/sh +set -eu + +# Intentional one-command CDPATH assignment. +# shellcheck disable=SC1007 +ROOT_DIR="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)" +BUILD_DIR="$ROOT_DIR/.tmp/update-flow-core-tests" + +mkdir -p "$BUILD_DIR" + +# Opt-in sanitizers, same convention as test-patch-core.sh: +# SANITIZE=1 npm run test:flow-core +SANITIZE_FLAGS="" +if [ "${SANITIZE:-0}" = "1" ]; then + SANITIZE_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer -g" + echo "Building update flow core tests with AddressSanitizer + UBSan" +fi + +# SANITIZE_FLAGS must split into compiler arguments. +# shellcheck disable=SC2086 +c++ \ + -std=c++17 \ + -Wall \ + -Wextra \ + $SANITIZE_FLAGS \ + "$ROOT_DIR/cpp/update_flow_core/flow_json.cpp" \ + "$ROOT_DIR/cpp/update_flow_core/update_flow_core.cpp" \ + "$ROOT_DIR/cpp/update_flow_core/tests/update_flow_core_test.cpp" \ + -o "$BUILD_DIR/update_flow_core_test" + +"$BUILD_DIR/update_flow_core_test" \ + "$ROOT_DIR/cpp/update_flow_core/tests/flow_vectors.json" diff --git a/scripts/verify-android-so.js b/scripts/verify-android-so.js index 53646bbb..51c433c9 100644 --- a/scripts/verify-android-so.js +++ b/scripts/verify-android-so.js @@ -37,6 +37,9 @@ const REQUIRED_SYMBOLS = [ 'Java_cn_reactnative_modules_update_UpdateContext_syncStateWithBinaryVersion', 'Java_cn_reactnative_modules_update_UpdateContext_runStateCore', 'Java_cn_reactnative_modules_update_NativeUpdateCore_getSupportedDiffVersion', + 'Java_cn_reactnative_modules_update_NativeUpdateFlow_buildCheckRequestBody', + 'Java_cn_reactnative_modules_update_NativeUpdateFlow_orderEndpointCandidates', + 'Java_cn_reactnative_modules_update_NativeUpdateFlow_handleCheckResponse', ]; const SHT_DYNSYM = 11; diff --git a/src/NativePushy.ts b/src/NativePushy.ts index 5e021dd8..7949461d 100644 --- a/src/NativePushy.ts +++ b/src/NativePushy.ts @@ -16,6 +16,20 @@ export interface Spec extends TurboModule { setLocalHashInfo(hash: string, info: string): Promise; getLocalHashInfo(hash: string): Promise; setUuid(uuid: string): Promise; + /** + * Persist the config subset the native cold-start update check consumes + * (appKey, endpoints, afterDownload policy; NATIVE_CHECKUPDATE_DESIGN + * §10.1). Stored as a raw JSON string, parsed natively on read. JS is the + * single config source — a native side without persisted config silently + * skips its check, which doubles as the feature's rollout gate. + */ + syncNativeConfig(config: string): Promise; + /** + * Raw response cached by the native cold-start check, including the request + * and config fingerprints that scope reuse (§10.3). Resolves to + * an empty string when absent; never rejects. + */ + getNativeCheckCache(): Promise; reloadUpdate(options: { hash: string }): Promise; restartApp(): Promise; setNeedUpdate(options: { hash: string }): Promise; diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index dfe5f715..90c8f1c7 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -34,6 +34,9 @@ const setupClientMocks = ({ supportedDiffVersion = 2, // 内嵌 bundle 的 sha256(core 层同步读预取值);'' 模拟未知 getBundleHash = mock(() => ''), + // undefined 模拟旧原生(无该方法,feature-detect 静默跳过) + syncNativeConfig = undefined, + getNativeCheckCache = undefined, }: { isFirstTime?: boolean; markSuccess?: ReturnType; @@ -47,6 +50,8 @@ const setupClientMocks = ({ resetToPackagedBundle?: ReturnType | null; supportedDiffVersion?: number; getBundleHash?: ReturnType; + syncNativeConfig?: ReturnType; + getNativeCheckCache?: ReturnType; } = {}) => { (globalThis as any).__DEV__ = false; @@ -75,6 +80,8 @@ const setupClientMocks = ({ downloadAndInstallApk: mock(() => Promise.resolve()), restartApp, resetToPackagedBundle, + ...(syncNativeConfig ? { syncNativeConfig } : {}), + ...(getNativeCheckCache ? { getNativeCheckCache } : {}), }, buildTime: '2023-01-01', cInfo: { @@ -836,6 +843,44 @@ describe('downloadUpdate fallback chain', () => { expect(downloadPatchFromPpk).toHaveBeenCalledTimes(1); }); + test('reports a release response with no downloadable artifact', async () => { + const { + downloadPatchFromPpk, + downloadPatchFromPackage, + downloadFullUpdate, + } = setupDownloadMocks(); + const logger = mock(() => {}); + const { Pushy, sharedState } = await importFreshClient('dl-no-artifact'); + sharedState.downloadedHash = undefined; + const client = new Pushy({ + appKey: 'demo-app', + logger, + disableTelemetry: true, + }); + + expect( + await client.downloadUpdate({ ...updateInfo, paths: [] }) + ).toBeUndefined(); + expect( + await client.downloadUpdate({ ...updateInfo, paths: [] }) + ).toBeUndefined(); + await Promise.resolve(); + + expect(downloadPatchFromPpk).not.toHaveBeenCalled(); + expect(downloadPatchFromPackage).not.toHaveBeenCalled(); + expect(downloadFullUpdate).not.toHaveBeenCalled(); + expect(logger).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'errorUpdate', + data: expect.objectContaining({ + newVersion: 'new-hash', + message: 'update response contains no downloadable artifact', + }), + }) + ); + expect(logger).toHaveBeenCalledTimes(1); + }); + test('adds computed progress to download progress callbacks', async () => { let progressListener: | ((data: { hash: string; received: number; total: number }) => void) @@ -1357,3 +1402,449 @@ describe('client singleton', () => { expect(() => client.claimProviderMount()).not.toThrow(); }); }); + +describe('syncNativeConfig', () => { + test('persists the native check config on construction (silent strategy activates)', async () => { + const syncNativeConfig = mock(() => Promise.resolve()); + setupClientMocks({ syncNativeConfig }); + const { Pushy } = await importFreshClient('sync-config-1'); + new Pushy({ appKey: 'demo-app', updateStrategy: 'silentAndLater' }); + + expect(syncNativeConfig).toHaveBeenCalled(); + const config = JSON.parse( + (syncNativeConfig.mock.calls.at(-1) as unknown as string[])[0] + ); + expect(config.appKey).toBe('demo-app'); + expect(config.afterDownload).toBe('setNeedUpdate'); + expect(config.endpoints.length).toBeGreaterThan(0); + expect(config.queryUrls.length).toBeGreaterThan(0); + expect(config.rnu).toBe('10.0.0'); + expect(config.rn).toBe('0.73.0'); + expect(config.packageVersion).toBe('1.0.0'); + }); + + test('disabling automatic checks keeps activation with JS too', async () => { + // checkStrategy: null means "never check on your own". The native + // cold-start check still runs (that is what rescues a bricked device), + // but it must not hand the app a version switch it never asked for — + // only the server's explicit forceBoot may still activate. + const syncNativeConfig = mock(() => Promise.resolve()); + setupClientMocks({ syncNativeConfig }); + const { Pushy } = await importFreshClient('sync-config-no-autocheck'); + new Pushy({ + appKey: 'demo-app', + updateStrategy: 'silentAndNow', + checkStrategy: null, + }); + + const config = JSON.parse( + (syncNativeConfig.mock.calls.at(-1) as unknown as string[])[0] + ); + expect(config.afterDownload).toBe('none'); + }); + + test('silent strategy with automatic checks still activates', async () => { + const syncNativeConfig = mock(() => Promise.resolve()); + setupClientMocks({ syncNativeConfig }); + const { Pushy } = await importFreshClient('sync-config-autocheck'); + new Pushy({ + appKey: 'demo-app', + updateStrategy: 'silentAndNow', + checkStrategy: 'onAppStart', + }); + + const config = JSON.parse( + (syncNativeConfig.mock.calls.at(-1) as unknown as string[])[0] + ); + expect(config.afterDownload).toBe('setNeedUpdate'); + }); + + test('alert strategies keep activation with JS (afterDownload none)', async () => { + const syncNativeConfig = mock(() => Promise.resolve()); + setupClientMocks({ syncNativeConfig }); + const { Pushy } = await importFreshClient('sync-config-2'); + new Pushy({ appKey: 'demo-app' }); + + const config = JSON.parse( + (syncNativeConfig.mock.calls.at(-1) as unknown as string[])[0] + ); + expect(config.afterDownload).toBe('none'); + }); + + test('setOptions re-syncs when the strategy changes', async () => { + const syncNativeConfig = mock(() => Promise.resolve()); + setupClientMocks({ syncNativeConfig }); + const { Pushy } = await importFreshClient('sync-config-3'); + const client = new Pushy({ appKey: 'demo-app' }); + client.setOptions({ updateStrategy: 'silentAndNow' }); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + const config = JSON.parse( + (syncNativeConfig.mock.calls.at(-1) as unknown as string[])[0] + ); + expect(config.afterDownload).toBe('setNeedUpdate'); + }); + + test('persists an explicit disabled state when the config becomes invalid', async () => { + const syncNativeConfig = mock(() => Promise.resolve()); + setupClientMocks({ syncNativeConfig }); + const { Pushy } = await importFreshClient('sync-config-disabled'); + const client = new Pushy({ appKey: 'demo-app' }); + + client.setOptions({ appKey: '' }); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + expect(syncNativeConfig).toHaveBeenCalledTimes(2); + expect( + JSON.parse((syncNativeConfig.mock.calls.at(-1) as unknown as string[])[0]) + ).toEqual({ disabled: true }); + }); + + test('persists and exposes the effective overridden package version', async () => { + const syncNativeConfig = mock(() => Promise.resolve()); + setupClientMocks({ syncNativeConfig }); + const { Pushy } = await importFreshClient('sync-config-package-override'); + const client = new Pushy({ + appKey: 'demo-app', + overridePackageVersion: '9.9.9', + }); + + const config = JSON.parse( + (syncNativeConfig.mock.calls.at(-1) as unknown as string[])[0] + ); + expect(config.packageVersion).toBe('9.9.9'); + expect(client.getEffectivePackageVersion()).toBe('9.9.9'); + }); + + test('skips duplicate config writes after the same value succeeds', async () => { + const syncNativeConfig = mock(() => Promise.resolve()); + setupClientMocks({ syncNativeConfig }); + const { Pushy } = await importFreshClient('sync-config-dedupe'); + const client = new Pushy({ appKey: 'demo-app' }); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + client.setOptions({ appKey: 'demo-app' }); + + expect(syncNativeConfig).toHaveBeenCalledTimes(1); + }); + + test('serializes config writes and preserves the newest pending value', async () => { + const resolvers: Array<() => void> = []; + const syncNativeConfig = mock( + () => + new Promise((resolve) => { + resolvers.push(resolve); + }) + ); + setupClientMocks({ syncNativeConfig }); + const { Pushy } = await importFreshClient('sync-config-serialized'); + const client = new Pushy({ appKey: 'demo-app' }); + + client.setOptions({ updateStrategy: 'silentAndNow' }); + expect(syncNativeConfig).toHaveBeenCalledTimes(1); + resolvers[0](); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + expect(syncNativeConfig).toHaveBeenCalledTimes(2); + const config = JSON.parse( + (syncNativeConfig.mock.calls.at(-1) as unknown as string[])[0] + ); + expect(config.afterDownload).toBe('setNeedUpdate'); + resolvers[1](); + }); + + test('persists a revert to the last synced value while a newer write is in flight', async () => { + const resolvers: Array<() => void> = []; + const syncNativeConfig = mock( + () => + new Promise((resolve) => { + resolvers.push(resolve); + }) + ); + setupClientMocks({ syncNativeConfig }); + const { Pushy } = await importFreshClient('sync-config-revert-race'); + const client = new Pushy({ appKey: 'demo-app' }); + + // Finish the initial alert-strategy value (A). + resolvers[0](); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + // Start B, then revert to A before B finishes. A must remain pending even + // though it still equals the last completed value at this instant. + client.setOptions({ updateStrategy: 'silentAndNow' }); + client.setOptions({ updateStrategy: 'alwaysAlert' }); + expect(syncNativeConfig).toHaveBeenCalledTimes(2); + + resolvers[1](); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + expect(syncNativeConfig).toHaveBeenCalledTimes(3); + const reverted = JSON.parse( + (syncNativeConfig.mock.calls.at(-1) as unknown as string[])[0] + ); + expect(reverted.afterDownload).toBe('none'); + resolvers[2](); + }); + + test('retries a config write that failed', async () => { + let calls = 0; + const syncNativeConfig = mock(() => { + calls++; + return calls === 1 + ? Promise.reject(new Error('write failed')) + : Promise.resolve(); + }); + setupClientMocks({ syncNativeConfig }); + const { Pushy } = await importFreshClient('sync-config-retry'); + const client = new Pushy({ appKey: 'demo-app' }); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + client.setOptions({ appKey: 'demo-app' }); + + expect(syncNativeConfig).toHaveBeenCalledTimes(2); + }); + + test('an older native module without the method is skipped silently', async () => { + setupClientMocks(); + const { Pushy } = await importFreshClient('sync-config-4'); + // Must not throw even though PushyModule lacks syncNativeConfig. + new Pushy({ appKey: 'demo-app' }); + }); +}); + +describe('native check cache reuse', () => { + const expectedRequestBody = JSON.stringify({ + packageVersion: '1.0.0', + hash: 'hash', + buildTime: '2023-01-01', + cInfo: { + rnu: '10.0.0', + rn: '0.73.0', + os: 'ios', + uuid: 'uuid', + }, + diffV: 2, + }); + + test('a fresh cached response is reused without a network check', async () => { + const cachedResult = { upToDate: true }; + let configJson = ''; + const syncNativeConfig = mock((value: string) => { + configJson = value; + return Promise.resolve(); + }); + const getNativeCheckCache = mock(() => + Promise.resolve( + JSON.stringify({ + ts: Math.floor(Date.now() / 1000) - 30, + body: JSON.stringify(cachedResult), + request: expectedRequestBody, + config: configJson, + }) + ) + ); + setupClientMocks({ getNativeCheckCache, syncNativeConfig }); + // setup.ts's default fetch throws, so any network attempt would surface + // as a failed check (undefined) instead of the cached result. + const { Pushy } = await importFreshClient('native-cache-fresh'); + const client = new Pushy({ appKey: 'demo-app' }); + + expect(await client.checkUpdate()).toEqual(cachedResult); + expect(getNativeCheckCache).toHaveBeenCalled(); + }); + + test('a stale cached response falls through to the network', async () => { + let configJson = ''; + const syncNativeConfig = mock((value: string) => { + configJson = value; + return Promise.resolve(); + }); + const getNativeCheckCache = mock(() => + Promise.resolve( + JSON.stringify({ + ts: Math.floor(Date.now() / 1000) - 600, + body: JSON.stringify({ upToDate: true }), + request: expectedRequestBody, + config: configJson, + }) + ) + ); + setupClientMocks({ getNativeCheckCache, syncNativeConfig }); + const networkResult = { update: true, hash: 'net-hash' }; + (globalThis as any).fetch = mock(async () => + createJsonResponse(networkResult) + ); + const { Pushy } = await importFreshClient('native-cache-stale'); + const client = new Pushy({ appKey: 'demo-app' }); + + expect(await client.checkUpdate()).toEqual(networkResult); + }); + + test('an unreadable cache never breaks the check', async () => { + const getNativeCheckCache = mock(() => Promise.resolve('not json')); + const syncNativeConfig = mock(() => Promise.resolve()); + setupClientMocks({ getNativeCheckCache, syncNativeConfig }); + const networkResult = { upToDate: true }; + (globalThis as any).fetch = mock(async () => + createJsonResponse(networkResult) + ); + const { Pushy } = await importFreshClient('native-cache-bad'); + const client = new Pushy({ appKey: 'demo-app' }); + + expect(await client.checkUpdate()).toEqual(networkResult); + }); + + test('a cache for a different request body is never reused', async () => { + let configJson = ''; + const syncNativeConfig = mock((value: string) => { + configJson = value; + return Promise.resolve(); + }); + const getNativeCheckCache = mock(() => + Promise.resolve( + JSON.stringify({ + ts: Math.floor(Date.now() / 1000) - 10, + body: JSON.stringify({ hash: 'wrong-hash', update: true }), + request: expectedRequestBody, + config: configJson, + }) + ) + ); + setupClientMocks({ getNativeCheckCache, syncNativeConfig }); + const networkResult = { update: true, hash: 'requested-hash' }; + (globalThis as any).fetch = mock(async () => + createJsonResponse(networkResult) + ); + const { Pushy } = await importFreshClient('native-cache-request-key'); + const client = new Pushy({ appKey: 'demo-app' }); + + expect(await client.checkUpdate({ toHash: 'requested-hash' })).toEqual( + networkResult + ); + }); + + test('request key order does not prevent reuse of the same request', async () => { + const cachedResult = { upToDate: true }; + let configJson = ''; + const syncNativeConfig = mock((value: string) => { + configJson = value; + return Promise.resolve(); + }); + const getNativeCheckCache = mock(() => + Promise.resolve( + JSON.stringify({ + ts: Math.floor(Date.now() / 1000) - 10, + body: JSON.stringify(cachedResult), + request: JSON.stringify({ + diffV: 2, + cInfo: { + uuid: 'uuid', + os: 'ios', + rn: '0.73.0', + rnu: '10.0.0', + }, + buildTime: '2023-01-01', + hash: 'hash', + packageVersion: '1.0.0', + }), + config: configJson, + }) + ) + ); + setupClientMocks({ getNativeCheckCache, syncNativeConfig }); + const { Pushy } = await importFreshClient('native-cache-key-order'); + const client = new Pushy({ appKey: 'demo-app' }); + + expect(await client.checkUpdate()).toEqual(cachedResult); + }); + + test('undefined request extras omitted by JSON do not prevent cache reuse', async () => { + const cachedResult = { upToDate: true }; + let configJson = ''; + const syncNativeConfig = mock((value: string) => { + configJson = value; + return Promise.resolve(); + }); + const getNativeCheckCache = mock(() => + Promise.resolve( + JSON.stringify({ + ts: Math.floor(Date.now() / 1000) - 10, + body: JSON.stringify(cachedResult), + request: expectedRequestBody, + config: configJson, + }) + ) + ); + setupClientMocks({ getNativeCheckCache, syncNativeConfig }); + const { Pushy } = await importFreshClient('native-cache-undefined-extra'); + const client = new Pushy({ appKey: 'demo-app' }); + + expect(await client.checkUpdate({ omitted: undefined })).toEqual( + cachedResult + ); + }); + + test('a cache for a different native config is never reused', async () => { + const getNativeCheckCache = mock(() => + Promise.resolve( + JSON.stringify({ + ts: Math.floor(Date.now() / 1000) - 10, + body: JSON.stringify({ upToDate: true }), + request: expectedRequestBody, + config: JSON.stringify({ appKey: 'another-app' }), + }) + ) + ); + const syncNativeConfig = mock(() => Promise.resolve()); + setupClientMocks({ getNativeCheckCache, syncNativeConfig }); + const networkResult = { update: true, hash: 'net-hash' }; + (globalThis as any).fetch = mock(async () => + createJsonResponse(networkResult) + ); + const { Pushy } = await importFreshClient('native-cache-config-key'); + const client = new Pushy({ appKey: 'demo-app' }); + + expect(await client.checkUpdate()).toEqual(networkResult); + }); + + test('a cache timestamp from the future is never reused', async () => { + let configJson = ''; + const syncNativeConfig = mock((value: string) => { + configJson = value; + return Promise.resolve(); + }); + const getNativeCheckCache = mock(() => + Promise.resolve( + JSON.stringify({ + ts: Math.floor(Date.now() / 1000) + 600, + body: JSON.stringify({ upToDate: true }), + request: expectedRequestBody, + config: configJson, + }) + ) + ); + setupClientMocks({ getNativeCheckCache, syncNativeConfig }); + const networkResult = { update: true, hash: 'net-hash' }; + (globalThis as any).fetch = mock(async () => + createJsonResponse(networkResult) + ); + const { Pushy } = await importFreshClient('native-cache-future'); + const client = new Pushy({ appKey: 'demo-app' }); + + expect(await client.checkUpdate()).toEqual(networkResult); + }); +}); diff --git a/src/__tests__/flowVectors.test.ts b/src/__tests__/flowVectors.test.ts new file mode 100644 index 00000000..35326a53 --- /dev/null +++ b/src/__tests__/flowVectors.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from 'bun:test'; +import vectorsFile from '../../cpp/update_flow_core/tests/flow_vectors.json'; +import { buildVectors } from '../../scripts/generate-flow-vectors'; + +describe('flow golden vectors', () => { + // The committed vectors are the parity contract between src/updateFlowCore.ts + // (reference) and cpp/update_flow_core (port, replayed by test:flow-core). + // A semantic change to the TS side must regenerate the file + // (bun scripts/generate-flow-vectors.ts) AND keep the C++ side green — + // this test catches the half-done state. + test('committed vectors match the TS reference implementation', () => { + // JSON round-trip applies the same undefined-dropping normalization the + // generator's serialization does. + expect(JSON.parse(JSON.stringify(buildVectors()))).toEqual( + vectorsFile.cases + ); + }); +}); diff --git a/src/__tests__/isInRollout.test.ts b/src/__tests__/isInRollout.test.ts index de61118e..26e2c928 100644 --- a/src/__tests__/isInRollout.test.ts +++ b/src/__tests__/isInRollout.test.ts @@ -1,30 +1,8 @@ -import { beforeEach, describe, expect, it, mock } from 'bun:test'; +import { describe, expect, it } from 'bun:test'; -// Use the preload setup file instead of inline mocks since bun resolves -// dynamic imports relative to the test runner's context and caching. import './setup'; -let mockUuid = ''; -// Installed per test: setup.ts hands every module back to its real -// implementation after each test, so a file-scope mock would only survive the -// first one. -beforeEach(() => { - mock.module('../core', () => { - return { - cInfo: { - get uuid() { - return mockUuid; - }, - }, - }; - }); -}); - -// Use a monotonic counter instead of Date.now() to avoid cache collisions -// when two dynamic imports happen within the same millisecond. -let importCounter = 0; - -import { murmurhash3_32_gc } from '../isInRollout'; +import { isInRollout, murmurhash3_32_gc } from '../updateFlowCore'; describe('murmurhash3_32_gc', () => { it('should be deterministic (return the same output for the same input)', () => { @@ -72,53 +50,31 @@ describe('murmurhash3_32_gc', () => { }); describe('isInRollout', () => { - it('should return true when the rollout is greater than the hash modulo', async () => { - mockUuid = 'test1'; // hash % 100 === 62 - const { isInRollout } = await import( - `../isInRollout?id=${++importCounter}` - ); - expect(isInRollout(63)).toBe(true); + it('should return true when the rollout is greater than the hash modulo', () => { + // murmur('test1') % 100 === 62 + expect(isInRollout(63, 'test1')).toBe(true); }); - it('should return false when the rollout is equal to the hash modulo', async () => { - mockUuid = 'test1'; - const { isInRollout } = await import( - `../isInRollout?id=${++importCounter}` - ); - expect(isInRollout(62)).toBe(false); + it('should return false when the rollout is equal to the hash modulo', () => { + expect(isInRollout(62, 'test1')).toBe(false); }); - it('should return false when the rollout is less than the hash modulo', async () => { - mockUuid = 'test1'; - const { isInRollout } = await import( - `../isInRollout?id=${++importCounter}` - ); - expect(isInRollout(61)).toBe(false); + it('should return false when the rollout is less than the hash modulo', () => { + expect(isInRollout(61, 'test1')).toBe(false); }); - it('should evaluate correctly for a different uuid', async () => { - mockUuid = 'test3'; // hash % 100 === 53 - const { isInRollout } = await import( - `../isInRollout?id=${++importCounter}` - ); - expect(isInRollout(54)).toBe(true); - expect(isInRollout(53)).toBe(false); - expect(isInRollout(-1)).toBe(false); + it('should evaluate correctly for a different uuid', () => { + // murmur('test3') % 100 === 53 + expect(isInRollout(54, 'test3')).toBe(true); + expect(isInRollout(53, 'test3')).toBe(false); + expect(isInRollout(-1, 'test3')).toBe(false); }); - it('should always return false for 0% rollout', async () => { - mockUuid = 'test1'; - const { isInRollout } = await import( - `../isInRollout?id=${++importCounter}` - ); - expect(isInRollout(0)).toBe(false); + it('should always return false for 0% rollout', () => { + expect(isInRollout(0, 'test1')).toBe(false); }); - it('should always return true for 100% rollout', async () => { - mockUuid = 'test1'; - const { isInRollout } = await import( - `../isInRollout?id=${++importCounter}` - ); - expect(isInRollout(100)).toBe(true); + it('should always return true for 100% rollout', () => { + expect(isInRollout(100, 'test1')).toBe(true); }); }); diff --git a/src/__tests__/provider.render.test.tsx b/src/__tests__/provider.render.test.tsx index cccdb5af..2fbc8c63 100644 --- a/src/__tests__/provider.render.test.tsx +++ b/src/__tests__/provider.render.test.tsx @@ -21,6 +21,8 @@ const updateResult: CheckResult = { name: '1.0.1', hash: 'next-hash', description: 'bugfix', + full: 'next.ppk', + paths: ['https://cdn.example.com'], }; const createClient = (options: Record = {}) => { @@ -34,11 +36,14 @@ const createClient = (options: Record = {}) => { autoMarkSuccess: false, ...options, }, + getEffectivePackageVersion: () => options.overridePackageVersion || '1.0.0', assertDebug: () => true, checkUpdate: mock( async (): Promise => ({ ...updateResult }) ), notifyAfterCheckUpdate: mock(() => {}), + report: mock(() => {}), + reportInvalidUpdateOnce: mock(() => {}), markSuccess: mock(() => {}), switchVersion: mock(async () => {}), switchVersionLater: mock(async () => {}), @@ -127,6 +132,73 @@ describe('UpdateProvider rendering', () => { expect(client.switchVersion).toHaveBeenCalledWith('next-hash'); }); + test('a hash-less update is reported but never shown as an actionable alert', async () => { + const client = createClient({ updateStrategy: 'alwaysAlert' }); + client.checkUpdate.mockImplementation(async () => ({ + update: true, + name: 'broken rollout entry', + })); + + await renderProvider(client); + + expect(client.reportInvalidUpdateOnce).toHaveBeenCalledWith('missingHash'); + expect(client.downloadUpdate).not.toHaveBeenCalled(); + expect(mockAlert).not.toHaveBeenCalled(); + }); + + test('an expired app package without a bundle hash keeps its download action', async () => { + const client = createClient({ updateStrategy: 'alwaysAlert' }); + client.checkUpdate.mockImplementation(async () => ({ + expired: true, + update: true, + downloadUrl: 'https://cdn.example.com/app-release.apk', + })); + + await renderProvider(client); + + expect(client.reportInvalidUpdateOnce).not.toHaveBeenCalled(); + expect(mockAlert).toHaveBeenCalledTimes(1); + const [, , buttons] = mockAlert.mock.calls[0] as any[]; + expect(buttons).toHaveLength(1); + expect(buttons[0].text).toBe('alert_update_button'); + }); + + test('an update without a downloadable artifact is never shown as actionable', async () => { + const client = createClient({ updateStrategy: 'alwaysAlert' }); + client.checkUpdate.mockImplementation(async () => ({ + update: true, + hash: 'broken-artifact-hash', + name: 'broken release', + paths: [], + })); + + await renderProvider(client); + + expect(client.reportInvalidUpdateOnce).toHaveBeenCalledWith( + 'noArtifact', + 'broken-artifact-hash' + ); + expect(client.downloadUpdate).not.toHaveBeenCalled(); + expect(mockAlert).not.toHaveBeenCalled(); + }); + + test('a rolled-back update is never shown as actionable', async () => { + const client = createClient({ updateStrategy: 'alwaysAlert' }); + client.checkUpdate.mockImplementation(async () => ({ + update: true, + hash: 'rolled-back-hash', + full: 'rolled-back-hash.ppk', + paths: ['https://cdn.example.com'], + })); + + await renderProvider(client); + + expect(client.checkUpdate).toHaveBeenCalled(); + expect(client.reportInvalidUpdateOnce).not.toHaveBeenCalled(); + expect(client.downloadUpdate).not.toHaveBeenCalled(); + expect(mockAlert).not.toHaveBeenCalled(); + }); + test('silentAndNow strategy downloads and switches without alerts', async () => { const client = createClient({ updateStrategy: 'silentAndNow' }); await renderProvider(client); diff --git a/src/__tests__/resolveCheckResult.test.ts b/src/__tests__/resolveCheckResult.test.ts index 0f603790..7eacf789 100644 --- a/src/__tests__/resolveCheckResult.test.ts +++ b/src/__tests__/resolveCheckResult.test.ts @@ -1,7 +1,12 @@ import { describe, expect, test } from 'bun:test'; -import { currentVersion, packageVersion } from '../core'; -import { resolveCheckResult } from '../resolveCheckResult'; import type { CheckResult } from '../type'; +import { resolveCheckResult, type UpdateIdentity } from '../updateFlowCore'; + +const identity: UpdateIdentity = { + packageVersion: '1.0.0', + currentVersion: 'current-hash', + uuid: 'any-uuid', +}; const createRootResult = ( overrides: Partial = {} @@ -24,16 +29,17 @@ describe('resolveCheckResult', () => { createRootResult({ expVersion: { name: 'gray-current', - hash: currentVersion, + hash: identity.currentVersion!, description: 'gray description', metaInfo: 'gray meta', config: { rollout: { - [packageVersion]: 100, + [identity.packageVersion]: 100, }, }, }, - }) + }), + identity ); expect(result).toEqual({ upToDate: true }); @@ -49,11 +55,12 @@ describe('resolveCheckResult', () => { metaInfo: 'gray meta', config: { rollout: { - [packageVersion]: 100, + [identity.packageVersion]: 100, }, }, }, - }) + }), + identity ); expect(result).toEqual({ @@ -64,7 +71,7 @@ describe('resolveCheckResult', () => { metaInfo: 'gray meta', config: { rollout: { - [packageVersion]: 100, + [identity.packageVersion]: 100, }, }, paths: ['cdn.example.com'], @@ -81,11 +88,12 @@ describe('resolveCheckResult', () => { metaInfo: 'gray meta', config: { rollout: { - [packageVersion]: 0, + [identity.packageVersion]: 0, }, }, }, - }) + }), + identity ); expect(result).toEqual(createRootResult()); @@ -93,9 +101,65 @@ describe('resolveCheckResult', () => { test('returns upToDate when root target is already current', () => { const result = resolveCheckResult( - createRootResult({ hash: currentVersion }) + createRootResult({ hash: identity.currentVersion }), + identity ); expect(result).toEqual({ upToDate: true }); }); + + test('ignores rollout config for a different packageVersion', () => { + const result = resolveCheckResult( + createRootResult({ + expVersion: { + name: 'gray-next', + hash: 'gray-hash', + description: 'gray description', + metaInfo: 'gray meta', + config: { + rollout: { + 'some-other-package': 100, + }, + }, + }, + }), + identity + ); + + expect(result).toEqual(createRootResult()); + }); + + test('does not treat two missing hashes as already current', () => { + const result = resolveCheckResult( + createRootResult({ + expVersion: { + name: 'gray-without-hash', + config: { + rollout: { + [identity.packageVersion]: 100, + }, + }, + } as any, + }), + { ...identity, currentVersion: undefined } + ); + + expect(result).toEqual({ + update: true, + name: 'gray-without-hash', + config: { + rollout: { + [identity.packageVersion]: 100, + }, + }, + paths: ['cdn.example.com'], + }); + + expect( + resolveCheckResult( + { update: true }, + { ...identity, currentVersion: undefined } + ) + ).toEqual({ update: true }); + }); }); diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts index 0f534b4a..f0514101 100644 --- a/src/__tests__/setup.ts +++ b/src/__tests__/setup.ts @@ -55,7 +55,7 @@ const installBaseMocks = () => { packageVersion: '1.0.0', currentVersion: 'hash', isFirstTime: false, - rolledBackVersion: '', + rolledBackVersion: 'rolled-back-hash', buildTime: '2023-01-01', uuid: 'uuid', setLocalHashInfo: () => {}, @@ -96,10 +96,9 @@ const realProjectModules: Record> = { '../core': { ...(await import('../core')) }, '../endpoint': { ...(await import('../endpoint')) }, '../error': { ...(await import('../error')) }, - '../isInRollout': { ...(await import('../isInRollout')) }, '../permissions': { ...(await import('../permissions')) }, - '../resolveCheckResult': { ...(await import('../resolveCheckResult')) }, '../telemetry': { ...(await import('../telemetry')) }, + '../updateFlowCore': { ...(await import('../updateFlowCore')) }, '../utils': { ...(await import('../utils')) }, }; diff --git a/src/__tests__/updateFlowCore.test.ts b/src/__tests__/updateFlowCore.test.ts new file mode 100644 index 00000000..3b458329 --- /dev/null +++ b/src/__tests__/updateFlowCore.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, test } from 'bun:test'; +import type { CheckResult } from '../type'; +import { + buildCheckRequestBody, + decideDownload, + orderEndpointCandidates, +} from '../updateFlowCore'; + +const cInfo = { rnu: '10.50.0', rn: '0.73.0', os: 'ios 17', uuid: 'uuid' }; + +const baseInput = { + packageVersion: '1.0.0', + currentVersion: 'current-hash', + buildTime: '2023-01-01', + cInfo, +}; + +describe('buildCheckRequestBody', () => { + test('builds the base body', () => { + expect(buildCheckRequestBody(baseInput)).toEqual({ + packageVersion: '1.0.0', + hash: 'current-hash', + buildTime: '2023-01-01', + cInfo, + }); + }); + + test('includes diffV and bundleHash when known', () => { + expect( + buildCheckRequestBody({ + ...baseInput, + supportedDiffVersion: 2, + bundleHash: 'a'.repeat(64), + }) + ).toEqual({ + packageVersion: '1.0.0', + hash: 'current-hash', + buildTime: '2023-01-01', + cInfo, + diffV: 2, + bundleHash: 'a'.repeat(64), + }); + }); + + test('omits diffV and bundleHash while unknown', () => { + const body = buildCheckRequestBody({ + ...baseInput, + supportedDiffVersion: 0, + bundleHash: '', + }); + expect(body).not.toHaveProperty('diffV'); + expect(body).not.toHaveProperty('bundleHash'); + }); + + test('spreads extra last so it can override fields', () => { + const body = buildCheckRequestBody({ + ...baseInput, + extra: { toHash: 'debug-hash', hash: 'override-hash' }, + }); + expect(body.toHash).toBe('debug-hash'); + expect(body.hash).toBe('override-hash'); + }); + + test('drops buildTime in dev, even when set via extra', () => { + const body = buildCheckRequestBody({ + ...baseInput, + isDev: true, + extra: { buildTime: 'injected' }, + }); + expect(body).not.toHaveProperty('buildTime'); + }); +}); + +describe('orderEndpointCandidates', () => { + test('keeps configured order when the sample picks the first', () => { + expect(orderEndpointCandidates(['a', 'b', 'c'], 0)).toEqual([ + 'a', + 'b', + 'c', + ]); + }); + + test('moves the sampled pick to the front, rest in configured order', () => { + expect(orderEndpointCandidates(['a', 'b', 'c'], 0.5)).toEqual([ + 'b', + 'a', + 'c', + ]); + expect(orderEndpointCandidates(['a', 'b', 'c'], 0.99)).toEqual([ + 'c', + 'a', + 'b', + ]); + }); + + test('clamps an out-of-range sample to the last candidate', () => { + expect(orderEndpointCandidates(['a', 'b', 'c'], 1)).toEqual([ + 'c', + 'a', + 'b', + ]); + }); + + test('dedupes and drops empty entries before ordering', () => { + expect(orderEndpointCandidates(['a', null, 'a', '', 'b'], 0)).toEqual([ + 'a', + 'b', + ]); + }); + + test('passes through empty and single-candidate lists', () => { + expect(orderEndpointCandidates([], 0.5)).toEqual([]); + expect(orderEndpointCandidates(['a'], 0.5)).toEqual(['a']); + }); + + test('clamps invalid or out-of-range samples before indexing', () => { + expect(orderEndpointCandidates(['a', 'b'], Number.NaN)).toEqual(['a', 'b']); + expect( + orderEndpointCandidates(['a', 'b'], Number.POSITIVE_INFINITY) + ).toEqual(['a', 'b']); + expect(orderEndpointCandidates(['a', 'b'], -1)).toEqual(['a', 'b']); + expect(orderEndpointCandidates(['a', 'b'], 2)).toEqual(['b', 'a']); + }); +}); + +describe('decideDownload', () => { + const identity = { + currentVersion: 'current-hash', + rolledBackVersion: 'bad-hash', + }; + + const updateInfo = (overrides: Partial = {}): CheckResult => ({ + update: true, + hash: 'next-hash', + diff: 'current-next.hdiff', + pdiff: 'package-next.phdiff', + full: 'next-hash.ppk', + paths: ['cdn.example.com', 'https://mirror.example.com'], + ...overrides, + }); + + test('declines when there is no update or no hash', () => { + expect(decideDownload({ upToDate: true }, identity)).toEqual({ + action: 'none', + reason: 'noUpdate', + }); + expect(decideDownload({ update: true }, identity)).toEqual({ + action: 'none', + reason: 'noUpdate', + }); + }); + + test('declines the currently running version', () => { + expect( + decideDownload(updateInfo({ hash: 'current-hash' }), identity) + ).toEqual({ action: 'none', reason: 'alreadyCurrent' }); + }); + + test('declines a rolled-back version', () => { + expect(decideDownload(updateInfo({ hash: 'bad-hash' }), identity)).toEqual({ + action: 'none', + reason: 'rolledBack', + }); + }); + + test('orders attempts diff → pdiff → full with joined candidate urls', () => { + const decision = decideDownload(updateInfo(), identity); + expect(decision).toEqual({ + action: 'download', + hash: 'next-hash', + devNoop: false, + attempts: [ + { + type: 'diff', + urls: [ + 'https://cdn.example.com/current-next.hdiff', + 'https://mirror.example.com/current-next.hdiff', + ], + }, + { + type: 'pdiff', + urls: [ + 'https://cdn.example.com/package-next.phdiff', + 'https://mirror.example.com/package-next.phdiff', + ], + }, + { + type: 'full', + urls: [ + 'https://cdn.example.com/next-hash.ppk', + 'https://mirror.example.com/next-hash.ppk', + ], + }, + ], + }); + }); + + test('skips artifacts the server did not offer', () => { + const decision = decideDownload(updateInfo({ diff: undefined }), identity); + if (decision.action !== 'download') { + throw new Error('expected a download decision'); + } + expect(decision.attempts.map((a) => a.type)).toEqual(['pdiff', 'full']); + }); + + test('declines a release update when no artifact URL can be built', () => { + expect(decideDownload(updateInfo({ paths: [] }), identity)).toEqual({ + action: 'none', + reason: 'noArtifact', + }); + }); + + test('dev only attempts full', () => { + const decision = decideDownload(updateInfo(), identity, true); + if (decision.action !== 'download') { + throw new Error('expected a download decision'); + } + expect(decision.attempts.map((a) => a.type)).toEqual(['full']); + expect(decision.devNoop).toBe(false); + }); + + test('dev with no full artifact is a no-op success', () => { + const decision = decideDownload( + updateInfo({ full: undefined }), + identity, + true + ); + if (decision.action !== 'download') { + throw new Error('expected a download decision'); + } + expect(decision.attempts).toEqual([]); + expect(decision.devNoop).toBe(true); + }); +}); diff --git a/src/client.ts b/src/client.ts index 871023de..a3edd2ab 100644 --- a/src/client.ts +++ b/src/client.ts @@ -41,13 +41,18 @@ import type { UpdateCheckState, UpdateServerConfig, } from './type'; +import { + buildCheckRequestBody, + type DownloadPlan, + type DownloadStrategyType, + decideDownload, +} from './updateFlowCore'; import { assertWeb, computeProgress, DEFAULT_FETCH_TIMEOUT_MS, fetchWithTimeout, info, - joinUrls, log, noop, promiseAny, @@ -91,6 +96,11 @@ const cloneServerConfig = (server: UpdateServerConfig): UpdateServerConfig => ({ queryUrls: server.queryUrls ? [...server.queryUrls] : undefined, }); +// Persist an object (rather than an empty string) so every native bridge keeps +// accepting the payload while the orchestrators treat the missing appKey and +// endpoints as an explicit disabled state. +const NATIVE_CONFIG_DISABLED_JSON = '{"disabled":true}'; + const excludeConfiguredEndpoints = ( endpoints: string[], configuredEndpoints: string[] @@ -161,6 +171,10 @@ export class Pushy { // Endpoint that most recently served a successful checkUpdate; telemetry // reuses it instead of re-running the fallback race. private lastWorkingEndpoint?: string; + private syncedNativeConfigJson?: string; + private pendingNativeConfigJson?: string; + private nativeConfigSyncInFlight = false; + private reportedInvalidUpdates = new Set(); version = cInfo.rnu; loggerPromise = (() => { @@ -259,6 +273,149 @@ export class Pushy { log('onOptionsChange listener error:', e?.message || e); } } + this.syncNativeConfig(); + }; + + /** Build the subset of options used by the native cold-start check. */ + private getNativeConfig = (): Record | undefined => { + if ( + Platform.OS === 'web' || + typeof PushyModule.syncNativeConfig !== 'function' + ) { + // Older natives lack the method; on web PushyModule is a noop Proxy + // and the feature-detect would false-positive. + return undefined; + } + const { appKey, server, updateStrategy, checkStrategy } = this.options; + if (!appKey || !server?.main?.length) { + return undefined; + } + // An app that turned automatic checks off (checkStrategy: null) must not + // be handed a version switch it never asked for. The cold-start check + // still runs and still downloads — that is what keeps a bricked device + // rescuable — but activation waits for the JS side, or for the server's + // explicit per-version forceBoot directive (shouldActivateAfterDownload). + const autoCheckEnabled = checkStrategy != null; + return { + appKey, + packageVersion: this.getEffectivePackageVersion(), + endpoints: server.main, + queryUrls: server.queryUrls ?? [], + // The native check may activate a downloaded version (next launch) + // only under the silent strategies; alert-style strategies keep + // activation with the JS side (§6/§10.1). + afterDownload: + autoCheckEnabled && + (updateStrategy === 'silentAndNow' || + updateStrategy === 'silentAndLater') + ? 'setNeedUpdate' + : 'none', + rnu: cInfo.rnu, + rn: cInfo.rn, + }; + }; + + private getNativeConfigJson = (): string | undefined => { + const config = this.getNativeConfig(); + return config ? JSON.stringify(config) : undefined; + }; + + private flushNativeConfig = () => { + if (this.nativeConfigSyncInFlight) { + return; + } + const configJson = this.pendingNativeConfigJson; + this.pendingNativeConfigJson = undefined; + if (!configJson || configJson === this.syncedNativeConfigJson) { + return; + } + this.nativeConfigSyncInFlight = true; + let syncResult: Promise; + try { + syncResult = Promise.resolve(PushyModule.syncNativeConfig(configJson)); + } catch (e: any) { + this.nativeConfigSyncInFlight = false; + log('syncNativeConfig failed:', e?.message || e); + return; + } + syncResult + .then(() => { + this.syncedNativeConfigJson = configJson; + }) + .catch((e: any) => { + log('syncNativeConfig failed:', e?.message || e); + }) + .finally(() => { + this.nativeConfigSyncInFlight = false; + this.flushNativeConfig(); + }); + }; + + private syncNativeConfig = () => { + if ( + Platform.OS === 'web' || + typeof PushyModule.syncNativeConfig !== 'function' + ) { + return; + } + const configJson = + this.getNativeConfigJson() ?? NATIVE_CONFIG_DISABLED_JSON; + // Always record the latest desired value, even when it matches the last + // completed write. Example: A synced -> B in flight -> options revert to + // A. Comparing only with synced(A) would drop the revert and leave native + // storage at B after that in-flight write completes. + // Coalesce rapid setOptions calls, but serialize bridge writes so an older + // completion can never overwrite the newest desired configuration. + this.pendingNativeConfigJson = configJson; + this.flushNativeConfig(); + }; + + /** Package version used by every server-side update decision. */ + getEffectivePackageVersion = () => + this.options.overridePackageVersion || packageVersion; + + private jsonValuesEqual = (left: unknown, right: unknown): boolean => { + const compare = (a: unknown, b: unknown): boolean => { + if (a === b) { + return true; + } + if (Array.isArray(a) || Array.isArray(b)) { + return ( + Array.isArray(a) && + Array.isArray(b) && + a.length === b.length && + a.every((value, index) => compare(value, b[index])) + ); + } + if ( + a === null || + b === null || + typeof a !== 'object' || + typeof b !== 'object' + ) { + return false; + } + const leftObject = a as Record; + const rightObject = b as Record; + // Match JSON.stringify semantics for request extras: object properties + // whose value is undefined are omitted from the wire fingerprint. + const leftKeys = Object.keys(leftObject) + .filter((key) => leftObject[key] !== undefined) + .sort(); + const rightKeys = Object.keys(rightObject) + .filter((key) => rightObject[key] !== undefined) + .sort(); + return ( + leftKeys.length === rightKeys.length && + leftKeys.every( + (key, index) => + key === rightKeys[index] && + compare(leftObject[key], rightObject[key]) + ) + ); + }; + + return compare(left, right); }; private providerMounted = false; @@ -291,6 +448,25 @@ export class Pushy { return i18n.t(key as any, values); }; + reportInvalidUpdateOnce = ( + reason: 'missingHash' | 'noArtifact', + hash = '' + ) => { + const key = `${this.options.appKey}:${reason}:${hash}`; + if (this.reportedInvalidUpdates.has(key)) { + return; + } + this.reportedInvalidUpdates.add(key); + this.report({ + type: 'errorUpdate', + message: + reason === 'missingHash' + ? 'update response is missing a version hash' + : 'update response contains no downloadable artifact', + ...(hash ? { data: { newVersion: hash } } : {}), + }); + }; + report = async ({ type, message = '', @@ -385,8 +561,7 @@ export class Pushy { body: JSON.stringify({ type: payloadType, hash, - packageVersion: - this.options.overridePackageVersion || packageVersion, + packageVersion: this.getEffectivePackageVersion(), cInfo, detail: truncateDetail(detail), }), @@ -577,6 +752,57 @@ export class Pushy { this.lastWorkingEndpoint = endpoint; return value; }; + /** + * Reuse the native cold-start check's cached response when fresh + * (NATIVE_CHECKUPDATE_DESIGN §10.3) instead of re-checking. Returns + * undefined whenever the cache is absent, stale, or unreadable — any + * failure falls through to a normal network check. + */ + private readNativeCheckCache = async ( + requestBody: Record + ): Promise => { + try { + if (__DEV__ || typeof PushyModule.getNativeCheckCache !== 'function') { + return undefined; + } + const raw = await Promise.resolve(PushyModule.getNativeCheckCache()); + if (!raw || typeof raw !== 'string') { + return undefined; + } + const entry = JSON.parse(raw); + const config = this.getNativeConfig(); + const cachedRequest = + typeof entry?.request === 'string' + ? JSON.parse(entry.request) + : undefined; + const cachedConfig = + typeof entry?.config === 'string' + ? JSON.parse(entry.config) + : undefined; + if ( + typeof entry?.ts !== 'number' || + typeof entry?.body !== 'string' || + !this.jsonValuesEqual(cachedRequest, requestBody) || + !config || + !this.jsonValuesEqual(cachedConfig, config) + ) { + return undefined; + } + const ageSeconds = Date.now() / 1000 - entry.ts; + if (ageSeconds < 0 || ageSeconds > 120) { + return undefined; + } + const result = JSON.parse(entry.body); + if (!result || typeof result !== 'object') { + return undefined; + } + log('reusing native check response cache'); + return result as CheckResult; + } catch { + return undefined; + } + }; + assertDebug = (matter: string) => { if (__DEV__ && !this.options.debug) { info(this.t('dev_debug_disabled', { matter })); @@ -697,19 +923,16 @@ export class Pushy { } } this.lastChecking = now; - const fetchBody: Record = { - packageVersion: this.options.overridePackageVersion || packageVersion, - hash: currentVersion, + const fetchBody = buildCheckRequestBody({ + packageVersion: this.getEffectivePackageVersion(), + currentVersion, buildTime, cInfo, - // 可消费的 diff 轨道版本(2 = hdiffv2 轨道),服务端据此门控下发 - ...(supportedDiffVersion ? { diffV: supportedDiffVersion } : {}), - ...(bundleHash ? { bundleHash } : {}), - ...extra, - }; - if (__DEV__) { - delete fetchBody.buildTime; - } + supportedDiffVersion, + bundleHash, + isDev: __DEV__, + extra, + }); const stringifyBody = JSON.stringify(fetchBody); // harmony fetch body is not string let body: any = fetchBody; @@ -730,7 +953,18 @@ export class Pushy { type: 'checking', message: `${this.options.appKey}: ${stringifyBody}`, }); - const respJsonPromise = this.fetchCheckResult(fetchPayload); + // The native cold-start check may have a fresh response on disk + // (§10.3); reuse it instead of re-checking. The read happens INSIDE + // the promise so no await lands between the dedup window above and the + // lastRespJson assignment below (the JS2-1 double-send lesson). + const respJsonPromise = (async (): Promise => { + // While bundleHash prefetch is still pending, the JS request omits + // that key whereas the native request always includes its synchronously + // computed value. That narrow first-launch window intentionally misses + // the cache rather than delaying checkUpdate for hashing. + const cached = await this.readNativeCheckCache(fetchBody); + return cached ?? (await this.fetchCheckResult(fetchPayload)); + })(); this.lastRespJson = respJsonPromise; const result: CheckResult = await respJsonPromise; @@ -766,7 +1000,6 @@ export class Pushy { updateInfo: CheckResult, onDownloadProgress?: (data: ProgressData) => void ) => { - const { hash } = updateInfo; if ( this.options.beforeDownloadUpdate && (await this.options.beforeDownloadUpdate(updateInfo)) === false @@ -774,17 +1007,26 @@ export class Pushy { log('beforeDownloadUpdate returned false, skipping download'); return; } - if (!updateInfo.update || !hash) { - return; - } - if (hash === currentVersion) { - log(`current hash ${currentVersion}, ignored`); - return; - } - if (rolledBackVersion === hash) { - log(`rolledback hash ${rolledBackVersion}, ignored`); + const decision = decideDownload( + updateInfo, + { currentVersion, rolledBackVersion }, + __DEV__ + ); + if (decision.action === 'none') { + if (decision.reason === 'alreadyCurrent') { + log(`current hash ${currentVersion}, ignored`); + } else if (decision.reason === 'rolledBack') { + log(`rolledback hash ${rolledBackVersion}, ignored`); + } else if (decision.reason === 'noArtifact') { + // A server response that advertises an update but provides no usable + // artifact is a bad release signal, not an ordinary no-update result. + // Keep the user flow silent and report at most once per bad release in + // this process: repeated checks must not inflate download_fail health. + this.reportInvalidUpdateOnce('noArtifact', updateInfo.hash || ''); + } return; } + const { hash } = decision; if (sharedState.downloadedHash === hash) { log(`duplicated downloaded hash ${sharedState.downloadedHash}, ignored`); return sharedState.downloadedHash; @@ -801,7 +1043,7 @@ export class Pushy { } return existingTask; } - const task = this.performDownload(updateInfo, onDownloadProgress); + const task = this.performDownload(updateInfo, decision, onDownloadProgress); sharedState.downloadingTasks[hash] = task; try { return await task; @@ -811,21 +1053,11 @@ export class Pushy { }; private performDownload = async ( updateInfo: CheckResult, + plan: DownloadPlan, onDownloadProgress?: (data: ProgressData) => void ) => { - const { - hash, - diff, - pdiff, - full, - paths = [], - name, - description = '', - metaInfo, - } = updateInfo; - if (!hash) { - return; - } + const { name, description = '', metaInfo } = updateInfo; + const { hash, attempts, devNoop } = plan; const patchStartTime = Date.now(); // One native listener per hash dispatching to a callback set, so // concurrent callers deduped onto this task can each observe progress @@ -870,61 +1102,35 @@ export class Pushy { let lastError: any; const errorMessages: string[] = []; - // Ordered download strategies, tried in sequence until one succeeds. Each - // resolves its candidate URL lazily (testUrls) and runs the matching native - // download. diff/pdiff are incremental and skipped entirely in dev; full is - // attempted whenever a URL exists, and in dev with no URL it is treated as a - // no-op success so the flow can proceed. - type DownloadStrategy = { - name: string; - candidate: string | undefined; - errorKey: - | 'error_diff_failed' - | 'error_pdiff_failed' - | 'error_full_patch_failed'; - skipInDev: boolean; - devNoopWhenNoUrl: boolean; - run: (url: string) => Promise; + // The ordered attempts come from decideDownload (the pure decision layer); + // this side only executes them: probe candidate URLs, run the matching + // native download, fall through to the next attempt on failure. + const runners: Record< + DownloadStrategyType, + (url: string) => Promise + > = { + diff: (url) => + PushyModule.downloadPatchFromPpk({ + updateUrl: url, + hash, + originHash: currentVersion, + }), + pdiff: (url) => + PushyModule.downloadPatchFromPackage({ + updateUrl: url, + hash, + }), + full: (url) => + PushyModule.downloadFullUpdate({ + updateUrl: url, + hash, + }), }; - const strategies: DownloadStrategy[] = [ - { - name: 'diff', - candidate: diff, - errorKey: 'error_diff_failed', - skipInDev: true, - devNoopWhenNoUrl: false, - run: (url) => - PushyModule.downloadPatchFromPpk({ - updateUrl: url, - hash, - originHash: currentVersion, - }), - }, - { - name: 'pdiff', - candidate: pdiff, - errorKey: 'error_pdiff_failed', - skipInDev: true, - devNoopWhenNoUrl: false, - run: (url) => - PushyModule.downloadPatchFromPackage({ - updateUrl: url, - hash, - }), - }, - { - name: 'full', - candidate: full, - errorKey: 'error_full_patch_failed', - skipInDev: false, - devNoopWhenNoUrl: true, - run: (url) => - PushyModule.downloadFullUpdate({ - updateUrl: url, - hash, - }), - }, - ]; + const errorKeys = { + diff: 'error_diff_failed', + pdiff: 'error_pdiff_failed', + full: 'error_full_patch_failed', + } as const; for (let attempt = 0; attempt <= maxRetries; attempt++) { if (attempt > 0) { @@ -942,34 +1148,36 @@ export class Pushy { attempt, }, }); - for (const strategy of strategies) { + if (devNoop) { + log(this.t('dev_incremental_update_disabled')); + succeeded = 'full'; + } + for (const { type, urls } of attempts) { if (succeeded) { break; } - const url = await testUrls(joinUrls(paths, strategy.candidate)); - if (url && !(strategy.skipInDev && __DEV__)) { - log(`downloading ${strategy.name}`); - try { - await strategy.run(url); - succeeded = strategy.name; - } catch (e: any) { - const errorMessage = this.t(strategy.errorKey, { - message: e.message, - }); - errorMessages.push(errorMessage); - // Keep the i18n message for display, but preserve the native - // rejection's stable code (e.g. PATCH_FAILED vs DOWNLOAD_FAILED — - // telemetry classifies on it) and the original error as cause. - lastError = new UpdateError( - errorMessage, - asUpdateErrorCode(e?.code) ?? 'DOWNLOAD_FAILED', - { cause: e } - ); - log(errorMessage); - } - } else if (!url && strategy.devNoopWhenNoUrl && __DEV__) { - log(this.t('dev_incremental_update_disabled')); - succeeded = strategy.name; + const url = await testUrls(urls); + if (!url) { + continue; + } + log(`downloading ${type}`); + try { + await runners[type](url); + succeeded = type; + } catch (e: any) { + const errorMessage = this.t(errorKeys[type], { + message: e.message, + }); + errorMessages.push(errorMessage); + // Keep the i18n message for display, but preserve the native + // rejection's stable code (e.g. PATCH_FAILED vs DOWNLOAD_FAILED — + // telemetry classifies on it) and the original error as cause. + lastError = new UpdateError( + errorMessage, + asUpdateErrorCode(e?.code) ?? 'DOWNLOAD_FAILED', + { cause: e } + ); + log(errorMessage); } } if (succeeded) { diff --git a/src/endpoint.ts b/src/endpoint.ts index dca24a49..49c3c5d1 100644 --- a/src/endpoint.ts +++ b/src/endpoint.ts @@ -1,4 +1,7 @@ import { UpdateError } from './error'; +import { dedupeEndpoints, orderEndpointCandidates } from './updateFlowCore'; + +export { dedupeEndpoints }; export interface EndpointAttemptSuccess { endpoint: string; @@ -28,33 +31,6 @@ const normalizeError = (error: unknown) => { return new Error(String(error)); }; -export const dedupeEndpoints = ( - endpoints: Array -): string[] => { - const result: string[] = []; - const visited = new Set(); - - for (const endpoint of endpoints) { - if (!endpoint || visited.has(endpoint)) { - continue; - } - visited.add(endpoint); - result.push(endpoint); - } - - return result; -}; - -export const pickRandomEndpoint = ( - endpoints: string[], - random: () => number = Math.random -) => { - if (!endpoints.length) { - throw new UpdateError('No endpoints configured', 'NO_ENDPOINTS'); - } - return endpoints[Math.floor(random() * endpoints.length)]; -}; - export const DEFAULT_HEDGE_DELAY_MS = 250; /** @@ -165,13 +141,15 @@ export async function executeEndpointFallback({ onFirstFailure, }: ExecuteEndpointFallbackOptions): Promise> { const excludedEndpoints = new Set(); - let candidates = dedupeEndpoints(configuredEndpoints); + // The candidate ordering (random first pick, configured order as fallback) + // is pure policy; this side only executes it. + let candidates = orderEndpointCandidates(configuredEndpoints, random()); if (!candidates.length) { throw new UpdateError('No endpoints configured', 'NO_ENDPOINTS'); } - const firstEndpoint = pickRandomEndpoint(candidates, random); + const firstEndpoint = candidates[0]; try { return { diff --git a/src/isInRollout.ts b/src/isInRollout.ts deleted file mode 100644 index e3a38e46..00000000 --- a/src/isInRollout.ts +++ /dev/null @@ -1,87 +0,0 @@ -/* eslint-disable no-fallthrough */ - -import { cInfo } from './core'; - -/* eslint-disable no-bitwise */ -export function murmurhash3_32_gc(key: string, seed = 0) { - let remainder: number, - bytes: number, - h1: number, - h1b: number, - c1: number, - c2: number, - k1: number, - i: number; - - remainder = key.length & 3; // key.length % 4 - bytes = key.length - remainder; - h1 = seed; - c1 = 0xcc9e2d51; - c2 = 0x1b873593; - i = 0; - - while (i < bytes) { - k1 = - (key.charCodeAt(i) & 0xff) | - ((key.charCodeAt(++i) & 0xff) << 8) | - ((key.charCodeAt(++i) & 0xff) << 16) | - ((key.charCodeAt(++i) & 0xff) << 24); - ++i; - - k1 = - ((k1 & 0xffff) * c1 + ((((k1 >>> 16) * c1) & 0xffff) << 16)) & 0xffffffff; - k1 = (k1 << 15) | (k1 >>> 17); - k1 = - ((k1 & 0xffff) * c2 + ((((k1 >>> 16) * c2) & 0xffff) << 16)) & 0xffffffff; - - h1 ^= k1; - h1 = (h1 << 13) | (h1 >>> 19); - h1b = - ((h1 & 0xffff) * 5 + ((((h1 >>> 16) * 5) & 0xffff) << 16)) & 0xffffffff; - h1 = (h1b & 0xffff) + 0x6b64 + ((((h1b >>> 16) + 0xe654) & 0xffff) << 16); - } - - k1 = 0; - - switch (remainder) { - // biome-ignore lint/suspicious/noFallthroughSwitchClause: MurmurHash fallthrough - case 3: - k1 ^= (key.charCodeAt(i + 2) & 0xff) << 16; - // biome-ignore lint/suspicious/noFallthroughSwitchClause: MurmurHash fallthrough - case 2: - k1 ^= (key.charCodeAt(i + 1) & 0xff) << 8; - case 1: - k1 ^= key.charCodeAt(i) & 0xff; - - k1 = - ((k1 & 0xffff) * c1 + ((((k1 >>> 16) * c1) & 0xffff) << 16)) & - 0xffffffff; - k1 = (k1 << 15) | (k1 >>> 17); - k1 = - ((k1 & 0xffff) * c2 + ((((k1 >>> 16) * c2) & 0xffff) << 16)) & - 0xffffffff; - h1 ^= k1; - } - - h1 ^= key.length; - - h1 ^= h1 >>> 16; - h1 = - ((h1 & 0xffff) * 0x85ebca6b + - ((((h1 >>> 16) * 0x85ebca6b) & 0xffff) << 16)) & - 0xffffffff; - h1 ^= h1 >>> 13; - h1 = - ((h1 & 0xffff) * 0xc2b2ae35 + - ((((h1 >>> 16) * 0xc2b2ae35) & 0xffff) << 16)) & - 0xffffffff; - h1 ^= h1 >>> 16; - - return h1 >>> 0; -} - -const intForUUID = murmurhash3_32_gc(cInfo.uuid); - -export function isInRollout(rollout: number) { - return intForUUID % 100 < rollout; -} diff --git a/src/provider.tsx b/src/provider.tsx index 9ecfffc4..d11a9fdc 100644 --- a/src/provider.tsx +++ b/src/provider.tsx @@ -18,13 +18,15 @@ import { URL } from 'react-native-url-polyfill'; import { type Cresc, type Pushy, sharedState } from './client'; import { ProgressContext, UpdateContext } from './context'; import { + cInfo, currentVersion, currentVersionInfo, getCurrentVersionInfo, packageVersion, + rolledBackVersion, } from './core'; -import { resolveCheckResult } from './resolveCheckResult'; import type { CheckResult, ProgressData, UpdateTestPayload } from './type'; +import { decideDownload, resolveCheckResult } from './updateFlowCore'; import { assertWeb, log, noop } from './utils'; export const UpdateProvider = ({ @@ -222,7 +224,41 @@ export const UpdateProvider = ({ // known updateInfo instead of overwriting it with an empty object. return; } - const info = resolveCheckResult(rootInfo); + let info = resolveCheckResult( + rootInfo, + { + packageVersion: client.getEffectivePackageVersion(), + currentVersion, + uuid: cInfo.uuid, + }, + log + ); + if ( + !info.expired && + info.update && + (typeof info.hash !== 'string' || info.hash.length === 0) + ) { + // A malformed rollout/root entry must not produce an alert whose + // confirm button can never download anything. Surface it to the + // developer telemetry/logger and present it to the app as no update. + client.reportInvalidUpdateOnce('missingHash'); + info = { upToDate: true }; + } + if (info.update && !info.expired) { + const decision = decideDownload( + info, + { currentVersion, rolledBackVersion }, + __DEV__ + ); + if (decision.action === 'none') { + if (decision.reason === 'noArtifact') { + // Invalid server data is worth reporting; local rollout guards are + // expected no-ops and stay silent. + client.reportInvalidUpdateOnce('noArtifact', info.hash || ''); + } + info = { upToDate: true }; + } + } if (info.update) { info.description = info.description ?? ''; } diff --git a/src/resolveCheckResult.ts b/src/resolveCheckResult.ts deleted file mode 100644 index b44eb806..00000000 --- a/src/resolveCheckResult.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { currentVersion, packageVersion } from './core'; -import { isInRollout } from './isInRollout'; -import type { CheckResult } from './type'; -import { log } from './utils'; - -export function resolveCheckResult(rootInfo: CheckResult): CheckResult { - const { expVersion, ...rootResult } = rootInfo; - const rollout = expVersion?.config?.rollout?.[packageVersion]; - if (rootResult.update && expVersion && typeof rollout === 'number') { - if (isInRollout(rollout)) { - log(`${expVersion.name} in ${rollout}% rollout, continue`); - if (expVersion.hash === currentVersion) { - return { upToDate: true }; - } - const info: CheckResult = { - update: true, - ...expVersion, - }; - if (rootResult.paths) { - info.paths = rootResult.paths; - } - return info; - } - log(`${expVersion.name} not in ${rollout}% rollout, ignored`); - } - if (rootResult.update && rootResult.hash === currentVersion) { - return { upToDate: true }; - } - return rootResult; -} diff --git a/src/type.ts b/src/type.ts index ad7725fb..b6c57a4c 100644 --- a/src/type.ts +++ b/src/type.ts @@ -9,6 +9,14 @@ export interface VersionInfo { rollout: { [packageVersion: string]: number; }; + /** + * Server-set per-version override: the native cold-start check activates + * this version for the next launch regardless of the client's + * updateStrategy (the brick-rescue directive). Native-only — the JS + * interactive flow ignores it. The device-local rolledBack guard still + * wins, and first_time crash protection still applies. + */ + forceBoot?: boolean; [key: string]: any; }; pdiff?: string; diff --git a/src/updateFlowCore.ts b/src/updateFlowCore.ts new file mode 100644 index 00000000..f8447d2c --- /dev/null +++ b/src/updateFlowCore.ts @@ -0,0 +1,320 @@ +import type { CheckResult } from './type'; + +// 更新流程的纯决策层:无 IO、不依赖 react-native、无模块级状态,所有输入均由 +// 参数传入(仅允许 type-only import)。这一层必须保持可在裸 JS 引擎中求值—— +// 它就是未来 guardian bundle 的编译单元(NATIVE_CHECKUPDATE_DESIGN §5), +// IO(HTTP/下载/落盘)由调用方(现在是 client.ts,将来是原生编排)执行。 + +/* eslint-disable no-fallthrough */ +/* eslint-disable no-bitwise */ +export function murmurhash3_32_gc(key: string, seed = 0) { + let remainder: number, + bytes: number, + h1: number, + h1b: number, + c1: number, + c2: number, + k1: number, + i: number; + + remainder = key.length & 3; // key.length % 4 + bytes = key.length - remainder; + h1 = seed; + c1 = 0xcc9e2d51; + c2 = 0x1b873593; + i = 0; + + while (i < bytes) { + k1 = + (key.charCodeAt(i) & 0xff) | + ((key.charCodeAt(++i) & 0xff) << 8) | + ((key.charCodeAt(++i) & 0xff) << 16) | + ((key.charCodeAt(++i) & 0xff) << 24); + ++i; + + k1 = + ((k1 & 0xffff) * c1 + ((((k1 >>> 16) * c1) & 0xffff) << 16)) & 0xffffffff; + k1 = (k1 << 15) | (k1 >>> 17); + k1 = + ((k1 & 0xffff) * c2 + ((((k1 >>> 16) * c2) & 0xffff) << 16)) & 0xffffffff; + + h1 ^= k1; + h1 = (h1 << 13) | (h1 >>> 19); + h1b = + ((h1 & 0xffff) * 5 + ((((h1 >>> 16) * 5) & 0xffff) << 16)) & 0xffffffff; + h1 = (h1b & 0xffff) + 0x6b64 + ((((h1b >>> 16) + 0xe654) & 0xffff) << 16); + } + + k1 = 0; + + switch (remainder) { + // biome-ignore lint/suspicious/noFallthroughSwitchClause: MurmurHash fallthrough + case 3: + k1 ^= (key.charCodeAt(i + 2) & 0xff) << 16; + // biome-ignore lint/suspicious/noFallthroughSwitchClause: MurmurHash fallthrough + case 2: + k1 ^= (key.charCodeAt(i + 1) & 0xff) << 8; + case 1: + k1 ^= key.charCodeAt(i) & 0xff; + + k1 = + ((k1 & 0xffff) * c1 + ((((k1 >>> 16) * c1) & 0xffff) << 16)) & + 0xffffffff; + k1 = (k1 << 15) | (k1 >>> 17); + k1 = + ((k1 & 0xffff) * c2 + ((((k1 >>> 16) * c2) & 0xffff) << 16)) & + 0xffffffff; + h1 ^= k1; + } + + h1 ^= key.length; + + h1 ^= h1 >>> 16; + h1 = + ((h1 & 0xffff) * 0x85ebca6b + + ((((h1 >>> 16) * 0x85ebca6b) & 0xffff) << 16)) & + 0xffffffff; + h1 ^= h1 >>> 13; + h1 = + ((h1 & 0xffff) * 0xc2b2ae35 + + ((((h1 >>> 16) * 0xc2b2ae35) & 0xffff) << 16)) & + 0xffffffff; + h1 ^= h1 >>> 16; + + return h1 >>> 0; +} + +export function isInRollout(rollout: number, uuid: string) { + return murmurhash3_32_gc(uuid) % 100 < rollout; +} + +export function joinUrls(paths: string[], fileName?: string) { + if (fileName) { + return paths.map((path) => { + const normalizedPath = path.replace(/\/+$/, ''); + // Keep explicit http(s) URLs for local/self-hosted update sources. + const baseUrl = /^[a-z][a-z0-9+.-]*:\/\//i.test(normalizedPath) + ? normalizedPath + : `https://${normalizedPath}`; + return `${baseUrl}/${fileName}`; + }); + } +} + +export const dedupeEndpoints = ( + endpoints: Array +): string[] => { + const result: string[] = []; + const visited = new Set(); + + for (const endpoint of endpoints) { + if (!endpoint || visited.has(endpoint)) { + continue; + } + visited.add(endpoint); + result.push(endpoint); + } + + return result; +}; + +/** + * The endpoint plan: dedupe, then move the sampled pick to the front (load + * spreading), keeping the rest in configured order as the fallback sequence. + * `randomSample` ∈ [0, 1) is injected by the caller — this layer cannot draw + * randomness itself. + */ +export function orderEndpointCandidates( + endpoints: Array, + randomSample = 0 +): string[] { + const deduped = dedupeEndpoints(endpoints); + if (deduped.length < 2) { + return deduped; + } + const first = Math.max( + 0, + Math.min( + Number.isFinite(randomSample) + ? Math.floor(randomSample * deduped.length) + : 0, + deduped.length - 1 + ) + ); + return [ + deduped[first], + ...deduped.slice(0, first), + ...deduped.slice(first + 1), + ]; +} + +export interface CheckRequestInput { + /** Effective native package version (overridePackageVersion already applied). */ + packageVersion: string; + /** Hash of the currently running JS version ('' when on the packaged bundle). */ + currentVersion?: string; + buildTime: string; + cInfo: Record; + supportedDiffVersion?: number; + /** '' or undefined while unknown — the field is then omitted. */ + bundleHash?: string; + isDev?: boolean; + extra?: Record; +} + +export function buildCheckRequestBody({ + packageVersion, + currentVersion, + buildTime, + cInfo, + supportedDiffVersion, + bundleHash, + isDev, + extra, +}: CheckRequestInput): Record { + const body: Record = { + packageVersion, + hash: currentVersion, + buildTime, + cInfo, + // 可消费的 diff 轨道版本(2 = hdiffv2 轨道),服务端据此门控下发 + ...(supportedDiffVersion ? { diffV: supportedDiffVersion } : {}), + ...(bundleHash ? { bundleHash } : {}), + ...extra, + }; + if (isDev) { + delete body.buildTime; + } + return body; +} + +export interface UpdateIdentity { + packageVersion: string; + currentVersion?: string; + /** Stable client uuid — the gray-release bucketing key. */ + uuid: string; +} + +export function resolveCheckResult( + rootInfo: CheckResult, + identity: UpdateIdentity, + log: (...args: any[]) => void = () => {} +): CheckResult { + const { expVersion, ...rootResult } = rootInfo; + const rollout = expVersion?.config?.rollout?.[identity.packageVersion]; + if (rootResult.update && expVersion && typeof rollout === 'number') { + if (isInRollout(rollout, identity.uuid)) { + log(`${expVersion.name} in ${rollout}% rollout, continue`); + if ( + typeof expVersion.hash === 'string' && + expVersion.hash.length > 0 && + expVersion.hash === identity.currentVersion + ) { + return { upToDate: true }; + } + const info: CheckResult = { + update: true, + ...expVersion, + }; + if (rootResult.paths) { + info.paths = rootResult.paths; + } + return info; + } + log(`${expVersion.name} not in ${rollout}% rollout, ignored`); + } + if ( + rootResult.update && + typeof rootResult.hash === 'string' && + rootResult.hash.length > 0 && + rootResult.hash === identity.currentVersion + ) { + return { upToDate: true }; + } + return rootResult; +} + +/** + * Whether the native orchestrator should activate a downloaded version for + * the next launch: either the client opted in via its silent strategies + * (afterDownload === 'setNeedUpdate'), or the server marked this version + * `config.forceBoot` — the per-version remote override that closes the + * brick-rescue gap for alert-strategy apps (a bricked device never runs JS, + * so activation cannot wait for it). Native-only: the JS side's interactive + * strategies are not consulted and not affected. The device-local + * rolledBackVersion guard in decideDownload still wins over forceBoot, and + * the activated version keeps the first_time crash-protection rollback. + */ +export function shouldActivateAfterDownload( + info: CheckResult, + afterDownload?: string +): boolean { + return afterDownload === 'setNeedUpdate' || !!info?.config?.forceBoot; +} + +export type DownloadStrategyType = 'diff' | 'pdiff' | 'full'; + +export interface DownloadAttempt { + type: DownloadStrategyType; + /** Candidate URLs (paths × file name); the executor probes and picks one. */ + urls: string[]; +} + +export interface DownloadPlan { + hash: string; + /** Ordered attempts: incremental first, full as the last resort. */ + attempts: DownloadAttempt[]; + /** Dev-only: nothing to fetch — treat as an immediate no-op success. */ + devNoop: boolean; +} + +export type DownloadDecision = + | { + action: 'none'; + reason: 'noUpdate' | 'alreadyCurrent' | 'rolledBack' | 'noArtifact'; + } + | ({ action: 'download' } & DownloadPlan); + +export function decideDownload( + info: CheckResult, + identity: { currentVersion?: string; rolledBackVersion?: string }, + isDev = false +): DownloadDecision { + const { hash, diff, pdiff, full, paths = [] } = info; + if (!info.update || !hash) { + return { action: 'none', reason: 'noUpdate' }; + } + if (hash === identity.currentVersion) { + return { action: 'none', reason: 'alreadyCurrent' }; + } + if (identity.rolledBackVersion && hash === identity.rolledBackVersion) { + return { action: 'none', reason: 'rolledBack' }; + } + const attempts: DownloadAttempt[] = []; + // Incremental artifacts only exist against release builds; dev goes + // straight to full (or the no-op below when there is none). + if (!isDev) { + const diffUrls = joinUrls(paths, diff); + if (diffUrls?.length) { + attempts.push({ type: 'diff', urls: diffUrls }); + } + const pdiffUrls = joinUrls(paths, pdiff); + if (pdiffUrls?.length) { + attempts.push({ type: 'pdiff', urls: pdiffUrls }); + } + } + const fullUrls = joinUrls(paths, full); + if (fullUrls?.length) { + attempts.push({ type: 'full', urls: fullUrls }); + } + const devNoop = !!isDev && !fullUrls?.length; + if (!attempts.length && !devNoop) { + return { action: 'none', reason: 'noArtifact' }; + } + return { + action: 'download', + hash, + attempts, + devNoop, + }; +} diff --git a/src/utils.ts b/src/utils.ts index e6195686..caa13fb5 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -76,18 +76,7 @@ const ping = isWeb } }; -export function joinUrls(paths: string[], fileName?: string) { - if (fileName) { - return paths.map((path) => { - const normalizedPath = path.replace(/\/+$/, ''); - // Keep explicit http(s) URLs for local/self-hosted update sources. - const baseUrl = /^[a-z][a-z0-9+.-]*:\/\//i.test(normalizedPath) - ? normalizedPath - : `https://${normalizedPath}`; - return `${baseUrl}/${fileName}`; - }); - } -} +export { joinUrls } from './updateFlowCore'; export const testUrls = async (urls?: string[]): Promise => { if (!urls?.length) {