From 1532256717b536c27623feb708c5bda6b058a8b0 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:41:47 +0800 Subject: [PATCH 001/104] chore(lint): check C++ constant mirrors on Android and JS too Move check-constant-parity.js from harmony/pushy/src/test to scripts/ and extend it to compare archive_limits.h / install_record.h / error_codes.h against ArchiveLimits.java / InstallRecord.java / ErrorCodes.java and the error codes known to src/error.ts. Runs as part of bun lint. Co-Authored-By: Claude Opus 5.5 --- cpp/patch_core/archive_limits.h | 3 +- cpp/patch_core/error_codes.h | 6 +- cpp/patch_core/install_record.h | 3 +- package.json | 2 +- .../test => scripts}/check-constant-parity.js | 131 ++++++++++++++---- 5 files changed, 111 insertions(+), 34 deletions(-) rename {harmony/pushy/src/test => scripts}/check-constant-parity.js (50%) diff --git a/cpp/patch_core/archive_limits.h b/cpp/patch_core/archive_limits.h index 1699be4f..34d116e4 100644 --- a/cpp/patch_core/archive_limits.h +++ b/cpp/patch_core/archive_limits.h @@ -7,7 +7,8 @@ // disk or a zip bomb. The caps are generous for real packages (a large full // bundle with assets is tens of MB) and exist purely as damage bounds. // -// Mirrors that cannot include this header MUST stay in sync by hand: +// Mirrors that cannot include this header MUST stay in sync by hand +// (scripts/check-constant-parity.js, part of `bun lint`, asserts they do): // - android/.../ArchiveLimits.java // - harmony/pushy/src/main/ets/ArchiveLimits.ts // iOS (RCTPushy.mm) includes this header directly. diff --git a/cpp/patch_core/error_codes.h b/cpp/patch_core/error_codes.h index 852338f5..d3ac0da6 100644 --- a/cpp/patch_core/error_codes.h +++ b/cpp/patch_core/error_codes.h @@ -6,9 +6,11 @@ // so the JS layer (src/error.ts UpdateErrorCode) and user loggers can // aggregate errors across platforms and locales. // -// Mirrors that cannot include this header MUST stay in sync by hand: -// - src/error.ts (UpdateErrorCode union, JS layer) +// Mirrors that cannot include this header MUST stay in sync by hand +// (scripts/check-constant-parity.js, part of `bun lint`, asserts they do): +// - src/error.ts (UpdateErrorCode, JS layer) // - android/.../ErrorCodes.java (Java constants) +// - harmony/pushy/src/main/ets/ErrorCodes.ts // iOS (RCTPushy.mm) includes this header directly. // // Human-readable messages are NOT part of this contract: they may differ per diff --git a/cpp/patch_core/install_record.h b/cpp/patch_core/install_record.h index 0554dddc..95a87fe2 100644 --- a/cpp/patch_core/install_record.h +++ b/cpp/patch_core/install_record.h @@ -22,7 +22,8 @@ // - Legacy: an EMPTY file is a completed install written by SDK < 10.53 // (no digests); it stays trusted for presence, nothing to verify. // -// Mirrors that cannot include this header MUST stay in sync by hand: +// Mirrors that cannot include this header MUST stay in sync by hand +// (scripts/check-constant-parity.js, part of `bun lint`, asserts they do): // - android/.../InstallRecord.java // - harmony/pushy/src/main/ets/InstallRecord.ts // iOS (RCTPushy.mm) includes this header directly. diff --git a/package.json b/package.json index 6b53ba34..b770b02d 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ ], "scripts": { "prepack": "bun submodule && bun lint", - "lint": "biome check src && tsc --noEmit && node scripts/check-harmony-types.js && node scripts/check-native-spec-parity.js", + "lint": "biome check src && tsc --noEmit && node scripts/check-harmony-types.js && node scripts/check-native-spec-parity.js && node scripts/check-constant-parity.js", "lint:harmony": "node scripts/check-harmony-types.js", "submodule": "git submodule update --init --recursive", "test": "bun test src/__tests__", diff --git a/harmony/pushy/src/test/check-constant-parity.js b/scripts/check-constant-parity.js similarity index 50% rename from harmony/pushy/src/test/check-constant-parity.js rename to scripts/check-constant-parity.js index 0a997898..8bcb758a 100644 --- a/harmony/pushy/src/test/check-constant-parity.js +++ b/scripts/check-constant-parity.js @@ -1,47 +1,79 @@ #!/usr/bin/env node -// Asserts that the ArkTS "hand-kept mirrors" of the shared C++ headers still -// carry the same values. Plain Node, no SDK: runs on every PR -// (.github/workflows/harmony-build.yml) so a limit or code bumped in -// cpp/patch_core cannot silently drift on Harmony. +// Asserts that the "hand-kept mirrors" of the shared C++ headers (ArkTS, +// Java, JS) still carry the same values. Plain Node, no SDK: runs in +// `bun lint` on every PR so a limit or code bumped in cpp/patch_core cannot +// silently drift on Harmony, Android or the JS layer. // // cpp/patch_core/archive_limits.h <-> harmony/.../ArchiveLimits.ts +// android/.../ArchiveLimits.java // cpp/patch_core/install_record.h <-> harmony/.../InstallRecord.ts +// android/.../InstallRecord.java // cpp/patch_core/error_codes.h <-> harmony/.../ErrorCodes.ts +// android/.../ErrorCodes.java +// src/error.ts (every native code must +// be a known UpdateErrorCode) 'use strict'; const fs = require('fs'); const path = require('path'); -const root = path.resolve(__dirname, '..', '..', '..', '..'); +const root = path.resolve(__dirname, '..'); const read = (relative) => fs.readFileSync(path.join(root, relative), 'utf8'); -// `constexpr kName = ;` — the value is either a string literal or -// an integer expression built from literals, `*`, `+`, parentheses and the -// LL suffix. -function parseCppConstants(source) { - const re = - /(?:constexpr|inline)\s+(?:const\s+)?(?:long\s+long|int|char\s*\*)\s*(?:const\s+)?k([A-Za-z0-9]+)\s*=\s*([^;]+);/g; +function collect(re, source, nameGroup, valueGroup) { const values = new Map(); let match; while ((match = re.exec(source)) !== null) { - values.set(match[1], evaluate(match[2].trim(), match[1])); + const name = match[nameGroup]; + values.set(name, evaluate(match[valueGroup].trim(), name)); } return values; } +// `constexpr kName = ;` — the value is either a string literal or +// an integer expression built from literals, `*`, `+`, parentheses and the +// LL suffix. +const parseCppConstants = (source) => + collect( + /(?:constexpr|inline)\s+(?:const\s+)?(?:long\s+long|int|char\s*\*)\s*(?:const\s+)?k([A-Za-z0-9]+)\s*=\s*([^;]+);/g, + source, + 1, + 2 + ); + // `export const NAME = ;` -function parseArkTsConstants(source) { - const re = /export\s+const\s+([A-Z][A-Z0-9_]*)\s*(?::\s*[a-z]+)?\s*=\s*([^;]+);/g; +const parseArkTsConstants = (source) => + collect( + /export\s+const\s+([A-Z][A-Z0-9_]*)\s*(?::\s*[a-z]+)?\s*=\s*([^;]+);/g, + source, + 1, + 2 + ); + +// `static final NAME = ;` +const parseJavaConstants = (source) => + collect( + /static\s+final\s+(?:long|int|String)\s+([A-Z][A-Z0-9_]*)\s*=\s*([^;]+);/g, + source, + 1, + 2 + ); + +// Every quoted UPPER_SNAKE string literal, whatever the surrounding shape +// (union type, Set, `as const` array): the JS layer only has to *know* each +// native code; it also carries JS-only codes of its own. +function parseJsStringLiterals(source) { + const re = /(["'`])([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+)\1/g; const values = new Map(); let match; while ((match = re.exec(source)) !== null) { - values.set(match[1], evaluate(match[2].trim(), match[1])); + values.set(match[2], match[2]); } return values; } -// Encoded text (obscured_text.h / Texts.ts, scripts/encode-native-text.ts): -// byte i XORed with (0x5A + 0x1D * i) & 0xFF. +// Encoded text (obscured_text.h / Texts.ts / Texts.java, +// scripts/encode-native-text.ts): byte i XORed with (0x5A + 0x1D * i) & 0xFF. function reveal(hex) { let text = ''; for (let i = 0; i < hex.length / 2; i++) { @@ -53,7 +85,9 @@ function reveal(hex) { function evaluate(expression, name) { const text = expression.replace(/\s+/g, ' ').trim(); - const encoded = text.match(/^(?:text::RevealStatic|revealText)\((["'])([0-9a-f]+)\1\)$/); + const encoded = text.match( + /^(?:text::RevealStatic|revealText|Texts\.reveal|reveal)\((["'])([0-9a-f]+)\1\)$/ + ); if (encoded) { return reveal(encoded[2]); } @@ -61,7 +95,8 @@ function evaluate(expression, name) { if (stringLiteral) { return stringLiteral[2]; } - const numeric = text.replace(/(\d)LL\b/g, '$1'); + // C++ `LL` / Java `L` integer suffixes. + const numeric = text.replace(/(\d)L{1,2}\b/g, '$1'); if (!/^[\d\s()*+]+$/.test(numeric)) { throw new Error(`check-constant-parity: cannot evaluate ${name} = ${text}`); } @@ -74,18 +109,31 @@ function screamingSnake(name) { return name.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toUpperCase(); } +const javaDir = 'android/src/main/java/cn/reactnative/modules/update'; +// Must always be parsed from the header (guards the parser itself: a regex +// regression would otherwise silently drop the pair's coverage). +const archiveLimitsRequired = ['UnknownLengthFreeSpaceProbeBytes', 'MaxArchiveBytes']; +const errorCodesRequired = ['DeltaFailed', 'InvalidOptions']; + const pairs = [ { header: 'cpp/patch_core/archive_limits.h', mirror: 'harmony/pushy/src/main/ets/ArchiveLimits.ts', - mirrorName: (cppName) => screamingSnake(cppName), - // Must always be parsed from the header (guards the parser itself: a - // regex regression would otherwise silently drop the pair's coverage). - required: ['UnknownLengthFreeSpaceProbeBytes', 'MaxArchiveBytes'], + parse: parseArkTsConstants, + mirrorName: screamingSnake, + required: archiveLimitsRequired, + }, + { + header: 'cpp/patch_core/archive_limits.h', + mirror: `${javaDir}/ArchiveLimits.java`, + parse: parseJavaConstants, + mirrorName: screamingSnake, + required: archiveLimitsRequired, }, { header: 'cpp/patch_core/install_record.h', mirror: 'harmony/pushy/src/main/ets/InstallRecord.ts', + parse: parseArkTsConstants, mirrorName: (cppName) => ({ Schema: 'INSTALL_RECORD_SCHEMA', @@ -93,13 +141,38 @@ const pairs = [ StagingSuffix: 'STAGING_SUFFIX', })[cppName] || screamingSnake(cppName), }, + { + header: 'cpp/patch_core/install_record.h', + mirror: `${javaDir}/InstallRecord.java`, + parse: parseJavaConstants, + mirrorName: screamingSnake, + }, { header: 'cpp/patch_core/error_codes.h', mirror: 'harmony/pushy/src/main/ets/ErrorCodes.ts', + parse: parseArkTsConstants, mirrorName: (cppName) => `ERROR_${screamingSnake(cppName)}`, + required: errorCodesRequired, // Every ERROR_* export must exist in the header too (no invented codes). mirrorPrefix: 'ERROR_', }, + { + header: 'cpp/patch_core/error_codes.h', + mirror: `${javaDir}/ErrorCodes.java`, + parse: parseJavaConstants, + mirrorName: screamingSnake, + required: errorCodesRequired, + // Every constant in the class must exist in the header too. + mirrorPrefix: '', + }, + { + header: 'cpp/patch_core/error_codes.h', + mirror: 'src/error.ts', + parse: parseJsStringLiterals, + required: errorCodesRequired, + // The literal *is* the code: look each one up by the header's value. + byValue: true, + }, ]; const problems = []; @@ -107,7 +180,7 @@ let checked = 0; for (const pair of pairs) { const cpp = parseCppConstants(read(pair.header)); - const arkts = parseArkTsConstants(read(pair.mirror)); + const mirror = pair.parse(read(pair.mirror)); if (cpp.size === 0) { problems.push(`${pair.header}: no constexpr constants parsed`); continue; @@ -118,15 +191,15 @@ for (const pair of pairs) { } } for (const [cppName, cppValue] of cpp) { - const mirrorName = pair.mirrorName(cppName); + const mirrorName = pair.byValue ? cppValue : pair.mirrorName(cppName); checked += 1; - if (!arkts.has(mirrorName)) { + if (!mirror.has(mirrorName)) { problems.push( `${pair.mirror}: missing ${mirrorName} (k${cppName} in ${pair.header})` ); continue; } - const mirrorValue = arkts.get(mirrorName); + const mirrorValue = mirror.get(mirrorName); if (mirrorValue !== cppValue) { problems.push( `${pair.mirror}: ${mirrorName} = ${JSON.stringify(mirrorValue)} but ` + @@ -134,9 +207,9 @@ for (const pair of pairs) { ); } } - if (pair.mirrorPrefix) { + if (pair.mirrorPrefix !== undefined) { const known = new Set(Array.from(cpp.keys()).map(pair.mirrorName)); - for (const name of arkts.keys()) { + for (const name of mirror.keys()) { if (name.startsWith(pair.mirrorPrefix) && !known.has(name)) { problems.push(`${pair.mirror}: ${name} has no counterpart in ${pair.header}`); } From 0be289e09d63e8d657afd7f7c6e6176ad7e7d992 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:41:51 +0800 Subject: [PATCH 002/104] fix(flow-core): drop Object.hasOwn, missing from JSC before iOS 15.4 jsonDeepEqual (native config equivalence, C1) called Object.hasOwn, which throws on iOS < 15.4 JSC. Use Object.prototype.hasOwnProperty.call and add a test that runs the comparison with Object.hasOwn removed. Co-Authored-By: Claude Opus 5.5 --- src/__tests__/updateFlowCore.test.ts | 11 +++++++++++ src/updateFlowCore.ts | 5 ++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/__tests__/updateFlowCore.test.ts b/src/__tests__/updateFlowCore.test.ts index 331d5c93..20098f4e 100644 --- a/src/__tests__/updateFlowCore.test.ts +++ b/src/__tests__/updateFlowCore.test.ts @@ -420,4 +420,15 @@ describe('native config canonical equality (C1)', () => { expect(nativeConfigsEquivalent('[]', '[]')).toBe(false); expect(nativeConfigsEquivalent(json(base), '')).toBe(false); }); + + test('does not depend on Object.hasOwn (missing before iOS 15.4)', () => { + const hasOwn = Object.hasOwn; + // biome-ignore lint/performance/noDelete: simulate the old JSC runtime + delete (Object as any).hasOwn; + try { + expect(nativeConfigsEquivalent(json(base), json(base))).toBe(true); + } finally { + Object.hasOwn = hasOwn; + } + }); }); diff --git a/src/updateFlowCore.ts b/src/updateFlowCore.ts index 05b645e9..7da5934c 100644 --- a/src/updateFlowCore.ts +++ b/src/updateFlowCore.ts @@ -148,7 +148,10 @@ const jsonDeepEqual = (a: unknown, b: unknown): boolean => { return ( leftKeys.length === Object.keys(right).length && leftKeys.every( - (key) => Object.hasOwn(right, key) && jsonDeepEqual(left[key], right[key]) + (key) => + // Not Object.hasOwn: it is missing from JSC before iOS 15.4. + Object.prototype.hasOwnProperty.call(right, key) && + jsonDeepEqual(left[key], right[key]) ) ); }; From f8ce0f4f702d059228ce9e959ad7c4e9dc719fc7 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:42:24 +0800 Subject: [PATCH 003/104] refactor: single jsonDeepEqual in updateFlowCore client.ts carried its own deep-equal (jsonValuesEqual) for the native check-cache request comparison; the only difference to updateFlowCore's jsonDeepEqual was skipping undefined-valued members. Fold that rule into jsonDeepEqual (a no-op for the JSON-parsed native configs it already compared; flow vectors unchanged) and export it. Co-Authored-By: Claude Opus 5.5 --- src/__tests__/updateFlowCore.test.ts | 20 ++++++++++++ src/client.ts | 47 ++-------------------------- src/updateFlowCore.ts | 13 ++++++-- 3 files changed, 32 insertions(+), 48 deletions(-) diff --git a/src/__tests__/updateFlowCore.test.ts b/src/__tests__/updateFlowCore.test.ts index 20098f4e..8fe875f8 100644 --- a/src/__tests__/updateFlowCore.test.ts +++ b/src/__tests__/updateFlowCore.test.ts @@ -7,6 +7,7 @@ import { decideDownload, isMirrorRetryableCode, isValidCheckResult, + jsonDeepEqual, nativeConfigsEquivalent, orderEndpointCandidates, } from '../updateFlowCore'; @@ -432,3 +433,22 @@ describe('native config canonical equality (C1)', () => { } }); }); + +describe('jsonDeepEqual', () => { + test('ignores key order and undefined-valued members, like the wire', () => { + expect( + jsonDeepEqual( + { a: 1, b: { c: [1, { d: 'x' }] }, skip: undefined }, + { b: { c: [1, { d: 'x', gone: undefined }] }, a: 1 } + ) + ).toBe(true); + }); + + test('detects real differences', () => { + expect(jsonDeepEqual({ a: 1 }, { a: 2 })).toBe(false); + expect(jsonDeepEqual({ a: 1 }, { a: 1, b: null })).toBe(false); + expect(jsonDeepEqual({ a: null }, { a: undefined })).toBe(false); + expect(jsonDeepEqual([1, 2], [2, 1])).toBe(false); + expect(jsonDeepEqual([], {})).toBe(false); + }); +}); diff --git a/src/client.ts b/src/client.ts index e0cd24d5..47446130 100644 --- a/src/client.ts +++ b/src/client.ts @@ -55,6 +55,7 @@ import { decideDownload, isMirrorRetryableCode, isValidCheckResult, + jsonDeepEqual, nativeConfigsEquivalent, } from './updateFlowCore'; import { @@ -547,50 +548,6 @@ export class Pushy { 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; /** * Called by UpdateProvider on mount. A second concurrently mounted @@ -1100,7 +1057,7 @@ export class Pushy { typeof entry.config !== 'string' || !configJson || !nativeConfigsEquivalent(entry.config, configJson) || - !this.jsonValuesEqual(JSON.parse(entry.request), requestBody) + !jsonDeepEqual(JSON.parse(entry.request), requestBody) ) { return undefined; } diff --git a/src/updateFlowCore.ts b/src/updateFlowCore.ts index 7da5934c..2ba73da8 100644 --- a/src/updateFlowCore.ts +++ b/src/updateFlowCore.ts @@ -127,7 +127,12 @@ const NATIVE_CONFIG_DEFAULTS: Record = { afterDownload: 'none', }; -const jsonDeepEqual = (a: unknown, b: unknown): boolean => { +/** + * Deep equality of JSON values, key order ignored. Object members whose value + * is undefined count as absent, matching JSON.stringify (they do not exist on + * the wire). + */ +export const jsonDeepEqual = (a: unknown, b: unknown): boolean => { if (a === b) { return true; } @@ -144,9 +149,11 @@ const jsonDeepEqual = (a: unknown, b: unknown): boolean => { } const left = a as Record; const right = b as Record; - const leftKeys = Object.keys(left); + const definedKeys = (object: Record) => + Object.keys(object).filter((key) => object[key] !== undefined); + const leftKeys = definedKeys(left); return ( - leftKeys.length === Object.keys(right).length && + leftKeys.length === definedKeys(right).length && leftKeys.every( (key) => // Not Object.hasOwn: it is missing from JSC before iOS 15.4. From 57fdfecd0f46c5d966476f711c8a0fc405ffd71e Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:42:39 +0800 Subject: [PATCH 004/104] refactor(error): derive UpdateErrorCode from one code list The union type and KNOWN_CODES listed every code twice; keep a single 'as const' array and derive both from it. Co-Authored-By: Claude Opus 5.5 --- src/error.ts | 45 +++++++++++++-------------------------------- 1 file changed, 13 insertions(+), 32 deletions(-) diff --git a/src/error.ts b/src/error.ts index df9b6e9f..04cf0082 100644 --- a/src/error.ts +++ b/src/error.ts @@ -7,42 +7,13 @@ * Android/iOS/Harmony modules — and flow through promise rejections onto the * `code` property, which toUpdateError() preserves. */ -export type UpdateErrorCode = +const UPDATE_ERROR_CODES = [ // JS-layer codes - | 'MODULE_NOT_LOADED' - | 'APPKEY_REQUIRED' - | 'NO_ENDPOINTS' - | 'HTTP_STATUS' - // A 2xx check response that is not a check verdict (schema gate). - | 'INVALID_RESPONSE' - | 'CHECK_FAILED' - | 'DOWNLOAD_FAILED' - | 'SWITCH_VERSION_FAILED' - | 'MARK_SUCCESS_FAILED' - | 'APK_INSTALL_PENDING' - | 'APK_DOWNLOAD_FAILED' - // A throw from a user-provided hook (e.g. beforeReload) — not an update - // pipeline failure, and excluded from server-side patch-health telemetry. - | 'USER_HOOK_ERROR' - // A second client/provider in the same process — a hard integration error; - // the SDK is a process-level singleton. - | 'SINGLETON_VIOLATION' - // Native codes (see cpp/patch_core/error_codes.h) - | 'INVALID_OPTIONS' - | 'PATCH_FAILED' - | 'FILE_OPERATION_FAILED' - | 'RESTART_FAILED' - | 'RESET_FAILED' - | 'INVALID_HASH_INFO' - | 'UNSUPPORTED_PLATFORM' - | 'APK_INSTALL_PERMISSION_REQUIRED' - | 'APK_INSTALL_FAILED'; - -const KNOWN_CODES = new Set([ 'MODULE_NOT_LOADED', 'APPKEY_REQUIRED', 'NO_ENDPOINTS', 'HTTP_STATUS', + // A 2xx check response that is not a check verdict (schema gate). 'INVALID_RESPONSE', 'CHECK_FAILED', 'DOWNLOAD_FAILED', @@ -50,8 +21,13 @@ const KNOWN_CODES = new Set([ 'MARK_SUCCESS_FAILED', 'APK_INSTALL_PENDING', 'APK_DOWNLOAD_FAILED', + // A throw from a user-provided hook (e.g. beforeReload) — not an update + // pipeline failure, and excluded from server-side patch-health telemetry. 'USER_HOOK_ERROR', + // A second client/provider in the same process — a hard integration error; + // the SDK is a process-level singleton. 'SINGLETON_VIOLATION', + // Native codes (see cpp/patch_core/error_codes.h) 'INVALID_OPTIONS', 'PATCH_FAILED', 'FILE_OPERATION_FAILED', @@ -61,7 +37,12 @@ const KNOWN_CODES = new Set([ 'UNSUPPORTED_PLATFORM', 'APK_INSTALL_PERMISSION_REQUIRED', 'APK_INSTALL_FAILED', -]); +] as const; + +/** A stable error code (see UPDATE_ERROR_CODES). */ +export type UpdateErrorCode = (typeof UPDATE_ERROR_CODES)[number]; + +const KNOWN_CODES = new Set(UPDATE_ERROR_CODES); /** * Narrow an arbitrary `code` property (axios' ERR_NETWORK, Node's From 9bb13e0cf44f1ef010f2a136f0ad523f16c01cef Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:42:59 +0800 Subject: [PATCH 005/104] fix(harmony): stop retrying mirrors after a patch fails to apply The native cold-start orchestrator retried every mirror of a strategy even when the artifact downloaded fine but could not be applied (PATCH_FAILED). Every mirror serves the same bytes, so match JS client.ts and the C++ IsMirrorRetryableCode: break out to the next strategy instead. Co-Authored-By: Claude Opus 5.5 --- harmony/pushy/src/main/ets/SyncCoordinator.ts | 13 ++ src/__tests__/harmonySyncAttempts.test.ts | 126 ++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 src/__tests__/harmonySyncAttempts.test.ts diff --git a/harmony/pushy/src/main/ets/SyncCoordinator.ts b/harmony/pushy/src/main/ets/SyncCoordinator.ts index 6bbaa05d..f9adc1e9 100644 --- a/harmony/pushy/src/main/ets/SyncCoordinator.ts +++ b/harmony/pushy/src/main/ets/SyncCoordinator.ts @@ -9,7 +9,9 @@ import { isSafePathComponent } from './PathUtils'; import { QUERY_PATH, STATUS_NONE } from './Texts'; import { monotonicNowMs } from './MonotonicClock'; import { + ERROR_DELTA_FAILED, ERROR_DOWNLOAD_FAILED, + PushyError, createPushyError, getErrorMessage, } from './ErrorCodes'; @@ -745,8 +747,19 @@ async function performAttempts( return true; } catch (e) { logger.warn(TAG, `${type} attempt failed: ${getErrorMessage(e)}`); + if (!isMirrorRetryable(e)) { + // The bytes arrived but could not be applied: every mirror serves + // the same artifact, so move on to the next strategy instead of + // re-downloading it (update_flow_core IsMirrorRetryableCode, JS + // client.ts isMirrorRetryableCode). + break; + } } } } return false; } + +function isMirrorRetryable(error: unknown): boolean { + return !(error instanceof PushyError && error.code === ERROR_DELTA_FAILED); +} diff --git a/src/__tests__/harmonySyncAttempts.test.ts b/src/__tests__/harmonySyncAttempts.test.ts new file mode 100644 index 00000000..6cb42e64 --- /dev/null +++ b/src/__tests__/harmonySyncAttempts.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { URL } from 'node:url'; +import { runInNewContext } from 'node:vm'; +import { + ERROR_DELTA_FAILED, + ERROR_DOWNLOAD_FAILED, + PushyError, + createPushyError, +} from '../../harmony/pushy/src/main/ets/ErrorCodes'; + +// The download strategy loop of the Harmony cold-start orchestrator +// (SyncCoordinator.performAttempts), evaluated from source with its platform +// imports stripped. A patch that downloaded but failed to apply is not +// retried on the other mirrors (update_flow_core IsMirrorRetryableCode). +const source = readFileSync( + new URL( + '../../harmony/pushy/src/main/ets/SyncCoordinator.ts', + import.meta.url + ), + 'utf8' +) + .replace(/^import[\s\S]*?;\r?\n/gm, '') + .replace(/^export /gm, ''); +const javascript = new Bun.Transpiler({ loader: 'ts' }).transformSync(source); + +interface Attempt { + type: string; + urls: string[]; +} + +// failures: url -> error code (a coded PushyError) or any thrown value. +function harness(failures: Map) { + const calls: string[] = []; + const fail = async (url: string) => { + calls.push(url); + const failure = failures.get(url); + if (typeof failure === 'string') { + throw createPushyError(failure, `failed ${url}`); + } + if (failure !== undefined) { + throw failure; + } + }; + const context = { + downloadPatchFromPpk: (url: string) => fail(url), + downloadPatchFromPackage: (url: string) => fail(url), + downloadFullUpdate: (url: string) => fail(url), + }; + const performAttempts = runInNewContext( + `${javascript}\nperformAttempts;`, + { + PushyError, + ERROR_DELTA_FAILED, + ERROR_DOWNLOAD_FAILED, + createPushyError, + getErrorMessage: (error: unknown) => String(error), + logger: { info() {}, warn() {}, error() {} }, + monotonicNowMs: () => performance.now(), + setTimeout, + clearTimeout, + BundlePreparationRound: class {}, + bundlePreparationResult: () => ({}), + } + ) as ( + ctx: typeof context, + attempts: Attempt[], + hash: string, + originHash: string, + httpsOnly: boolean + ) => Promise; + return { + calls, + run: (attempts: Attempt[], originHash = 'origin') => + performAttempts(context, attempts, 'next', originHash, false), + }; +} + +describe('harmony download attempts', () => { + test('a patch that fails to apply skips the remaining mirrors', async () => { + const h = harness( + new Map([ + ['https://a/diff', ERROR_DELTA_FAILED], + ['https://b/diff', ERROR_DELTA_FAILED], + ]) + ); + const ok = await h.run([ + { type: 'diff', urls: ['https://a/diff', 'https://b/diff'] }, + { type: 'full', urls: ['https://a/full', 'https://b/full'] }, + ]); + expect(ok).toBe(true); + expect(h.calls).toEqual(['https://a/diff', 'https://a/full']); + }); + + test('a download failure still tries the next mirror', async () => { + const h = harness(new Map([['https://a/pdiff', ERROR_DOWNLOAD_FAILED]])); + const ok = await h.run([ + { type: 'pdiff', urls: ['https://a/pdiff', 'https://b/pdiff'] }, + { type: 'full', urls: ['https://a/full'] }, + ]); + expect(ok).toBe(true); + expect(h.calls).toEqual(['https://a/pdiff', 'https://b/pdiff']); + }); + + test('an uncoded error is retryable', async () => { + const h = harness(new Map([['https://a/full', new Error('socket closed')]])); + const ok = await h.run([ + { type: 'full', urls: ['https://a/full', 'https://b/full'] }, + ]); + expect(ok).toBe(true); + expect(h.calls).toEqual(['https://a/full', 'https://b/full']); + }); + + test('every strategy failing to apply reports failure', async () => { + const h = harness( + new Map([ + ['https://a/full', ERROR_DELTA_FAILED], + ['https://b/full', ERROR_DELTA_FAILED], + ]) + ); + expect( + await h.run([{ type: 'full', urls: ['https://a/full', 'https://b/full'] }]) + ).toBe(false); + expect(h.calls).toEqual(['https://a/full']); + }); +}); From 2d2ffc22ed89deffa6308c0f76ea2e7327891e9a Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:44:13 +0800 Subject: [PATCH 006/104] refactor(core): web-safe nativeMethod helper for optional native methods On web PushyModule is a noop Proxy, so every 'typeof PushyModule.x === "function"' feature detect was true and each call site carried its own Platform.OS === 'web' guard plus try/catch to turn a synchronous bridge throw into a rejection. nativeMethod(name) returns undefined on web and for older natives, and always returns a promise; the per-call guards and the dead web branch of ping() (web never reaches a download) are gone. The VM-evaluated client harness in nativeConfiguration.test.ts depended on client.ts's free identifiers; its assertions now live in client.test.ts (native ownership suppresses constructor/setOptions writes until handed back), so it is removed. Co-Authored-By: Claude Opus 5.5 --- src/__tests__/client.test.ts | 16 +++++ src/__tests__/nativeConfiguration.test.ts | 45 -------------- src/__tests__/updateFlowCore.test.ts | 3 +- src/__tests__/utils.test.ts | 23 -------- src/client.ts | 71 ++++++++--------------- src/core.ts | 47 +++++++++++---- src/updateFlowCore.ts | 2 +- src/utils.ts | 57 ++++++++---------- 8 files changed, 100 insertions(+), 164 deletions(-) diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index e652fb42..0f2d9d79 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -2274,6 +2274,22 @@ describe('syncNativeConfig', () => { ); }); + test('native ownership suppresses constructor and setOptions writes until handed back', async () => { + const syncNativeConfig = mock(() => Promise.resolve()); + setupClientMocks({ syncNativeConfig }); + const { Pushy } = await importFreshClient('sync-config-native-owner'); + const client = new Pushy({ + appKey: 'demo-app', + nativeConfigSource: 'native', + }); + client.setOptions({ updateStrategy: 'silentAndLater' }); + await Promise.resolve(); + expect(syncNativeConfig).not.toHaveBeenCalled(); + + client.setOptions({ nativeConfigSource: 'javascript' }); + expect(syncNativeConfig).toHaveBeenCalledTimes(1); + }); + test('an older native module without the method is skipped silently', async () => { setupClientMocks(); const { Pushy } = await importFreshClient('sync-config-4'); diff --git a/src/__tests__/nativeConfiguration.test.ts b/src/__tests__/nativeConfiguration.test.ts index 1c548cd3..9a3390e1 100644 --- a/src/__tests__/nativeConfiguration.test.ts +++ b/src/__tests__/nativeConfiguration.test.ts @@ -16,7 +16,6 @@ function runtimeSource(relativePath: string): string { const storeSource = runtimeSource( '../../harmony/pushy/src/main/ets/UpdateContext.ts' ); -const clientSource = runtimeSource('../client.ts'); interface ConfigStore { setNativeConfig: (config: string) => Promise; @@ -67,33 +66,6 @@ function storeHarness() { }; } -function clientHarness(native: boolean) { - const writes: string[] = []; - const client = runInNewContext( - `${clientSource}\nnew Pushy({appKey: 'test-app', nativeConfigSource: source, disableTelemetry: true});`, - { - source: native ? 'native' : 'javascript', - __DEV__: false, - assertWeb() {}, - noop() {}, - log() {}, - setDebugLogging() {}, - cInfo: { rnu: 'test-sdk', rn: 'test-rn' }, - packageVersion: '1.0', - isRolledBack: false, - Platform: { OS: 'android' }, - i18n: { setLocale() {} }, - dedupeEndpoints: (urls: string[]) => [...new Set(urls)], - PushyModule: { - syncNativeConfig: async (config: string) => { - writes.push(config); - }, - }, - } - ) as { setOptions: (options: Record) => void }; - return { client, writes }; -} - describe('native configuration normalization', () => { test('appKey alone supplies Pushy endpoints without automatically activating', () => { const config = JSON.parse( @@ -209,20 +181,3 @@ describe('actual native configuration store', () => { expect(h.flushes()).toBe(2); }); }); - -describe('JS native configuration ownership', () => { - test('native ownership suppresses constructor and setOptions writes', () => { - const h = clientHarness(true); - expect(h.writes).toEqual([]); - h.client.setOptions({ updateStrategy: 'silentAndLater' }); - expect(h.writes).toEqual([]); - }); - - test('JS ownership retains existing synchronization, with explicit handover', () => { - const js = clientHarness(false); - expect(js.writes).toHaveLength(1); - const native = clientHarness(true); - native.client.setOptions({ nativeConfigSource: 'javascript' }); - expect(native.writes).toHaveLength(1); - }); -}); diff --git a/src/__tests__/updateFlowCore.test.ts b/src/__tests__/updateFlowCore.test.ts index 8fe875f8..17f1f03e 100644 --- a/src/__tests__/updateFlowCore.test.ts +++ b/src/__tests__/updateFlowCore.test.ts @@ -424,8 +424,7 @@ describe('native config canonical equality (C1)', () => { test('does not depend on Object.hasOwn (missing before iOS 15.4)', () => { const hasOwn = Object.hasOwn; - // biome-ignore lint/performance/noDelete: simulate the old JSC runtime - delete (Object as any).hasOwn; + Reflect.deleteProperty(Object, 'hasOwn'); try { expect(nativeConfigsEquivalent(json(base), json(base))).toBe(true); } finally { diff --git a/src/__tests__/utils.test.ts b/src/__tests__/utils.test.ts index 542ee78b..4908b2a3 100644 --- a/src/__tests__/utils.test.ts +++ b/src/__tests__/utils.test.ts @@ -454,26 +454,3 @@ describe('createNonSecureId', () => { expect(createNonSecureId(8)).toHaveLength(8); }); }); - -describe('testUrls on web', () => { - test('resolves the first url without probing or throwing', async () => { - mock.module('react-native', () => ({ Platform: { OS: 'web' } })); - const fetchMock = mock(async () => { - throw new Error('must not probe on web'); - }); - (globalThis as any).fetch = fetchMock; - const cacheKey = 'web-ping'; - const webUtils: typeof import('../utils') = await import( - `../utils?${cacheKey}` - ); - - expect(webUtils.isWeb).toBe(true); - expect( - await webUtils.testUrls([ - 'https://a.example.com', - 'https://b.example.com', - ]) - ).toBe('https://a.example.com'); - expect(fetchMock).not.toHaveBeenCalled(); - }); -}); diff --git a/src/client.ts b/src/client.ts index 47446130..1baff5a7 100644 --- a/src/client.ts +++ b/src/client.ts @@ -11,6 +11,7 @@ import { getBundleHash, isFirstTime, isRolledBack, + nativeMethod, PushyModule, packageVersion, pushyNativeEventEmitter, @@ -379,12 +380,8 @@ export class Pushy { /** 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. + if (!nativeMethod('syncNativeConfig')) { + // Web, or an older native without the native check. return undefined; } const { @@ -442,7 +439,8 @@ export class Pushy { this.syncedNativeConfigJson = undefined; return; } - if (this.nativeConfigSyncInFlight) { + const sync = nativeMethod('syncNativeConfig'); + if (!sync || this.nativeConfigSyncInFlight) { return; } const configJson = this.pendingNativeConfigJson; @@ -451,15 +449,7 @@ export class Pushy { 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 + sync(configJson) .then(() => { // A switch to 'native' while this write was in flight hands storage // to the host; remembering the write would skip it after switching @@ -485,28 +475,22 @@ export class Pushy { * rescue must keep running when JS could not reach the server. */ private markJsCheckCompleted = () => { - if ( - Platform.OS === 'web' || - typeof PushyModule.markJsCheckCompleted !== 'function' - ) { + const markCompleted = nativeMethod('markJsCheckCompleted'); + if (!markCompleted) { return; } const configJson = this.getNativeConfigJson(); if (!configJson || configJson === this.jsCheckCompletedConfigJson) { return; } - try { - Promise.resolve(PushyModule.markJsCheckCompleted(configJson)).then( - () => { - this.jsCheckCompletedConfigJson = configJson; - }, - (e: any) => { - log('markJsCheckCompleted failed:', e?.message || e); - } - ); - } catch (e: any) { - log('markJsCheckCompleted failed:', e?.message || e); - } + markCompleted(configJson).then( + () => { + this.jsCheckCompletedConfigJson = configJson; + }, + (e: any) => { + log('markJsCheckCompleted failed:', e?.message || e); + } + ); }; private syncNativeConfig = () => { @@ -518,10 +502,7 @@ export class Pushy { this.syncedNativeConfigJson = undefined; return; } - if ( - Platform.OS === 'web' || - typeof PushyModule.syncNativeConfig !== 'function' - ) { + if (!nativeMethod('syncNativeConfig')) { return; } const configJson = this.getNativeConfigJson(); @@ -1030,14 +1011,11 @@ export class Pushy { requestBody: Record ): Promise => { try { - if ( - __DEV__ || - nativeCheckCacheExhausted || - typeof PushyModule.getNativeCheckCache !== 'function' - ) { + const getCache = nativeMethod('getNativeCheckCache'); + if (__DEV__ || nativeCheckCacheExhausted || !getCache) { return undefined; } - const raw = await Promise.resolve(PushyModule.getNativeCheckCache()); + const raw = await getCache(); if (!raw || typeof raw !== 'string') { nativeCheckCacheExhausted = true; return undefined; @@ -1794,13 +1772,10 @@ export class Pushy { restart?: boolean; }): Promise => { if (!assertWeb()) { - // On web PushyModule is a Proxy of noops, so the feature-detect below - // would report a false success. return false; } - if (typeof PushyModule.resetToPackagedBundle !== 'function') { - // The JS layer can arrive via hot update onto an older binary whose - // native module predates this method. + const reset = nativeMethod('resetToPackagedBundle'); + if (!reset) { const err = new UpdateError( this.t('error_reset_not_supported'), 'RESET_FAILED' @@ -1810,7 +1785,7 @@ export class Pushy { return false; } try { - await PushyModule.resetToPackagedBundle(); + await reset(); } catch (e) { const err = toUpdateError(e, 'RESET_FAILED'); this.emitError(err, 'errorReset'); diff --git a/src/core.ts b/src/core.ts index 89ab5ed0..1da147cd 100644 --- a/src/core.ts +++ b/src/core.ts @@ -35,6 +35,34 @@ if (!PushyModule) { ); } +/** + * `module[name]` as an always-async function (a synchronous bridge throw + * becomes a rejection), or undefined when the module lacks it: the JS layer + * can arrive via hot update onto an older binary whose native module predates + * the method. Always undefined on web, where the module is a noop Proxy that + * would pass any typeof check. + */ +export const bindNativeMethod = ( + module: Record, + name: string +): ((...args: any[]) => Promise) | undefined => { + const method = module === emptyModule ? undefined : module[name]; + if (typeof method !== 'function') { + return undefined; + } + return (...args) => { + try { + return Promise.resolve(method.apply(module, args)); + } catch (e) { + return Promise.reject(e); + } + }; +}; + +/** An optional PushyModule method, see bindNativeMethod. */ +export const nativeMethod = (name: string) => + bindNativeMethod(PushyModule, name); + // On web PushyModule is a Proxy whose every property is a noop function, so // reading the constants off it would make each of them a function: // `isRolledBack` true (a fake rollback report on every page load), a uuid of @@ -121,18 +149,13 @@ let uuid = PushyConstants.uuid; // between its dedup window and the fetch, and a hash is never worth delaying // a check for. let bundleHash = ''; -// The JS layer can arrive via hot update onto an older binary whose native -// module predates the method, hence the feature detect (on web the noop Proxy -// would pass it, so web is excluded explicitly). -if (!isWebPlatform && typeof PushyModule.getBundleHash === 'function') { - Promise.resolve(PushyModule.getBundleHash()) - .then((hash: unknown) => { - bundleHash = String(hash || ''); - }) - .catch((e: any) => { - log('getBundleHash failed:', e?.message || e); - }); -} +nativeMethod('getBundleHash')?.() + .then((hash: unknown) => { + bundleHash = String(hash || ''); + }) + .catch((e: any) => { + log('getBundleHash failed:', e?.message || e); + }); /** '' while unknown (not yet computed, debug build, no embedded bundle, older native). */ export const getBundleHash = (): string => bundleHash; diff --git a/src/updateFlowCore.ts b/src/updateFlowCore.ts index 2ba73da8..122f5cd5 100644 --- a/src/updateFlowCore.ts +++ b/src/updateFlowCore.ts @@ -156,7 +156,7 @@ export const jsonDeepEqual = (a: unknown, b: unknown): boolean => { leftKeys.length === definedKeys(right).length && leftKeys.every( (key) => - // Not Object.hasOwn: it is missing from JSC before iOS 15.4. + // biome-ignore lint/suspicious/noPrototypeBuiltins: Object.hasOwn is missing from JSC before iOS 15.4 Object.prototype.hasOwnProperty.call(right, key) && jsonDeepEqual(left[key], right[key]) ) diff --git a/src/utils.ts b/src/utils.ts index 2a8302b3..e3edda19 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -82,41 +82,32 @@ export const emptyModule = new Proxy(emptyModuleTarget, { }, }); -// Web never probes (assertWeb gates every flow); the identity keeps the -// signature. Not the bare `Promise.resolve`: called unbound through map() it -// throws. -const ping = isWeb - ? (url: string) => Promise.resolve(url) - : async (url: string) => { - try { - const { - status, - statusText, - url: finalUrl, - } = await fetchWithTimeout( - url, - { - method: 'HEAD', - }, - DEFAULT_FETCH_TIMEOUT_MS - ); - if (status === 200) { - if (isProtocolDowngrade(url, finalUrl)) { - // The probe was redirected from https to plaintext http. Never - // let a downgraded final URL win the race: the winner is what - // the real artifact download uses. - log('ping rejected: https redirected to http', url, finalUrl); - throw Error(i18n.t('error_ping_failed')); - } - return finalUrl; - } - log('ping failed', url, status, statusText); +const ping = async (url: string) => { + try { + const { + status, + statusText, + url: finalUrl, + } = await fetchWithTimeout(url, { + method: 'HEAD', + }); + if (status === 200) { + if (isProtocolDowngrade(url, finalUrl)) { + // The probe was redirected from https to plaintext http. Never let a + // downgraded final URL win the race: the winner is what the real + // artifact download uses. + log('ping rejected: https redirected to http', url, finalUrl); throw Error(i18n.t('error_ping_failed')); - } catch (e) { - log('ping error', url, e); - throw e; } - }; + return finalUrl; + } + log('ping failed', url, status, statusText); + throw Error(i18n.t('error_ping_failed')); + } catch (e) { + log('ping error', url, e); + throw e; + } +}; export { joinUrls } from './updateFlowCore'; From c66f83b5062a0ce37da257d483df5b9ee4d9ad56 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:44:47 +0800 Subject: [PATCH 007/104] refactor(harmony): drop dead code and generated files - UpdateContext isFirstTime()/clearFirstTime() and the ArkTS STATE_OP_CLEAR_FIRST_TIME mirror (the C++ enum stays). - DownloadTask types that are never created (TASK_TYPE_CLEANUP, TASK_TYPE_PLAIN_DOWNLOAD): doCleanUp, their switch cases, the plain download failure branch and the now always-true isPatchTask checks. The params default type becomes TASK_TYPE_PATCH_FULL. - Test-only ErrorCodes.parseErrorCodePrefix, unused ARCHIVE_PATCH_TYPE_FULL, an empty extern "C" block in pushy.cpp. - PushyPackage.h used a copy-pasted GEO_LOCATION_PACKAGE_H guard. - BuildProfile.ets is regenerated by hvigor on every build (nothing imports it): untrack and ignore it. OAT.xml was an unused template. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 2 + harmony/pushy/BuildProfile.ets | 19 ------ harmony/pushy/OAT.xml | 38 ----------- harmony/pushy/src/main/cpp/PushyPackage.h | 4 +- harmony/pushy/src/main/cpp/pushy.cpp | 3 - harmony/pushy/src/main/ets/DownloadTask.ts | 68 +++++-------------- .../pushy/src/main/ets/DownloadTaskParams.ts | 4 +- harmony/pushy/src/main/ets/ErrorCodes.ts | 13 ---- harmony/pushy/src/main/ets/NativePatchCore.ts | 2 - harmony/pushy/src/main/ets/UpdateContext.ts | 19 +----- .../src/test/DownloadTaskParams.test.ets | 4 +- harmony/pushy/src/test/ErrorCodes.test.ets | 14 ---- 12 files changed, 23 insertions(+), 167 deletions(-) delete mode 100644 harmony/pushy/BuildProfile.ets delete mode 100644 harmony/pushy/OAT.xml diff --git a/.gitignore b/.gitignore index b5ac7c6f..2f08e9ad 100644 --- a/.gitignore +++ b/.gitignore @@ -65,6 +65,8 @@ harmony/pushy.har **/oh_modules harmony/pushy/.preview harmony/pushy/.cxx +# Regenerated by hvigor on every HAR build. +harmony/pushy/BuildProfile.ets harmony/.tsconfig.harmony.json Example/harmony_use_pushy/harmony/entry/src/main/resources/rawfile/meta.json **/.hvigor diff --git a/harmony/pushy/BuildProfile.ets b/harmony/pushy/BuildProfile.ets deleted file mode 100644 index 58c9fdc4..00000000 --- a/harmony/pushy/BuildProfile.ets +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Use these variables when you tailor your ArkTS code. They must be of the const type. - */ -import packageJson from '../../package.json' - -export const HAR_VERSION = packageJson.version; -export const BUILD_MODE_NAME = 'debug'; -export const DEBUG = true; -export const TARGET_NAME = 'default'; - -/** - * BuildProfile Class is used only for compatibility purposes. - */ -export default class BuildProfile { - static readonly HAR_VERSION = HAR_VERSION; - static readonly BUILD_MODE_NAME = BUILD_MODE_NAME; - static readonly DEBUG = DEBUG; - static readonly TARGET_NAME = TARGET_NAME; -} \ No newline at end of file diff --git a/harmony/pushy/OAT.xml b/harmony/pushy/OAT.xml deleted file mode 100644 index 55e3f13b..00000000 --- a/harmony/pushy/OAT.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - LICENSE - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/harmony/pushy/src/main/cpp/PushyPackage.h b/harmony/pushy/src/main/cpp/PushyPackage.h index 52d16012..b8702e74 100644 --- a/harmony/pushy/src/main/cpp/PushyPackage.h +++ b/harmony/pushy/src/main/cpp/PushyPackage.h @@ -22,8 +22,8 @@ * SOFTWARE. */ -#ifndef GEO_LOCATION_PACKAGE_H -#define GEO_LOCATION_PACKAGE_H +#ifndef PUSHY_PACKAGE_H +#define PUSHY_PACKAGE_H #include "RNOH/Package.h" #include "PushyTurboModule.h" diff --git a/harmony/pushy/src/main/cpp/pushy.cpp b/harmony/pushy/src/main/cpp/pushy.cpp index 31f35e7b..d343e9c7 100644 --- a/harmony/pushy/src/main/cpp/pushy.cpp +++ b/harmony/pushy/src/main/cpp/pushy.cpp @@ -18,9 +18,6 @@ #include "state_core.h" #include "state_ops.h" -extern "C" { -} - namespace { using pushy::state_ops::StateOperation; diff --git a/harmony/pushy/src/main/ets/DownloadTask.ts b/harmony/pushy/src/main/ets/DownloadTask.ts index d7e960b2..c5a1eb3e 100644 --- a/harmony/pushy/src/main/ets/DownloadTask.ts +++ b/harmony/pushy/src/main/ets/DownloadTask.ts @@ -1237,25 +1237,7 @@ export class DownloadTask { } } - private async doCleanUp(params: DownloadTaskParams): Promise { - try { - await NativePatchCore.cleanupOldEntries( - params.unzipDirectory, - [params.hash, params.originHash].filter(name => !!name), - 3, - ); - } catch (error) { - const message = `Cleanup failed: ${getErrorMessage(error)}`; - logger.error(TAG, message); - throw createPushyError(ERROR_FILE_OPERATION_FAILED, message); - } - } - 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: @@ -1267,24 +1249,13 @@ export class DownloadTask { case DownloadTaskParams.TASK_TYPE_PATCH_FROM_PPK: await this.doPatchFromPpk(params); break; - case DownloadTaskParams.TASK_TYPE_CLEANUP: - await this.doCleanUp(params); - break; - case DownloadTaskParams.TASK_TYPE_PLAIN_DOWNLOAD: - await this.downloadFile(params); - // 文件在完成时即是最终产物——残留的 sidecar 会让下次同 URL 的 - // 下载不发请求直接返回这份旧字节。 - await this.deleteResumeSidecar(params.targetFile); - break; default: throw createPushyError( ERROR_INVALID_OPTIONS, `Unknown task type: ${params.type}`, ); } - if (isPatchTask) { - await this.promoteStaging(params); - } + await this.promoteStaging(params); } catch (rawError) { // 稳定错误码:下载阶段的失败是 DOWNLOAD_FAILED;归档收齐之后的失败 // (解压/hpatch/资源拷贝含 copiesCrc 校验/完成记录)一律 PATCH_FAILED, @@ -1295,29 +1266,22 @@ export class DownloadTask { this.downloadPhaseCompleted ? ERROR_DELTA_FAILED : ERROR_DOWNLOAD_FAILED, ); logger.error(TAG, `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); - await this.deleteResumeSidecar(params.targetFile); - } else { - // Only the staging directory is ours to drop: the final version - // directory is never touched by a failed install, so a failed - // duplicate task cannot remove an earlier task's install. - await this.removeDirectory(this.stagingDirectory(params)); - if (this.downloadPhaseCompleted) { - // 收齐后才失败的是解压/patch 失败:归档有毒,续传只会撞上 - // 同一个失败。下载阶段的失败则保留 partial + sidecar——那 - // 就是续传状态。 - await this.deleteArchiveAndSidecar(params.targetFile); - } - } - } catch (cleanupError) { - logger.error( - TAG, - `Cleanup after error failed: ${getErrorMessage(cleanupError)}`, - ); + try { + // Only the staging directory is ours to drop: the final version + // directory is never touched by a failed install, so a failed + // duplicate task cannot remove an earlier task's install. + await this.removeDirectory(this.stagingDirectory(params)); + if (this.downloadPhaseCompleted) { + // 收齐后才失败的是解压/patch 失败:归档有毒,续传只会撞上 + // 同一个失败。下载阶段的失败则保留 partial + sidecar——那 + // 就是续传状态。 + await this.deleteArchiveAndSidecar(params.targetFile); } + } catch (cleanupError) { + logger.error( + TAG, + `Cleanup after error failed: ${getErrorMessage(cleanupError)}`, + ); } throw error; } diff --git a/harmony/pushy/src/main/ets/DownloadTaskParams.ts b/harmony/pushy/src/main/ets/DownloadTaskParams.ts index ff612466..6c1c94b9 100644 --- a/harmony/pushy/src/main/ets/DownloadTaskParams.ts +++ b/harmony/pushy/src/main/ets/DownloadTaskParams.ts @@ -3,13 +3,11 @@ */ export class DownloadTaskParams { // 任务类型常量 - static readonly TASK_TYPE_CLEANUP: number = 0; // 保留hash和originHash static readonly TASK_TYPE_PATCH_FULL: number = 1; // 全量补丁 static readonly TASK_TYPE_PATCH_FROM_APP: number = 2; // 从APP补丁 static readonly TASK_TYPE_PATCH_FROM_PPK: number = 3; // 从PPK补丁 - static readonly TASK_TYPE_PLAIN_DOWNLOAD: number = 4; // 普通下载 - type: number = DownloadTaskParams.TASK_TYPE_CLEANUP; // 任务类型 + type: number = DownloadTaskParams.TASK_TYPE_PATCH_FULL; // 任务类型 url: string = ''; // 下载URL hash: string = ''; // 文件哈希值 originHash: string = ''; // 原始文件哈希值 diff --git a/harmony/pushy/src/main/ets/ErrorCodes.ts b/harmony/pushy/src/main/ets/ErrorCodes.ts index c03f1f58..c65c8790 100644 --- a/harmony/pushy/src/main/ets/ErrorCodes.ts +++ b/harmony/pushy/src/main/ets/ErrorCodes.ts @@ -62,16 +62,3 @@ export function toPushyError(error: unknown, defaultCode: string): PushyError { } return new PushyError(defaultCode, getErrorMessage(error)); } - -/** 从 `[CODE] message` 形式的消息里解析错误码;无前缀返回空串。 */ -export function parseErrorCodePrefix(message: string): string { - if (typeof message !== 'string' || !message.startsWith('[')) { - return ''; - } - const end = message.indexOf('] '); - if (end <= 1) { - return ''; - } - const code = message.substring(1, end); - return /^[A-Z][A-Z0-9_]*$/.test(code) ? code : ''; -} diff --git a/harmony/pushy/src/main/ets/NativePatchCore.ts b/harmony/pushy/src/main/ets/NativePatchCore.ts index 5f32d7ae..93d84a98 100644 --- a/harmony/pushy/src/main/ets/NativePatchCore.ts +++ b/harmony/pushy/src/main/ets/NativePatchCore.ts @@ -3,11 +3,9 @@ import NativeCore from 'librnpushy.so'; export const STATE_OP_SWITCH_VERSION = 1; export const STATE_OP_MARK_SUCCESS = 2; export const STATE_OP_ROLLBACK = 3; -export const STATE_OP_CLEAR_FIRST_TIME = 4; export const STATE_OP_CLEAR_ROLLBACK_MARK = 5; export const STATE_OP_RESOLVE_LAUNCH = 6; -export const ARCHIVE_PATCH_TYPE_FULL = 1; export const ARCHIVE_PATCH_TYPE_FROM_PACKAGE = 2; export const ARCHIVE_PATCH_TYPE_FROM_PPK = 3; diff --git a/harmony/pushy/src/main/ets/UpdateContext.ts b/harmony/pushy/src/main/ets/UpdateContext.ts index f8199dbc..530530b9 100644 --- a/harmony/pushy/src/main/ets/UpdateContext.ts +++ b/harmony/pushy/src/main/ets/UpdateContext.ts @@ -19,7 +19,6 @@ import { scheduleNativeSync, } from './SyncCoordinator'; import NativePatchCore, { - STATE_OP_CLEAR_FIRST_TIME, STATE_OP_CLEAR_ROLLBACK_MARK, STATE_OP_MARK_SUCCESS, STATE_OP_RESOLVE_LAUNCH, @@ -500,13 +499,9 @@ 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)) { + if (this.hasDownloadedVersion(params.hash)) { return Promise.resolve(); } const downloadTask = new DownloadTask(this.context); @@ -594,10 +589,6 @@ export class UpdateContext { return this.flushPreferences(`remove key ${key}`); } - public isFirstTime(): boolean { - return this.getStateSnapshot().firstTime; - } - public rolledBackVersion(): string { return this.getStateSnapshot().rolledBackVersion || ''; } @@ -614,14 +605,6 @@ export class UpdateContext { await this.flushPreferences('mark success'); } - public async clearFirstTime(): Promise { - this.runStateOperation(STATE_OP_CLEAR_FIRST_TIME, '', { - cleanUp: true, - clearFirstLoadMarker: true, - }); - await this.flushPreferences('clear first time'); - } - /** getConstants(同步)里调用:落盘失败只记日志。 */ public clearRollbackMark(): void { this.runStateOperation(STATE_OP_CLEAR_ROLLBACK_MARK, '', { diff --git a/harmony/pushy/src/test/DownloadTaskParams.test.ets b/harmony/pushy/src/test/DownloadTaskParams.test.ets index 9ac3496a..784cad22 100644 --- a/harmony/pushy/src/test/DownloadTaskParams.test.ets +++ b/harmony/pushy/src/test/DownloadTaskParams.test.ets @@ -4,16 +4,14 @@ import { DownloadTaskParams } from '../main/ets/DownloadTaskParams'; export default function downloadTaskParamsTest() { describe('DownloadTaskParams', () => { it('has correct task type constants', 0, () => { - expect(DownloadTaskParams.TASK_TYPE_CLEANUP).assertEqual(0); expect(DownloadTaskParams.TASK_TYPE_PATCH_FULL).assertEqual(1); expect(DownloadTaskParams.TASK_TYPE_PATCH_FROM_APP).assertEqual(2); expect(DownloadTaskParams.TASK_TYPE_PATCH_FROM_PPK).assertEqual(3); - expect(DownloadTaskParams.TASK_TYPE_PLAIN_DOWNLOAD).assertEqual(4); }); it('has correct default values', 0, () => { const params = new DownloadTaskParams(); - expect(params.type).assertEqual(DownloadTaskParams.TASK_TYPE_CLEANUP); + expect(params.type).assertEqual(DownloadTaskParams.TASK_TYPE_PATCH_FULL); expect(params.url).assertEqual(''); expect(params.hash).assertEqual(''); expect(params.originHash).assertEqual(''); diff --git a/harmony/pushy/src/test/ErrorCodes.test.ets b/harmony/pushy/src/test/ErrorCodes.test.ets index b42a0a42..1f0a858f 100644 --- a/harmony/pushy/src/test/ErrorCodes.test.ets +++ b/harmony/pushy/src/test/ErrorCodes.test.ets @@ -5,7 +5,6 @@ import { PushyError, createPushyError, getErrorMessage, - parseErrorCodePrefix, toPushyError, } from '../main/ets/ErrorCodes'; @@ -38,19 +37,6 @@ export default function errorCodesTest() { }); }); - describe('ErrorCodes.parseErrorCodePrefix', () => { - it('extracts a well-formed code', 0, () => { - expect(parseErrorCodePrefix('[PATCH_FAILED] boom')).assertEqual('PATCH_FAILED'); - }); - - it('returns an empty string otherwise', 0, () => { - expect(parseErrorCodePrefix('boom')).assertEqual(''); - expect(parseErrorCodePrefix('[lower] boom')).assertEqual(''); - expect(parseErrorCodePrefix('[] boom')).assertEqual(''); - expect(parseErrorCodePrefix('[NO_SPACE]boom')).assertEqual(''); - }); - }); - describe('ErrorCodes.getErrorMessage', () => { it('reads messages from errors, objects and primitives', 0, () => { expect(getErrorMessage(new Error('e'))).assertEqual('e'); From b2e561c67656befcedc3db8e3c96dc2af92c5b2d Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:45:17 +0800 Subject: [PATCH 008/104] refactor(client): trim endpoint plumbing - getRemoteEndpoints no longer filters out configured endpoints: the endpoint fallback already dedupes candidates and drops failed ones. - getConfiguredCheckEndpoints returns server.main as is (deduplicated on assignment and again by the endpoint plan). - Inline the single-caller getCheckUrl; the per-endpoint failure log moves from fetchCheckResult's tryEndpoint wrapper into requestCheckResult. - endpoint.ts no longer re-exports dedupeEndpoints; fetchWithTimeout drops the unused params.signal fallback; drop explicit default timeouts. Co-Authored-By: Claude Opus 5.5 --- src/__tests__/client.test.ts | 4 +- src/__tests__/endpoint.test.ts | 2 +- src/client.ts | 125 ++++++++++++++------------------- src/endpoint.ts | 2 - src/utils.ts | 18 +++-- 5 files changed, 63 insertions(+), 88 deletions(-) diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index 0f2d9d79..972ca9cd 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -187,7 +187,7 @@ describe('Pushy server config', () => { ]); }); - test('filters remote endpoints against all configured main endpoints', async () => { + test('dedupes remote endpoints (the fallback drops configured ones)', async () => { setupClientMocks(); (globalThis as any).fetch = mock(async () => createJsonResponse([ @@ -203,7 +203,9 @@ describe('Pushy server config', () => { const client = new Pushy({ appKey: 'demo-app' }); expect(await client.getRemoteEndpoints()).toEqual([ + 'https://update.react-native.cn/api', 'https://edge-a.example.com', + 'https://update.reactnative.cn/api', 'https://edge-b.example.com', ]); }); diff --git a/src/__tests__/endpoint.test.ts b/src/__tests__/endpoint.test.ts index c4600de5..b3faf651 100644 --- a/src/__tests__/endpoint.test.ts +++ b/src/__tests__/endpoint.test.ts @@ -1,9 +1,9 @@ import { describe, expect, mock, test } from 'bun:test'; import { - dedupeEndpoints, executeEndpointFallback, selectFastestSuccessfulEndpoint, } from '../endpoint'; +import { dedupeEndpoints } from '../updateFlowCore'; const delay = (ms: number) => new Promise((resolve) => { diff --git a/src/client.ts b/src/client.ts index 1baff5a7..60ca8663 100644 --- a/src/client.ts +++ b/src/client.ts @@ -19,7 +19,7 @@ import { setLocalHashInfo, supportedDiffVersion, } from './core'; -import { dedupeEndpoints, executeEndpointFallback } from './endpoint'; +import { executeEndpointFallback } from './endpoint'; import { readErrorCode, toUpdateError, @@ -54,6 +54,7 @@ import { type DownloadPlan, type DownloadStrategyType, decideDownload, + dedupeEndpoints, isMirrorRetryableCode, isValidCheckResult, jsonDeepEqual, @@ -117,14 +118,6 @@ const RELOAD_WATCHDOG_MS = 5000; // for the error message (it can end up in an alert) and the logger. const MAX_HTTP_ERROR_TEXT = 200; -const excludeConfiguredEndpoints = ( - endpoints: string[], - configuredEndpoints: string[] -) => { - const configured = new Set(configuredEndpoints); - return endpoints.filter((endpoint) => !configured.has(endpoint)); -}; - assertWeb(); const createDefaultClientOptions = (): ClientOptions => ({ @@ -881,16 +874,9 @@ export class Pushy { } return true; }; - getCheckUrl = (endpoint: string) => { - return `${endpoint}/checkUpdate/${this.options.appKey}`; - }; - getConfiguredCheckEndpoints = () => { - const { server } = this.options; - if (!server) { - return []; - } - return dedupeEndpoints(server.main); - }; + // server.main is deduplicated on assignment (cloneServerConfig), and the + // endpoint plan dedupes again anyway. + getConfiguredCheckEndpoints = () => this.options.server?.main ?? []; getRemoteEndpoints = async () => { const { server } = this.options; if (!server?.queryUrls?.length) { @@ -903,11 +889,7 @@ export class Pushy { // with an error page must not beat the healthy one. const remoteEndpoints = await promiseAny( server.queryUrls.map(async (queryUrl) => { - const resp = await fetchWithTimeout( - queryUrl, - {}, - DEFAULT_FETCH_TIMEOUT_MS - ); + const resp = await fetchWithTimeout(queryUrl, {}); if (!resp.ok) { throw Error(`${queryUrl}: ${resp.status}`); } @@ -919,16 +901,15 @@ export class Pushy { }) ); log('fetch endpoints:', remoteEndpoints); - return excludeConfiguredEndpoints( - dedupeEndpoints( - remoteEndpoints.filter( - // The list is fetched from a public mirror; it may add - // endpoints but never move the check outside TLS. - (endpoint): endpoint is string => - typeof endpoint === 'string' && /^https:\/\//i.test(endpoint) - ) - ), - this.getConfiguredCheckEndpoints() + // Configured and already-failed endpoints are dropped by the endpoint + // fallback itself. + return dedupeEndpoints( + remoteEndpoints.filter( + // The list is fetched from a public mirror; it may add endpoints + // but never move the check outside TLS. + (endpoint): endpoint is string => + typeof endpoint === 'string' && /^https:\/\//i.test(endpoint) + ) ); } catch (e) { log('failed to fetch endpoints from: ', server.queryUrls, e); @@ -939,54 +920,50 @@ export class Pushy { endpoint: string, fetchPayload: Parameters[1], signal?: AbortSignal - ) => { - const resp = await fetchWithTimeout( - this.getCheckUrl(endpoint), - fetchPayload, - DEFAULT_FETCH_TIMEOUT_MS, - signal - ); - - if (!resp.ok) { - const respText = (await resp.text()).slice(0, MAX_HTTP_ERROR_TEXT); - throw new UpdateError( - this.t('error_http_status', { - status: resp.status, - statusText: respText, - }), - 'HTTP_STATUS', - { extra: { status: resp.status } } + ): Promise => { + try { + const resp = await fetchWithTimeout( + `${endpoint}/checkUpdate/${this.options.appKey}`, + fetchPayload, + DEFAULT_FETCH_TIMEOUT_MS, + signal ); - } - const result: unknown = await resp.json(); - if (!isValidCheckResult(result)) { - // A 2xx that is not a verdict (`{"error": ...}`, a captive-portal page - // that parsed, ...) is a failed endpoint: throwing keeps the fallback - // moving to the next one instead of treating it as "no update". - throw new UpdateError( - this.t('error_invalid_check_response'), - 'INVALID_RESPONSE' - ); + if (!resp.ok) { + const respText = (await resp.text()).slice(0, MAX_HTTP_ERROR_TEXT); + throw new UpdateError( + this.t('error_http_status', { + status: resp.status, + statusText: respText, + }), + 'HTTP_STATUS', + { extra: { status: resp.status } } + ); + } + + const result: unknown = await resp.json(); + if (!isValidCheckResult(result)) { + // A 2xx that is not a verdict (`{"error": ...}`, a captive-portal + // page that parsed, ...) is a failed endpoint: throwing keeps the + // fallback moving to the next one instead of treating it as "no + // update". + throw new UpdateError( + this.t('error_invalid_check_response'), + 'INVALID_RESPONSE' + ); + } + return result; + } catch (e) { + log('check endpoint failed', endpoint, e); + throw e; } - return result; }; fetchCheckResult = async (fetchPayload: Parameters[1]) => { const { endpoint, value } = await executeEndpointFallback({ configuredEndpoints: this.getConfiguredCheckEndpoints(), getRemoteEndpoints: this.getRemoteEndpoints, - tryEndpoint: async (currentEndpoint, signal) => { - try { - return await this.requestCheckResult( - currentEndpoint, - fetchPayload, - signal - ); - } catch (e) { - log('check endpoint failed', currentEndpoint, e); - throw e; - } - }, + tryEndpoint: (currentEndpoint, signal) => + this.requestCheckResult(currentEndpoint, fetchPayload, signal), onFirstFailure: ({ error }) => { this.report({ type: 'errorChecking', diff --git a/src/endpoint.ts b/src/endpoint.ts index 4c0dc4e6..5ed24bc1 100644 --- a/src/endpoint.ts +++ b/src/endpoint.ts @@ -2,8 +2,6 @@ import { UpdateError } from './error'; import i18n from './i18n'; import { dedupeEndpoints, orderEndpointCandidates } from './updateFlowCore'; -export { dedupeEndpoints }; - export interface EndpointAttemptSuccess { endpoint: string; value: T; diff --git a/src/utils.ts b/src/utils.ts index e3edda19..553234fa 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -173,16 +173,14 @@ export const fetchWithTimeout = ( // Promise.race the losing fetch kept running (and kept the connection busy) // long after the caller had already moved on. const controller = new AbortController(); - // The timeout controller replaces params.signal on the fetch call, so a - // caller-provided signal (e.g. the hedged endpoint race cancelling losers) - // must be chained onto it manually. - const externalSignal = - signal ?? ((params as any)?.signal as AbortSignal | undefined); + // The timeout controller is the fetch call's signal, so a caller-provided + // signal (e.g. the hedged endpoint race cancelling losers) must be chained + // onto it manually. const onExternalAbort = () => controller.abort(); - if (externalSignal?.aborted) { + if (signal?.aborted) { controller.abort(); - } else if (externalSignal) { - externalSignal.addEventListener('abort', onExternalAbort); + } else if (signal) { + signal.addEventListener('abort', onExternalAbort); } const timeoutId = setTimeout(() => { log('fetch timeout', url); @@ -191,7 +189,7 @@ export const fetchWithTimeout = ( return enhancedFetch(url, { ...params, signal: controller.signal as any }) .catch((e: any) => { - if (externalSignal?.aborted) { + if (signal?.aborted) { // Cancelled by the caller, not timed out; keep the abort semantics. throw e; } @@ -204,7 +202,7 @@ export const fetchWithTimeout = ( clearTimeout(timeoutId); // A long-lived caller signal (one per endpoint race) would otherwise // keep every finished request's listener — and its controller — alive. - externalSignal?.removeEventListener('abort', onExternalAbort); + signal?.removeEventListener('abort', onExternalAbort); }); }; From 65e4c734ebea1c5ee711867b8a2409376c0fc47c Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:45:20 +0800 Subject: [PATCH 009/104] build(android): drop pre-7.5 Gradle exec path, dedupe gradle and ndk flags Expo SDK 50+ (the only caller of the node fallback) needs Gradle 8, so the Project.exec branch is dead. Set version once, drop the commented useExpoPublishing, and declare Java 8 explicitly so lambdas compile on AGP < 4.2. Android.mk repeated -std=c++17 and --exclude-libs that Application.mk already applies to every module. Co-Authored-By: Claude Opus 5.5 --- android/build.gradle | 52 ++++++++++++++++++------------------------ android/jni/Android.mk | 2 -- 2 files changed, 22 insertions(+), 32 deletions(-) diff --git a/android/build.gradle b/android/build.gradle index 328df88f..764f5a00 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -56,34 +56,17 @@ def parseDeclaredMajorVersion(String declared) { def resolveInstalledExpoMajorVersion(File projectDir) { def nodeArgs = ['node', '--print', "require('expo/package.json').version"] try { - String stdout - if (GradleVersion.current() >= GradleVersion.version('7.5')) { - // providers.exec exists since Gradle 7.5; Project.exec is gone in 9. - def output = providers.exec { - workingDir projectDir - commandLine nodeArgs - ignoreExitValue = true - } - if (output.result.get().exitValue != 0) { - return null - } - stdout = output.standardOutput.asText.get() - } else { - def out = new ByteArrayOutputStream() - def result = project.exec { - workingDir projectDir - commandLine nodeArgs - standardOutput = out - errorOutput = new ByteArrayOutputStream() - ignoreExitValue true - } - if (result.exitValue != 0) { - return null - } - stdout = out.toString() + // providers.exec exists since Gradle 7.5 (Expo SDK 50+ requires + // Gradle 8); Project.exec is gone in 9. + def output = providers.exec { + workingDir projectDir + commandLine nodeArgs + ignoreExitValue = true } - - return parseMajorVersion(stdout.trim()) + if (output.result.get().exitValue != 0) { + return null + } + return parseMajorVersion(output.standardOutput.asText.get().trim()) } catch (Exception ignored) { return null } @@ -154,18 +137,16 @@ if (isNewArchitectureEnabled()) { apply plugin: 'com.facebook.react' } +version = projectVersion if (expoProject) { group = 'expo.modules.pushy' - version = projectVersion def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") apply from: expoModulesCorePlugin applyKotlinExpoModulesCorePlugin() - // useExpoPublishing() useCoreDependencies() } else { group = 'cn.reactnative.modules.update' - version = projectVersion } android { @@ -179,6 +160,9 @@ android { } } compileSdkVersion safeExtGet('compileSdkVersion', 34) + // Follows the app's ext.buildToolsVersion: without it AGP falls back to + // its own default, which hosts that only install the app's build-tools + // then have to download. buildToolsVersion safeExtGet('buildToolsVersion', '34.0.0') defaultConfig { minSdkVersion safeExtGet('minSdkVersion', 21) @@ -186,6 +170,14 @@ android { consumerProguardFiles "proguard.pro" buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString() } + + // The sources use lambdas, which AGP < 4.2 (React Native < 0.65) only + // compiles with an explicit Java 8 level. React Native's Gradle plugin + // (0.73+) raises every module to its own JDK level in finalizeDsl. + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } sourceSets { main { diff --git a/android/jni/Android.mk b/android/jni/Android.mk index 5de674f7..30f2360f 100644 --- a/android/jni/Android.mk +++ b/android/jni/Android.mk @@ -3,8 +3,6 @@ LOCAL_PATH := $(call my-dir) include $(CLEAR_VARS) LOCAL_MODULE := rnpushy -LOCAL_CPPFLAGS += -std=c++17 -LOCAL_LDFLAGS += -Wl,--exclude-libs,ALL LOCAL_LDLIBS := -llog LOCAL_C_INCLUDES := \ $(LOCAL_PATH) \ From 1e139beca9ebae320c35b3a924cd9b033d285739 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:45:45 +0800 Subject: [PATCH 010/104] fix(android-jni): build Java strings from UTF-8 with NewString, not NewStringUTF update_core_android.cpp passed std::string values to NewStringUTF, which expects JNI modified UTF-8 and mangles supplementary (4-byte) code points. Move update_flow_jni.cpp's correct UTF-8 -> UTF-16 ToJString into the shared jni_util.h and use it for every Java string the patch/state glue returns. Co-Authored-By: Claude Opus 5.5 --- cpp/patch_core/jni_util.h | 72 +++++++++++++++++++++++- cpp/patch_core/update_core_android.cpp | 5 +- cpp/update_flow_core/update_flow_jni.cpp | 69 +---------------------- 3 files changed, 73 insertions(+), 73 deletions(-) diff --git a/cpp/patch_core/jni_util.h b/cpp/patch_core/jni_util.h index 123e847b..a02fc65a 100644 --- a/cpp/patch_core/jni_util.h +++ b/cpp/patch_core/jni_util.h @@ -2,12 +2,15 @@ #include +#include +#include #include #include -// Small JNI helpers shared by the Android glue translation units -// (patch_core_android.cpp and update_core_android.cpp). Header-only with inline -// linkage so both can include it without a separate compilation unit. +// Small JNI helpers shared by every Android glue translation unit +// (patch_core_android.cpp, update_core_android.cpp and +// ../update_flow_core/update_flow_jni.cpp). Header-only with inline linkage so +// each can include it without a separate compilation unit. namespace pushy { namespace jni_util { @@ -27,6 +30,69 @@ inline std::string JStringToString(JNIEnv* env, jstring value) { return result; } +// Standard UTF-8 -> Java string. NewStringUTF expects JNI's *modified* UTF-8 +// and corrupts supplementary code points (4-byte sequences, e.g. emoji), so +// decode explicitly and pass UTF-16 code units to NewString. Encoded +// surrogate code points are accepted too: strings that originated in Java may +// have entered through modified UTF-8 (JStringToString). Invalid bytes become +// U+FFFD. Returns nullptr when the result would not fit a jsize. +inline jstring ToJString(JNIEnv* env, const std::string& value) { + 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())); +} + inline std::vector JArrayToVector( JNIEnv* env, jobjectArray values) { diff --git a/cpp/patch_core/update_core_android.cpp b/cpp/patch_core/update_core_android.cpp index 00a2d684..a0a302b1 100644 --- a/cpp/patch_core/update_core_android.cpp +++ b/cpp/patch_core/update_core_android.cpp @@ -19,6 +19,7 @@ namespace { using pushy::jni_util::JArrayToVector; using pushy::jni_util::JStringToString; using pushy::jni_util::ThrowRuntimeException; +using pushy::jni_util::ToJString; using pushy::state_ops::StateOperation; constexpr const char* kStringSig = "Ljava/lang/String;"; @@ -189,7 +190,7 @@ void SetStringField( return; } - jstring java_value = env->NewStringUTF(value.c_str()); + jstring java_value = ToJString(env, value); env->SetObjectField(target, field, java_value); env->DeleteLocalRef(java_value); } @@ -292,7 +293,7 @@ jobject NewCopyGroupResult( if (to_paths != nullptr) { for (jsize index = 0; index < static_cast(group.to_paths.size()); ++index) { - jstring value = env->NewStringUTF(group.to_paths[index].c_str()); + jstring value = ToJString(env, group.to_paths[index]); env->SetObjectArrayElement(to_paths, index, value); env->DeleteLocalRef(value); } diff --git a/cpp/update_flow_core/update_flow_jni.cpp b/cpp/update_flow_core/update_flow_jni.cpp index c7ab42dd..55a8e7d9 100644 --- a/cpp/update_flow_core/update_flow_jni.cpp +++ b/cpp/update_flow_core/update_flow_jni.cpp @@ -8,80 +8,13 @@ #include "jni_natives.h" -#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 +using pushy::jni_util::ToJString; jstring pushy::jni_natives::FlowBuildRequestBody( JNIEnv* env, jclass, jstring inputJson) { From 1de1fb79bb3cff4021e14e200e0a905562713918 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:46:10 +0800 Subject: [PATCH 011/104] refactor(client): one telemetry POST for events and exceptions reportExceptionToServer and reportToServer each built the same POST to {endpoint}/report/{appKey}; both now go through postReport. The event shape {type,message,code,data} is a single ReportEvent type, the single-caller send closure is inlined, and captureException's guard absorbs the duplicated opt-out checks (plus the no-hot-update check). Co-Authored-By: Claude Opus 5.5 --- src/client.ts | 139 ++++++++++++++++++++------------------------------ 1 file changed, 56 insertions(+), 83 deletions(-) diff --git a/src/client.ts b/src/client.ts index 60ca8663..baed0d1c 100644 --- a/src/client.ts +++ b/src/client.ts @@ -79,6 +79,14 @@ import { * The UpdateProvider subscribes to surface errors as lastError/Alert; user * code can subscribe too. Listeners run before any throwError rethrow. */ +/** One report() event: the logger payload and the source of server telemetry. */ +interface ReportEvent { + type: EventType; + message?: string; + code?: UpdateErrorCode; + data?: Record; +} + export type UpdateErrorListener = ( error: UpdateError, eventType: EventType @@ -584,7 +592,8 @@ export class Pushy { if ( __DEV__ || this.options.disableTelemetry || - this.options.disableErrorReporting + this.options.disableErrorReporting || + !currentVersion ) { return; } @@ -598,7 +607,11 @@ export class Pushy { } this.reportedExceptions.add(object); } - this.reportExceptionToServer(serializeException(error, context)); + this.postReport({ + type: 'js_exception', + hash: currentVersion, + error: serializeException(error, context), + }); } catch (e: any) { log('exception telemetry error:', e?.message || e); } @@ -642,55 +655,7 @@ export class Pushy { return cleanup; }; - private reportExceptionToServer = (error: SerializedException): void => { - try { - if ( - __DEV__ || - this.options.disableTelemetry || - this.options.disableErrorReporting || - !currentVersion - ) { - return; - } - const { appKey } = this.options; - const endpoint = - this.lastWorkingEndpoint || this.options.server?.main?.[0]; - if (!appKey || !endpoint) { - return; - } - fetchWithTimeout( - `${endpoint}/report/${appKey}`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - type: 'js_exception', - hash: currentVersion, - packageVersion: this.getEffectivePackageVersion(), - cInfo, - error, - }), - }, - DEFAULT_FETCH_TIMEOUT_MS - ).catch((e: any) => { - log('exception telemetry report failed:', e?.message || e); - }); - } catch (e: any) { - log('exception telemetry error:', e?.message || e); - } - }; - - report = async ({ - type, - message = '', - code, - data = {}, - }: { - type: EventType; - message?: string; - code?: UpdateErrorCode; - data?: Record; - }) => { + report = async ({ type, message = '', code, data = {} }: ReportEvent) => { log(`${type} ${code ? `[${code}] ` : ''}${message}`); // Fire-and-forget server telemetry; must not wait for the logger below. this.reportToServer({ type, message, code, data }); @@ -742,15 +707,10 @@ export class Pushy { */ private reportToServer = ({ type, - message = '', + message, code, data = {}, - }: { - type: EventType; - message?: string; - code?: UpdateErrorCode; - data?: Record; - }) => { + }: ReportEvent) => { try { if (__DEV__ || this.options.disableTelemetry) { return; @@ -759,43 +719,56 @@ export class Pushy { if (!serverType) { return; } - const { appKey } = this.options; - const endpoint = - this.lastWorkingEndpoint || this.options.server?.main?.[0]; - if (!appKey || !endpoint) { - return; - } const hash = resolveServerEventHash({ serverType, data, currentVersion }); if (!hash) { return; } - const send = (payloadType: typeof serverType, detail?: string) => - fetchWithTimeout( - `${endpoint}/report/${appKey}`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - type: payloadType, - hash, - packageVersion: this.getEffectivePackageVersion(), - cInfo, - detail: truncateDetail(detail), - }), - }, - DEFAULT_FETCH_TIMEOUT_MS - ).catch((e: any) => { - log('telemetry report failed:', e?.message || e); - }); // A download rescued by a later strategy reports its patch-health // signal through downloadFallback, which only counts patch-apply // failures; downloadSuccess's data.error (any earlier failure, network // errors included) stays local. - send(serverType, message || undefined); + this.postReport({ + type: serverType, + hash, + detail: truncateDetail(message), + }); } catch (e: any) { log('telemetry error:', e?.message || e); } }; + /** + * The single telemetry POST ({endpoint}/report/{appKey}) to the last known + * working endpoint; no retry, failures only logged. + */ + private postReport = ({ + type, + hash, + ...payload + }: { + type: string; + hash: string; + detail?: string; + error?: SerializedException; + }) => { + const { appKey } = this.options; + const endpoint = this.lastWorkingEndpoint || this.options.server?.main?.[0]; + if (!appKey || !endpoint) { + return; + } + fetchWithTimeout(`${endpoint}/report/${appKey}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type, + hash, + packageVersion: this.getEffectivePackageVersion(), + cInfo, + ...payload, + }), + }).catch((e: any) => { + log('telemetry report failed:', e?.message || e); + }); + }; throwIfEnabled = (e: Error) => { if (this.options.throwError) { throw e; From 0f878728c90e1d5511f4e2596be84b24e2ad6560 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:46:11 +0800 Subject: [PATCH 012/104] refactor(android-jni): dedupe manifest building and exception guards - delta::BuildPatchManifest (patch_core.h): one pure builder for the parallel copy_froms/copy_tos lists with the length-mismatch check, so the Harmony bridge can reuse it too; jni_util::ReadPatchManifest wraps it for the three JNI entry points that built it by hand. - jni_util::GuardJni replaces the six copies of the catch(std::exception)/catch(...) -> RuntimeException block. - Drop the pure forwarders ToArchivePatchType and MakeStateResult (NewStateCoreResult takes the default arguments now). Co-Authored-By: Claude Opus 5.5 --- cpp/patch_core/jni_util.h | 41 +++ cpp/patch_core/patch_core.cpp | 20 ++ cpp/patch_core/patch_core.h | 10 + cpp/patch_core/patch_core_android.cpp | 39 +-- cpp/patch_core/tests/patch_core_test.cpp | 31 +++ cpp/patch_core/update_core_android.cpp | 312 +++++++++-------------- 6 files changed, 235 insertions(+), 218 deletions(-) diff --git a/cpp/patch_core/jni_util.h b/cpp/patch_core/jni_util.h index a02fc65a..83ad7d29 100644 --- a/cpp/patch_core/jni_util.h +++ b/cpp/patch_core/jni_util.h @@ -3,10 +3,14 @@ #include #include +#include #include #include +#include #include +#include "patch_core.h" + // Small JNI helpers shared by every Android glue translation unit // (patch_core_android.cpp, update_core_android.cpp and // ../update_flow_core/update_flow_jni.cpp). Header-only with inline linkage so @@ -122,5 +126,42 @@ inline void ThrowRuntimeException(JNIEnv* env, const std::string& message) { ThrowRuntimeException(env, message.c_str()); } +// Reads the parallel copy_froms/copy_tos arrays (+ deletes, may be null) into +// a PatchManifest via delta::BuildPatchManifest. On failure (length mismatch) +// a RuntimeException is pending and false is returned. +inline bool ReadPatchManifest( + JNIEnv* env, + jobjectArray copy_froms, + jobjectArray copy_tos, + jobjectArray deletes, + delta::PatchManifest* out) { + const delta::Status status = delta::BuildPatchManifest( + JArrayToVector(env, copy_froms), JArrayToVector(env, copy_tos), + JArrayToVector(env, deletes), out); + if (!status.ok) { + ThrowRuntimeException(env, status.message); + } + return status.ok; +} + +// Runs a native method body so no C++ exception (bad_alloc/length_error from +// input-proportional STL allocations) ever unwinds through the JNI boundary: +// it becomes a Java RuntimeException like every other failure of the entry +// point, carrying `fallback_message` when the exception has no message of its +// own. Returns a value-initialized Result (nullptr / void) in that case. +template +auto GuardJni(JNIEnv* env, const char* fallback_message, Body&& body) + -> decltype(body()) { + using Result = decltype(body()); + try { + return std::forward(body)(); + } catch (const std::exception& error) { + ThrowRuntimeException(env, error.what()); + } catch (...) { + ThrowRuntimeException(env, fallback_message); + } + return Result(); +} + } // namespace jni_util } // namespace pushy diff --git a/cpp/patch_core/patch_core.cpp b/cpp/patch_core/patch_core.cpp index a2e7a476..21e14517 100644 --- a/cpp/patch_core/patch_core.cpp +++ b/cpp/patch_core/patch_core.cpp @@ -15,6 +15,7 @@ #include #include +#include #include #include "hbc_transform.h" @@ -499,6 +500,25 @@ Status ValidateManifest(const PatchManifest& manifest) { return ValidateManifestImpl(manifest); } +Status BuildPatchManifest( + std::vector copy_froms, + std::vector copy_tos, + std::vector deletes, + PatchManifest* out) { + if (copy_froms.size() != copy_tos.size()) { + return Status::Error("copy_froms and copy_tos length mismatch"); + } + PatchManifest manifest; + manifest.copies.reserve(copy_froms.size()); + for (size_t index = 0; index < copy_froms.size(); ++index) { + manifest.copies.push_back(CopyOperation{ + std::move(copy_froms[index]), std::move(copy_tos[index])}); + } + manifest.deletes = std::move(deletes); + *out = std::move(manifest); + return Status::Ok(); +} + const BundleRebuilder& DefaultBundleRebuilder() { static const HdiffBundleRebuilder kPatcher; return kPatcher; diff --git a/cpp/patch_core/patch_core.h b/cpp/patch_core/patch_core.h index cf69856d..a410cf66 100644 --- a/cpp/patch_core/patch_core.h +++ b/cpp/patch_core/patch_core.h @@ -67,6 +67,16 @@ const BundleRebuilder& DefaultBundleRebuilder(); Status ValidateManifest(const PatchManifest& manifest); +// Assembles a manifest from the parallel copy source/target lists the +// platform bridges receive (deletes are taken as-is). Fails without touching +// *out when the two copy lists differ in length; path safety is still +// ValidateManifest's job. +Status BuildPatchManifest( + std::vector copy_froms, + std::vector copy_tos, + std::vector deletes, + PatchManifest* out); + Status ApplyPatchFromFileSource( const FileSourcePatchOptions& options, const BundleRebuilder& bundle_patcher = DefaultBundleRebuilder()); diff --git a/cpp/patch_core/patch_core_android.cpp b/cpp/patch_core/patch_core_android.cpp index 89233983..051240e4 100644 --- a/cpp/patch_core/patch_core_android.cpp +++ b/cpp/patch_core/patch_core_android.cpp @@ -2,7 +2,6 @@ #include "jni_natives.h" -#include #include #include @@ -11,8 +10,10 @@ namespace { +using pushy::jni_util::GuardJni; using pushy::jni_util::JArrayToVector; using pushy::jni_util::JStringToString; +using pushy::jni_util::ReadPatchManifest; using pushy::jni_util::ThrowRuntimeException; } // namespace @@ -31,19 +32,11 @@ void pushy::jni_natives::ApplyDeltaFromSource( jobjectArray copy_tos, jobjectArray deletes, jstring hbc_transform_meta) { - // C++ exceptions (bad_alloc/length_error from input-proportional STL - // allocations) must not unwind through the JNI boundary: convert them to a - // Java RuntimeException like every other failure of this entry point. - try { - const std::vector from_values = JArrayToVector(env, copy_froms); - const std::vector to_values = JArrayToVector(env, copy_tos); - - if (from_values.size() != to_values.size()) { - ThrowRuntimeException(env, "copy_froms and copy_tos length mismatch"); + GuardJni(env, "Unexpected native exception while applying delta", [&] { + pushy::delta::FileSourcePatchOptions options; + if (!ReadPatchManifest(env, copy_froms, copy_tos, deletes, &options.manifest)) { return; } - - pushy::delta::FileSourcePatchOptions options; options.source_root = JStringToString(env, source_root); options.target_root = JStringToString(env, target_root); options.origin_bundle_path = JStringToString(env, origin_bundle_path); @@ -53,24 +46,12 @@ void pushy::jni_natives::ApplyDeltaFromSource( options.enable_merge = enable_merge == JNI_TRUE; options.bundle_hbc_transform_meta = JStringToString(env, hbc_transform_meta); - for (size_t index = 0; index < from_values.size(); ++index) { - options.manifest.copies.push_back(pushy::delta::CopyOperation{ - from_values[index], - to_values[index], - }); - } - options.manifest.deletes = JArrayToVector(env, deletes); - const pushy::delta::Status status = pushy::delta::ApplyPatchFromFileSource(options); if (!status.ok) { ThrowRuntimeException(env, status.message); } - } catch (const std::exception& error) { - ThrowRuntimeException(env, error.what()); - } catch (...) { - ThrowRuntimeException(env, "Unexpected native exception while applying delta"); - } + }); } void pushy::jni_natives::CleanupOldEntries( @@ -80,7 +61,7 @@ void pushy::jni_natives::CleanupOldEntries( jobjectArray keep_names, jint max_age_days, jstring trash_dir) { - try { + GuardJni(env, "Unexpected native exception in cleanupOldEntries", [&] { const std::string root = JStringToString(env, root_dir); const std::vector keep = JArrayToVector(env, keep_names); const std::string trash = JStringToString(env, trash_dir); @@ -94,9 +75,5 @@ void pushy::jni_natives::CleanupOldEntries( if (!status.ok) { ThrowRuntimeException(env, status.message); } - } catch (const std::exception& error) { - ThrowRuntimeException(env, error.what()); - } catch (...) { - ThrowRuntimeException(env, "Unexpected native exception in cleanupOldEntries"); - } + }); } diff --git a/cpp/patch_core/tests/patch_core_test.cpp b/cpp/patch_core/tests/patch_core_test.cpp index 3fafc8b9..1038a8a3 100644 --- a/cpp/patch_core/tests/patch_core_test.cpp +++ b/cpp/patch_core/tests/patch_core_test.cpp @@ -480,6 +480,35 @@ void TestIsSafeRelativePathRejectsControlBytes() { "manifest with a NUL-bearing delete must be rejected"); } +void TestBuildPatchManifestPairsCopiesAndRejectsMismatch() { + PatchManifest manifest; + pushy::delta::Status status = pushy::delta::BuildPatchManifest( + {"assets/a.png", "assets/b.png"}, {"x/a.png", "x/b.png"}, {"old.png"}, + &manifest); + Expect(status.ok, "matching copy lists must build: " + status.message); + Expect(manifest.copies.size() == 2, "both copies kept"); + Expect( + manifest.copies[1].from == "assets/b.png" && + manifest.copies[1].to == "x/b.png" && + !manifest.copies[1].has_expected_crc, + "copies pair up by index"); + Expect( + manifest.deletes.size() == 1 && manifest.deletes[0] == "old.png", + "deletes kept as-is"); + + PatchManifest untouched; + untouched.deletes.push_back("keep"); + status = pushy::delta::BuildPatchManifest( + {"a", "b"}, {"a"}, {}, &untouched); + Expect(!status.ok, "copy list length mismatch must fail"); + Expect( + status.message == "copy_froms and copy_tos length mismatch", + "mismatch message is stable: " + status.message); + Expect( + untouched.copies.empty() && untouched.deletes.size() == 1, + "a failed build leaves the output untouched"); +} + // Builds `levels` nested directories under `base` and returns the deepest. std::string MakeDeepTree(const std::string& base, int levels) { std::string deep = base; @@ -1135,6 +1164,8 @@ int main(int argc, char** argv) { {"ApplyPatchFromFileSourceCanLimitMergeSubdir", TestApplyPatchFromFileSourceCanLimitMergeSubdir}, {"ApplyPatchFromFileSourceRejectsUnsafePaths", TestApplyPatchFromFileSourceRejectsUnsafePaths}, {"IsSafeRelativePathRejectsControlBytes", TestIsSafeRelativePathRejectsControlBytes}, + {"BuildPatchManifestPairsCopiesAndRejectsMismatch", + TestBuildPatchManifestPairsCopiesAndRejectsMismatch}, {"DirectoryWalkersBoundNestingDepth", TestDirectoryWalkersBoundNestingDepth}, {"HpatchRejectsOversizedLzmaDictionary", TestHpatchRejectsOversizedLzmaDictionary}, {"CleanupOldEntriesRemovesOnlyExpiredPaths", TestCleanupOldEntriesRemovesOnlyExpiredPaths}, diff --git a/cpp/patch_core/update_core_android.cpp b/cpp/patch_core/update_core_android.cpp index a0a302b1..462b3732 100644 --- a/cpp/patch_core/update_core_android.cpp +++ b/cpp/patch_core/update_core_android.cpp @@ -4,7 +4,6 @@ #include "jni_natives.h" #include "obscured_text.h" -#include #include #include @@ -16,8 +15,10 @@ namespace { +using pushy::jni_util::GuardJni; using pushy::jni_util::JArrayToVector; using pushy::jni_util::JStringToString; +using pushy::jni_util::ReadPatchManifest; using pushy::jni_util::ThrowRuntimeException; using pushy::jni_util::ToJString; using pushy::state_ops::StateOperation; @@ -233,11 +234,11 @@ pushy::state::State ReadStateFromResult(JNIEnv* env, jobject state_result) { jobject NewStateCoreResult( JNIEnv* env, const pushy::state::State& state, - bool changed, - const std::string& stale_version_to_delete, - const std::string& load_version, - bool did_rollback, - bool consumed_first_time) { + bool changed = false, + const std::string& stale_version_to_delete = std::string(), + const std::string& load_version = std::string(), + bool did_rollback = false, + bool consumed_first_time = false) { jobject result = env->NewObject(g_state.clazz, g_state.constructor); if (result == nullptr) { return nullptr; @@ -304,46 +305,6 @@ jobject NewCopyGroupResult( return result; } -bool ToArchivePatchType( - jint patch_type, - pushy::archive_patch::ArchivePatchType* out) { - // Delegate to the shared parser so Android and HarmonyOS agree on which - // integer values are valid and neither silently falls back to kFull. - return pushy::archive_patch::TryParseArchivePatchType( - static_cast(patch_type), out); -} - -pushy::delta::PatchManifest BuildManifest( - const std::vector& copy_froms, - const std::vector& copy_tos, - const std::vector& deletes) { - pushy::delta::PatchManifest manifest; - for (size_t index = 0; index < copy_froms.size(); ++index) { - manifest.copies.push_back( - pushy::delta::CopyOperation{copy_froms[index], copy_tos[index]}); - } - manifest.deletes = deletes; - return manifest; -} - -jobject MakeStateResult( - JNIEnv* env, - const pushy::state::State& state, - bool changed = false, - const std::string& stale_version_to_delete = std::string(), - const std::string& load_version = std::string(), - bool did_rollback = false, - bool consumed_first_time = false) { - return NewStateCoreResult( - env, - state, - changed, - stale_version_to_delete, - load_version, - did_rollback, - consumed_first_time); -} - } // namespace bool pushy::jni_natives::InitResultClassCache(JNIEnv* env) { @@ -368,25 +329,20 @@ jobject pushy::jni_natives::SyncStateWithBinaryVersion( jstring package_version, jstring build_time, jobject state_result) { - // C++ exceptions (bad_alloc/length_error from input-proportional STL - // allocations) must not unwind through the JNI boundary: convert them to a - // Java RuntimeException like every other failure of these entry points. - try { - if (!RequireReady(env, g_state.ready, "state result")) { - return nullptr; - } - pushy::state::State state = ReadStateFromResult(env, state_result); - pushy::state::BinaryVersionSyncResult result = pushy::state::SyncBinaryVersion( - state, - JStringToString(env, package_version), - JStringToString(env, build_time)); - return MakeStateResult(env, result.state, result.changed); - } catch (const std::exception& error) { - ThrowRuntimeException(env, error.what()); - } catch (...) { - ThrowRuntimeException(env, "Unexpected native exception in syncStateWithBinaryVersion"); - } - return nullptr; + return GuardJni( + env, "Unexpected native exception in syncStateWithBinaryVersion", + [&]() -> jobject { + if (!RequireReady(env, g_state.ready, "state result")) { + return nullptr; + } + pushy::state::State state = ReadStateFromResult(env, state_result); + pushy::state::BinaryVersionSyncResult result = + pushy::state::SyncBinaryVersion( + state, + JStringToString(env, package_version), + JStringToString(env, build_time)); + return NewStateCoreResult(env, result.state, result.changed); + }); } jobject pushy::jni_natives::RunStateCore( @@ -397,56 +353,52 @@ jobject pushy::jni_natives::RunStateCore( jstring string_arg, jboolean flag_a, jboolean flag_b) { - try { - if (!RequireReady(env, g_state.ready, "state result")) { - return nullptr; - } - const pushy::state::State state = ReadStateFromResult(env, state_result); - switch (static_cast(operation)) { - case StateOperation::kSwitchVersion: - return MakeStateResult( - env, - pushy::state::SwitchVersion(state, JStringToString(env, string_arg))); - case StateOperation::kMarkSuccess: { - const pushy::state::MarkSuccessResult result = - pushy::state::MarkSuccess(state); - return MakeStateResult( - env, - result.state, - false, - result.stale_version_to_delete); - } - case StateOperation::kRollback: { - const pushy::state::State next = pushy::state::Rollback(state); - return MakeStateResult( - env, next, false, std::string(), next.current_version, true); - } - case StateOperation::kClearFirstTime: - return MakeStateResult(env, pushy::state::ClearFirstTime(state)); - case StateOperation::kClearRollbackMark: - return MakeStateResult(env, pushy::state::ClearRollbackMark(state)); - case StateOperation::kResolveLaunch: { - const pushy::state::LaunchDecision decision = - pushy::state::ResolveLaunchState( - state, flag_a == JNI_TRUE, flag_b == JNI_TRUE); - return MakeStateResult( - env, - decision.state, - false, - std::string(), - decision.load_version, - decision.did_rollback, - decision.consumed_first_time); - } - } - - ThrowRuntimeException(env, "Unknown state operation"); - } catch (const std::exception& error) { - ThrowRuntimeException(env, error.what()); - } catch (...) { - ThrowRuntimeException(env, "Unexpected native exception in runStateCore"); - } - return nullptr; + return GuardJni( + env, "Unexpected native exception in runStateCore", [&]() -> jobject { + if (!RequireReady(env, g_state.ready, "state result")) { + return nullptr; + } + const pushy::state::State state = ReadStateFromResult(env, state_result); + switch (static_cast(operation)) { + case StateOperation::kSwitchVersion: + return NewStateCoreResult( + env, + pushy::state::SwitchVersion( + state, JStringToString(env, string_arg))); + case StateOperation::kMarkSuccess: { + const pushy::state::MarkSuccessResult result = + pushy::state::MarkSuccess(state); + return NewStateCoreResult( + env, result.state, false, result.stale_version_to_delete); + } + case StateOperation::kRollback: { + const pushy::state::State next = pushy::state::Rollback(state); + return NewStateCoreResult( + env, next, false, std::string(), next.current_version, true); + } + case StateOperation::kClearFirstTime: + return NewStateCoreResult(env, pushy::state::ClearFirstTime(state)); + case StateOperation::kClearRollbackMark: + return NewStateCoreResult( + env, pushy::state::ClearRollbackMark(state)); + case StateOperation::kResolveLaunch: { + const pushy::state::LaunchDecision decision = + pushy::state::ResolveLaunchState( + state, flag_a == JNI_TRUE, flag_b == JNI_TRUE); + return NewStateCoreResult( + env, + decision.state, + false, + std::string(), + decision.load_version, + decision.did_rollback, + decision.consumed_first_time); + } + } + + ThrowRuntimeException(env, "Unknown state operation"); + return nullptr; + }); } jobject pushy::jni_natives::BuildArchivePlan( @@ -457,39 +409,32 @@ jobject pushy::jni_natives::BuildArchivePlan( jobjectArray copy_froms, jobjectArray copy_tos, jobjectArray deletes) { - try { - const std::vector from_values = JArrayToVector(env, copy_froms); - const std::vector to_values = JArrayToVector(env, copy_tos); - if (from_values.size() != to_values.size()) { - ThrowRuntimeException(env, "copy_froms and copy_tos length mismatch"); - return nullptr; - } - - pushy::delta::PatchManifest manifest = - BuildManifest(from_values, to_values, JArrayToVector(env, deletes)); - pushy::archive_patch::ArchivePatchType archive_type; - if (!ToArchivePatchType(patch_type, &archive_type)) { - ThrowRuntimeException(env, "Unknown archive delta type"); - return nullptr; - } - pushy::archive_patch::ArchivePatchPlan plan; - pushy::delta::Status status = pushy::archive_patch::BuildArchivePatchPlan( - archive_type, - manifest, - JArrayToVector(env, entry_names), - &plan); - if (!status.ok) { - ThrowRuntimeException(env, status.message); - return nullptr; - } - - return NewArchivePatchPlanResult(env, plan); - } catch (const std::exception& error) { - ThrowRuntimeException(env, error.what()); - } catch (...) { - ThrowRuntimeException(env, "Unexpected native exception while planning archive"); - } - return nullptr; + return GuardJni( + env, "Unexpected native exception while planning archive", + [&]() -> jobject { + pushy::delta::PatchManifest manifest; + if (!ReadPatchManifest(env, copy_froms, copy_tos, deletes, &manifest)) { + return nullptr; + } + // The shared parser keeps Android and HarmonyOS in agreement on which + // integer values are valid; neither silently falls back to kFull. + pushy::archive_patch::ArchivePatchType archive_type; + if (!pushy::archive_patch::TryParseArchivePatchType( + static_cast(patch_type), &archive_type)) { + ThrowRuntimeException(env, "Unknown archive delta type"); + return nullptr; + } + pushy::archive_patch::ArchivePatchPlan plan; + pushy::delta::Status status = + pushy::archive_patch::BuildArchivePatchPlan( + archive_type, manifest, JArrayToVector(env, entry_names), &plan); + if (!status.ok) { + ThrowRuntimeException(env, status.message); + return nullptr; + } + + return NewArchivePatchPlanResult(env, plan); + }); } jobjectArray pushy::jni_natives::BuildCopyGroups( @@ -497,43 +442,36 @@ jobjectArray pushy::jni_natives::BuildCopyGroups( jclass, jobjectArray copy_froms, jobjectArray copy_tos) { - try { - const std::vector from_values = JArrayToVector(env, copy_froms); - const std::vector to_values = JArrayToVector(env, copy_tos); - if (from_values.size() != to_values.size()) { - ThrowRuntimeException(env, "copy_froms and copy_tos length mismatch"); - return nullptr; - } - - pushy::delta::PatchManifest manifest = BuildManifest( - from_values, to_values, std::vector()); - std::vector groups; - pushy::delta::Status status = - pushy::archive_patch::BuildCopyGroups(manifest, &groups); - if (!status.ok) { - ThrowRuntimeException(env, status.message); - return nullptr; - } - - if (!RequireReady(env, g_copy_group.ready, "copy group result")) { - return nullptr; - } - jobjectArray result = env->NewObjectArray( - static_cast(groups.size()), g_copy_group.clazz, nullptr); - if (result == nullptr) { - return nullptr; - } - - for (jsize index = 0; index < static_cast(groups.size()); ++index) { - jobject group = NewCopyGroupResult(env, groups[index]); - env->SetObjectArrayElement(result, index, group); - env->DeleteLocalRef(group); - } - return result; - } catch (const std::exception& error) { - ThrowRuntimeException(env, error.what()); - } catch (...) { - ThrowRuntimeException(env, "Unexpected native exception in buildCopyGroups"); - } - return nullptr; + return GuardJni( + env, "Unexpected native exception in buildCopyGroups", + [&]() -> jobjectArray { + pushy::delta::PatchManifest manifest; + if (!ReadPatchManifest(env, copy_froms, copy_tos, nullptr, &manifest)) { + return nullptr; + } + std::vector groups; + pushy::delta::Status status = + pushy::archive_patch::BuildCopyGroups(manifest, &groups); + if (!status.ok) { + ThrowRuntimeException(env, status.message); + return nullptr; + } + + if (!RequireReady(env, g_copy_group.ready, "copy group result")) { + return nullptr; + } + jobjectArray result = env->NewObjectArray( + static_cast(groups.size()), g_copy_group.clazz, nullptr); + if (result == nullptr) { + return nullptr; + } + + for (jsize index = 0; index < static_cast(groups.size()); + ++index) { + jobject group = NewCopyGroupResult(env, groups[index]); + env->SetObjectArrayElement(result, index, group); + env->DeleteLocalRef(group); + } + return result; + }); } From 624ef5eab3a06f8452cb0221e578025bbd804792 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:47:01 +0800 Subject: [PATCH 013/104] refactor(client): one fail() exit for caught update-flow errors The toUpdateError -> emitError -> throw/throwIfEnabled sequence was spelled out at every catch site; fail() does the first two and returns the error. runBeforeReload now wraps a beforeReload throw as USER_HOOK_ERROR itself (event type/data derived from the context) instead of switchVersion and restartApp each doing it. Co-Authored-By: Claude Opus 5.5 --- src/client.ts | 119 ++++++++++++++++++++++++++++---------------------- 1 file changed, 68 insertions(+), 51 deletions(-) diff --git a/src/client.ts b/src/client.ts index baed0d1c..684eb9de 100644 --- a/src/client.ts +++ b/src/client.ts @@ -825,6 +825,32 @@ export class Pushy { } } }; + /** + * toUpdateError + emitError: how a caught failure leaves the client. + * Returns the error for the caller to throw or pass to throwIfEnabled. + * `fallbackMessage` is reported when the error has no message of its own. + */ + private fail = ( + e: unknown, + code: UpdateErrorCode, + type: EventType, + { + message, + fallbackMessage, + data, + }: { + message?: string; + fallbackMessage?: string; + data?: Record; + } = {} + ): UpdateError => { + const err = toUpdateError(e, code); + this.emitError(err, type, { + message: message ?? (err.message || fallbackMessage), + data, + }); + return err; + }; notifyAfterCheckUpdate = (state: UpdateCheckState) => { const { afterCheckUpdate } = this.options; if (!afterCheckUpdate) { @@ -840,7 +866,22 @@ export class Pushy { if (!beforeReload) { return true; } - const shouldReload = await beforeReload(context); + let shouldReload: boolean | undefined; + try { + shouldReload = await beforeReload(context); + } catch (e) { + // A throw from the user's beforeReload hook is business-code failure, + // not an update-pipeline one: give it a distinct code so telemetry + // excludes it from the server-side patch-health stats. + throw this.fail( + e, + 'USER_HOOK_ERROR', + context.type === 'switchVersion' + ? 'errorSwitchVersion' + : 'errorRestart', + context.hash ? { data: { newVersion: context.hash } } : {} + ); + } if (shouldReload === false) { log('beforeReload returned false, skipping reload'); return false; @@ -1029,9 +1070,7 @@ export class Pushy { try { await Promise.resolve(PushyModule.markSuccess()); } catch (e) { - const err = toUpdateError(e, 'MARK_SUCCESS_FAILED'); - this.emitError(err, 'errorMarkSuccess'); - throw err; + throw this.fail(e, 'MARK_SUCCESS_FAILED', 'errorMarkSuccess'); } sharedState.marked = true; this.report({ type: 'markSuccess' }); @@ -1062,21 +1101,19 @@ export class Pushy { } log(`switchVersion: ${hash}`); const applyingGeneration = beginApplyingUpdate(); + let shouldReload = false; try { - if (!(await this.runBeforeReload({ type: 'switchVersion', hash }))) { + shouldReload = await this.runBeforeReload({ + type: 'switchVersion', + hash, + }); + } finally { + if (!shouldReload) { finishApplyingUpdate(applyingGeneration); - return false; } - } catch (e) { - finishApplyingUpdate(applyingGeneration); - // A throw from the user's beforeReload hook is business-code failure, - // not an update-pipeline one: give it a distinct code so telemetry - // excludes it from the server-side patch-health stats. - const err = toUpdateError(e, 'USER_HOOK_ERROR'); - this.emitError(err, 'errorSwitchVersion', { - data: { newVersion: hash }, - }); - throw err; + } + if (!shouldReload) { + return false; } // resetToPackagedBundle may have completed while beforeReload awaited. if (!ownsApplyingUpdate(applyingGeneration)) { @@ -1087,11 +1124,9 @@ export class Pushy { } catch (e) { // Do not let a superseded rejection clear a newer switch. finishApplyingUpdate(applyingGeneration); - const err = toUpdateError(e, 'SWITCH_VERSION_FAILED'); - this.emitError(err, 'errorSwitchVersion', { + throw this.fail(e, 'SWITCH_VERSION_FAILED', 'errorSwitchVersion', { data: { newVersion: hash }, }); - throw err; } // A resolved reloadUpdate normally tears this JS context down right away. // iOS has been seen resolving without restarting (89c638e); a stuck @@ -1118,11 +1153,9 @@ export class Pushy { try { return await PushyModule.setNeedUpdate({ hash }); } catch (e) { - const err = toUpdateError(e, 'SWITCH_VERSION_FAILED'); - this.emitError(err, 'errorSwitchVersion', { + throw this.fail(e, 'SWITCH_VERSION_FAILED', 'errorSwitchVersion', { data: { newVersion: hash }, }); - throw err; } } }; @@ -1251,9 +1284,8 @@ export class Pushy { this.lastRespJson = undefined; this.lastCheckFingerprint = undefined; } - const err = toUpdateError(e, 'CHECK_FAILED'); - this.emitError(err, 'errorChecking', { - message: err.message || this.t('error_cannot_connect_server'), + const err = this.fail(e, 'CHECK_FAILED', 'errorChecking', { + fallbackMessage: this.t('error_cannot_connect_server'), }); this.notifyAfterCheckUpdate({ status: 'error', error: err }); this.throwIfEnabled(err); @@ -1575,12 +1607,10 @@ export class Pushy { if (!succeeded) { const message = errorMessages.join(';'); if (lastError) { - const err = toUpdateError(lastError, 'DOWNLOAD_FAILED'); - this.emitError(err, 'errorUpdate', { + throw this.fail(lastError, 'DOWNLOAD_FAILED', 'errorUpdate', { message, data: { newVersion: hash }, }); - throw err; } // No download URL was even attempted (e.g. dev without a full URL): // report for diagnostics but there is no error object to surface. @@ -1620,12 +1650,9 @@ export class Pushy { try { await setLocalHashInfo(hash, hashInfo); } catch (e: any) { - const err = toUpdateError(e, 'FILE_OPERATION_FAILED'); - this.emitError(err, 'errorUpdate', { - message: err.message, + throw this.fail(e, 'FILE_OPERATION_FAILED', 'errorUpdate', { data: { newVersion: hash }, }); - throw err; } sharedState.downloadedHash = hash; return hash; @@ -1677,11 +1704,11 @@ export class Pushy { } catch (e) { sharedState.apkStatus = null; // Keep the native error (message/stack) instead of discarding it. - const err = toUpdateError(e, 'APK_DOWNLOAD_FAILED'); - this.emitError(err, 'errorDownloadAndInstallApk', { - message: err.message || this.t('error_apk_download_failed'), - }); - this.throwIfEnabled(err); + this.throwIfEnabled( + this.fail(e, 'APK_DOWNLOAD_FAILED', 'errorDownloadAndInstallApk', { + fallbackMessage: this.t('error_apk_download_failed'), + }) + ); } finally { if (sharedState.progressHandlers[progressKey]) { sharedState.progressHandlers[progressKey].remove(); @@ -1690,21 +1717,13 @@ export class Pushy { } }; restartApp = async () => { - try { - if (!(await this.runBeforeReload({ type: 'restartApp' }))) { - return; - } - } catch (e) { - const err = toUpdateError(e, 'USER_HOOK_ERROR'); - this.emitError(err, 'errorRestart'); - throw err; + if (!(await this.runBeforeReload({ type: 'restartApp' }))) { + return; } try { return await PushyModule.restartApp(); } catch (e) { - const err = toUpdateError(e, 'RESTART_FAILED'); - this.emitError(err, 'errorRestart'); - throw err; + throw this.fail(e, 'RESTART_FAILED', 'errorRestart'); } }; /** @@ -1737,9 +1756,7 @@ export class Pushy { try { await reset(); } catch (e) { - const err = toUpdateError(e, 'RESET_FAILED'); - this.emitError(err, 'errorReset'); - this.throwIfEnabled(err); + this.throwIfEnabled(this.fail(e, 'RESET_FAILED', 'errorReset')); return false; } // The downloaded versions are gone; drop JS bookkeeping referring to them From 01073606d59f6095e71a23e407f432ac7c6c5650 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:47:36 +0800 Subject: [PATCH 014/104] fix(android): stop retrying mirrors after PATCH_FAILED in the native round The cold-start orchestrator re-downloaded the same artifact from every mirror even when the failure was a patch failure, which no mirror can change. Break out to the next strategy like update_flow_core's IsMirrorRetryableCode and the JS client do. The download-failure code mapping moves to ErrorCodes so the module and the orchestrator share it, and the attempt loop takes a DownloadStarter so it is JVM-testable. Co-Authored-By: Claude Opus 5.5 --- .../modules/update/ErrorCodes.java | 27 +++++ .../modules/update/SyncCoordinator.java | 53 ++++++--- .../modules/update/UpdateModuleImpl.java | 17 +-- .../modules/update/SyncCoordinatorTest.java | 108 ++++++++++++++++++ 4 files changed, 173 insertions(+), 32 deletions(-) create mode 100644 android/src/test/java/cn/reactnative/modules/update/SyncCoordinatorTest.java diff --git a/android/src/main/java/cn/reactnative/modules/update/ErrorCodes.java b/android/src/main/java/cn/reactnative/modules/update/ErrorCodes.java index 74f70916..7459272e 100644 --- a/android/src/main/java/cn/reactnative/modules/update/ErrorCodes.java +++ b/android/src/main/java/cn/reactnative/modules/update/ErrorCodes.java @@ -25,4 +25,31 @@ final class ErrorCodes { private ErrorCodes() { } + + /** + * Rejection code of a failed download task, from DownloadTask's + * classification: post-download failures (unzip / hdiff / resource copy, + * incl. copiesCrc verification) are PATCH_FAILED so telemetry can + * separate patch health from network health; a local refusal + * (reinstalling the running version in place) is FILE_OPERATION_FAILED. + */ + static String forDownloadFailure(Throwable error) { + if (error instanceof PatchFailedException) { + return DELTA_FAILED; + } + if (error instanceof FileOperationException) { + return FILE_OPERATION_FAILED; + } + return DOWNLOAD_FAILED; + } + + /** + * Whether another mirror of the same artifact can change the outcome + * (mirrors update_flow_core::IsMirrorRetryableCode and the JS + * isMirrorRetryableCode). A patch failure cannot: the bytes arrived and + * every mirror serves the same artifact. + */ + static boolean isMirrorRetryable(String code) { + return !DELTA_FAILED.equals(code); + } } diff --git a/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java b/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java index e6ef1d99..8f11c41d 100644 --- a/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java +++ b/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java @@ -10,6 +10,7 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Request; @@ -469,8 +470,8 @@ private static void runConfiguredRound( boolean downloaded = context.hasCompletedVersion(hash); if (!downloaded) { downloaded = performAttempts( - context, decision.optJSONArray("attempts"), hash, currentVersion, - deadlineNanos); + decision.optJSONArray("attempts"), currentVersion, deadlineNanos, + downloadStarter(context, hash, currentVersion)); } if (!downloaded) { // The native attempt has finished, so JS may safely reuse the @@ -724,9 +725,29 @@ private static long capToHoldBudget(long phaseDeadlineNanos, long holdDeadlineNa return Math.min(phaseDeadlineNanos, holdDeadlineNanos); } - private static boolean performAttempts( - UpdateContext context, JSONArray attempts, String hash, String originHash, - long holdDeadlineNanos + /** Queues one download attempt; null when it was rejected up front (the listener was told). */ + interface DownloadStarter { + @Nullable + DownloadTaskParams start( + String type, String url, UpdateContext.DownloadFileListener listener, long deadlineNanos); + } + + private static DownloadStarter downloadStarter( + final UpdateContext context, final String hash, final String originHash + ) { + return (type, url, listener, deadlineNanos) -> { + if ("diff".equals(type)) { + return context.downloadPatchFromPpk(url, hash, originHash, listener, deadlineNanos); + } + if ("pdiff".equals(type)) { + return context.downloadPatchFromApk(url, hash, listener, deadlineNanos); + } + return context.downloadFullUpdate(url, hash, listener, deadlineNanos); + }; + } + + static boolean performAttempts( + @Nullable JSONArray attempts, @Nullable String originHash, long holdDeadlineNanos, DownloadStarter starter ) { if (attempts == null) { return false; @@ -776,6 +797,7 @@ private static boolean performAttempts( } final CountDownLatch latch = new CountDownLatch(1); final AtomicBoolean succeeded = new AtomicBoolean(false); + final AtomicReference failure = new AtomicReference<>(); final String attemptType = type; UpdateContext.DownloadFileListener listener = new UpdateContext.DownloadFileListener() { @@ -789,20 +811,11 @@ public void onDownloadCompleted(DownloadTaskParams params) { public void onDownloadFailed(Throwable error) { Log.i(Texts.LOG_TAG, "native sync: " + attemptType + " attempt failed: " + error); + failure.set(error); latch.countDown(); } }; - DownloadTaskParams task; - if ("diff".equals(type)) { - task = context.downloadPatchFromPpk( - url, hash, originHash, listener, deadlineNanos); - } else if ("pdiff".equals(type)) { - task = context.downloadPatchFromApk( - url, hash, listener, deadlineNanos); - } else { - task = context.downloadFullUpdate( - url, hash, listener, deadlineNanos); - } + DownloadTaskParams task = starter.start(type, url, listener, deadlineNanos); try { if (!latch.await(remainingNanos, TimeUnit.NANOSECONDS)) { Log.w(Texts.LOG_TAG, @@ -829,6 +842,14 @@ public void onDownloadFailed(Throwable error) { if (succeeded.get()) { return true; } + Throwable error = failure.get(); + if (error != null + && !ErrorCodes.isMirrorRetryable(ErrorCodes.forDownloadFailure(error))) { + // The bytes arrived but could not be applied: every + // mirror serves the same artifact, so move on to the + // next strategy instead of downloading it again. + break; + } } } return false; 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 f57c6615..4e07cab0 100644 --- a/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java +++ b/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java @@ -129,26 +129,11 @@ public void onDownloadCompleted(DownloadTaskParams params) { @Override public void onDownloadFailed(Throwable error) { - promise.reject(downloadErrorCode(error), error); + promise.reject(ErrorCodes.forDownloadFailure(error), error); } }; } - // Post-download failures (unzip / hdiff / resource copy, incl. copiesCrc - // verification) reject as PATCH_FAILED so JS-side telemetry can separate - // patch health from network health; a local refusal (reinstalling the - // running version in place) as FILE_OPERATION_FAILED. DownloadTask - // classifies them. - private static String downloadErrorCode(Throwable error) { - if (error instanceof PatchFailedException) { - return ErrorCodes.DELTA_FAILED; - } - if (error instanceof FileOperationException) { - return ErrorCodes.FILE_OPERATION_FAILED; - } - return ErrorCodes.DOWNLOAD_FAILED; - } - public void downloadFullUpdate(final ReadableMap options, final Promise promise) { String url = readRequiredString(options, OPTION_URL, promise); if (url == null) { diff --git a/android/src/test/java/cn/reactnative/modules/update/SyncCoordinatorTest.java b/android/src/test/java/cn/reactnative/modules/update/SyncCoordinatorTest.java new file mode 100644 index 00000000..f7aaab0b --- /dev/null +++ b/android/src/test/java/cn/reactnative/modules/update/SyncCoordinatorTest.java @@ -0,0 +1,108 @@ +package cn.reactnative.modules.update; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.Test; + +public class SyncCoordinatorTest { + /** Settles every attempt synchronously from a per-url script. */ + private static final class ScriptedStarter implements SyncCoordinator.DownloadStarter { + final List started = new ArrayList<>(); + private final java.util.Map failures = new java.util.HashMap<>(); + + ScriptedStarter fail(String url, Throwable error) { + failures.put(url, error); + return this; + } + + @Override + public DownloadTaskParams start( + String type, String url, UpdateContext.DownloadFileListener listener, long deadlineNanos + ) { + started.add(type + " " + url); + Throwable error = failures.get(url); + if (error != null) { + listener.onDownloadFailed(error); + } else { + listener.onDownloadCompleted(new DownloadTaskParams()); + } + return null; + } + } + + private static JSONArray attempts(Object[]... attempts) throws Exception { + JSONArray result = new JSONArray(); + for (Object[] attempt : attempts) { + JSONObject entry = new JSONObject(); + entry.put("type", attempt[0]); + entry.put("urls", new JSONArray(Arrays.asList(attempt).subList(1, attempt.length))); + result.put(entry); + } + return result; + } + + private static PatchFailedException patchFailed() { + return new PatchFailedException("hpatch failed", null); + } + + @Test + public void patchFailureSkipsTheStrategysRemainingMirrors() throws Exception { + ScriptedStarter starter = new ScriptedStarter() + .fail("https://a/diff", patchFailed()) + .fail("https://a/full", new IOException("reset")); + boolean downloaded = SyncCoordinator.performAttempts( + attempts( + new Object[] {"diff", "https://a/diff", "https://b/diff"}, + new Object[] {"full", "https://a/full", "https://b/full"}), + "origin", 0, starter); + + assertTrue(downloaded); + assertEquals( + Arrays.asList("diff https://a/diff", "full https://a/full", "full https://b/full"), + starter.started); + } + + @Test + public void retryableFailuresTryEveryMirror() throws Exception { + ScriptedStarter starter = new ScriptedStarter() + .fail("https://a/full", new IOException("timeout")) + .fail("https://b/full", new FileOperationException("refused")); + boolean downloaded = SyncCoordinator.performAttempts( + attempts(new Object[] {"full", "https://a/full", "https://b/full"}), + "", 0, starter); + + assertFalse(downloaded); + assertEquals( + Arrays.asList("full https://a/full", "full https://b/full"), starter.started); + } + + @Test + public void diffNeedsARunningVersion() throws Exception { + ScriptedStarter starter = new ScriptedStarter(); + assertTrue(SyncCoordinator.performAttempts( + attempts( + new Object[] {"diff", "https://a/diff"}, + new Object[] {"pdiff", "https://a/pdiff"}), + "", 0, starter)); + assertEquals(Arrays.asList("pdiff https://a/pdiff"), starter.started); + } + + @Test + public void onlyPatchFailuresAreNotMirrorRetryable() { + assertEquals("PATCH_FAILED", ErrorCodes.forDownloadFailure(patchFailed())); + assertEquals("FILE_OPERATION_FAILED", + ErrorCodes.forDownloadFailure(new FileOperationException("x"))); + assertEquals("DOWNLOAD_FAILED", ErrorCodes.forDownloadFailure(new IOException("x"))); + assertFalse(ErrorCodes.isMirrorRetryable("PATCH_FAILED")); + assertTrue(ErrorCodes.isMirrorRetryable("DOWNLOAD_FAILED")); + assertTrue(ErrorCodes.isMirrorRetryable("FILE_OPERATION_FAILED")); + } +} From a989041534310aeb329f2814eb29bf46ef173d7f Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:47:41 +0800 Subject: [PATCH 015/104] fix(android): keep https-configured native rounds off plaintext Same rule as Harmony's SyncCoordinator (isHttpsOnly): when every configured endpoint is https, endpoints fetched from queryUrls and artifact URLs in the decision must not downgrade the round to http. Non-https remote endpoints and cleartext artifact URLs are skipped. Co-Authored-By: Claude Opus 5.5 --- .../reactnative/modules/update/HttpUtils.java | 5 +++ .../modules/update/SyncCoordinator.java | 38 +++++++++++++++++-- .../modules/update/HttpUtilsTest.java | 10 +++++ .../modules/update/SyncCoordinatorTest.java | 29 ++++++++++++-- 4 files changed, 75 insertions(+), 7 deletions(-) diff --git a/android/src/main/java/cn/reactnative/modules/update/HttpUtils.java b/android/src/main/java/cn/reactnative/modules/update/HttpUtils.java index f40a1732..d4129848 100644 --- a/android/src/main/java/cn/reactnative/modules/update/HttpUtils.java +++ b/android/src/main/java/cn/reactnative/modules/update/HttpUtils.java @@ -15,6 +15,11 @@ static boolean isHttpsUrl(String url) { return url != null && url.regionMatches(true, 0, "https://", 0, 8); } + /** True for a plaintext "http:" URL, case-insensitively; false for anything else (incl. null). */ + static boolean isCleartextUrl(String url) { + return url != null && url.regionMatches(true, 0, "http://", 0, 7); + } + /** Request path appended to an endpoint base, kept out of the plain string table. */ static final String QUERY_PATH = Texts.reveal("7514fcd4ad805d55263e08fc99"); diff --git a/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java b/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java index 8f11c41d..43bc14b7 100644 --- a/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java +++ b/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java @@ -433,7 +433,8 @@ private static void runConfiguredRound( return; } - String responseText = runQueryRequest(config, appKey, body, deadlineNanos); + boolean httpsOnly = isHttpsOnly(config.optJSONArray("endpoints")); + String responseText = runQueryRequest(config, appKey, body, httpsOnly, deadlineNanos); if (responseText == null) { Log.i(Texts.LOG_TAG, "native sync: no endpoint reachable, giving up until next launch"); @@ -470,7 +471,7 @@ private static void runConfiguredRound( boolean downloaded = context.hasCompletedVersion(hash); if (!downloaded) { downloaded = performAttempts( - decision.optJSONArray("attempts"), currentVersion, deadlineNanos, + decision.optJSONArray("attempts"), currentVersion, httpsOnly, deadlineNanos, downloadStarter(context, hash, currentVersion)); } if (!downloaded) { @@ -648,7 +649,7 @@ private static boolean isValidResponse(String responseText) { * path is latency-insensitive. */ private static String runQueryRequest( - JSONObject config, String appKey, String body, long deadlineNanos + JSONObject config, String appKey, String body, boolean httpsOnly, long deadlineNanos ) { JSONArray endpoints = config.optJSONArray("endpoints"); String orderedJson = FlowBridge.orderEndpointCandidates( @@ -702,6 +703,12 @@ private static String runQueryRequest( if (base.isEmpty() || tried.contains(base)) { continue; } + if (httpsOnly && !HttpUtils.isHttpsUrl(base)) { + // A remotely injected endpoint must not downgrade an + // https configuration to plaintext. + Log.w(Texts.LOG_TAG, "native sync: ignoring non-https remote endpoint " + base); + continue; + } if (httpAttempts++ >= MAX_QUERY_HTTP_ATTEMPTS) { return null; } @@ -746,8 +753,27 @@ private static DownloadStarter downloadStarter( }; } + /** + * Shared https rule (SyncCoordinator.ts isHttpsOnly): when every + * configured endpoint is https, server-provided endpoints and artifact + * URLs must not downgrade the round to plaintext. + */ + static boolean isHttpsOnly(@Nullable JSONArray endpoints) { + if (endpoints == null || endpoints.length() == 0) { + return false; + } + for (int i = 0; i < endpoints.length(); i++) { + Object endpoint = endpoints.opt(i); + if (!(endpoint instanceof String) || !HttpUtils.isHttpsUrl((String) endpoint)) { + return false; + } + } + return true; + } + static boolean performAttempts( - @Nullable JSONArray attempts, @Nullable String originHash, long holdDeadlineNanos, DownloadStarter starter + @Nullable JSONArray attempts, @Nullable String originHash, boolean httpsOnly, + long holdDeadlineNanos, DownloadStarter starter ) { if (attempts == null) { return false; @@ -786,6 +812,10 @@ static boolean performAttempts( if (url.isEmpty()) { continue; } + if (httpsOnly && HttpUtils.isCleartextUrl(url)) { + Log.w(Texts.LOG_TAG, "native sync: ignoring cleartext " + type + " url " + url); + 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(); diff --git a/android/src/test/java/cn/reactnative/modules/update/HttpUtilsTest.java b/android/src/test/java/cn/reactnative/modules/update/HttpUtilsTest.java index eaf74b10..725f50ee 100644 --- a/android/src/test/java/cn/reactnative/modules/update/HttpUtilsTest.java +++ b/android/src/test/java/cn/reactnative/modules/update/HttpUtilsTest.java @@ -73,6 +73,16 @@ public void isHttpsUrlIsCaseInsensitiveAndStrict() { assertFalse(HttpUtils.isHttpsUrl(null)); } + @Test + public void isCleartextUrlMatchesOnlyPlainHttp() { + assertTrue(HttpUtils.isCleartextUrl("http://example.com/a.ppk")); + assertTrue(HttpUtils.isCleartextUrl("HTTP://example.com/a.ppk")); + assertFalse(HttpUtils.isCleartextUrl("https://example.com/a.ppk")); + assertFalse(HttpUtils.isCleartextUrl("ftp://example.com")); + assertFalse(HttpUtils.isCleartextUrl("http:/example.com")); + assertFalse(HttpUtils.isCleartextUrl(null)); + } + @Test public void revealDecodesEncodedText() { // Encoded with scripts/encode-native-text.ts. diff --git a/android/src/test/java/cn/reactnative/modules/update/SyncCoordinatorTest.java b/android/src/test/java/cn/reactnative/modules/update/SyncCoordinatorTest.java index f7aaab0b..69cefee6 100644 --- a/android/src/test/java/cn/reactnative/modules/update/SyncCoordinatorTest.java +++ b/android/src/test/java/cn/reactnative/modules/update/SyncCoordinatorTest.java @@ -62,7 +62,7 @@ public void patchFailureSkipsTheStrategysRemainingMirrors() throws Exception { attempts( new Object[] {"diff", "https://a/diff", "https://b/diff"}, new Object[] {"full", "https://a/full", "https://b/full"}), - "origin", 0, starter); + "origin", true, 0, starter); assertTrue(downloaded); assertEquals( @@ -77,13 +77,27 @@ public void retryableFailuresTryEveryMirror() throws Exception { .fail("https://b/full", new FileOperationException("refused")); boolean downloaded = SyncCoordinator.performAttempts( attempts(new Object[] {"full", "https://a/full", "https://b/full"}), - "", 0, starter); + "", true, 0, starter); assertFalse(downloaded); assertEquals( Arrays.asList("full https://a/full", "full https://b/full"), starter.started); } + @Test + public void httpsOnlyRoundsSkipCleartextArtifactUrls() throws Exception { + ScriptedStarter starter = new ScriptedStarter(); + assertTrue(SyncCoordinator.performAttempts( + attempts(new Object[] {"full", "http://a/full", "HTTP://b/full", "https://c/full"}), + "", true, 0, starter)); + assertEquals(Arrays.asList("full https://c/full"), starter.started); + + ScriptedStarter plain = new ScriptedStarter(); + assertTrue(SyncCoordinator.performAttempts( + attempts(new Object[] {"full", "http://a/full"}), "", false, 0, plain)); + assertEquals(Arrays.asList("full http://a/full"), plain.started); + } + @Test public void diffNeedsARunningVersion() throws Exception { ScriptedStarter starter = new ScriptedStarter(); @@ -91,10 +105,19 @@ public void diffNeedsARunningVersion() throws Exception { attempts( new Object[] {"diff", "https://a/diff"}, new Object[] {"pdiff", "https://a/pdiff"}), - "", 0, starter)); + "", true, 0, starter)); assertEquals(Arrays.asList("pdiff https://a/pdiff"), starter.started); } + @Test + public void httpsOnlyRequiresEveryConfiguredEndpointToBeHttps() { + assertTrue(SyncCoordinator.isHttpsOnly(new JSONArray(Arrays.asList("https://a", "HTTPS://b")))); + assertFalse(SyncCoordinator.isHttpsOnly(new JSONArray(Arrays.asList("https://a", "http://b")))); + assertFalse(SyncCoordinator.isHttpsOnly(new JSONArray())); + assertFalse(SyncCoordinator.isHttpsOnly(null)); + assertFalse(SyncCoordinator.isHttpsOnly(new JSONArray(Arrays.asList(1)))); + } + @Test public void onlyPatchFailuresAreNotMirrorRetryable() { assertEquals("PATCH_FAILED", ErrorCodes.forDownloadFailure(patchFailed())); From f01140facf2deb145bebb7136f7df1a7ef3fb947 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:48:01 +0800 Subject: [PATCH 016/104] refactor(client): simplify performDownload bookkeeping - patchFailed/patchErrorMessages are exactly the exhausted strategies (only PATCH_FAILED exhausts one, and an exhausted strategy never runs again), so derive them instead of tracking them in parallel. - Drop the unreachable 'failed without lastError' branch: every attempt runs at least one download, so a failed download always has an error. - Progress listeners are local subscriptions released in finally (also on an unexpected throw) instead of sharedState.progressHandlers entries. Co-Authored-By: Claude Opus 5.5 --- src/client.ts | 277 ++++++++++++++++++++++---------------------------- 1 file changed, 120 insertions(+), 157 deletions(-) diff --git a/src/client.ts b/src/client.ts index 684eb9de..39b85005 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,8 +1,4 @@ -import { - DeviceEventEmitter, - type EmitterSubscription, - Platform, -} from 'react-native'; +import { DeviceEventEmitter, Platform } from 'react-native'; import { buildTime, cInfo, @@ -140,7 +136,6 @@ const createDefaultClientOptions = (): ClientOptions => ({ }); export const sharedState: { - progressHandlers: Record; downloadingTasks: Record>; // Progress callbacks per hash: concurrent downloadUpdate callers of the // same hash each register theirs here instead of the second one being @@ -152,7 +147,6 @@ export const sharedState: { marked: boolean; applyingUpdate: boolean; } = { - progressHandlers: {}, downloadingTasks: {}, progressCallbacks: {}, downloadedHash: undefined, @@ -1399,15 +1393,14 @@ export class Pushy { } sharedState.progressCallbacks[hash] = progressCallbacks; const dispatchProgress = (data: ProgressData) => { - const callbacks = sharedState.progressCallbacks[hash]; - if (!callbacks || callbacks.size === 0) { + if (progressCallbacks.size === 0) { return; } const payload = { ...data, progress: computeProgress(data.received, data.total), }; - callbacks.forEach((callback) => { + progressCallbacks.forEach((callback) => { // One subscriber throwing must not starve the others or abort the // download: business callbacks are isolated, the error is logged. try { @@ -1427,17 +1420,10 @@ export class Pushy { } }; // @ts-expect-error harmony not in existing platforms - if (Platform.OS === 'harmony') { - sharedState.progressHandlers[hash] = DeviceEventEmitter.addListener( - 'RCTPushyDownloadProgress', - onNativeProgress - ); - } else { - sharedState.progressHandlers[hash] = pushyNativeEventEmitter.addListener( - 'RCTPushyDownloadProgress', - onNativeProgress - ); - } + const isHarmony = Platform.OS === 'harmony'; + const progressSubscription = ( + isHarmony ? DeviceEventEmitter : pushyNativeEventEmitter + ).addListener('RCTPushyDownloadProgress', onNativeProgress); const maxRetries = Math.max(0, Math.floor(this.options.maxRetries ?? 3)); let succeeded = ''; let lastError: UpdateError | undefined; @@ -1447,13 +1433,6 @@ export class Pushy { // message, so the final report still names it — and skip it on later // attempts instead of downloading and patching it again. const exhaustedStrategies = new Map(); - // Whether any strategy, in any attempt, failed at the patch stage. The - // downloadFallback report carries that as its code even when a later, - // unrelated transport failure happened to be the last error seen. - let patchFailed = false; - // The errors of the strategies that failed at the patch stage. A strategy - // that failed to apply is never retried, so these do not repeat. - const patchErrorMessages: string[] = []; // The ordered attempts come from decideDownload (the pure decision layer); // this side only executes them: probe candidate URLs, run the matching @@ -1490,100 +1469,101 @@ export class Pushy { full: 'error_full_patch_failed', } as const; - for (let attempt = 0; attempt <= maxRetries; attempt++) { - if (attempt > 0) { - // Exponential backoff with jitter: a bad CDN edge otherwise gets - // every client back at the same 1/2/4s marks. - const backoffMs = Math.round( - Math.min(1000 * 2 ** (attempt - 1), 10000) * - (0.75 + Math.random() * 0.5) - ); - log(`retry attempt ${attempt}/${maxRetries}, waiting ${backoffMs}ms`); - await new Promise((r) => setTimeout(() => r(), backoffMs)); - errorMessages.length = 0; - errorMessages.push(...exhaustedStrategies.values()); - lastError = undefined; - succeeded = ''; - } - this.report({ - type: 'downloading', - data: { - newVersion: hash, - attempt, - }, - }); - for (const { type, urls } of attempts) { - if (succeeded) { - break; - } - if (exhaustedStrategies.has(type)) { - continue; - } - // The HEAD race only ranks mirrors; it is not proof that a mirror can - // serve the whole artifact (CDNs answer HEAD and GET differently, and - // a transfer can die mid-stream). Every mirror of the same artifact - // gets a real download attempt before the strategy is abandoned — - // the native cold-start path already behaves this way. - let orderedUrls = rankedUrls.get(type); - if (!orderedUrls) { - orderedUrls = await this.rankDownloadUrls(urls); - rankedUrls.set(type, orderedUrls); + try { + for (let attempt = 0; attempt <= maxRetries; attempt++) { + if (attempt > 0) { + // Exponential backoff with jitter: a bad CDN edge otherwise gets + // every client back at the same 1/2/4s marks. + const backoffMs = Math.round( + Math.min(1000 * 2 ** (attempt - 1), 10000) * + (0.75 + Math.random() * 0.5) + ); + log(`retry attempt ${attempt}/${maxRetries}, waiting ${backoffMs}ms`); + await new Promise((r) => setTimeout(() => r(), backoffMs)); + errorMessages.length = 0; + errorMessages.push(...exhaustedStrategies.values()); + lastError = undefined; + succeeded = ''; } - for (const url of orderedUrls) { - log(`downloading ${type} from ${url}`); - try { - await runners[type](url); - succeeded = type; + this.report({ + type: 'downloading', + data: { + newVersion: hash, + attempt, + }, + }); + for (const { type, urls } of attempts) { + if (succeeded) { break; - } catch (e: unknown) { - // The native rejection's stable code (e.g. PATCH_FAILED vs - // DOWNLOAD_FAILED — telemetry classifies on it) may sit on the - // `code` property or, from bridges that cannot set it, as a - // `[CODE] ` message prefix. Either way it survives into the - // i18n-wrapped error together with the original as cause. - const { code, message } = readErrorCode(e); - if (url === orderedUrls[0] && orderedUrls.length > 1) { - rankedUrls.set(type, [...orderedUrls.slice(1), url]); - } - const errorMessage = this.t(errorKeys[type], { - message: message || String(e ?? ''), - }); - errorMessages.push(errorMessage); - lastError = new UpdateError( - errorMessage, - code ?? 'DOWNLOAD_FAILED', - { - cause: e, - } - ); - log(errorMessage); - if (lastError.code === 'PATCH_FAILED') { - patchFailed = true; - patchErrorMessages.push(errorMessage); - } - if (!isMirrorRetryableCode(lastError.code)) { - // The bytes arrived but could not be applied (patch/manifest - // mismatch): every mirror serves the same artifact, so move - // on to the next strategy instead of re-downloading it. - exhaustedStrategies.set(type, errorMessage); + } + if (exhaustedStrategies.has(type)) { + continue; + } + // The HEAD race only ranks mirrors; it is not proof that a mirror can + // serve the whole artifact (CDNs answer HEAD and GET differently, and + // a transfer can die mid-stream). Every mirror of the same artifact + // gets a real download attempt before the strategy is abandoned — + // the native cold-start path already behaves this way. + let orderedUrls = rankedUrls.get(type); + if (!orderedUrls) { + orderedUrls = await this.rankDownloadUrls(urls); + rankedUrls.set(type, orderedUrls); + } + for (const url of orderedUrls) { + log(`downloading ${type} from ${url}`); + try { + await runners[type](url); + succeeded = type; break; + } catch (e: unknown) { + // The native rejection's stable code (e.g. PATCH_FAILED vs + // DOWNLOAD_FAILED — telemetry classifies on it) may sit on the + // `code` property or, from bridges that cannot set it, as a + // `[CODE] ` message prefix. Either way it survives into the + // i18n-wrapped error together with the original as cause. + const { code, message } = readErrorCode(e); + if (url === orderedUrls[0] && orderedUrls.length > 1) { + rankedUrls.set(type, [...orderedUrls.slice(1), url]); + } + const errorMessage = this.t(errorKeys[type], { + message: message || String(e ?? ''), + }); + errorMessages.push(errorMessage); + lastError = new UpdateError( + errorMessage, + code ?? 'DOWNLOAD_FAILED', + { + cause: e, + } + ); + log(errorMessage); + if (!isMirrorRetryableCode(lastError.code)) { + // The bytes arrived but could not be applied (patch/manifest + // mismatch): every mirror serves the same artifact, so move + // on to the next strategy instead of re-downloading it. + exhaustedStrategies.set(type, errorMessage); + break; + } } } } + if ( + succeeded || + attempts.every(({ type }) => exhaustedStrategies.has(type)) + ) { + // Nothing left that a retry could change. + break; + } } - if ( - succeeded || - attempts.every(({ type }) => exhaustedStrategies.has(type)) - ) { - // Nothing left that a retry could change. - break; - } - } - if (sharedState.progressHandlers[hash]) { - sharedState.progressHandlers[hash].remove(); - delete sharedState.progressHandlers[hash]; - } - delete sharedState.progressCallbacks[hash]; + } finally { + progressSubscription.remove(); + delete sharedState.progressCallbacks[hash]; + } + // Only a patch-stage failure exhausts a strategy (isMirrorRetryableCode), + // and an exhausted strategy is never retried: these are exactly the patch + // failures, each once. + const patchErrorMessages = [...exhaustedStrategies.values()]; + const patchFailed = patchErrorMessages.length > 0; if (succeeded && errorMessages.length > 0) { // An earlier strategy failed and a later one rescued the download // (e.g. pdiff copiesCrc mismatch on a rebuilt binary → full). Surface @@ -1591,9 +1571,10 @@ export class Pushy { // platform that incremental delivery is failing for this binary. this.report({ type: 'downloadFallback', - // The patch-health signal wins over whatever transport failure - // happened to come last (diff PATCH_FAILED, then pdiff network - // error, then full succeeded is still a patch_fail server-side). + // The patch-health signal (any strategy, in any attempt, failing at + // the patch stage) wins over whatever transport failure happened to + // come last (diff PATCH_FAILED, then pdiff network error, then full + // succeeded is still a patch_fail server-side). code: patchFailed ? 'PATCH_FAILED' : lastError?.code, data: { newVersion: hash, @@ -1605,36 +1586,25 @@ export class Pushy { }); } if (!succeeded) { - const message = errorMessages.join(';'); - if (lastError) { - throw this.fail(lastError, 'DOWNLOAD_FAILED', 'errorUpdate', { - message, - data: { newVersion: hash }, - }); - } - // No download URL was even attempted (e.g. dev without a full URL): - // report for diagnostics but there is no error object to surface. - this.report({ - type: 'errorUpdate', + // Every attempt runs at least one download (a plan always has an + // artifact URL, devNoop returned above), so a failure left lastError. + throw this.fail(lastError, 'DOWNLOAD_FAILED', 'errorUpdate', { + message: errorMessages.join(';'), data: { newVersion: hash }, - message, - }); - return; - } else { - const duration = Date.now() - patchStartTime; - const data: Record = { - newVersion: hash, - diff: succeeded, - duration, - }; - if (errorMessages.length > 0) { - data.error = errorMessages.join(';'); - } - this.report({ - type: 'downloadSuccess', - data, }); } + const data: Record = { + newVersion: hash, + diff: succeeded, + duration: Date.now() - patchStartTime, + }; + if (errorMessages.length > 0) { + data.error = errorMessages.join(';'); + } + this.report({ + type: 'downloadSuccess', + data, + }); log(`downloaded ${succeeded} hash:`, hash); const hashInfo: Record = { name, @@ -1679,12 +1649,8 @@ export class Pushy { sharedState.apkStatus = 'downloading'; this.report({ type: 'downloadingApk' }); const progressKey = 'downloadingApk'; - if (onDownloadProgress) { - if (sharedState.progressHandlers[progressKey]) { - sharedState.progressHandlers[progressKey].remove(); - } - sharedState.progressHandlers[progressKey] = - pushyNativeEventEmitter.addListener( + const progressSubscription = onDownloadProgress + ? pushyNativeEventEmitter.addListener( 'RCTPushyDownloadProgress', (event: unknown) => { const progressData = event as ProgressData; @@ -1692,8 +1658,8 @@ export class Pushy { onDownloadProgress(progressData); } } - ); - } + ) + : undefined; try { await PushyModule.downloadAndInstallApk({ url, @@ -1710,10 +1676,7 @@ export class Pushy { }) ); } finally { - if (sharedState.progressHandlers[progressKey]) { - sharedState.progressHandlers[progressKey].remove(); - delete sharedState.progressHandlers[progressKey]; - } + progressSubscription?.remove(); } }; restartApp = async () => { From 78e1318b55e4b5ba6d553abd2beedb19d9cd519a Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:48:25 +0800 Subject: [PATCH 017/104] refactor(harmony): share the NAPI async-work plumbing in pushy.cpp The four promise-returning bindings (applyDeltaFromSource, cleanupOldEntries, sha256HexFileAsync, sha256HexRawFdAsync) each repeated ~70 lines of create-promise / create-async-work / queue / settle / free. QueueWork(env, name, data, execute, settle) now owns that lifecycle; every failure path (promise creation, work creation, queueing, aborted work) still settles the promise exactly once and frees the call. NewStateResult takes a StateResultExtras struct with defaults instead of five positional extras, and NewString absorbs MakeUtf8String (it now throws on failure like the latter did). Co-Authored-By: Claude Opus 5.5 --- harmony/pushy/src/main/cpp/pushy.cpp | 516 +++++++++++---------------- 1 file changed, 209 insertions(+), 307 deletions(-) diff --git a/harmony/pushy/src/main/cpp/pushy.cpp b/harmony/pushy/src/main/cpp/pushy.cpp index d343e9c7..36593a81 100644 --- a/harmony/pushy/src/main/cpp/pushy.cpp +++ b/harmony/pushy/src/main/cpp/pushy.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include "archive_patch_core.h" @@ -314,7 +315,11 @@ napi_value NewBoolean(napi_env env, bool value) { napi_value NewString(napi_env env, const std::string& value) { napi_value result = nullptr; - napi_create_string_utf8(env, value.c_str(), value.size(), &result); + if (napi_create_string_utf8(env, value.c_str(), value.size(), &result) != + napi_ok) { + ThrowError(env, "Failed to create string"); + return nullptr; + } return result; } @@ -337,14 +342,19 @@ void SetBoolProperty( napi_set_named_property(env, object, name, NewBoolean(env, value)); } +// Per-operation outputs reported next to the state; all default to "nothing". +struct StateResultExtras { + bool changed = false; + std::string stale_version_to_delete; + std::string load_version; + bool did_rollback = false; + bool consumed_first_time = false; +}; + napi_value NewStateResult( napi_env env, const pushy::state::State& state, - bool changed, - const std::string& stale_version_to_delete, - const std::string& load_version, - bool did_rollback, - bool consumed_first_time) { + const StateResultExtras& extras = StateResultExtras()) { napi_value result = nullptr; napi_create_object(env, &result); @@ -355,11 +365,12 @@ napi_value NewStateResult( SetBoolProperty(env, result, "firstTime", state.first_time); SetBoolProperty(env, result, "firstTimeOk", state.first_time_ok); SetStringProperty(env, result, "rolledBackVersion", state.rolled_back_version); - SetBoolProperty(env, result, "changed", changed); - SetStringProperty(env, result, "staleVersionToDelete", stale_version_to_delete); - SetStringProperty(env, result, "loadVersion", load_version); - SetBoolProperty(env, result, "didRollback", did_rollback); - SetBoolProperty(env, result, "consumedFirstTime", consumed_first_time); + SetBoolProperty(env, result, "changed", extras.changed); + SetStringProperty( + env, result, "staleVersionToDelete", extras.stale_version_to_delete); + SetStringProperty(env, result, "loadVersion", extras.load_version); + SetBoolProperty(env, result, "didRollback", extras.did_rollback); + SetBoolProperty(env, result, "consumedFirstTime", extras.consumed_first_time); return result; } @@ -436,7 +447,9 @@ napi_value SyncStateWithBinaryVersion(napi_env env, napi_callback_info info) { const pushy::state::BinaryVersionSyncResult result = pushy::state::SyncBinaryVersion(state, package_version, build_time); - return NewStateResult(env, result.state, result.changed, std::string(), std::string(), false, false); + StateResultExtras extras; + extras.changed = result.changed; + return NewStateResult(env, result.state, extras); } napi_value RunStateCore(napi_env env, napi_callback_info info) { @@ -477,59 +490,32 @@ napi_value RunStateCore(napi_env env, napi_callback_info info) { switch (static_cast(operation)) { case StateOperation::kSwitchVersion: - return NewStateResult( - env, - pushy::state::SwitchVersion(state, string_arg), - false, - std::string(), - std::string(), - false, - false); + return NewStateResult(env, pushy::state::SwitchVersion(state, string_arg)); case StateOperation::kMarkSuccess: { const pushy::state::MarkSuccessResult result = pushy::state::MarkSuccess(state); - return NewStateResult( - env, - result.state, - false, - result.stale_version_to_delete, - std::string(), - false, - false); + StateResultExtras extras; + extras.stale_version_to_delete = result.stale_version_to_delete; + return NewStateResult(env, result.state, extras); } case StateOperation::kRollback: { const pushy::state::State next = pushy::state::Rollback(state); - return NewStateResult( - env, next, false, std::string(), next.current_version, true, false); + StateResultExtras extras; + extras.load_version = next.current_version; + extras.did_rollback = true; + return NewStateResult(env, next, extras); } case StateOperation::kClearFirstTime: - return NewStateResult( - env, - pushy::state::ClearFirstTime(state), - false, - std::string(), - std::string(), - false, - false); + return NewStateResult(env, pushy::state::ClearFirstTime(state)); case StateOperation::kClearRollbackMark: - return NewStateResult( - env, - pushy::state::ClearRollbackMark(state), - false, - std::string(), - std::string(), - false, - false); + return NewStateResult(env, pushy::state::ClearRollbackMark(state)); case StateOperation::kResolveLaunch: { const pushy::state::LaunchDecision decision = pushy::state::ResolveLaunchState(state, flag_a, flag_b); - return NewStateResult( - env, - decision.state, - false, - std::string(), - decision.load_version, - decision.did_rollback, - decision.consumed_first_time); + StateResultExtras extras; + extras.load_version = decision.load_version; + extras.did_rollback = decision.did_rollback; + extras.consumed_first_time = decision.consumed_first_time; + return NewStateResult(env, decision.state, extras); } } @@ -643,8 +629,7 @@ napi_value BuildCopyGroups(napi_env env, napi_callback_info info) { // back on the JS thread. // --------------------------------------------------------------------------- -// Reject an already-created deferred with an Error(message). Used when async -// work fails to be created/queued, so the Promise never hangs pending. +// Reject an already-created deferred with an Error(message). void RejectDeferredWithMessage( napi_env env, napi_deferred deferred, @@ -656,16 +641,101 @@ void RejectDeferredWithMessage( napi_reject_deferred(env, deferred, error); } -struct ApplyPatchWork { +void ResolveDeferredWithUndefined(napi_env env, napi_deferred deferred) { + napi_value undefined_value = nullptr; + napi_get_undefined(env, &undefined_value); + napi_resolve_deferred(env, deferred, undefined_value); +} + +// Resolves with undefined, or rejects with the status message. +void SettleWithStatus( + napi_env env, + napi_deferred deferred, + const pushy::delta::Status& status) { + if (status.ok) { + ResolveDeferredWithUndefined(env, deferred); + } else { + RejectDeferredWithMessage(env, deferred, status.message.c_str()); + } +} + +template +struct AsyncCall { napi_async_work work = nullptr; napi_deferred deferred = nullptr; + W data; + void (*execute)(W&) = nullptr; + void (*settle)(napi_env, napi_deferred, const W&) = nullptr; +}; + +// Runs execute(data) on a libuv worker thread and returns a Promise that +// settle(env, deferred, data) settles back on the JS thread. Every path +// settles the promise exactly once and frees the call: a creation or queue +// failure rejects immediately, and work that was cancelled or aborted before +// it ran is rejected instead of settled from meaningless results. +template +napi_value QueueWork( + napi_env env, + const char* name, + W data, + void (*execute)(W&), + void (*settle)(napi_env, napi_deferred, const W&)) { + auto* call = new AsyncCall(); + call->data = std::move(data); + call->execute = execute; + call->settle = settle; + + napi_value promise = nullptr; + if (napi_create_promise(env, &call->deferred, &promise) != napi_ok) { + delete call; + ThrowError(env, "Unable to create promise"); + return nullptr; + } + + napi_value resource_name = nullptr; + napi_create_string_utf8(env, name, NAPI_AUTO_LENGTH, &resource_name); + if (napi_create_async_work( + env, + nullptr, + resource_name, + [](napi_env, void* raw) { + auto* c = static_cast*>(raw); + c->execute(c->data); + }, + [](napi_env cb_env, napi_status status, void* raw) { + auto* c = static_cast*>(raw); + if (status != napi_ok) { + RejectDeferredWithMessage(cb_env, c->deferred, "async work aborted"); + } else { + c->settle(cb_env, c->deferred, c->data); + } + napi_delete_async_work(cb_env, c->work); + delete c; + }, + call, + &call->work) != napi_ok) { + // Work was never created: settle the promise and free the call so it + // neither leaks nor hangs pending forever. + RejectDeferredWithMessage(env, call->deferred, "Unable to create async work"); + delete call; + return promise; + } + if (napi_queue_async_work(env, call->work) != napi_ok) { + // Queueing failed: the complete callback will never run, so clean up here. + napi_delete_async_work(env, call->work); + RejectDeferredWithMessage(env, call->deferred, "Unable to queue async work"); + delete call; + return promise; + } + return promise; +} + +struct ApplyPatchWork { pushy::delta::FileSourcePatchOptions options; pushy::delta::Status status{false, ""}; }; struct CleanupWork { - napi_async_work work = nullptr; - napi_deferred deferred = nullptr; std::string root_dir; // Every version that may still be in use: persisted current/last AND the // version this process booted from (two switches without a restart must not @@ -679,8 +749,6 @@ struct CleanupWork { // of MB; hashing it synchronously on the UI thread (where the TurboModule // runs) froze the app for hundreds of ms per archive/bundle/activation. struct Sha256FileWork { - napi_async_work work = nullptr; - napi_deferred deferred = nullptr; std::string path; std::string hex; }; @@ -690,8 +758,6 @@ struct Sha256FileWork { // the HAP). The fd is never closed here; JS closes it after the promise // settles. struct Sha256RangeWork { - napi_async_work work = nullptr; - napi_deferred deferred = nullptr; int fd = -1; int64_t offset = 0; int64_t length = 0; @@ -701,34 +767,34 @@ struct Sha256RangeWork { // pread loop (positioned: never moves a file offset the caller may share). // A short read before `length` bytes is an error, not EOF. -void HashFdRange(Sha256RangeWork* w) { +void HashFdRange(Sha256RangeWork& w) { pushy::digest::Sha256 hasher; std::vector buffer(256 * 1024); - int64_t position = w->offset; - int64_t remaining = w->length; + int64_t position = w.offset; + int64_t remaining = w.length; while (remaining > 0) { const size_t want = static_cast( remaining < static_cast(buffer.size()) ? remaining : static_cast(buffer.size())); - const ssize_t read = pread(w->fd, buffer.data(), want, static_cast(position)); + const ssize_t read = pread(w.fd, buffer.data(), want, static_cast(position)); if (read < 0) { if (errno == EINTR) { continue; } - w->error = "sha256HexRawFdAsync: read failed (errno " + + w.error = "sha256HexRawFdAsync: read failed (errno " + std::to_string(errno) + ")"; return; } if (read == 0) { - w->error = "sha256HexRawFdAsync: unexpected end of file"; + w.error = "sha256HexRawFdAsync: unexpected end of file"; return; } hasher.Update(buffer.data(), static_cast(read)); position += read; remaining -= read; } - w->hex = hasher.HexDigest(); + w.hex = hasher.HexDigest(); } bool GetNonNegativeInt64( @@ -790,74 +856,27 @@ napi_value ApplyPatchFromFileSource(napi_env env, napi_callback_info info) { return nullptr; } - auto* work_data = new ApplyPatchWork(); - work_data->options.manifest = BuildManifest(copy_froms, copy_tos, deletes); - work_data->options.source_root = source_root; - work_data->options.target_root = target_root; - work_data->options.origin_bundle_path = origin_bundle_path; - work_data->options.bundle_patch_path = bundle_patch_path; - work_data->options.bundle_output_path = bundle_output_path; - work_data->options.merge_source_subdir = merge_source_subdir; - work_data->options.bundle_hbc_transform_meta = bundle_hbc_transform_meta; - work_data->options.enable_merge = enable_merge; + ApplyPatchWork work; + work.options.manifest = BuildManifest(copy_froms, copy_tos, deletes); + work.options.source_root = source_root; + work.options.target_root = target_root; + work.options.origin_bundle_path = origin_bundle_path; + work.options.bundle_patch_path = bundle_patch_path; + work.options.bundle_output_path = bundle_output_path; + work.options.merge_source_subdir = merge_source_subdir; + work.options.bundle_hbc_transform_meta = bundle_hbc_transform_meta; + work.options.enable_merge = enable_merge; - napi_value promise = nullptr; - if (napi_create_promise(env, &work_data->deferred, &promise) != napi_ok) { - delete work_data; - ThrowError(env, "Unable to create promise"); - return nullptr; - } - - napi_value resource_name = nullptr; - napi_create_string_utf8( - env, "applyDeltaFromSource", NAPI_AUTO_LENGTH, &resource_name); - if (napi_create_async_work( - env, - nullptr, - resource_name, - [](napi_env, void* data) { - auto* w = static_cast(data); - w->status = pushy::delta::ApplyPatchFromFileSource(w->options); - }, - [](napi_env cb_env, napi_status status, void* data) { - auto* w = static_cast(data); - if (status != napi_ok) { - // Cancelled/aborted before execute ran: w->status is - // meaningless; still settle the promise so it never hangs. - RejectDeferredWithMessage(cb_env, w->deferred, "async work aborted"); - } else if (w->status.ok) { - napi_value undefined_value = nullptr; - napi_get_undefined(cb_env, &undefined_value); - napi_resolve_deferred(cb_env, w->deferred, undefined_value); - } else { - napi_value error = nullptr; - napi_value message = nullptr; - napi_create_string_utf8( - cb_env, w->status.message.c_str(), NAPI_AUTO_LENGTH, &message); - napi_create_error(cb_env, nullptr, message, &error); - napi_reject_deferred(cb_env, w->deferred, error); - } - napi_delete_async_work(cb_env, w->work); - delete w; - }, - work_data, - &work_data->work) != napi_ok) { - // Work was never created: settle the promise and free the data so it does - // not leak / hang pending forever. - RejectDeferredWithMessage( - env, work_data->deferred, "Unable to create async work"); - delete work_data; - return promise; - } - if (napi_queue_async_work(env, work_data->work) != napi_ok) { - // Queued failed: the complete callback will never run, so clean up here. - napi_delete_async_work(env, work_data->work); - RejectDeferredWithMessage( - env, work_data->deferred, "Unable to queue async work"); - delete work_data; - return promise; - } - return promise; + return QueueWork( + env, + "applyDeltaFromSource", + std::move(work), + [](ApplyPatchWork& w) { + w.status = pushy::delta::ApplyPatchFromFileSource(w.options); + }, + [](napi_env cb_env, napi_deferred deferred, const ApplyPatchWork& w) { + SettleWithStatus(cb_env, deferred, w.status); + }); } // cleanupOldEntries(rootDir, keepNames: string[], maxAgeDays) @@ -883,64 +902,22 @@ napi_value CleanupOldEntries(napi_env env, napi_callback_info info) { return nullptr; } - auto* work_data = new CleanupWork(); - work_data->root_dir = root_dir; - work_data->keep_names = std::move(keep_names); - work_data->max_age_days = max_age_days; - - napi_value promise = nullptr; - if (napi_create_promise(env, &work_data->deferred, &promise) != napi_ok) { - delete work_data; - ThrowError(env, "Unable to create promise"); - return nullptr; - } + CleanupWork work; + work.root_dir = root_dir; + work.keep_names = std::move(keep_names); + work.max_age_days = max_age_days; - napi_value resource_name = nullptr; - napi_create_string_utf8( - env, "cleanupOldEntries", NAPI_AUTO_LENGTH, &resource_name); - if (napi_create_async_work( - env, - nullptr, - resource_name, - [](napi_env, void* data) { - auto* w = static_cast(data); - w->status = pushy::delta::CleanupOldEntries( - w->root_dir, w->keep_names, w->max_age_days); - }, - [](napi_env cb_env, napi_status status, void* data) { - auto* w = static_cast(data); - if (status != napi_ok) { - RejectDeferredWithMessage(cb_env, w->deferred, "async work aborted"); - } else if (w->status.ok) { - napi_value undefined_value = nullptr; - napi_get_undefined(cb_env, &undefined_value); - napi_resolve_deferred(cb_env, w->deferred, undefined_value); - } else { - napi_value error = nullptr; - napi_value message = nullptr; - napi_create_string_utf8( - cb_env, w->status.message.c_str(), NAPI_AUTO_LENGTH, &message); - napi_create_error(cb_env, nullptr, message, &error); - napi_reject_deferred(cb_env, w->deferred, error); - } - napi_delete_async_work(cb_env, w->work); - delete w; - }, - work_data, - &work_data->work) != napi_ok) { - RejectDeferredWithMessage( - env, work_data->deferred, "Unable to create async work"); - delete work_data; - return promise; - } - if (napi_queue_async_work(env, work_data->work) != napi_ok) { - napi_delete_async_work(env, work_data->work); - RejectDeferredWithMessage( - env, work_data->deferred, "Unable to queue async work"); - delete work_data; - return promise; - } - return promise; + return QueueWork( + env, + "cleanupOldEntries", + std::move(work), + [](CleanupWork& w) { + w.status = pushy::delta::CleanupOldEntries( + w.root_dir, w.keep_names, w.max_age_days); + }, + [](napi_env cb_env, napi_deferred deferred, const CleanupWork& w) { + SettleWithStatus(cb_env, deferred, w.status); + }); } // sha256HexFileAsync(path) -> Promise. Rejects when the file cannot be @@ -962,55 +939,22 @@ napi_value Sha256HexFileAsync(napi_env env, napi_callback_info info) { return nullptr; } - auto* work_data = new Sha256FileWork(); - work_data->path = std::move(path); - - napi_value promise = nullptr; - if (napi_create_promise(env, &work_data->deferred, &promise) != napi_ok) { - delete work_data; - ThrowError(env, "Unable to create promise"); - return nullptr; - } + Sha256FileWork work; + work.path = std::move(path); - napi_value resource_name = nullptr; - napi_create_string_utf8( - env, "sha256HexFileAsync", NAPI_AUTO_LENGTH, &resource_name); - if (napi_create_async_work( - env, - nullptr, - resource_name, - [](napi_env, void* data) { - auto* w = static_cast(data); - w->hex = pushy::digest::Sha256File(w->path); - }, - [](napi_env cb_env, napi_status status, void* data) { - auto* w = static_cast(data); - if (status != napi_ok) { - RejectDeferredWithMessage(cb_env, w->deferred, "async work aborted"); - } else if (w->hex.empty()) { - RejectDeferredWithMessage( - cb_env, w->deferred, "sha256HexFileAsync: cannot read file"); - } else { - napi_resolve_deferred(cb_env, w->deferred, NewString(cb_env, w->hex)); - } - napi_delete_async_work(cb_env, w->work); - delete w; - }, - work_data, - &work_data->work) != napi_ok) { - RejectDeferredWithMessage( - env, work_data->deferred, "Unable to create async work"); - delete work_data; - return promise; - } - if (napi_queue_async_work(env, work_data->work) != napi_ok) { - napi_delete_async_work(env, work_data->work); - RejectDeferredWithMessage( - env, work_data->deferred, "Unable to queue async work"); - delete work_data; - return promise; - } - return promise; + return QueueWork( + env, + "sha256HexFileAsync", + std::move(work), + [](Sha256FileWork& w) { w.hex = pushy::digest::Sha256File(w.path); }, + [](napi_env cb_env, napi_deferred deferred, const Sha256FileWork& w) { + if (w.hex.empty()) { + RejectDeferredWithMessage( + cb_env, deferred, "sha256HexFileAsync: cannot read file"); + } else { + napi_resolve_deferred(cb_env, deferred, NewString(cb_env, w.hex)); + } + }); } // sha256HexRawFdAsync(fd, offset, length) -> Promise, hashed on a libuv @@ -1039,59 +983,27 @@ napi_value Sha256HexRawFdAsync(napi_env env, napi_callback_info info) { return nullptr; } - auto* work_data = new Sha256RangeWork(); - work_data->fd = fd; - work_data->offset = offset; - work_data->length = length; + Sha256RangeWork work; + work.fd = fd; + work.offset = offset; + work.length = length; - napi_value promise = nullptr; - if (napi_create_promise(env, &work_data->deferred, &promise) != napi_ok) { - delete work_data; - ThrowError(env, "Unable to create promise"); - return nullptr; - } - - napi_value resource_name = nullptr; - napi_create_string_utf8( - env, "sha256HexRawFdAsync", NAPI_AUTO_LENGTH, &resource_name); - if (napi_create_async_work( - env, - nullptr, - resource_name, - [](napi_env, void* data) { - HashFdRange(static_cast(data)); - }, - [](napi_env cb_env, napi_status status, void* data) { - auto* w = static_cast(data); - if (status != napi_ok) { - RejectDeferredWithMessage(cb_env, w->deferred, "async work aborted"); - } else if (!w->error.empty() || w->hex.empty()) { - RejectDeferredWithMessage( - cb_env, - w->deferred, - w->error.empty() ? "sha256HexRawFdAsync: no digest" - : w->error.c_str()); - } else { - napi_resolve_deferred(cb_env, w->deferred, NewString(cb_env, w->hex)); - } - napi_delete_async_work(cb_env, w->work); - delete w; - }, - work_data, - &work_data->work) != napi_ok) { - RejectDeferredWithMessage( - env, work_data->deferred, "Unable to create async work"); - delete work_data; - return promise; - } - if (napi_queue_async_work(env, work_data->work) != napi_ok) { - napi_delete_async_work(env, work_data->work); - RejectDeferredWithMessage( - env, work_data->deferred, "Unable to queue async work"); - delete work_data; - return promise; - } - return promise; + return QueueWork( + env, + "sha256HexRawFdAsync", + std::move(work), + HashFdRange, + [](napi_env cb_env, napi_deferred deferred, const Sha256RangeWork& w) { + if (!w.error.empty() || w.hex.empty()) { + RejectDeferredWithMessage( + cb_env, + deferred, + w.error.empty() ? "sha256HexRawFdAsync: no digest" + : w.error.c_str()); + } else { + napi_resolve_deferred(cb_env, deferred, NewString(cb_env, w.hex)); + } + }); } bool ExportFunction( @@ -1177,16 +1089,6 @@ static napi_value GetSupportedDiffVersion(napi_env env, napi_callback_info) { // 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 FlowBuildRequestBody(napi_env env, napi_callback_info info) { size_t argc = 1; @@ -1205,7 +1107,7 @@ static napi_value FlowBuildRequestBody(napi_env env, if (!parsed || !input.IsObject()) { return nullptr; } - return MakeUtf8String( + return NewString( env, flowjson::Stringify(flowcore::BuildRequestBody(input))); } @@ -1232,7 +1134,7 @@ static napi_value FlowOrderEndpointCandidates(napi_env env, if (!parsed || !endpoints.IsArray()) { return nullptr; } - return MakeUtf8String(env, + return NewString(env, flowjson::Stringify(flowcore::OrderEndpointCandidates( endpoints, sample))); } @@ -1284,7 +1186,7 @@ static napi_value FlowHandleResponse(napi_env env, if (!parsed || !identity.IsObject()) { return nullptr; } - return MakeUtf8String(env, + return NewString(env, flowjson::Stringify(flowcore::HandleResponse( response_text, identity, false, after_download))); } From 452b1eea220f0ff2ecfa8c84ed709ed38a52d283 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:48:25 +0800 Subject: [PATCH 018/104] refactor(harmony): single X-macro method table in PushyTurboModule.cpp Method names were written twice (host-function macro + registerMethod) and could drift. PUSHY_METHODS(X) now lists (name, argc, SYNC|ASYNC) once and generates both the host functions and the registrations. check-native-spec-parity.js parses that table: a spec method missing from it, a Promise method registered SYNC, a SYNC entry wrapping an async ArkTS method, or a duplicated row all still fail the check. Co-Authored-By: Claude Opus 5.5 --- .../pushy/src/main/cpp/PushyTurboModule.cpp | 149 +++++++----------- .../pushy/src/main/ets/PushyTurboModule.ts | 2 +- scripts/check-native-spec-parity.js | 43 +++-- 3 files changed, 83 insertions(+), 111 deletions(-) diff --git a/harmony/pushy/src/main/cpp/PushyTurboModule.cpp b/harmony/pushy/src/main/cpp/PushyTurboModule.cpp index 444606c0..5fb03eaa 100644 --- a/harmony/pushy/src/main/cpp/PushyTurboModule.cpp +++ b/harmony/pushy/src/main/cpp/PushyTurboModule.cpp @@ -3,82 +3,61 @@ using namespace facebook; using namespace rnoh; -namespace { - -jsi::Value CallSync( - jsi::Runtime &rt, - react::TurboModule &turboModule, - const char *methodName, - const jsi::Value *args, - size_t count) { - return jsi::Value( - static_cast(turboModule).call( - rt, methodName, args, count)); -} +// The JS-visible method table: (name, argument count, SYNC | ASYNC). +// +// This table is the only bridge RNOH gives JS into the ArkTS module: a method +// missing here is `undefined` on the JS side, and the JS feature-detects then +// treat the whole capability as "old native" and silently skip it — which is +// how the native cold-start check shipped dark on Harmony. Every spec method +// (src/NativePushy.ts) MUST be listed. +// +// Only the constants and the RN event plumbing are SYNC. Every +// Promise-returning spec method MUST be ASYNC: a sync registration converts +// the ArkTS Promise into an empty jsi object and its rejection becomes an +// unhandled ArkTS rejection, so JS would report e.g. setNeedUpdate as +// successful while nothing was switched. scripts/check-native-spec-parity.js +// enforces both rules against this table. +#define PUSHY_METHODS(X) \ + X(getConstants, 0, SYNC) \ + X(addListener, 1, SYNC) \ + X(removeListeners, 1, SYNC) \ + X(setLocalHashInfo, 2, ASYNC) \ + X(getLocalHashInfo, 1, ASYNC) \ + X(setUuid, 1, ASYNC) \ + X(reloadUpdate, 1, ASYNC) \ + X(restartApp, 0, ASYNC) \ + X(setNeedUpdate, 1, ASYNC) \ + X(markSuccess, 0, ASYNC) \ + X(downloadPatchFromPpk, 1, ASYNC) \ + X(downloadPatchFromPackage, 1, ASYNC) \ + X(downloadFullUpdate, 1, ASYNC) \ + X(downloadAndInstallApk, 1, ASYNC) \ + X(syncNativeConfig, 1, ASYNC) \ + X(getNativeCheckCache, 0, ASYNC) \ + X(markJsCheckCompleted, 1, ASYNC) \ + X(getBundleHash, 0, ASYNC) \ + X(resetToPackagedBundle, 0, ASYNC) -jsi::Value CallAsync( - jsi::Runtime &rt, - react::TurboModule &turboModule, - const char *methodName, - const jsi::Value *args, - size_t count) { - return jsi::Value( - static_cast(turboModule).callAsync( - rt, methodName, args, count)); -} +// ArkTSTurboModule entry point per kind. +#define PUSHY_INVOKE_SYNC call +#define PUSHY_INVOKE_ASYNC callAsync -#define PUSHY_SYNC_METHOD(method_name) \ - static jsi::Value HostFunction_##method_name( \ - jsi::Runtime &rt, \ - react::TurboModule &turboModule, \ - const jsi::Value *args, \ - size_t count) { \ - return CallSync(rt, turboModule, #method_name, args, count); \ - } +namespace { -#define PUSHY_ASYNC_METHOD(method_name) \ - static jsi::Value HostFunction_##method_name( \ +#define PUSHY_HOST_FUNCTION(method_name, arg_count, kind) \ + jsi::Value HostFunction_##method_name( \ jsi::Runtime &rt, \ react::TurboModule &turboModule, \ const jsi::Value *args, \ size_t count) { \ - return CallAsync(rt, turboModule, #method_name, args, count); \ + return jsi::Value( \ + static_cast(turboModule) \ + .PUSHY_INVOKE_##kind(rt, #method_name, args, count)); \ } -// Only the constants and the RN event plumbing are synchronous. Every -// Promise-returning spec method (src/NativePushy.ts) MUST be registered -// with PUSHY_ASYNC_METHOD: a sync registration converts the ArkTS Promise -// into an empty jsi object and its rejection becomes an unhandled ArkTS -// rejection, so JS would report e.g. setNeedUpdate as successful while -// nothing was switched. scripts/check-native-spec-parity.js enforces this. -PUSHY_SYNC_METHOD(getConstants) -PUSHY_SYNC_METHOD(addListener) -PUSHY_SYNC_METHOD(removeListeners) +PUSHY_METHODS(PUSHY_HOST_FUNCTION) -PUSHY_ASYNC_METHOD(setLocalHashInfo) -PUSHY_ASYNC_METHOD(getLocalHashInfo) -PUSHY_ASYNC_METHOD(setUuid) -PUSHY_ASYNC_METHOD(setNeedUpdate) -PUSHY_ASYNC_METHOD(markSuccess) -PUSHY_ASYNC_METHOD(reloadUpdate) -PUSHY_ASYNC_METHOD(restartApp) -PUSHY_ASYNC_METHOD(downloadPatchFromPpk) -PUSHY_ASYNC_METHOD(downloadPatchFromPackage) -PUSHY_ASYNC_METHOD(downloadFullUpdate) -PUSHY_ASYNC_METHOD(downloadAndInstallApk) -// 10.50+ additions. This table is the only bridge RNOH gives JS into the -// ArkTS module: a method missing here is `undefined` on the JS side, and the -// JS feature-detects then treat the whole capability as "old native" and -// silently skip it — which is how the native cold-start check shipped dark -// on Harmony. Every new spec method MUST be registered here. -PUSHY_ASYNC_METHOD(syncNativeConfig) -PUSHY_ASYNC_METHOD(getNativeCheckCache) -PUSHY_ASYNC_METHOD(markJsCheckCompleted) -PUSHY_ASYNC_METHOD(getBundleHash) -PUSHY_ASYNC_METHOD(resetToPackagedBundle) - -#undef PUSHY_SYNC_METHOD -#undef PUSHY_ASYNC_METHOD +#undef PUSHY_HOST_FUNCTION } // namespace @@ -86,33 +65,15 @@ PushyTurboModule::PushyTurboModule( const ArkTSTurboModule::Context ctx, const std::string name) : ArkTSTurboModule(ctx, name) { - const auto registerMethod = - [this](const std::string &methodName, - size_t argCount, - auto hostFunction) { - methodMap_[methodName] = MethodMetadata{argCount, hostFunction}; - }; +#define PUSHY_REGISTER_METHOD(method_name, arg_count, kind) \ + methodMap_[#method_name] = \ + MethodMetadata{arg_count, HostFunction_##method_name}; + + PUSHY_METHODS(PUSHY_REGISTER_METHOD) - registerMethod("getConstants", 0, HostFunction_getConstants); - registerMethod("setLocalHashInfo", 2, HostFunction_setLocalHashInfo); - registerMethod("getLocalHashInfo", 1, HostFunction_getLocalHashInfo); - registerMethod("setUuid", 1, HostFunction_setUuid); - registerMethod("reloadUpdate", 1, HostFunction_reloadUpdate); - registerMethod("restartApp", 0, HostFunction_restartApp); - registerMethod("setNeedUpdate", 1, HostFunction_setNeedUpdate); - registerMethod("markSuccess", 0, HostFunction_markSuccess); - registerMethod("downloadPatchFromPpk", 1, HostFunction_downloadPatchFromPpk); - registerMethod( - "downloadPatchFromPackage", 1, HostFunction_downloadPatchFromPackage); - registerMethod("downloadFullUpdate", 1, HostFunction_downloadFullUpdate); - registerMethod( - "downloadAndInstallApk", 1, HostFunction_downloadAndInstallApk); - registerMethod("addListener", 1, HostFunction_addListener); - registerMethod("removeListeners", 1, HostFunction_removeListeners); - registerMethod("syncNativeConfig", 1, HostFunction_syncNativeConfig); - registerMethod("getNativeCheckCache", 0, HostFunction_getNativeCheckCache); - registerMethod("markJsCheckCompleted", 1, HostFunction_markJsCheckCompleted); - registerMethod("getBundleHash", 0, HostFunction_getBundleHash); - registerMethod( - "resetToPackagedBundle", 0, HostFunction_resetToPackagedBundle); +#undef PUSHY_REGISTER_METHOD } + +#undef PUSHY_INVOKE_SYNC +#undef PUSHY_INVOKE_ASYNC +#undef PUSHY_METHODS diff --git a/harmony/pushy/src/main/ets/PushyTurboModule.ts b/harmony/pushy/src/main/ets/PushyTurboModule.ts index ad73d032..01197cb1 100644 --- a/harmony/pushy/src/main/ets/PushyTurboModule.ts +++ b/harmony/pushy/src/main/ets/PushyTurboModule.ts @@ -222,7 +222,7 @@ export class PushyTurboModule extends UITurboModule { } // 以下方法在 JS spec(src/NativePushy.ts)里都返回 Promise,C++ 方法表 - // (PushyTurboModule.cpp)相应用 PUSHY_ASYNC_METHOD 注册:同步注册会把 + // (PushyTurboModule.cpp 的 PUSHY_METHODS)相应登记为 ASYNC:同步注册会把 // ArkTS 的 Promise 变成空对象、把 reject 丢成 ArkTS 侧的 unhandled rejection。 // 每个拒绝都带稳定错误码(ErrorCodes.ts):`code` 属性 + `[CODE] ` 消息前缀。 diff --git a/scripts/check-native-spec-parity.js b/scripts/check-native-spec-parity.js index 1bf769b0..4a343002 100644 --- a/scripts/check-native-spec-parity.js +++ b/scripts/check-native-spec-parity.js @@ -42,8 +42,26 @@ const ios = read('ios/RCTPushy/RCTPushy.mm'); const harmonyCpp = read('harmony/pushy/src/main/cpp/PushyTurboModule.cpp'); const harmonyTs = read('harmony/pushy/src/main/ets/PushyTurboModule.ts'); -const has = (source, re) => re.test(source); +// The Harmony method table: `X(name, argCount, SYNC|ASYNC)` rows of the +// PUSHY_METHODS X-macro in PushyTurboModule.cpp. +const harmonyTable = new Map(); const problems = []; +const tableRe = /\bX\(\s*([A-Za-z]+)\s*,\s*(\d+)\s*,\s*(SYNC|ASYNC)\s*\)/g; +while ((m = tableRe.exec(harmonyCpp)) !== null) { + if (harmonyTable.has(m[1])) { + problems.push(`harmony/cpp: ${m[1]} listed twice in PUSHY_METHODS`); + } + harmonyTable.set(m[1], m[3]); +} +if (harmonyTable.size < 10) { + console.error( + 'check-native-spec-parity: failed to parse the PUSHY_METHODS table in ' + + 'harmony/pushy/src/main/cpp/PushyTurboModule.cpp' + ); + process.exit(1); +} + +const has = (source, re) => re.test(source); for (const { name, returns } of methods) { if (plumbing.has(name)) { @@ -62,32 +80,25 @@ for (const { name, returns } of methods) { ) { problems.push(`ios: ${name} not exported`); } - const syncReg = new RegExp(`PUSHY_SYNC_METHOD\\(${name}\\)`); - const asyncReg = new RegExp(`PUSHY_ASYNC_METHOD\\(${name}\\)`); - const registered = new RegExp(`registerMethod\\(\\s*"${name}"`); - if (!has(harmonyCpp, registered)) { - problems.push(`harmony/cpp: ${name} not in registerMethod table`); + const kind = harmonyTable.get(name); + if (!kind) { + problems.push(`harmony/cpp: ${name} not in the PUSHY_METHODS table`); } const isPromise = /^Promise Date: Mon, 12 Oct 2026 14:48:34 +0800 Subject: [PATCH 019/104] chore(test): share the cpp test scripts' SANITIZE/FUZZ plumbing scripts/lib/cpp-test-common.sh holds CORE_WARNINGS, the SANITIZE=1 flags and the libFuzzer probe that test-patch-core.sh and test-update-flow-core.sh each carried a copy of. The probe also tries Homebrew LLVM (Apple clang has no libFuzzer runtime) and honours FUZZ_CXX. test.yml installs clang with the other build dependencies instead of a second apt-get update. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test.yml | 6 +-- scripts/lib/cpp-test-common.sh | 48 +++++++++++++++++++++ scripts/test-patch-core.sh | 73 +++++++++++++------------------ scripts/test-update-flow-core.sh | 74 +++++++++++++------------------- 4 files changed, 111 insertions(+), 90 deletions(-) create mode 100644 scripts/lib/cpp-test-common.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 72618738..7d0bd193 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,6 +40,7 @@ jobs: - 'cpp/**' - 'android/jni/**' - 'scripts/test-*' + - 'scripts/lib/**' # TS reference the C++ golden vectors are generated from. - 'src/updateFlowCore.ts' - 'scripts/generate-flow-vectors.ts' @@ -80,7 +81,8 @@ jobs: submodules: recursive - name: Install build dependencies - run: sudo apt-get update && sudo apt-get install -y zlib1g-dev libbz2-dev + # clang (+ libFuzzer runtime) is for the bounded fuzz step below. + run: sudo apt-get update && sudo apt-get install -y zlib1g-dev libbz2-dev clang - name: Build HDiffPatch library working-directory: android/jni/HDiffPatch @@ -113,8 +115,6 @@ jobs: # libFuzzer is unavailable on the runner. - name: Fuzz parsers (bounded) run: | - sudo apt-get update - sudo apt-get install -y clang FUZZ=1 ./scripts/test-update-flow-core.sh FUZZ=1 ./scripts/test-patch-core.sh diff --git a/scripts/lib/cpp-test-common.sh b/scripts/lib/cpp-test-common.sh new file mode 100644 index 00000000..a144a7aa --- /dev/null +++ b/scripts/lib/cpp-test-common.sh @@ -0,0 +1,48 @@ +# shellcheck shell=sh +# Shared settings for the cpp/ core test scripts (test-patch-core.sh, +# test-update-flow-core.sh). POSIX sh; sourced, not executed. Expects +# BUILD_DIR to be set by the caller. + +# The cpp/ cores must build warning-free (-Werror). +# shellcheck disable=SC2034 # used by the sourcing script +CORE_WARNINGS="-Wall -Wextra -Werror" + +# Opt-in AddressSanitizer + UndefinedBehaviorSanitizer build: the cores +# process untrusted (downloaded) data, so running the tests under sanitizers +# catches memory/UB regressions. Enable with SANITIZE=1. +# cpp_test_sanitize_flags "" +# sets SANITIZE_FLAGS (empty unless SANITIZE=1). +cpp_test_sanitize_flags() { + SANITIZE_FLAGS="" + if [ "${SANITIZE:-0}" = "1" ]; then + SANITIZE_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer -g" + echo "Building $1 with AddressSanitizer + UBSan" + fi +} + +# Opt-in libFuzzer runs (FUZZ=1; FUZZ_SECONDS bounds each run, default 20) +# need clang with the libFuzzer runtime (libclang_rt.fuzzer). Apple clang +# ships without it, so Homebrew LLVM is tried too; FUZZ_CXX picks a compiler +# explicitly. Without a usable one the run is skipped, not failed. +# if cpp_test_fuzz_compiler; then "$FUZZ_CXX" ...; fi +# returns 0 and sets FUZZ_CXX when FUZZ=1 and a compiler links a fuzzer. +cpp_test_fuzz_compiler() { + [ "${FUZZ:-0}" = "1" ] || return 1 + fuzz_probe="$BUILD_DIR/fuzz_probe.cpp" + printf '#include \n#include \nextern "C" int LLVMFuzzerTestOneInput(const uint8_t*, size_t) { return 0; }\n' > "$fuzz_probe" + found_clang=0 + for candidate in ${FUZZ_CXX:-} clang++ /opt/homebrew/opt/llvm/bin/clang++ /usr/local/opt/llvm/bin/clang++; do + command -v "$candidate" >/dev/null 2>&1 || continue + found_clang=1 + if "$candidate" -fsanitize=fuzzer,address,undefined "$fuzz_probe" -o "$BUILD_DIR/fuzz_probe" >/dev/null 2>&1; then + FUZZ_CXX="$candidate" + return 0 + fi + done + if [ "$found_clang" = "0" ]; then + echo "FUZZ=1: clang++ not found, skipping the libFuzzer run" + else + echo "FUZZ=1: clang++ has no libFuzzer runtime (libclang_rt.fuzzer), skipping the libFuzzer run" + fi + return 1 +} diff --git a/scripts/test-patch-core.sh b/scripts/test-patch-core.sh index 6634056f..7c0066fb 100755 --- a/scripts/test-patch-core.sh +++ b/scripts/test-patch-core.sh @@ -1,23 +1,20 @@ #!/bin/sh +# Flag variables ($SANITIZE_FLAGS, $CORE_WARNINGS, ...) must split into +# compiler arguments. +# shellcheck disable=SC2086 set -eu +# Intentional one-command CDPATH assignment. +# shellcheck disable=SC1007 ROOT_DIR="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)" BUILD_DIR="$ROOT_DIR/.tmp/patch-core-tests" mkdir -p "$BUILD_DIR" -# Opt-in AddressSanitizer + UndefinedBehaviorSanitizer build. The patch core -# processes untrusted (downloaded) patch data, so running the tests under -# sanitizers catches memory/UB regressions. Enable with: SANITIZE=1 npm run test:patch-core -SANITIZE_FLAGS="" -if [ "${SANITIZE:-0}" = "1" ]; then - SANITIZE_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer -g" - echo "Building patch core tests with AddressSanitizer + UBSan" -fi - -# The cpp/ cores must build warning-free (-Werror); the vendored HDiffPatch / -# lzma C sources are compiled with -Wall -Wextra only. -CORE_WARNINGS="-Wall -Wextra -Werror" +# SANITIZE=1 / FUZZ=1 conventions and CORE_WARNINGS. +# shellcheck source-path=SCRIPTDIR source=lib/cpp-test-common.sh +. "$ROOT_DIR/scripts/lib/cpp-test-common.sh" +cpp_test_sanitize_flags "patch core tests" COMMON_INCLUDES=" -I$ROOT_DIR/cpp/patch_core @@ -27,6 +24,8 @@ COMMON_INCLUDES=" -I$ROOT_DIR/android/jni/lzma/C " +# The vendored HDiffPatch / lzma C sources are compiled with -Wall -Wextra +# only (no -Werror). cc -Wall -Wextra $SANITIZE_FLAGS $COMMON_INCLUDES -c "$ROOT_DIR/android/jni/hpatch.c" -o "$BUILD_DIR/hpatch.o" cc -Wall -Wextra $SANITIZE_FLAGS $COMMON_INCLUDES -c "$ROOT_DIR/android/jni/HDiffPatch/libHDiffPatch/HPatch/patch.c" -o "$BUILD_DIR/patch.o" cc -Wall -Wextra $SANITIZE_FLAGS $COMMON_INCLUDES -c "$ROOT_DIR/android/jni/HDiffPatch/file_for_patch.c" -o "$BUILD_DIR/file_for_patch.o" @@ -65,34 +64,24 @@ c++ \ "$BUILD_DIR/hbc_transform_test" "$ROOT_DIR/cpp/patch_core/tests/fixtures" # Opt-in libFuzzer run of the hbcTransform wire parser (untrusted __diff.json -# metadata): FUZZ=1 npm run test:patch-core. Needs clang with the libFuzzer -# runtime (libclang_rt.fuzzer); without it the step is skipped, not failed. -# FUZZ_SECONDS bounds the run (default 20). -if [ "${FUZZ:-0}" = "1" ]; then - FUZZ_PROBE="$BUILD_DIR/fuzz_probe.cpp" - printf '#include \n#include \nextern "C" int LLVMFuzzerTestOneInput(const uint8_t*, size_t) { return 0; }\n' > "$FUZZ_PROBE" - if ! command -v clang++ >/dev/null 2>&1; then - echo "FUZZ=1: clang++ not found, skipping the libFuzzer run" - elif ! clang++ -fsanitize=fuzzer,address,undefined "$FUZZ_PROBE" -o "$BUILD_DIR/fuzz_probe" >/dev/null 2>&1; then - echo "FUZZ=1: clang++ has no libFuzzer runtime (libclang_rt.fuzzer), skipping the libFuzzer run" - else - echo "Fuzzing hbcTransform wire parser for ${FUZZ_SECONDS:-20}s" - clang++ \ - -std=c++17 \ - $CORE_WARNINGS \ - -g -O1 -fno-omit-frame-pointer \ - -fsanitize=fuzzer,address,undefined \ - "$ROOT_DIR/cpp/patch_core/tests/fuzz_hbc_transform_wire.cpp" \ - "$ROOT_DIR/cpp/patch_core/hbc_transform.cpp" \ - "$ROOT_DIR/cpp/patch_core/hbc_transform_wire.cpp" \ - -o "$BUILD_DIR/fuzz_hbc_transform_wire" - CORPUS_DIR="$BUILD_DIR/fuzz-corpus-hbc-transform-wire" - mkdir -p "$CORPUS_DIR" - cp "$ROOT_DIR/cpp/patch_core/tests/fixtures/v96.meta.json" "$CORPUS_DIR/seed-v96-meta.json" - "$BUILD_DIR/fuzz_hbc_transform_wire" \ - -max_total_time="${FUZZ_SECONDS:-20}" \ - -max_len=8192 \ - -print_final_stats=1 \ - "$CORPUS_DIR" - fi +# metadata): FUZZ=1 bun run test:patch-core. +if cpp_test_fuzz_compiler; then + echo "Fuzzing hbcTransform wire parser for ${FUZZ_SECONDS:-20}s ($FUZZ_CXX)" + "$FUZZ_CXX" \ + -std=c++17 \ + $CORE_WARNINGS \ + -g -O1 -fno-omit-frame-pointer \ + -fsanitize=fuzzer,address,undefined \ + "$ROOT_DIR/cpp/patch_core/tests/fuzz_hbc_transform_wire.cpp" \ + "$ROOT_DIR/cpp/patch_core/hbc_transform.cpp" \ + "$ROOT_DIR/cpp/patch_core/hbc_transform_wire.cpp" \ + -o "$BUILD_DIR/fuzz_hbc_transform_wire" + CORPUS_DIR="$BUILD_DIR/fuzz-corpus-hbc-transform-wire" + mkdir -p "$CORPUS_DIR" + cp "$ROOT_DIR/cpp/patch_core/tests/fixtures/v96.meta.json" "$CORPUS_DIR/seed-v96-meta.json" + "$BUILD_DIR/fuzz_hbc_transform_wire" \ + -max_total_time="${FUZZ_SECONDS:-20}" \ + -max_len=8192 \ + -print_final_stats=1 \ + "$CORPUS_DIR" fi diff --git a/scripts/test-update-flow-core.sh b/scripts/test-update-flow-core.sh index e84524d5..bdee4811 100755 --- a/scripts/test-update-flow-core.sh +++ b/scripts/test-update-flow-core.sh @@ -1,4 +1,7 @@ #!/bin/sh +# Flag variables ($SANITIZE_FLAGS, $CORE_WARNINGS) must split into compiler +# arguments. +# shellcheck disable=SC2086 set -eu # Intentional one-command CDPATH assignment. @@ -8,19 +11,11 @@ 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 - -# The cpp/ cores must build warning-free. -CORE_WARNINGS="-Wall -Wextra -Werror" +# SANITIZE=1 / FUZZ=1 conventions and CORE_WARNINGS. +# shellcheck source-path=SCRIPTDIR source=lib/cpp-test-common.sh +. "$ROOT_DIR/scripts/lib/cpp-test-common.sh" +cpp_test_sanitize_flags "update flow core tests" -# SANITIZE_FLAGS must split into compiler arguments. -# shellcheck disable=SC2086 c++ \ -std=c++17 \ $CORE_WARNINGS \ @@ -34,37 +29,26 @@ c++ \ "$ROOT_DIR/cpp/update_flow_core/tests/flow_vectors.json" # Opt-in libFuzzer run of the JSON parser + decision pipeline (raw server -# bytes): FUZZ=1 npm run test:flow-core. Needs clang with the libFuzzer -# runtime (libclang_rt.fuzzer); without it the step is skipped, not failed. -# FUZZ_SECONDS bounds the run (default 20). -if [ "${FUZZ:-0}" = "1" ]; then - FUZZ_PROBE="$BUILD_DIR/fuzz_probe.cpp" - printf '#include \n#include \nextern "C" int LLVMFuzzerTestOneInput(const uint8_t*, size_t) { return 0; }\n' > "$FUZZ_PROBE" - if ! command -v clang++ >/dev/null 2>&1; then - echo "FUZZ=1: clang++ not found, skipping the libFuzzer run" - elif ! clang++ -fsanitize=fuzzer,address,undefined "$FUZZ_PROBE" -o "$BUILD_DIR/fuzz_probe" >/dev/null 2>&1; then - echo "FUZZ=1: clang++ has no libFuzzer runtime (libclang_rt.fuzzer), skipping the libFuzzer run" - else - echo "Fuzzing flow JSON parser + decision pipeline for ${FUZZ_SECONDS:-20}s" - # shellcheck disable=SC2086 - clang++ \ - -std=c++17 \ - $CORE_WARNINGS \ - -g -O1 -fno-omit-frame-pointer \ - -fsanitize=fuzzer,address,undefined \ - "$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/fuzz_flow_json.cpp" \ - -o "$BUILD_DIR/fuzz_flow_json" - CORPUS_DIR="$BUILD_DIR/fuzz-corpus-flow-json" - mkdir -p "$CORPUS_DIR" - printf '{"update":true,"hash":"h2","full":"h2.ppk","diff":"d.hdiff","paths":["cdn.x.com","https://m.x.com/"],"name":"v2","config":{"forceBoot":true}}' > "$CORPUS_DIR/seed-download.json" - printf '{"update":true,"hash":"root","expVersion":{"hash":"gray","full":"g.ppk","config":{"rollout":{"2.3.4":63}}},"paths":["cdn"]}' > "$CORPUS_DIR/seed-rollout.json" - printf '{"upToDate":true,"s":"\\ud83d\\ude00 \\u00e9 \\n","n":[1,-0.5,1e2,null,false]}' > "$CORPUS_DIR/seed-scalars.json" - "$BUILD_DIR/fuzz_flow_json" \ - -max_total_time="${FUZZ_SECONDS:-20}" \ - -max_len=4096 \ - -print_final_stats=1 \ - "$CORPUS_DIR" - fi +# bytes): FUZZ=1 bun run test:flow-core. +if cpp_test_fuzz_compiler; then + echo "Fuzzing flow JSON parser + decision pipeline for ${FUZZ_SECONDS:-20}s ($FUZZ_CXX)" + "$FUZZ_CXX" \ + -std=c++17 \ + $CORE_WARNINGS \ + -g -O1 -fno-omit-frame-pointer \ + -fsanitize=fuzzer,address,undefined \ + "$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/fuzz_flow_json.cpp" \ + -o "$BUILD_DIR/fuzz_flow_json" + CORPUS_DIR="$BUILD_DIR/fuzz-corpus-flow-json" + mkdir -p "$CORPUS_DIR" + printf '{"update":true,"hash":"h2","full":"h2.ppk","diff":"d.hdiff","paths":["cdn.x.com","https://m.x.com/"],"name":"v2","config":{"forceBoot":true}}' > "$CORPUS_DIR/seed-download.json" + printf '{"update":true,"hash":"root","expVersion":{"hash":"gray","full":"g.ppk","config":{"rollout":{"2.3.4":63}}},"paths":["cdn"]}' > "$CORPUS_DIR/seed-rollout.json" + printf '{"upToDate":true,"s":"\\ud83d\\ude00 \\u00e9 \\n","n":[1,-0.5,1e2,null,false]}' > "$CORPUS_DIR/seed-scalars.json" + "$BUILD_DIR/fuzz_flow_json" \ + -max_total_time="${FUZZ_SECONDS:-20}" \ + -max_len=4096 \ + -print_final_stats=1 \ + "$CORPUS_DIR" fi From 5e1ca68d593ff95bf6c6c71fc4b85dad2d7dfe5c Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:48:54 +0800 Subject: [PATCH 020/104] refactor(provider): dedupe catch blocks and context actions - checkUpdate/downloadUpdate share surfaceCaughtError for errors that bypassed the client pipeline (user hooks). - switchVersion/switchVersionLater come from one forUpdateHash factory. - restartApp/resetToPackagedBundle are the client's own stable arrow methods, like markSuccess. - downloadAndInstallApk drops the Android check the client already does. - Linking cleanup prefers subscription.remove() and only falls back to removeEventListener on RN < 0.65 (no deprecation warning on 0.65-0.69). Co-Authored-By: Claude Opus 5.5 --- src/provider.tsx | 94 ++++++++++++++++++++++-------------------------- 1 file changed, 43 insertions(+), 51 deletions(-) diff --git a/src/provider.tsx b/src/provider.tsx index b8163a79..6c8b7d49 100644 --- a/src/provider.tsx +++ b/src/provider.tsx @@ -22,6 +22,16 @@ import type { CheckResult, ProgressData, UpdateTestPayload } from './type'; import { decideDownload, resolveCheckResult } from './updateFlowCore'; import { assertWeb, isWeb, log, noop, parseQueryParams } from './utils'; +// A context action applied to the hash of the given (default: the last +// checked) update info; a no-op without one. +const forUpdateHash = + ( + updateInfoRef: { current?: CheckResult }, + apply: (hash: string) => Promise + ) => + async (info: CheckResult | undefined = updateInfoRef.current) => + info?.hash ? apply(info.hash) : undefined; + export const UpdateProvider = ({ client, children, @@ -100,22 +110,32 @@ export const UpdateProvider = ({ [client, alertError] ); - const switchVersion = useCallback( - async (info: CheckResult | undefined = updateInfoRef.current) => { - if (info?.hash) { - return client.switchVersion(info.hash); - } - }, + const switchVersion = useMemo( + () => forUpdateHash(updateInfoRef, client.switchVersion), [client] ); - const switchVersionLater = useCallback( - async (info: CheckResult | undefined = updateInfoRef.current) => { - if (info?.hash) { - return client.switchVersionLater(info.hash); + const switchVersionLater = useMemo( + () => forUpdateHash(updateInfoRef, client.switchVersionLater), + [client] + ); + + // Client pipeline errors were already surfaced via the onError + // subscription; errors thrown by user hooks (beforeCheckUpdate, + // afterDownloadUpdate) bypass the pipeline and are surfaced here. Asking + // the client instead of checking `e.code` matters: axios/system errors + // carry their own code without ever entering the pipeline. + const surfaceCaughtError = useCallback( + (e: any, title: string) => { + if (!client.wasEmitted(e)) { + setLastError(e); + alertError(title, e.message); + } + if (options.throwError) { + throw e; } }, - [client] + [client, options, alertError] ); const downloadUpdate = useCallback( @@ -162,27 +182,17 @@ export const UpdateProvider = ({ ]); return true; } catch (e: any) { - // Client pipeline errors were already surfaced via the onError - // subscription; errors thrown by user hooks (afterDownloadUpdate) - // bypass the pipeline and are surfaced here. Asking the client - // instead of checking `e.code` matters: axios/system errors carry - // their own code without ever entering the pipeline. - if (!client.wasEmitted(e)) { - setLastError(e); - alertError(client.t('update_failed'), e.message); - } - if (options.throwError) { - throw e; - } + surfaceCaughtError(e, client.t('update_failed')); return false; } }, - [client, options, alertUpdate, alertError] + [client, options, alertUpdate, surfaceCaughtError] ); const downloadAndInstallApk = useCallback( async (downloadUrl: string) => { - if (Platform.OS === 'android' && downloadUrl) { + // The client itself is a no-op off Android. + if (downloadUrl) { await client.downloadAndInstallApk(downloadUrl, setProgress); } }, @@ -219,16 +229,7 @@ export const UpdateProvider = ({ try { rootInfo = await client.checkUpdate(extra); } catch (e: any) { - // Client pipeline errors were already surfaced via the onError - // subscription; errors thrown by user hooks (beforeCheckUpdate) - // bypass the pipeline and are surfaced here (see wasEmitted). - if (!client.wasEmitted(e)) { - setLastError(e); - alertError(client.t('error_update_check_failed'), e.message); - } - if (options.throwError) { - throw e; - } + surfaceCaughtError(e, client.t('error_update_check_failed')); return; } if (!rootInfo) { @@ -354,13 +355,14 @@ export const UpdateProvider = ({ client, options, alertUpdate, - alertError, + surfaceCaughtError, openExpiredDownload, downloadUpdate, ] ); - const markSuccess = client.markSuccess; + // Stable arrow-function properties of the (pinned) client. + const { markSuccess, restartApp, resetToPackagedBundle } = client; // The lifecycle effects below key on the primitive option values they read // (re-read on every render, i.e. after every setOptions), so a setOptions @@ -528,17 +530,6 @@ export const UpdateProvider = ({ [parseTestPayload] ); - const restartApp = useCallback(async () => { - return client.restartApp(); - }, [client]); - - const resetToPackagedBundle = useCallback( - async (resetOptions?: { restart?: boolean }) => { - return client.resetToPackagedBundle(resetOptions); - }, - [client] - ); - // parseTestPayload's identity follows checkUpdate (and through it the // update strategy callbacks); the Linking subscription reads it through a // ref so it is set up once instead of re-running — and replaying the @@ -579,10 +570,11 @@ export const UpdateProvider = ({ }; const linkingListener = Linking.addEventListener('url', linkingHandler); return () => { - if ('removeEventListener' in Linking) { - (Linking as any).removeEventListener('url', linkingHandler); - } else { + // RN < 0.65 returns no subscription, only removeEventListener. + if (typeof linkingListener?.remove === 'function') { linkingListener.remove(); + } else { + (Linking as any).removeEventListener('url', linkingHandler); } }; }, []); From 6117a353b4520fae41b1f1eaf3ce2a2e5a4bb464 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:49:17 +0800 Subject: [PATCH 021/104] chore(packlist): derive the whitelist from package.json files The allowed prefixes were a hand copy of package.json `files`. Build them from `files` instead (globs included) and also flag a packed path that a negated `files` entry should have excluded. Co-Authored-By: Claude Opus 5.5 --- scripts/check-packlist.js | 55 +++++++++++++++++++++++---------------- 1 file changed, 33 insertions(+), 22 deletions(-) diff --git a/scripts/check-packlist.js b/scripts/check-packlist.js index 0933528e..d42e54dd 100644 --- a/scripts/check-packlist.js +++ b/scripts/check-packlist.js @@ -4,30 +4,37 @@ // must and must not be there. Run in lint CI and right before `npm publish` // (with --release, which additionally requires the Harmony HAR). const { execFileSync } = require('child_process'); +const { files: filesField } = require('../package.json'); const release = process.argv.includes('--release'); -const allowedPrefixes = [ - 'src/', - 'android/src/', - 'android/lib/', - 'android/jni/', - 'android/build.gradle', - 'android/proguard.pro', - 'ios/', - 'cpp/patch_core/', - 'cpp/update_flow_core/', - 'harmony/pushy.har', - 'harmony/hvigor-plugin.ts', - 'harmony/pushy/src/', - 'react-native-update.podspec', - 'react-native.config.js', - 'expo-module.config.json', - 'package.json', - 'README.md', - 'README-CN.md', - 'LICENSE', -]; +// A `files` entry (glob, file or directory) as a regex over packed paths: +// the entry itself or anything below it. +function entryPattern(entry) { + const source = entry + .replace(/\/+$/, '') + .split(/(\*\*\/?|\*)/) + .map((part) => { + if (part === '**' || part === '**/') { + return '(?:.*/)?'; + } + if (part === '*') { + return '[^/]*'; + } + return part.replace(/[.+?^${}()|[\]\\]/g, '\\$&'); + }) + .join(''); + return new RegExp(`^${source}(/|$)`); +} + +// Derived from package.json `files` (the single list of what ships), plus +// package.json which npm always packs. Negated entries must not show up. +const allowed = ['package.json', ...filesField.filter((f) => !f.startsWith('!'))].map( + entryPattern +); +const excluded = filesField + .filter((f) => f.startsWith('!')) + .map((f) => ({ entry: f, re: entryPattern(f.slice(1)) })); const forbidden = [ /(^|\/)(tests?|__tests__)\//, @@ -78,9 +85,13 @@ const set = new Set(files); const problems = []; for (const file of files) { - if (!allowedPrefixes.some((p) => file === p || file.startsWith(p))) { + if (!allowed.some((re) => re.test(file))) { problems.push(`not whitelisted: ${file}`); } + const hit = excluded.find(({ re }) => re.test(file)); + if (hit) { + problems.push(`excluded by "${hit.entry}" but packed: ${file}`); + } if (forbidden.some((re) => re.test(file))) { problems.push(`forbidden: ${file}`); } From 2b7f2431f4dc42b66223a41968ee5c4c4dc49847 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:49:27 +0800 Subject: [PATCH 022/104] refactor(harmony): dedupe DownloadTask file helpers - writeFileContent writes through SaveFile.writeFully (now exported, and checks short writes) instead of its own 64KB chunk loop; toArrayBufferSlice and FILE_COPY_BUFFER_SIZE go away. - SaveFile.unlinkIfExists replaces the access-then-unlink pattern in DownloadTask and InstallRecord. - One buildResumeMeta closure builds the resume sidecar for both the initial and the final write. - 'bundle.harmony.js' literals use HARMONY_BUNDLE_FILE_NAME. Co-Authored-By: Claude Opus 5.5 --- harmony/pushy/src/main/ets/DownloadTask.ts | 127 ++++++++------------ harmony/pushy/src/main/ets/InstallRecord.ts | 5 +- harmony/pushy/src/main/ets/SaveFile.ts | 10 +- harmony/pushy/src/main/ets/UpdateContext.ts | 5 +- 4 files changed, 66 insertions(+), 81 deletions(-) diff --git a/harmony/pushy/src/main/ets/DownloadTask.ts b/harmony/pushy/src/main/ets/DownloadTask.ts index c5a1eb3e..6d0c90c1 100644 --- a/harmony/pushy/src/main/ets/DownloadTask.ts +++ b/harmony/pushy/src/main/ets/DownloadTask.ts @@ -4,7 +4,12 @@ import common from '@ohos.app.ability.common'; import { zlib } from '@kit.BasicServicesKit'; import { EventHub } from './EventHub'; import { DownloadTaskParams } from './DownloadTaskParams'; -import { saveFileToSandbox, withRawFd } from './SaveFile'; +import { + saveFileToSandbox, + unlinkIfExists, + withRawFd, + writeFully, +} from './SaveFile'; import { util } from '@kit.ArkTS'; import NativePatchCore, { ARCHIVE_PATCH_TYPE_FROM_PACKAGE, @@ -115,21 +120,8 @@ interface DeltaInputs { manifestArrays: PatchManifestArrays; } -function toArrayBufferSlice( - payload: Uint8Array, - offset: number, - length: number, -): ArrayBuffer { - return (payload.buffer as ArrayBuffer).slice( - payload.byteOffset + offset, - payload.byteOffset + offset + length, - ); -} - const DIFF_MANIFEST_ENTRY = '__diff.json'; -const TEMP_ORIGIN_BUNDLE_ENTRY = '.origin.bundle.harmony.js'; -const EMBEDDED_BUNDLE_RAWFILE = 'bundle.harmony.js'; -const FILE_COPY_BUFFER_SIZE = 64 * 1024; +const TEMP_ORIGIN_BUNDLE_ENTRY = `.origin.${HARMONY_BUNDLE_FILE_NAME}`; const DOWNLOAD_CALL_TIMEOUT_MS = 10 * 60 * 1000; // 断点续传 sidecar(NATIVE_CHECKUPDATE_DESIGN §11.4):记录 partial 属于 @@ -260,10 +252,7 @@ export class DownloadTask { private async deleteResumeSidecar(targetFile: string): Promise { try { - const sidecar = this.resumeSidecarPath(targetFile); - if (fileIo.accessSync(sidecar)) { - await fileIo.unlink(sidecar); - } + await unlinkIfExists(this.resumeSidecarPath(targetFile)); } catch (e) { logger.error(TAG, `Failed to delete resume sidecar: ${getErrorMessage(e)}`); } @@ -272,9 +261,7 @@ export class DownloadTask { // 归档连同 sidecar 一起删除(被消费或被判定有毒)。 private async deleteArchiveAndSidecar(targetFile: string): Promise { try { - if (fileIo.accessSync(targetFile)) { - await fileIo.unlink(targetFile); - } + await unlinkIfExists(targetFile); } catch (e) { logger.error(TAG, `Failed to delete archive: ${getErrorMessage(e)}`); } @@ -418,33 +405,26 @@ export class DownloadTask { ): Promise { const payload = content instanceof Uint8Array ? content : new Uint8Array(content); + // writeFully writes from the start of its buffer: a view into a larger + // buffer at a non-zero offset needs its own copy. + const bytes = + payload.byteOffset === 0 + ? (payload.buffer as ArrayBuffer) + : (payload.buffer as ArrayBuffer).slice( + payload.byteOffset, + payload.byteOffset + payload.byteLength, + ); await this.ensureParentDirectory(targetFile); - if (fileIo.accessSync(targetFile)) { - await fileIo.unlink(targetFile); - } + await unlinkIfExists(targetFile); - let writer: fileIo.File | null = null; + const writer = await fileIo.open( + targetFile, + fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY, + ); try { - writer = await fileIo.open( - targetFile, - fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY, - ); - const chunkSize = FILE_COPY_BUFFER_SIZE; - let bytesWritten = 0; - - while (bytesWritten < payload.byteLength) { - const chunkLength = Math.min( - chunkSize, - payload.byteLength - bytesWritten, - ); - const chunk = toArrayBufferSlice(payload, bytesWritten, chunkLength); - await fileIo.write(writer.fd, chunk); - bytesWritten += chunkLength; - } + await writeFully(writer.fd, bytes, payload.byteLength); } finally { - if (writer) { - await fileIo.close(writer); - } + await fileIo.close(writer); } } @@ -498,7 +478,7 @@ export class DownloadTask { try { await withRawFd( this.context.resourceManager, - EMBEDDED_BUNDLE_RAWFILE, + HARMONY_BUNDLE_FILE_NAME, descriptor => saveFileToSandbox(descriptor, originBundlePath), ); await NativePatchCore.applyDeltaFromSource({ @@ -519,9 +499,7 @@ export class DownloadTask { `Failed to process bundle delta: ${getErrorMessage(error)}`, ); } finally { - if (fileIo.accessSync(originBundlePath)) { - await fileIo.unlink(originBundlePath); - } + await unlinkIfExists(originBundlePath); } } @@ -626,6 +604,24 @@ export class DownloadTask { authorize = resolve; }); + // 本 url 的 sidecar 内容:验证器优先取本次响应头,缺失时沿用上次的; + // 总长未知(0)时不写。 + const buildResumeMeta = (total: number): ResumeMeta => { + const meta: ResumeMeta = { url: params.url }; + const etag = etagHeader || resumeMeta?.etag; + const lastModified = lastModifiedHeader || resumeMeta?.lastModified; + if (etag) { + meta.etag = etag; + } + if (lastModified) { + meta.lastModified = lastModified; + } + if (total > 0) { + meta.total = total; + } + return meta; + }; + const reportProgress = () => { const overall = baseOffset + received; const overallTotal = totalAll > 0 @@ -907,9 +903,7 @@ export class DownloadTask { ); } else { // 服务端忽略了 Range(或本就没发):从零开始。 - if (fileIo.accessSync(params.targetFile)) { - await fileIo.unlink(params.targetFile); - } + await unlinkIfExists(params.targetFile); baseOffset = 0; totalAll = contentLength > 0 ? contentLength : 0; writer = await fileIo.open( @@ -929,19 +923,7 @@ export class DownloadTask { totalAll > 0 ? totalAll - baseOffset : 0, ); // 先落 sidecar 再放行写入,流中崩溃才有得续。 - const meta: ResumeMeta = { url: params.url }; - const etag = etagHeader || resumeMeta?.etag; - const lastModified = lastModifiedHeader || resumeMeta?.lastModified; - if (etag) { - meta.etag = etag; - } - if (lastModified) { - meta.lastModified = lastModified; - } - if (totalAll > 0) { - meta.total = totalAll; - } - await this.writeResumeMeta(params.targetFile, meta); + await this.writeResumeMeta(params.targetFile, buildResumeMeta(totalAll)); // 鉴权通过:回放缓冲,之后的数据直写。 const buffered = pendingChunks ?? []; @@ -983,15 +965,10 @@ export class DownloadTask { } // 用最终观测值刷新 sidecar:首次写入可能因头部竞态缺 total/验证器, // 补齐后“下载完、解压前死”的窗口才能被 complete 检测覆盖。 - const finalMeta: ResumeMeta = { url: params.url, total: stats.size }; - if (etagHeader || resumeMeta?.etag) { - finalMeta.etag = etagHeader || resumeMeta?.etag; - } - if (lastModifiedHeader || resumeMeta?.lastModified) { - finalMeta.lastModified = - lastModifiedHeader || resumeMeta?.lastModified; - } - await this.writeResumeMeta(params.targetFile, finalMeta); + await this.writeResumeMeta( + params.targetFile, + buildResumeMeta(stats.size), + ); } else { // 编码传输不可续传:盘上是解码后的字节,编码域的长度与偏移都失效。 await this.deleteResumeSidecar(params.targetFile); @@ -1127,7 +1104,7 @@ export class DownloadTask { deletes: manifestArrays.deletes, sourceRoot: params.originDirectory, targetRoot: work, - originBundlePath: `${params.originDirectory}/bundle.harmony.js`, + originBundlePath: `${params.originDirectory}/${HARMONY_BUNDLE_FILE_NAME}`, bundleDeltaPath: `${work}/${BUNDLE_DELTA_ENTRY}`, bundleOutputPath: `${work}/${HARMONY_BUNDLE_FILE_NAME}`, mergeSourceSubdir: plan.mergeSourceSubdir, diff --git a/harmony/pushy/src/main/ets/InstallRecord.ts b/harmony/pushy/src/main/ets/InstallRecord.ts index aeade0e6..032e1d21 100644 --- a/harmony/pushy/src/main/ets/InstallRecord.ts +++ b/harmony/pushy/src/main/ets/InstallRecord.ts @@ -1,6 +1,7 @@ import fileIo from '@ohos.file.fs'; import { util } from '@kit.ArkTS'; import NativePatchCore from './NativePatchCore'; +import { unlinkIfExists } from './SaveFile'; import { ERROR_DELTA_FAILED, ERROR_SWITCH_VERSION_FAILED, @@ -65,9 +66,7 @@ export async function writeInstallRecord( recordJson: string, ): Promise { const path = `${versionDir}/${INSTALL_RECORD_FILE_NAME}`; - if (fileIo.accessSync(path)) { - await fileIo.unlink(path); - } + await unlinkIfExists(path); const file = await fileIo.open( path, fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY, diff --git a/harmony/pushy/src/main/ets/SaveFile.ts b/harmony/pushy/src/main/ets/SaveFile.ts index 0b1ffdbd..f29db3e8 100644 --- a/harmony/pushy/src/main/ets/SaveFile.ts +++ b/harmony/pushy/src/main/ets/SaveFile.ts @@ -53,7 +53,8 @@ export async function withRawFd( } } -async function writeFully( +/** 把 buffer 的前 length 字节完整写入 fd(处理短写)。 */ +export async function writeFully( fd: number, buffer: ArrayBuffer, length: number, @@ -69,6 +70,13 @@ async function writeFully( } } +/** 文件存在则删除(删除失败照常抛出)。 */ +export async function unlinkIfExists(path: string): Promise { + if (fs.accessSync(path)) { + await fs.unlink(path); + } +} + /** * 把 rawfile descriptor 指向的区间异步拷贝到沙箱文件(覆盖)。全程异步 * fileIo:调用方在 UI 线程(TurboModule),同步 IO 会冻结界面。读到预期 diff --git a/harmony/pushy/src/main/ets/UpdateContext.ts b/harmony/pushy/src/main/ets/UpdateContext.ts index 530530b9..58826e01 100644 --- a/harmony/pushy/src/main/ets/UpdateContext.ts +++ b/harmony/pushy/src/main/ets/UpdateContext.ts @@ -1,6 +1,7 @@ import preferences from '@ohos.data.preferences'; import fileIo from '@ohos.file.fs'; import { + HARMONY_BUNDLE_FILE_NAME, INSTALL_RECORD_FILE_NAME, isInstallComplete, readInstallRecord, @@ -404,7 +405,7 @@ export class UpdateContext { } private getBundlePath(hash: string): string { - return `${this.rootDir}/${hash}/bundle.harmony.js`; + return `${this.rootDir}/${hash}/${HARMONY_BUNDLE_FILE_NAME}`; } private getStateSnapshot(): StateCoreResult { @@ -1075,7 +1076,7 @@ export class UpdateContext { // fd 在 promise 落定后才关闭(withRawFd)。 hash = await withRawFd( this.context.resourceManager, - 'bundle.harmony.js', + HARMONY_BUNDLE_FILE_NAME, descriptor => NativePatchCore.sha256HexRawFdAsync( descriptor.fd, From 4a233f4878af6c1cedd30ac802a2f6ebe6a38d3f Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:49:56 +0800 Subject: [PATCH 023/104] refactor(harmony): trim UpdateContext wrappers and the getAllSync probe downloadFullUpdate/downloadPatchFromPackage wrapped executeTask in a try/log/rethrow that duplicated DownloadTask's own failure log (downloadPatchFromPpk never had it). Preferences.getAllSync exists since API 10 and the HAR targets API 12, so reset calls it directly; only the API 14 flushSync keeps its structural probe. Co-Authored-By: Claude Opus 5.5 --- harmony/pushy/src/main/ets/UpdateContext.ts | 81 ++++++++------------- 1 file changed, 32 insertions(+), 49 deletions(-) diff --git a/harmony/pushy/src/main/ets/UpdateContext.ts b/harmony/pushy/src/main/ets/UpdateContext.ts index 58826e01..fe696bb3 100644 --- a/harmony/pushy/src/main/ets/UpdateContext.ts +++ b/harmony/pushy/src/main/ets/UpdateContext.ts @@ -43,11 +43,10 @@ const TAG = 'PushyContext'; // 常规清理保留最近 3 天内触碰过的条目(续传 partial、staging 同样按 mtime)。 const CLEANUP_MAX_AGE_DAYS = 3; -// Preferences.flushSync 是 API 14 才有、getAllSync 老 SDK 声明里没有:HAR 兼容 -// API 12,按结构化视图探测(不用交叉类型——ArkTS 不支持)。 +// Preferences.flushSync 是 API 14 才有:HAR 兼容 API 12,按结构化视图探测 +// (不用交叉类型——ArkTS 不支持)。 interface OptionalPreferencesApi { flushSync?: () => void; - getAllSync?: () => Record; } // C1 跨平台配置等价(JS/Android/iOS 同一规则):缺省键补默认值、端点/ @@ -641,23 +640,20 @@ export class UpdateContext { let flushed: Promise = Promise.resolve(); try { // 删除已下载版本的 hash_* 元信息(不走 clear():它是异步的,且会连带清掉 - // uuid —— 见 syncStateWithBinaryVersion 的注释)。getAllSync 在旧 SDK 上 - // 可能不存在,此时残留的 hash_* 只是无害孤儿数据,不影响 reset 语义。 - const api = this.optionalPreferencesApi(); - if (typeof api.getAllSync === 'function') { - try { - const all = api.getAllSync(); - for (const key of Object.keys(all)) { - if (key.startsWith('hash_')) { - this.preferences.deleteSync(key); - } + // uuid —— 见 syncStateWithBinaryVersion 的注释)。读取失败时残留的 + // hash_* 只是无害孤儿数据,不影响 reset 语义。 + try { + const all = this.preferences.getAllSync(); + for (const key of Object.keys(all)) { + if (key.startsWith('hash_')) { + this.preferences.deleteSync(key); } - } catch (e) { - logger.error( - TAG, - `Failed to clear hash info on reset: ${getErrorMessage(e)}`, - ); } + } catch (e) { + logger.error( + TAG, + `Failed to clear hash info on reset: ${getErrorMessage(e)}`, + ); } this.writeState(resetState, { clearFirstLoadMarker: true }); // 缓存里的响应仍在宣告本次 reset 刚删掉的版本,一并丢弃,避免 JS 侧复用。 @@ -738,20 +734,15 @@ export class UpdateContext { hash: string, deadlineUptimeMs: number = 0, ): Promise { - try { - const params = this.createTaskParams( - DownloadTaskParams.TASK_TYPE_PATCH_FULL, - url, - hash, - ); - params.targetFile = `${this.rootDir}/${hash}.ppk`; - params.unzipDirectory = `${this.rootDir}/${hash}`; - params.deadlineUptimeMs = deadlineUptimeMs; - await this.executeTask(params); - } catch (e) { - logger.error(TAG, `Failed to download full package: ${getErrorMessage(e)}`); - throw e; - } + const params = this.createTaskParams( + DownloadTaskParams.TASK_TYPE_PATCH_FULL, + url, + hash, + ); + params.targetFile = `${this.rootDir}/${hash}.ppk`; + params.unzipDirectory = `${this.rootDir}/${hash}`; + params.deadlineUptimeMs = deadlineUptimeMs; + await this.executeTask(params); } public async downloadPatchFromPpk( @@ -778,23 +769,15 @@ export class UpdateContext { hash: string, deadlineUptimeMs: number = 0, ): Promise { - try { - const params = this.createTaskParams( - DownloadTaskParams.TASK_TYPE_PATCH_FROM_APP, - url, - hash, - ); - params.targetFile = `${this.rootDir}/${hash}${APP_DELTA_SUFFIX}`; - params.unzipDirectory = `${this.rootDir}/${hash}`; - params.deadlineUptimeMs = deadlineUptimeMs; - return await this.executeTask(params); - } catch (e) { - logger.error( - TAG, - `Failed to download package delta: ${getErrorMessage(e)}`, - ); - throw e; - } + const params = this.createTaskParams( + DownloadTaskParams.TASK_TYPE_PATCH_FROM_APP, + url, + hash, + ); + params.targetFile = `${this.rootDir}/${hash}${APP_DELTA_SUFFIX}`; + params.unzipDirectory = `${this.rootDir}/${hash}`; + params.deadlineUptimeMs = deadlineUptimeMs; + await this.executeTask(params); } // 运行中热更版本安装记录里的 bundleSha256(崩溃归因用);内置 bundle / From 3909fee37f878669b2140f26a2f282d765feaf10 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:50:13 +0800 Subject: [PATCH 024/104] chore: remove dead JS exports and locale keys - i18n: getLocale/addTranslations and the I18n class export were only used by tests; t() resolves its template in one expression; the stale usage comment is gone. Tests use the real singleton instead. - locales: checking_update / download_progress were only referenced by the i18n test and that comment. - telemetry ServerReportPayload, type.ts Up-to-date/UpdateAvailable/ Expired CheckResult variants (not exported from index), core isFirstTimeDebug, the utils joinUrls re-export (tests import it from updateFlowCore) and the defaultContext export. Co-Authored-By: Claude Opus 5.5 --- src/__tests__/client.test.ts | 2 - src/__tests__/i18n.test.ts | 97 +++++++-------------------------- src/__tests__/utils.test.ts | 2 +- src/context.ts | 2 +- src/core.ts | 2 - src/i18n.ts | 101 +++++++---------------------------- src/locales/en.ts | 4 -- src/locales/zh.ts | 4 -- src/telemetry.ts | 13 ----- src/type.ts | 15 ------ src/utils.ts | 2 - 11 files changed, 38 insertions(+), 206 deletions(-) diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index 972ca9cd..8ba051ae 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -1093,8 +1093,6 @@ describe('downloadUpdate fallback chain', () => { DEFAULT_FETCH_TIMEOUT_MS: 5000, fetchWithTimeout, info: mock(() => {}), - joinUrls: (paths: string[], fileName?: string) => - fileName ? paths.map((p) => `${p}/${fileName}`) : undefined, log: mock(() => {}), noop: () => {}, promiseAny: mock(() => Promise.resolve()), diff --git a/src/__tests__/i18n.test.ts b/src/__tests__/i18n.test.ts index 3613fc9e..761c3580 100644 --- a/src/__tests__/i18n.test.ts +++ b/src/__tests__/i18n.test.ts @@ -1,99 +1,38 @@ import { describe, expect, test } from 'bun:test'; -describe('I18n', () => { - // Use a helper function to import the real implementation - // since `../i18n` is globally mocked in `setup.ts` - const getRealI18n = async () => { - // Generate a random cache-buster so we get a fresh instance if needed, - // although singleton is usually fine, the `setup.ts` mock needs to be bypassed. - const cacheKey = 'real'; - const module = await import(`../i18n?${cacheKey}`); - return { I18n: module.I18n, i18nSingleton: module.default }; - }; +describe('i18n', () => { + // `../i18n` is globally mocked in setup.ts; a cache-busting specifier loads + // the real module (a fresh instance per key). + const getRealI18n = async (cacheKey: string) => + (await import(`../i18n?${cacheKey}`)) + .default as typeof import('../i18n').default; - test('setLocale and getLocale', async () => { - const { I18n } = await getRealI18n(); - const i18n = new I18n(); - - expect(i18n.getLocale()).toBe('en'); // Default is 'en' + test('translates in the current locale (default en)', async () => { + const i18n = await getRealI18n('locale'); + expect(i18n.t('update_failed')).toBe('Update failed'); i18n.setLocale('zh'); - expect(i18n.getLocale()).toBe('zh'); - + expect(i18n.t('update_failed')).toBe('更新失败'); i18n.setLocale('en'); - expect(i18n.getLocale()).toBe('en'); + expect(i18n.t('update_failed')).toBe('Update failed'); }); - test('basic translations and fallback', async () => { - const { I18n } = await getRealI18n(); - const i18n = new I18n(); - - // English translation - i18n.setLocale('en'); - expect(i18n.t('checking_update')).toBe('Checking for updates...'); + test('an unknown key falls back to the key itself', async () => { + const i18n = await getRealI18n('missing'); - // Chinese translation - i18n.setLocale('zh'); - expect(i18n.t('checking_update')).toBe('正在检查更新...'); - - // Fallback logic - // Add translation only to 'zh', check if 'en' falls back to 'zh' - i18n.addTranslations('zh', { only_in_zh: '中文专属' }); - i18n.setLocale('en'); - expect(i18n.t('only_in_zh' as any)).toBe('中文专属'); - - // Add translation only to 'en', check if 'zh' falls back to 'en' - i18n.addTranslations('en', { only_in_en: 'English Only' }); - i18n.setLocale('zh'); - expect(i18n.t('only_in_en' as any)).toBe('English Only'); - - // Key missing in both locales expect(i18n.t('missing_key' as any)).toBe('missing_key'); }); test('string interpolation', async () => { - const { I18n } = await getRealI18n(); - const i18n = new I18n(); + const i18n = await getRealI18n('interpolation'); - i18n.setLocale('en'); - - // Normal interpolation - expect(i18n.t('download_progress', { progress: 50 })).toBe( - 'Download progress: 50%' - ); - - // Multiple interpolations expect( i18n.t('error_http_status', { status: 502, statusText: 'Bad' }) ).toBe('502 Bad'); - - // Interpolation with missing values (should leave placeholder as is) - expect(i18n.t('download_progress')).toBe( - 'Download progress: {{progress}}%' - ); - - // Invalid interpolation syntax (spaces) are ignored - i18n.addTranslations('en', { invalid_syntax: 'Test {{ key }} here' }); - expect(i18n.t('invalid_syntax' as any, { key: 'value' })).toBe( - 'Test {{ key }} here' + // Missing values leave their placeholder as is. + expect(i18n.t('error_http_status', { status: 502 })).toBe( + '502 {{statusText}}' ); - }); - - test('addTranslations', async () => { - const { I18n } = await getRealI18n(); - const i18n = new I18n(); - - i18n.addTranslations('en', { new_key: 'New Value' }); - i18n.setLocale('en'); - - expect(i18n.t('new_key' as any)).toBe('New Value'); - }); - - test('singleton export', async () => { - const { i18nSingleton } = await getRealI18n(); - - // Verify it works - expect(i18nSingleton.getLocale()).toBeDefined(); - expect(i18nSingleton.t('checking_update')).toBeDefined(); + expect(i18n.t('error_http_status')).toBe('{{status}} {{statusText}}'); }); }); diff --git a/src/__tests__/utils.test.ts b/src/__tests__/utils.test.ts index 4908b2a3..f46c29d3 100644 --- a/src/__tests__/utils.test.ts +++ b/src/__tests__/utils.test.ts @@ -16,6 +16,7 @@ mock.module('../i18n', () => { }; }); +import { joinUrls } from '../updateFlowCore'; import { computeProgress, createNonSecureId, @@ -23,7 +24,6 @@ import { fetchWithTimeout, info, isProtocolDowngrade, - joinUrls, log, parseQueryParams, promiseAny, diff --git a/src/context.ts b/src/context.ts index 7f08b4c0..1d1b2efc 100644 --- a/src/context.ts +++ b/src/context.ts @@ -46,7 +46,7 @@ export interface UpdateContextValue { lastError?: Error; } -export const defaultContext: UpdateContextValue = { +const defaultContext: UpdateContextValue = { checkUpdate: asyncNoop, switchVersion: asyncNoop, switchVersionLater: asyncNoop, diff --git a/src/core.ts b/src/core.ts index 1da147cd..8f697307 100644 --- a/src/core.ts +++ b/src/core.ts @@ -127,7 +127,6 @@ export const currentBundleSha256: string = : ''; export const isFirstTime: boolean = PushyConstants.isFirstTime; -export const isFirstTimeDebug: boolean = isFirstTime && isDebugChannel; export const rolledBackVersion: string = PushyConstants.rolledBackVersion; export const isRolledBack: boolean = !!rolledBackVersion; @@ -204,7 +203,6 @@ log('bootup status', { currentVersion, currentVersionInfo, isFirstTime, - isFirstTimeDebug, isDebugChannel, cInfo, }); diff --git a/src/i18n.ts b/src/i18n.ts index 90d11d97..a3c1355c 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -1,108 +1,43 @@ import enTranslations from './locales/en'; import zhTranslations from './locales/zh'; +type Locale = 'zh' | 'en'; type TranslationKey = keyof typeof zhTranslations | keyof typeof enTranslations; type TranslationValues = Record; +const translations: Record> = { + zh: zhTranslations, + en: enTranslations, +}; + class I18n { - private currentLocale: 'zh' | 'en' = 'en'; - private translations = { - zh: zhTranslations, - en: enTranslations, - }; + private currentLocale: Locale = 'en'; - /** - * Set locale directly - * @param locale - 'zh' or 'en' - */ - setLocale(locale: 'zh' | 'en') { + setLocale(locale: Locale) { this.currentLocale = locale; } /** - * Get current locale - */ - getLocale(): 'zh' | 'en' { - return this.currentLocale; - } - - /** - * Translate a key with optional interpolation - * @param key - Translation key - * @param values - Values for interpolation (optional) - * @returns Translated string with interpolated values + * Translate a key, filling `{{name}}` placeholders from `values` (unknown + * placeholders are kept). A key missing from the current locale falls back + * to the other one, then to the key itself. */ t(key: TranslationKey, values?: TranslationValues): string { - const translation = - this.translations[this.currentLocale][ - key as keyof (typeof this.translations)[typeof this.currentLocale] - ]; - - if (!translation) { - // Fallback to the other locale if key not found - const fallbackLocale = this.currentLocale === 'zh' ? 'en' : 'zh'; - const fallbackTranslation = - this.translations[fallbackLocale][ - key as keyof (typeof this.translations)[typeof fallbackLocale] - ]; - - if (!fallbackTranslation) { - // If still not found, return the key itself - return String(key); - } - - return this.interpolate(fallbackTranslation, values); - } - - return this.interpolate(translation, values); - } - - /** - * Interpolate values into a string template - * Supports {{key}} syntax - * @param template - String template with {{key}} placeholders - * @param values - Values to interpolate - * @returns Interpolated string - */ - private interpolate(template: string, values?: TranslationValues): string { + const fallbackLocale = this.currentLocale === 'zh' ? 'en' : 'zh'; + const template = + translations[this.currentLocale][key] || + translations[fallbackLocale][key] || + String(key); if (!values) { return template; } - - return template.replace(/\{\{(\w+)\}\}/g, (match, key) => { - const value = values[key]; + return template.replace(/\{\{(\w+)\}\}/g, (match, name) => { + const value = values[name]; return value !== undefined ? String(value) : match; }); } - - /** - * Add or update translations for a specific locale - * @param locale - Target locale - * @param translations - Translation object to merge - */ - addTranslations(locale: 'zh' | 'en', translations: Record) { - this.translations[locale] = { - ...this.translations[locale], - ...translations, - }; - } } -// Create singleton instance const i18n = new I18n(); -// Export both the instance and the class for flexibility -export { I18n, i18n }; export default i18n; - -/** - * Usage examples: - * - * // Direct locale setting (new preferred method) - * i18n.setLocale('zh'); // Chinese - * i18n.setLocale('en'); // English - * - * // Get translations - * i18n.t('checking_update'); // Based on current locale - * i18n.t('download_progress', { progress: 50 }); // With interpolation - */ diff --git a/src/locales/en.ts b/src/locales/en.ts index 502b50a7..78dc3663 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -1,11 +1,7 @@ export default { // Common messages - checking_update: 'Checking for updates...', update_failed: 'Update failed', - // Progress messages with interpolation - download_progress: 'Download progress: {{progress}}%', - // Alert messages alert_title: 'Notice', alert_update_ready: 'Download completed. Update now?', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index c11f6211..de24b441 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -1,11 +1,7 @@ export default { // Common messages - checking_update: '正在检查更新...', update_failed: '更新失败', - // Progress messages with interpolation - download_progress: '下载进度: {{progress}}%', - // Alert messages alert_title: '提示', alert_update_ready: '下载完毕,是否立即更新?', diff --git a/src/telemetry.ts b/src/telemetry.ts index 5b910777..a8a2d887 100644 --- a/src/telemetry.ts +++ b/src/telemetry.ts @@ -14,19 +14,6 @@ export type ServerEventType = // Failure detail is stored server-side in a VARCHAR(512); stay under it. export const MAX_DETAIL_LENGTH = 500; -export interface ServerReportPayload { - type: ServerEventType; - hash: string; - packageVersion?: string; - cInfo?: { - rnu?: string; - rn?: string; - os?: string; - uuid?: string; - }; - detail?: string; -} - /** * Maps a local report event to the server-side aggregate type, or undefined * for events the server does not collect. A DOWNLOAD_FAILED whose underlying diff --git a/src/type.ts b/src/type.ts index 6931c273..d1e9238e 100644 --- a/src/type.ts +++ b/src/type.ts @@ -41,21 +41,6 @@ interface RootResult { bundleStatus?: 'matched' | 'rebuiltSameJs' | 'unknownBundle'; } -export type UpToDateCheckResult = RootResult & { - upToDate: true; -}; - -export type UpdateAvailableCheckResult = RootResult & - Partial & { - update: true; - expVersion?: VersionInfo; - }; - -export type ExpiredCheckResult = RootResult & { - expired: true; - downloadUrl: string; -}; - export type CheckResult = RootResult & Partial & { expVersion?: VersionInfo; diff --git a/src/utils.ts b/src/utils.ts index 553234fa..2b32a1dd 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -109,8 +109,6 @@ const ping = async (url: string) => { } }; -export { joinUrls } from './updateFlowCore'; - export const testUrls = async (urls?: string[]): Promise => { if (!urls?.length) { return null; From 2492ab8f09ace28adb4b3f36e148550d735cc5e2 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:50:38 +0800 Subject: [PATCH 025/104] chore(flow-core): drop the unused uuid input of buildCheckFingerprint No caller ever passed it (the client uuid is inside the request body's cInfo). The fingerprint is in-memory only and not part of the flow vectors (unchanged). Co-Authored-By: Claude Opus 5.5 --- src/client.ts | 1 - src/updateFlowCore.ts | 15 +++++---------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/src/client.ts b/src/client.ts index 39b85005..4a6252da 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1194,7 +1194,6 @@ export class Pushy { appKey: this.options.appKey, endpoints: this.options.server?.main, queryUrls: this.options.server?.queryUrls, - // The client uuid travels inside cInfo, i.e. inside `body`. body: stringifyBody, }); const now = Date.now(); diff --git a/src/updateFlowCore.ts b/src/updateFlowCore.ts index 122f5cd5..bcf17592 100644 --- a/src/updateFlowCore.ts +++ b/src/updateFlowCore.ts @@ -334,8 +334,10 @@ export interface CheckFingerprintInput { appKey?: string; endpoints?: string[]; queryUrls?: string[]; - uuid?: string; - /** The exact serialized request body (identity fields + extra). */ + /** + * The exact serialized request body (identity fields + extra; the client + * uuid travels inside its cInfo). + */ body: string; } @@ -347,16 +349,9 @@ export function buildCheckFingerprint({ appKey, endpoints, queryUrls, - uuid, body, }: CheckFingerprintInput): string { - return JSON.stringify([ - appKey ?? '', - endpoints ?? [], - queryUrls ?? [], - uuid ?? '', - body, - ]); + return JSON.stringify([appKey ?? '', endpoints ?? [], queryUrls ?? [], body]); } /** From bfe93423bf720c1afeccedebba47279e855a4cfb Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:51:11 +0800 Subject: [PATCH 026/104] refactor(harmony): dedupe SyncCoordinator query and commit paths runQueryRequest walked configured and remotely discovered endpoint bases with two copies of the normalize / dedupe / budget / request / validate sequence; one tryBase closure (plus claimAttempt for the shared 8-request budget) now serves both. The three commitSyncResult calls share one commit helper that also maps "reset during the round" to cancelled/reset, and the response cache JSON is built once. harmonySyncQuery.test.ts pins the fallback order, dedupe, cleartext filter, single remote list and request budget (it passes against the previous implementation too). Co-Authored-By: Claude Opus 5.5 --- harmony/pushy/src/main/ets/SyncCoordinator.ts | 103 +++++++------ src/__tests__/harmonySyncQuery.test.ts | 144 ++++++++++++++++++ 2 files changed, 203 insertions(+), 44 deletions(-) create mode 100644 src/__tests__/harmonySyncQuery.test.ts diff --git a/harmony/pushy/src/main/ets/SyncCoordinator.ts b/harmony/pushy/src/main/ets/SyncCoordinator.ts index f9adc1e9..20b0d766 100644 --- a/harmony/pushy/src/main/ets/SyncCoordinator.ts +++ b/harmony/pushy/src/main/ets/SyncCoordinator.ts @@ -387,6 +387,32 @@ async function runConfiguredRound( } // Anchor cache freshness to response arrival, before download/patch work. const responseAtSeconds = Math.floor(Date.now() / 1000); + const responseCacheJson = buildResponseCacheJson( + configJson, + body, + responseText, + responseAtSeconds, + ); + // Commits the round's persistent outcome (see commitSyncResult) and maps + // it to the round result; a reset during the round wins over `result`. + const commit = async ( + hash: string, + hashInfoJson: string, + activate: boolean, + result: BundlePreparationResult, + ): Promise => { + const committed = await context.commitSyncResult( + resetGeneration, + hash, + hashInfoJson, + activate, + responseCacheJson, + ); + roundResult = committed + ? result + : bundlePreparationResult('cancelled', 'reset'); + return committed; + }; const decisionJson = NativePatchCore.handleResponse( responseText, @@ -399,16 +425,12 @@ async function runConfiguredRound( } const decision = JSON.parse(decisionJson) as Decision; if (decision.action !== 'download') { - const committed = await context.commitSyncResult( - resetGeneration, + await commit( '', '', false, - buildResponseCacheJson(configJson, body, responseText, responseAtSeconds), + bundlePreparationResult(STATUS_NONE, decision.reason ?? ''), ); - roundResult = committed - ? bundlePreparationResult(STATUS_NONE, decision.reason ?? '') - : bundlePreparationResult('cancelled', 'reset'); logger.info(TAG, `nothing to do (${decision.reason ?? ''})`); return; } @@ -434,16 +456,7 @@ async function runConfiguredRound( } if (!downloaded) { logger.warn(TAG, `all download attempts for ${hash} failed`); - const committed = await context.commitSyncResult( - resetGeneration, - '', - '', - false, - buildResponseCacheJson(configJson, body, responseText, responseAtSeconds), - ); - roundResult = committed - ? bundlePreparationResult('failed', 'download_failed') - : bundlePreparationResult('cancelled', 'reset'); + await commit('', '', false, bundlePreparationResult('failed', 'download_failed')); return; } @@ -476,12 +489,11 @@ async function runConfiguredRound( const activate = decision.activate === true; let committed = false; try { - committed = await context.commitSyncResult( - resetGeneration, + committed = await commit( hash, hashInfoJson, activate, - buildResponseCacheJson(configJson, body, responseText, responseAtSeconds), + bundlePreparationResult('downloaded', '', hash, activate), ); } catch (e) { logger.error(TAG, `commit failed: ${getErrorMessage(e)}`); @@ -495,9 +507,6 @@ async function runConfiguredRound( } else { logger.info(TAG, `downloaded ${hash}, activation left to JS`); } - roundResult = committed - ? bundlePreparationResult('downloaded', '', hash, activate) - : bundlePreparationResult('cancelled', 'reset'); } function buildResponseCacheJson( @@ -590,25 +599,44 @@ async function runQueryRequest( const ordered = JSON.parse(orderedJson) as string[]; const tried = new Set(); let httpAttempts = 0; - for (const rawBase of ordered) { + // Claims one HTTP request from the round's budget (endpoint queries and + // remote list fetches alike); false once it is spent. + const claimAttempt = (): boolean => httpAttempts++ < MAX_QUERY_HTTP_ATTEMPTS; + // Queries one endpoint base unless it is empty, already tried or (for + // remotely injected bases) a cleartext downgrade. Resolves to the valid + // response, to null once the budget is spent (stop), or to undefined + // (continue with the next base). + const tryBase = async ( + rawBase: string, + rejectCleartext: boolean, + ): Promise => { const base = normalizeEndpointBase(rawBase); if (!base || tried.has(base)) { - continue; + return undefined; } - if (httpAttempts++ >= MAX_QUERY_HTTP_ATTEMPTS) { + if (rejectCleartext && !isHttpsUrl(base)) { + // 远程注入的端点不得把 https 配置降级成明文。 + logger.warn(TAG, `ignoring non-https remote endpoint ${base}`); return undefined; } + if (!claimAttempt()) { + return null; + } tried.add(base); const response = await httpRequest(`${base}${QUERY_PATH}${appKey}`, body); - if (isValidResponse(response)) { - return response; + return isValidResponse(response) ? response : undefined; + }; + for (const rawBase of ordered) { + const response = await tryBase(rawBase, false); + if (response !== undefined) { + return response ?? undefined; } } for (const listUrl of config.queryUrls ?? []) { if (!listUrl) { continue; } - if (httpAttempts++ >= MAX_QUERY_HTTP_ATTEMPTS) { + if (!claimAttempt()) { return undefined; } const listText = await httpRequest(listUrl); @@ -629,22 +657,9 @@ async function runQueryRequest( if (typeof rawBase !== 'string') { continue; } - const base = normalizeEndpointBase(rawBase); - if (!base || tried.has(base)) { - continue; - } - if (httpsOnly && !isHttpsUrl(base)) { - // 远程注入的端点不得把 https 配置降级成明文。 - logger.warn(TAG, `ignoring non-https remote endpoint ${base}`); - continue; - } - if (httpAttempts++ >= MAX_QUERY_HTTP_ATTEMPTS) { - return undefined; - } - tried.add(base); - const response = await httpRequest(`${base}${QUERY_PATH}${appKey}`, body); - if (isValidResponse(response)) { - return response; + const response = await tryBase(rawBase, httpsOnly); + if (response !== undefined) { + return response ?? undefined; } } // 拉到一份可解析的远程列表就够了。 diff --git a/src/__tests__/harmonySyncQuery.test.ts b/src/__tests__/harmonySyncQuery.test.ts new file mode 100644 index 00000000..70738b50 --- /dev/null +++ b/src/__tests__/harmonySyncQuery.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { URL } from 'node:url'; +import { runInNewContext } from 'node:vm'; + +// The endpoint fallback of the Harmony cold-start orchestrator +// (SyncCoordinator.runQueryRequest), evaluated from source with its platform +// imports stripped and HTTP replaced by a scripted fake. +const source = readFileSync( + new URL( + '../../harmony/pushy/src/main/ets/SyncCoordinator.ts', + import.meta.url + ), + 'utf8' +) + .replace(/^import[\s\S]*?;\r?\n/gm, '') + .replace(/^export /gm, ''); +const javascript = new Bun.Transpiler({ loader: 'ts' }).transformSync(source); + +const VALID = '{"ok":true}'; + +interface Config { + endpoints?: string[]; + queryUrls?: string[]; +} + +function run( + config: Config, + responses: Record, + httpsOnly = false +) { + const requests: string[] = []; + const runQueryRequest = runInNewContext( + `${javascript}\nhttpRequest = fakeHttp;\nrunQueryRequest;`, + { + QUERY_PATH: '/q/', + logger: { info() {}, warn() {}, error() {} }, + BundlePreparationRound: class {}, + bundlePreparationResult: () => ({}), + NativePatchCore: { + // Keep the configured order: ordering is the C++ core's concern. + orderEndpointCandidates: (json: string) => json, + isValidResponse: (text: string) => text === VALID, + }, + fakeHttp: async (url: string) => { + requests.push(url); + return responses[url]; + }, + } + ) as ( + config: Config, + appKey: string, + body: string, + httpsOnly: boolean + ) => Promise; + return runQueryRequest(config, 'key', '{}', httpsOnly).then((result) => ({ + result, + requests, + })); +} + +describe('harmony query endpoint fallback', () => { + test('falls back through configured endpoints, deduped by normalized base', async () => { + const { result, requests } = await run( + { endpoints: ['https://a/', 'https://a', '', 'https://b'] }, + { 'https://b/q/key': VALID } + ); + expect(result).toBe(VALID); + expect(requests).toEqual(['https://a/q/key', 'https://b/q/key']); + }); + + test('an invalid response does not stop the fallback', async () => { + const { result } = await run( + { endpoints: ['https://a', 'https://b'] }, + { 'https://a/q/key': '{"error":"x"}', 'https://b/q/key': VALID } + ); + expect(result).toBe(VALID); + }); + + test('discovers remote endpoints, skipping tried, non-string and cleartext ones', async () => { + const { result, requests } = await run( + { + endpoints: ['https://a'], + queryUrls: ['', 'https://list'], + }, + { + 'https://list': JSON.stringify([ + 'https://a/', + 42, + 'http://plain', + 'https://c', + ]), + 'https://c/q/key': VALID, + }, + true + ); + expect(result).toBe(VALID); + expect(requests).toEqual([ + 'https://a/q/key', + 'https://list', + 'https://c/q/key', + ]); + }); + + test('cleartext remote endpoints are allowed without an https-only config', async () => { + const { result } = await run( + { endpoints: ['http://a'], queryUrls: ['https://list'] }, + { + 'https://list': JSON.stringify(['http://plain']), + 'http://plain/q/key': VALID, + } + ); + expect(result).toBe(VALID); + }); + + test('only the first parsable remote list is used', async () => { + const { requests } = await run( + { endpoints: [], queryUrls: ['https://bad', 'https://l1', 'https://l2'] }, + { + 'https://bad': '{"not":"array"}', + 'https://l1': JSON.stringify(['https://x']), + 'https://l2': JSON.stringify(['https://y']), + } + ); + expect(requests).toEqual(['https://bad', 'https://l1', 'https://x/q/key']); + }); + + test('stops after 8 HTTP requests, list fetches included', async () => { + const endpoints = Array.from({ length: 6 }, (_, i) => `https://e${i}`); + const { result, requests } = await run( + { endpoints, queryUrls: ['https://list'] }, + { + 'https://list': JSON.stringify(['https://r0', 'https://r1', 'https://r2']), + 'https://r2/q/key': VALID, + } + ); + expect(result).toBeUndefined(); + expect(requests).toEqual([ + ...endpoints.map((e) => `${e}/q/key`), + 'https://list', + 'https://r0/q/key', + ]); + }); +}); From b527d16ca2332d9551b0fd2d46bf850fc42431c0 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:51:39 +0800 Subject: [PATCH 027/104] refactor(harmony): one Logger.log for all levels; log constants minus uuid The four hilog levels shared one body, now Logger.log(fn, args). getConstants logged a hand-copied subset of its result to keep the install uuid out of hilog; it now logs the whole result without uuid (plus uuidSet), so new constants are not silently left out. Co-Authored-By: Claude Opus 5.5 --- harmony/pushy/src/main/ets/Logger.ts | 54 +++++++------------ .../pushy/src/main/ets/PushyTurboModule.ts | 18 +++---- 2 files changed, 26 insertions(+), 46 deletions(-) diff --git a/harmony/pushy/src/main/ets/Logger.ts b/harmony/pushy/src/main/ets/Logger.ts index b85aecdc..8aa0d5de 100644 --- a/harmony/pushy/src/main/ets/Logger.ts +++ b/harmony/pushy/src/main/ets/Logger.ts @@ -1,5 +1,12 @@ import hilog from '@ohos.hilog'; +type HilogFunction = ( + domain: number, + tag: string, + format: string, + ...args: string[] +) => void; + class Logger { private domain: number; private prefix: string; @@ -24,54 +31,33 @@ class Logger { this.isDebug = enabled; } - private tagOf(args: string[]): string { - return args.length > 0 ? args[0] : ''; - } - - private messageOf(args: string[]): string { - return args.length > 1 ? args.slice(1).join(' ') : ''; + // args[0] is the tag, the rest is joined into the message. + private log(fn: HilogFunction, args: string[]): void { + fn( + this.domain, + this.prefix, + this.format, + args.length > 0 ? args[0] : '', + args.length > 1 ? args.slice(1).join(' ') : '', + ); } debug(...args: string[]): void { if (this.isDebug) { - hilog.debug( - this.domain, - this.prefix, - this.format, - this.tagOf(args), - this.messageOf(args), - ); + this.log(hilog.debug, args); } } info(...args: string[]): void { - hilog.info( - this.domain, - this.prefix, - this.format, - this.tagOf(args), - this.messageOf(args), - ); + this.log(hilog.info, args); } warn(...args: string[]): void { - hilog.warn( - this.domain, - this.prefix, - this.format, - this.tagOf(args), - this.messageOf(args), - ); + this.log(hilog.warn, args); } error(...args: string[]): void { - hilog.error( - this.domain, - this.prefix, - this.format, - this.tagOf(args), - this.messageOf(args), - ); + this.log(hilog.error, args); } } diff --git a/harmony/pushy/src/main/ets/PushyTurboModule.ts b/harmony/pushy/src/main/ets/PushyTurboModule.ts index 01197cb1..986ac921 100644 --- a/harmony/pushy/src/main/ets/PushyTurboModule.ts +++ b/harmony/pushy/src/main/ets/PushyTurboModule.ts @@ -206,18 +206,12 @@ export class PushyTurboModule extends UITurboModule { uuid, supportedDiffVersion: NativePatchCore.getSupportedDiffVersion(), }; - const logResult = { - downloadRootDir: result.downloadRootDir, - currentVersionInfo: result.currentVersionInfo, - packageVersion: result.packageVersion, - currentVersion: result.currentVersion, - buildTime: result.buildTime, - isUsingBundleUrl: result.isUsingBundleUrl, - isFirstTime: result.isFirstTime, - rolledBackVersion: result.rolledBackVersion, - uuidSet: !!result.uuid, - }; - logger.info(TAG, `,getConstants result: ${JSON.stringify(logResult)}`); + // The install uuid stays out of the logs; only whether it is set. + const { uuid: _uuid, ...logResult } = result; + logger.info( + TAG, + `,getConstants result: ${JSON.stringify({ ...logResult, uuidSet: !!uuid })}`, + ); return result; } From e66970aa6031d298247edd8bb1b6c07e1357e4fe Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:51:56 +0800 Subject: [PATCH 028/104] refactor(harmony): share the install record predicate isRecordForVersion (presence) and expectedBundleSha256ForActivation (activation) each spelled out the legacy-empty and schema/version rules. Activation now reuses isRecordForVersion and only adds the mandatory bundle digest (#655) on top, so the two cannot drift. Co-Authored-By: Claude Opus 5.5 --- harmony/pushy/src/main/ets/InstallRecord.ts | 27 ++++++++++----------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/harmony/pushy/src/main/ets/InstallRecord.ts b/harmony/pushy/src/main/ets/InstallRecord.ts index 032e1d21..64803034 100644 --- a/harmony/pushy/src/main/ets/InstallRecord.ts +++ b/harmony/pushy/src/main/ets/InstallRecord.ts @@ -120,6 +120,11 @@ export function readInstallRecord(versionDir: string): InstallRecordData | null } } +/** 历史空记录(SDK < 10.53 写的空文件):视为完整安装,无摘要可验。 */ +function isLegacyRecord(record: InstallRecordData): boolean { + return Object.keys(record).length === 0; +} + /** 记录是否宣告本版本完整安装(历史空记录视为是)。纯函数。 */ export function isRecordForVersion( record: InstallRecordData | null, @@ -128,11 +133,10 @@ export function isRecordForVersion( if (record === null) { return false; } - if (Object.keys(record).length === 0) { - return true; - } return ( - record.schema === INSTALL_RECORD_SCHEMA && record.versionHash === versionHash + isLegacyRecord(record) || + (record.schema === INSTALL_RECORD_SCHEMA && + record.versionHash === versionHash) ); } @@ -156,25 +160,20 @@ export function expectedBundleSha256ForActivation( `Bundle version ${versionHash} has no valid completion record.`, ); } - if (Object.keys(record).length === 0) { + if (isLegacyRecord(record)) { return ''; } + const expected = record.bundleSha256; if ( - record.schema !== INSTALL_RECORD_SCHEMA || - record.versionHash !== versionHash + !isRecordForVersion(record, versionHash) || + typeof expected !== 'string' || + expected.length === 0 ) { throw createPushyError( ERROR_SWITCH_VERSION_FAILED, `Bundle version ${versionHash} completion record mismatch.`, ); } - const expected = record.bundleSha256; - if (typeof expected !== 'string' || expected.length === 0) { - throw createPushyError( - ERROR_SWITCH_VERSION_FAILED, - `Bundle version ${versionHash} completion record mismatch.`, - ); - } return expected; } From fb03dbfbc16a361d0b0889bd0d5a6b685ec61045 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:52:09 +0800 Subject: [PATCH 029/104] refactor(harmony): call applicationContext.restartApp directly restartApp is API 12, the HAR's minimum (compatibleSdkVersion 12), so the structural typeof probe is dead. A throwing restartApp still falls back to startAbility/terminateSelf exactly as before. Co-Authored-By: Claude Opus 5.5 --- harmony/pushy/src/main/ets/PushyTurboModule.ts | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/harmony/pushy/src/main/ets/PushyTurboModule.ts b/harmony/pushy/src/main/ets/PushyTurboModule.ts index 986ac921..6380440c 100644 --- a/harmony/pushy/src/main/ets/PushyTurboModule.ts +++ b/harmony/pushy/src/main/ets/PushyTurboModule.ts @@ -37,11 +37,6 @@ interface RestartWant { abilityName?: string; } -// applicationContext.restartApp 是 API 12 才有的可选能力,按结构探测。 -interface RestartableApplicationContext { - restartApp?: (want: RestartWant) => void; -} - interface RestartEventEmitter { emit(event: string, payload: Object): void; } @@ -122,13 +117,10 @@ export class PushyTurboModule extends UITurboModule { abilityName: this.mUiCtx.abilityInfo?.name, }; try { - const applicationContext = - this.mUiCtx.getApplicationContext() as unknown as RestartableApplicationContext; - if (applicationContext && typeof applicationContext.restartApp === 'function') { - logger.debug(TAG, 'restartAbility via applicationContext.restartApp'); - applicationContext.restartApp(want); - return; - } + // applicationContext.restartApp exists since API 12, the HAR's minimum. + logger.debug(TAG, 'restartAbility via applicationContext.restartApp'); + this.mUiCtx.getApplicationContext().restartApp(want); + return; } catch (e) { logger.error(TAG, `restartAbility via restartApp failed: ${getErrorMessage(e)}`); } From 7555ee0f2f8d1fbd0b812f66e244b1acf4919e0c Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:53:07 +0800 Subject: [PATCH 030/104] refactor(android): one UpdateModule for both architectures src/oldarch now only holds a hand-written NativePushySpec mirroring the codegen spec (name, constants hook, abstract methods), so UpdateModule in src/main absorbs UpdateModuleImpl and extends NativePushySpec under either architecture. Every spec method carries @ReactMethod for the old bridge, which reads the annotations from the module class itself. The unused UpdateModuleImpl(ReactApplicationContext) constructor goes with it; removeListeners takes the codegen's double on the old bridge too. scripts/check-native-spec-parity.js still reads the deleted per-arch UpdateModule files and needs to follow. Co-Authored-By: Claude Opus 5.5 --- android/build.gradle | 6 +- .../modules/update/ApkInstaller.java | 2 +- .../update/FileOperationException.java | 2 +- .../modules/update/PatchFailedException.java | 2 +- ...pdateModuleImpl.java => UpdateModule.java} | 88 +++++++++--- .../modules/update/UpdatePackage.java | 8 +- .../modules/update/UpdateModule.java | 121 ----------------- .../modules/update/NativePushySpec.java | 69 ++++++++++ .../modules/update/UpdateModule.java | 125 ------------------ 9 files changed, 149 insertions(+), 274 deletions(-) rename android/src/main/java/cn/reactnative/modules/update/{UpdateModuleImpl.java => UpdateModule.java} (89%) delete mode 100644 android/src/newarch/cn/reactnative/modules/update/UpdateModule.java create mode 100644 android/src/oldarch/cn/reactnative/modules/update/NativePushySpec.java delete mode 100644 android/src/oldarch/cn/reactnative/modules/update/UpdateModule.java diff --git a/android/build.gradle b/android/build.gradle index 764f5a00..f5549e39 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -183,9 +183,9 @@ android { main { // let gradle pack the shared library into apk jniLibs.srcDirs = ['./lib'] - if (isNewArchitectureEnabled()) { - java.srcDirs += ['src/newarch'] - } else { + // Under the new architecture codegen generates NativePushySpec; + // the old one compiles the hand-written stand-in instead. + if (!isNewArchitectureEnabled()) { java.srcDirs += ['src/oldarch'] } diff --git a/android/src/main/java/cn/reactnative/modules/update/ApkInstaller.java b/android/src/main/java/cn/reactnative/modules/update/ApkInstaller.java index 64c559b9..219a4258 100644 --- a/android/src/main/java/cn/reactnative/modules/update/ApkInstaller.java +++ b/android/src/main/java/cn/reactnative/modules/update/ApkInstaller.java @@ -137,7 +137,7 @@ static void install( // before staging it. The `hash` JS passes to downloadAndInstallApk is // the progress-event key ("downloadingApk"), not a digest, so there // is nothing to check against yet; until the server sends one, the - // https-only rule in UpdateModuleImpl is the integrity boundary. + // https-only rule in UpdateModule is the integrity boundary. PackageInstaller packageInstaller = null; int sessionId = -1; boolean committed = false; diff --git a/android/src/main/java/cn/reactnative/modules/update/FileOperationException.java b/android/src/main/java/cn/reactnative/modules/update/FileOperationException.java index c610a22f..af79ec92 100644 --- a/android/src/main/java/cn/reactnative/modules/update/FileOperationException.java +++ b/android/src/main/java/cn/reactnative/modules/update/FileOperationException.java @@ -6,7 +6,7 @@ * A download task refused by a local file-system rule rather than by the * network or the patch itself — today: reinstalling the version this process * is running from in place (DownloadTask.ensureNotReinstallingRunningVersion). - * UpdateModuleImpl rejects these with FILE_OPERATION_FAILED. + * UpdateModule rejects these with FILE_OPERATION_FAILED. */ class FileOperationException extends IOException { FileOperationException(String message) { diff --git a/android/src/main/java/cn/reactnative/modules/update/PatchFailedException.java b/android/src/main/java/cn/reactnative/modules/update/PatchFailedException.java index 22f713a3..eb2acb06 100644 --- a/android/src/main/java/cn/reactnative/modules/update/PatchFailedException.java +++ b/android/src/main/java/cn/reactnative/modules/update/PatchFailedException.java @@ -5,7 +5,7 @@ /** * A download task failure that happened after the artifact was fully * downloaded — unzip, hdiff apply, or bundled-resource copy (including the - * copiesCrc content verification). UpdateModuleImpl rejects these with + * copiesCrc content verification). UpdateModule rejects these with * PATCH_FAILED instead of DOWNLOAD_FAILED so the JS layer and server-side * telemetry can separate patch health from network health. */ diff --git a/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java b/android/src/main/java/cn/reactnative/modules/update/UpdateModule.java similarity index 89% rename from android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java rename to android/src/main/java/cn/reactnative/modules/update/UpdateModule.java index 4e07cab0..9ba8ea59 100644 --- a/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java +++ b/android/src/main/java/cn/reactnative/modules/update/UpdateModule.java @@ -4,6 +4,7 @@ import androidx.annotation.Nullable; import com.facebook.react.bridge.Promise; import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactMethod; import com.facebook.react.bridge.ReadableMap; import java.util.HashMap; import java.util.Map; @@ -12,32 +13,33 @@ import org.json.JSONTokener; /** - * Everything the "Pushy" native module does, independent of the bridge it is - * registered with. The old-architecture and new-architecture UpdateModule - * classes are thin wrappers that only carry their annotations / spec - * overrides and forward here, so the two cannot drift apart. + * The "Pushy" native module for both architectures. NativePushySpec is the + * codegen spec under the new architecture and the hand-written stand-in in + * src/oldarch otherwise; the old bridge discovers methods through the + * @ReactMethod annotations here, so every spec method carries one. Each + * state-changing method takes a trailing Promise, so a failed persistence + * rejects instead of resolving early (CODE_AUDIT 2.3). */ -public class UpdateModuleImpl { +public class UpdateModule extends NativePushySpec { // Download URL option key, stored encoded. private static final String OPTION_URL = Texts.reveal("2f07f0d0ba8e5d572e"); - - public static final String NAME = "Pushy"; - private final ReactApplicationContext reactContext; private final UpdateContext updateContext; - public UpdateModuleImpl(ReactApplicationContext reactContext) { + public UpdateModule(ReactApplicationContext reactContext) { this(reactContext, UpdateContext.getInstance(reactContext)); } - public UpdateModuleImpl(ReactApplicationContext reactContext, UpdateContext updateContext) { + public UpdateModule(ReactApplicationContext reactContext, UpdateContext updateContext) { + super(reactContext); this.reactContext = reactContext; this.updateContext = updateContext; UpdateEventEmitter.register(reactContext); } - public Map getConstants() { + @Override + protected Map getTypedExportedConstants() { final Map constants = new HashMap(); constants.put("downloadRootDir", updateContext.getRootDir()); constants.put("packageVersion", updateContext.getPackageVersion()); @@ -134,6 +136,8 @@ public void onDownloadFailed(Throwable error) { }; } + @ReactMethod + @Override public void downloadFullUpdate(final ReadableMap options, final Promise promise) { String url = readRequiredString(options, OPTION_URL, promise); if (url == null) { @@ -146,6 +150,8 @@ public void downloadFullUpdate(final ReadableMap options, final Promise promise) updateContext.downloadFullUpdate(url, hash, downloadListener(promise)); } + @ReactMethod + @Override public void downloadAndInstallApk(final ReadableMap options, final Promise promise) { // Every failure of this path — a missing REQUEST_INSTALL_PACKAGES // declaration above all — has to come back as a promise rejection, @@ -190,6 +196,8 @@ public void onDownloadFailed(Throwable error) { } } + @ReactMethod + @Override public void downloadPatchFromPackage(final ReadableMap options, final Promise promise) { String url = readRequiredString(options, OPTION_URL, promise); if (url == null) { @@ -202,6 +210,8 @@ public void downloadPatchFromPackage(final ReadableMap options, final Promise pr updateContext.downloadPatchFromApk(url, hash, downloadListener(promise)); } + @ReactMethod + @Override public void downloadPatchFromPpk(final ReadableMap options, final Promise promise) { String url = readRequiredString(options, OPTION_URL, promise); if (url == null) { @@ -218,15 +228,23 @@ public void downloadPatchFromPpk(final ReadableMap options, final Promise promis updateContext.downloadPatchFromPpk(url, hash, originHash, downloadListener(promise)); } + @ReactMethod + @Override public void reloadUpdate(final ReadableMap options, final Promise promise) { String hash = readRequiredString(options, "hash", promise); if (hash == null) { return; } - restartApp(hash, promise); + restart(hash, promise); } - public void restartApp(@Nullable final String hash, final Promise promise) { + @ReactMethod + @Override + public void restartApp(Promise promise) { + restart(null, promise); + } + + private void restart(@Nullable final String hash, final Promise promise) { // Two hops (CODE_AUDIT 2.2): the switch (bundle digest + commit) and // the launch resolution run on the state serial thread like every // other state write; only the React reload itself is posted to the @@ -246,6 +264,8 @@ public void run() throws Throwable { }); } + @ReactMethod + @Override public void setNeedUpdate(final ReadableMap options, final Promise promise) { final String hash = readRequiredString(options, "hash", promise); if (hash == null) { @@ -260,6 +280,8 @@ public void run() { }); } + @ReactMethod + @Override public void markSuccess(final Promise promise) { StateSerialRunner.run(promise, ErrorCodes.MARK_SUCCESS_FAILED, "markSuccess", new StateSerialRunner.Operation() { @Override @@ -270,6 +292,8 @@ public void run() { }); } + @ReactMethod + @Override public void getBundleHash(final Promise promise) { // Threading lives in UpdateContext (its bundle-hash thread); resolve is // thread-safe. Never rejects — empty string means "unknown". @@ -281,6 +305,8 @@ public void onBundleHash(String hash) { }); } + @ReactMethod + @Override public void resetToPackagedBundle(final Promise promise) { StateSerialRunner.run(promise, ErrorCodes.RESET_FAILED, "resetToPackagedBundle", new StateSerialRunner.Operation() { @Override @@ -296,18 +322,19 @@ public void run() { * reuse instead of re-checking (§10.3). Empty string when absent; never * rejects. */ + @ReactMethod + @Override public void getNativeCheckCache(final Promise promise) { String cached = updateContext.getNativeCheckCache(); promise.resolve(cached == null ? "" : cached); } /** - * 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. + * JS obtained a valid check response for this config in this process; the + * delayed native round then skips its duplicate request (§10.3). */ + @ReactMethod + @Override public void markJsCheckCompleted(final String config, final Promise promise) { if (requireNonEmpty(config, "config", promise) == null) { return; @@ -316,6 +343,15 @@ public void markJsCheckCompleted(final String config, final Promise promise) { promise.resolve(true); } + /** + * 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. + */ + @ReactMethod + @Override public void syncNativeConfig(final String config, final Promise promise) { if (requireNonEmpty(config, "config", promise) == null) { return; @@ -335,6 +371,8 @@ public void run() { }); } + @ReactMethod + @Override public void setUuid(final String uuid, final Promise promise) { if (requireNonEmpty(uuid, "uuid", promise) == null) { return; @@ -348,6 +386,8 @@ public void run() { }); } + @ReactMethod + @Override public void setLocalHashInfo(final String hash, final String info, final Promise promise) { if (requireNonEmpty(hash, "hash", promise) == null) { return; @@ -367,6 +407,8 @@ public void run() { }); } + @ReactMethod + @Override public void getLocalHashInfo(final String hash, final Promise promise) { if (requireNonEmpty(hash, "hash", promise) == null) { return; @@ -385,4 +427,14 @@ public void getLocalHashInfo(final String hash, final Promise promise) { promise.resolve(value); } + + @ReactMethod + @Override + public void addListener(String eventName) { + } + + @ReactMethod + @Override + public void removeListeners(double count) { + } } diff --git a/android/src/main/java/cn/reactnative/modules/update/UpdatePackage.java b/android/src/main/java/cn/reactnative/modules/update/UpdatePackage.java index 4702e6d2..97dd5e87 100644 --- a/android/src/main/java/cn/reactnative/modules/update/UpdatePackage.java +++ b/android/src/main/java/cn/reactnative/modules/update/UpdatePackage.java @@ -18,7 +18,7 @@ public class UpdatePackage extends TurboReactPackage { @Nullable @Override public NativeModule getModule(String name, ReactApplicationContext reactContext) { - return name.equals(UpdateModuleImpl.NAME) ? new UpdateModule(reactContext) : null; + return name.equals(UpdateModule.NAME) ? new UpdateModule(reactContext) : null; } @Override @@ -29,10 +29,10 @@ public Map getReactModuleInfos() { final Map moduleInfos = new HashMap<>(); boolean isTurboModule = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED; moduleInfos.put( - UpdateModuleImpl.NAME, + UpdateModule.NAME, new ReactModuleInfo( - UpdateModuleImpl.NAME, - UpdateModuleImpl.NAME, + UpdateModule.NAME, + UpdateModule.NAME, false, // canOverrideExistingModule false, // needsEagerInit true, // hasConstants diff --git a/android/src/newarch/cn/reactnative/modules/update/UpdateModule.java b/android/src/newarch/cn/reactnative/modules/update/UpdateModule.java deleted file mode 100644 index de6f02d6..00000000 --- a/android/src/newarch/cn/reactnative/modules/update/UpdateModule.java +++ /dev/null @@ -1,121 +0,0 @@ -package cn.reactnative.modules.update; - -import com.facebook.react.bridge.Promise; -import com.facebook.react.bridge.ReactApplicationContext; -import com.facebook.react.bridge.ReadableMap; -import java.util.Map; - -/** - * New-architecture bridge: the codegen spec overrides only, every method - * forwards to UpdateModuleImpl. - */ -public class UpdateModule extends NativePushySpec { - private final UpdateModuleImpl impl; - - public UpdateModule(ReactApplicationContext reactContext, UpdateContext updateContext) { - super(reactContext); - this.impl = new UpdateModuleImpl(reactContext, updateContext); - } - - public UpdateModule(ReactApplicationContext reactContext) { - this(reactContext, UpdateContext.getInstance(reactContext)); - } - - @Override - protected Map getTypedExportedConstants() { - return impl.getConstants(); - } - - @Override - public String getName() { - return UpdateModuleImpl.NAME; - } - - @Override - public void downloadFullUpdate(ReadableMap options, Promise promise) { - impl.downloadFullUpdate(options, promise); - } - - @Override - public void downloadAndInstallApk(ReadableMap options, Promise promise) { - impl.downloadAndInstallApk(options, promise); - } - - @Override - public void downloadPatchFromPackage(ReadableMap options, Promise promise) { - impl.downloadPatchFromPackage(options, promise); - } - - @Override - public void downloadPatchFromPpk(ReadableMap options, Promise promise) { - impl.downloadPatchFromPpk(options, promise); - } - - @Override - public void reloadUpdate(ReadableMap options, Promise promise) { - impl.reloadUpdate(options, promise); - } - - @Override - public void restartApp(Promise promise) { - impl.restartApp(null, promise); - } - - @Override - public void setNeedUpdate(ReadableMap options, Promise promise) { - impl.setNeedUpdate(options, promise); - } - - @Override - public void markSuccess(Promise promise) { - impl.markSuccess(promise); - } - - @Override - public void getBundleHash(Promise promise) { - impl.getBundleHash(promise); - } - - @Override - public void resetToPackagedBundle(Promise promise) { - impl.resetToPackagedBundle(promise); - } - - @Override - public void setUuid(String uuid, Promise promise) { - impl.setUuid(uuid, promise); - } - - @Override - public void syncNativeConfig(String config, Promise promise) { - impl.syncNativeConfig(config, promise); - } - - @Override - public void getNativeCheckCache(Promise promise) { - impl.getNativeCheckCache(promise); - } - - @Override - public void markJsCheckCompleted(String config, Promise promise) { - impl.markJsCheckCompleted(config, promise); - } - - @Override - public void setLocalHashInfo(String hash, String info, Promise promise) { - impl.setLocalHashInfo(hash, info, promise); - } - - @Override - public void getLocalHashInfo(String hash, Promise promise) { - impl.getLocalHashInfo(hash, promise); - } - - @Override - public void addListener(String eventName) { - } - - @Override - public void removeListeners(double count) { - } -} diff --git a/android/src/oldarch/cn/reactnative/modules/update/NativePushySpec.java b/android/src/oldarch/cn/reactnative/modules/update/NativePushySpec.java new file mode 100644 index 00000000..9c329d0e --- /dev/null +++ b/android/src/oldarch/cn/reactnative/modules/update/NativePushySpec.java @@ -0,0 +1,69 @@ +package cn.reactnative.modules.update; + +import com.facebook.react.bridge.Promise; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReadableMap; +import java.util.Map; + +/** + * Old-architecture stand-in for the NativePushySpec that codegen generates + * from src/NativePushy.ts under the new architecture: same name, methods and + * constants hook, so a single UpdateModule serves both. The bridge reads the + * @ReactMethod annotations from UpdateModule itself. + */ +public abstract class NativePushySpec extends ReactContextBaseJavaModule { + public static final String NAME = "Pushy"; + + public NativePushySpec(ReactApplicationContext reactContext) { + super(reactContext); + } + + @Override + public String getName() { + return NAME; + } + + protected abstract Map getTypedExportedConstants(); + + @Override + public final Map getConstants() { + return getTypedExportedConstants(); + } + + public abstract void setLocalHashInfo(String hash, String info, Promise promise); + + public abstract void getLocalHashInfo(String hash, Promise promise); + + public abstract void setUuid(String uuid, Promise promise); + + public abstract void syncNativeConfig(String config, Promise promise); + + public abstract void getNativeCheckCache(Promise promise); + + public abstract void markJsCheckCompleted(String config, Promise promise); + + public abstract void reloadUpdate(ReadableMap options, Promise promise); + + public abstract void restartApp(Promise promise); + + public abstract void setNeedUpdate(ReadableMap options, Promise promise); + + public abstract void markSuccess(Promise promise); + + public abstract void getBundleHash(Promise promise); + + public abstract void resetToPackagedBundle(Promise promise); + + public abstract void downloadPatchFromPpk(ReadableMap options, Promise promise); + + public abstract void downloadPatchFromPackage(ReadableMap options, Promise promise); + + public abstract void downloadFullUpdate(ReadableMap options, Promise promise); + + public abstract void downloadAndInstallApk(ReadableMap options, Promise promise); + + public abstract void addListener(String eventName); + + public abstract void removeListeners(double count); +} diff --git a/android/src/oldarch/cn/reactnative/modules/update/UpdateModule.java b/android/src/oldarch/cn/reactnative/modules/update/UpdateModule.java deleted file mode 100644 index addb07f1..00000000 --- a/android/src/oldarch/cn/reactnative/modules/update/UpdateModule.java +++ /dev/null @@ -1,125 +0,0 @@ -package cn.reactnative.modules.update; - -import com.facebook.react.bridge.Promise; -import com.facebook.react.bridge.ReactApplicationContext; -import com.facebook.react.bridge.ReactContextBaseJavaModule; -import com.facebook.react.bridge.ReactMethod; -import com.facebook.react.bridge.ReadableMap; -import java.util.Map; - -/** - * Old-architecture bridge: @ReactMethod declarations only, every method - * forwards to UpdateModuleImpl. Each state-changing method takes a trailing - * Promise, like the TurboModule spec, so a failed persistence rejects instead - * of resolving early (CODE_AUDIT 2.3). - */ -public class UpdateModule extends ReactContextBaseJavaModule { - private final UpdateModuleImpl impl; - - public UpdateModule(ReactApplicationContext reactContext, UpdateContext updateContext) { - super(reactContext); - this.impl = new UpdateModuleImpl(reactContext, updateContext); - } - - public UpdateModule(ReactApplicationContext reactContext) { - this(reactContext, UpdateContext.getInstance(reactContext)); - } - - @Override - public Map getConstants() { - return impl.getConstants(); - } - - @Override - public String getName() { - return UpdateModuleImpl.NAME; - } - - @ReactMethod - public void downloadFullUpdate(ReadableMap options, Promise promise) { - impl.downloadFullUpdate(options, promise); - } - - @ReactMethod - public void downloadAndInstallApk(ReadableMap options, Promise promise) { - impl.downloadAndInstallApk(options, promise); - } - - @ReactMethod - public void downloadPatchFromPackage(ReadableMap options, Promise promise) { - impl.downloadPatchFromPackage(options, promise); - } - - @ReactMethod - public void downloadPatchFromPpk(ReadableMap options, Promise promise) { - impl.downloadPatchFromPpk(options, promise); - } - - @ReactMethod - public void reloadUpdate(ReadableMap options, Promise promise) { - impl.reloadUpdate(options, promise); - } - - @ReactMethod - public void restartApp(Promise promise) { - impl.restartApp(null, promise); - } - - @ReactMethod - public void setNeedUpdate(ReadableMap options, Promise promise) { - impl.setNeedUpdate(options, promise); - } - - @ReactMethod - public void markSuccess(Promise promise) { - impl.markSuccess(promise); - } - - @ReactMethod - public void getBundleHash(Promise promise) { - impl.getBundleHash(promise); - } - - @ReactMethod - public void resetToPackagedBundle(Promise promise) { - impl.resetToPackagedBundle(promise); - } - - @ReactMethod - public void setUuid(String uuid, Promise promise) { - impl.setUuid(uuid, promise); - } - - @ReactMethod - public void syncNativeConfig(String config, Promise promise) { - impl.syncNativeConfig(config, promise); - } - - @ReactMethod - public void getNativeCheckCache(Promise promise) { - impl.getNativeCheckCache(promise); - } - - @ReactMethod - public void markJsCheckCompleted(String config, Promise promise) { - impl.markJsCheckCompleted(config, promise); - } - - @ReactMethod - public void setLocalHashInfo(String hash, String info, Promise promise) { - impl.setLocalHashInfo(hash, info, promise); - } - - @ReactMethod - public void getLocalHashInfo(String hash, Promise promise) { - impl.getLocalHashInfo(hash, promise); - } - - @ReactMethod - public void addListener(String eventName) { - } - - @ReactMethod - public void removeListeners(Integer count) { - } -} From b005fc24ce6fbb16853539c5c071189c9bb434c8 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:53:46 +0800 Subject: [PATCH 031/104] refactor(harmony): require hvigor task registration in the build-meta plugin The plugin fell back to writing meta.json at apply time (every hvigor invocation) when the node had no registerTask or registration threw. The hvigor versions RNOH supports all register tasks, so a missing registerTask now fails loudly instead of degrading. The project-level hvigorfile -> entry/ module-dir fallback is unchanged. Co-Authored-By: Claude Opus 5.5 --- harmony/hvigor-plugin.ts | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/harmony/hvigor-plugin.ts b/harmony/hvigor-plugin.ts index 433343d8..d0ffc2c9 100644 --- a/harmony/hvigor-plugin.ts +++ b/harmony/hvigor-plugin.ts @@ -156,26 +156,18 @@ export function reactNativeUpdatePlugin() { console.log(`Build time written to ${metaFilePath} (${buildMode})`); }; - if (typeof safeNode.registerTask === 'function') { - try { - safeNode.registerTask({ - name: TASK_NAME, - run: writeMeta, - dependencies: ['default@PreBuild'], - postDependencies: ['default@ProcessResource'], - }); - return; - } catch (e) { - console.warn( - `reactNativeUpdatePlugin: task registration failed (${ - e instanceof Error ? e.message : String(e) - }), writing meta.json at apply time`, - ); - } + if (typeof safeNode.registerTask !== 'function') { + throw new Error( + 'reactNativeUpdatePlugin: this hvigor cannot register build tasks; ' + + 'apply the plugin from a module hvigorfile with hvigor 5 or later.', + ); } - // Very old hvigor without task registration: keep the previous - // behaviour (write at apply time), still only for assemble builds. - writeMeta(); + safeNode.registerTask({ + name: TASK_NAME, + run: writeMeta, + dependencies: ['default@PreBuild'], + postDependencies: ['default@ProcessResource'], + }); }, }; } From fc273279c6fca041048b409b65cd98c3370adbe0 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:53:55 +0800 Subject: [PATCH 032/104] ci(e2e): share the e2e setup between Android and iOS, dedupe config - .github/actions/e2e-common: Node/Bun + Bun cache, e2e project deps, the local react-native-update link and react-native-update-cli, used by e2e-android-setup and the e2e-ios build job (which carried a copy). The iOS jobs move from Node 22 to 24 like every other workflow (22 was never bumped when Android moved to 24; no recorded reason). - e2e_android.yml: DETOX_ANDROID_ARCHS/DETOX_AVD_NAME set once at workflow level; the three emulator-runner configs share one YAML anchor, each step passing its commands via EMULATOR_SCRIPT. - e2e_ios.yml: the Xcode version (also part of every cache key) is one env. Co-Authored-By: Claude Opus 5.5 --- .github/actions/e2e-android-setup/action.yml | 84 +++-------------- .github/actions/e2e-common/action.yml | 98 ++++++++++++++++++++ .github/workflows/e2e_android.yml | 63 +++++-------- .github/workflows/e2e_ios.yml | 73 ++++----------- 4 files changed, 152 insertions(+), 166 deletions(-) create mode 100644 .github/actions/e2e-common/action.yml diff --git a/.github/actions/e2e-android-setup/action.yml b/.github/actions/e2e-android-setup/action.yml index 5163ad68..259efa09 100644 --- a/.github/actions/e2e-android-setup/action.yml +++ b/.github/actions/e2e-android-setup/action.yml @@ -1,8 +1,9 @@ name: E2E Android setup description: >- - Shared setup for the Android Detox jobs: toolchains, caches, KVM, the e2e - project's dependencies and the local react-native-update link. Runs after - the react-native-update checkout (this action lives in it). + Shared setup for the Android Detox jobs: Java (+ Gradle cache) and KVM, then + the platform-neutral e2e setup (../e2e-common: toolchains, Bun cache, the + e2e project's dependencies and the local react-native-update link). Runs + after the react-native-update checkout (this action lives in it). inputs: project-dir: description: E2E app directory, relative to the workspace. @@ -32,32 +33,6 @@ inputs: runs: using: composite steps: - - name: Checkout react-native-update-cli - if: inputs.checkout-cli == 'true' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: reactnativecn/react-native-update-cli - path: react-native-update-cli - - - name: Setup Node.js - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version: 24 - - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version: 1.4.0 - - - name: Cache Bun dependencies - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-bun-e2e${{ inputs.bun-cache-scope }}-${{ hashFiles('Example/e2etest/bun.lock', 'react-native-update-cli/bun.lock') }}${{ inputs.bun-cache-key-suffix }} - restore-keys: | - ${{ runner.os }}-bun-e2e${{ inputs.bun-cache-scope }}- - ${{ runner.os }}-bun-e2e- - - name: Setup Java uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: @@ -73,45 +48,12 @@ runs: sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - - name: Create e2e project - if: inputs.create-project != '' - shell: bash - env: - CREATE_PROJECT: ${{ inputs.create-project }} - run: eval "$CREATE_PROJECT" - - - name: Install e2e project dependencies - shell: bash - working-directory: ${{ inputs.project-dir }} - env: - FROZEN: ${{ inputs.frozen-lockfile }} - run: | - if [ "$FROZEN" = 'true' ]; then - bun install --frozen-lockfile - else - bun install - fi - - - name: Link local react-native-update - # Mirrors what an npm install ships (package.json `files`), plus cpp/ - # for the iOS/Android native builds; scripts/ is dev-only and unused - # by any build step. - shell: bash - env: - E2E_DIR: ${{ inputs.project-dir }} - run: | - set -euo pipefail - export LOCAL_RNU_VERSION="$(node -p "require('./${E2E_DIR}/node_modules/react-native-update/package.json').version")" - export LOCAL_RNU_DIR="$PWD/$E2E_DIR/.e2e-artifacts/local-react-native-update" - rm -rf "$LOCAL_RNU_DIR" "$E2E_DIR/node_modules/react-native-update" - mkdir -p "$LOCAL_RNU_DIR" - cp package.json react-native-update.podspec react-native.config.js expo-module.config.json "$LOCAL_RNU_DIR"/ - rsync -a --exclude='.git' src ios android cpp "$LOCAL_RNU_DIR"/ - node -e "const fs = require('fs'); const file = process.env.LOCAL_RNU_DIR + '/package.json'; const pkg = JSON.parse(fs.readFileSync(file, 'utf8')); pkg.version = process.env.LOCAL_RNU_VERSION; fs.writeFileSync(file, JSON.stringify(pkg, null, 2) + '\n');" - ln -s ../.e2e-artifacts/local-react-native-update "$E2E_DIR/node_modules/react-native-update" - - - name: Install react-native-update-cli dependencies - if: inputs.checkout-cli == 'true' - shell: bash - working-directory: react-native-update-cli - run: bun install --frozen-lockfile && bun run build + - name: Common e2e setup + uses: ./.github/actions/e2e-common + with: + project-dir: ${{ inputs.project-dir }} + checkout-cli: ${{ inputs.checkout-cli }} + bun-cache-scope: ${{ inputs.bun-cache-scope }} + bun-cache-key-suffix: ${{ inputs.bun-cache-key-suffix }} + create-project: ${{ inputs.create-project }} + frozen-lockfile: ${{ inputs.frozen-lockfile }} diff --git a/.github/actions/e2e-common/action.yml b/.github/actions/e2e-common/action.yml new file mode 100644 index 00000000..ae33999f --- /dev/null +++ b/.github/actions/e2e-common/action.yml @@ -0,0 +1,98 @@ +name: E2E common setup +description: >- + Platform-neutral part of the Detox e2e setup: Node/Bun toolchains, the Bun + cache, the e2e project's dependencies, the local react-native-update link + and (optionally) a built react-native-update-cli. Runs after the + react-native-update checkout (this action lives in it); platform toolchains + (Java/KVM, Xcode/Homebrew) are set up by the caller. +inputs: + project-dir: + description: E2E app directory, relative to the workspace. + default: Example/e2etest + checkout-cli: + description: Check out and build react-native-update-cli (needed to prepare update artifacts). + default: 'true' + bun-cache-scope: + description: Extra bun cache key segment for jobs whose dependency set differs. + default: '' + bun-cache-key-suffix: + description: Extra hash appended to the bun cache key. + default: '' + create-project: + description: Command that generates the e2e project before its dependencies are installed. + default: '' + frozen-lockfile: + description: Install with --frozen-lockfile (false only for generated projects that have no lockfile). + default: 'true' +runs: + using: composite + steps: + - name: Checkout react-native-update-cli + if: inputs.checkout-cli == 'true' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: reactnativecn/react-native-update-cli + path: react-native-update-cli + + - name: Setup Node.js + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.4.0 + + - name: Cache Bun dependencies + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-e2e${{ inputs.bun-cache-scope }}-${{ hashFiles('Example/e2etest/bun.lock', 'react-native-update-cli/bun.lock') }}${{ inputs.bun-cache-key-suffix }} + restore-keys: | + ${{ runner.os }}-bun-e2e${{ inputs.bun-cache-scope }}- + ${{ runner.os }}-bun-e2e- + + - name: Create e2e project + if: inputs.create-project != '' + shell: bash + env: + CREATE_PROJECT: ${{ inputs.create-project }} + run: eval "$CREATE_PROJECT" + + - name: Install e2e project dependencies + shell: bash + working-directory: ${{ inputs.project-dir }} + env: + FROZEN: ${{ inputs.frozen-lockfile }} + run: | + if [ "$FROZEN" = 'true' ]; then + bun install --frozen-lockfile + else + bun install + fi + + - name: Link local react-native-update + # Mirrors what an npm install ships (package.json `files`), plus cpp/ + # for the iOS/Android native builds; scripts/ is dev-only and unused + # by any build step. + shell: bash + env: + E2E_DIR: ${{ inputs.project-dir }} + run: | + set -euo pipefail + LOCAL_RNU_VERSION="$(node -p "require('./${E2E_DIR}/node_modules/react-native-update/package.json').version")" + LOCAL_RNU_DIR="$PWD/$E2E_DIR/.e2e-artifacts/local-react-native-update" + export LOCAL_RNU_VERSION LOCAL_RNU_DIR + rm -rf "$LOCAL_RNU_DIR" "$E2E_DIR/node_modules/react-native-update" + mkdir -p "$LOCAL_RNU_DIR" + cp package.json react-native-update.podspec react-native.config.js expo-module.config.json "$LOCAL_RNU_DIR"/ + rsync -a --exclude='.git' src ios android cpp "$LOCAL_RNU_DIR"/ + node -e "const fs = require('fs'); const file = process.env.LOCAL_RNU_DIR + '/package.json'; const pkg = JSON.parse(fs.readFileSync(file, 'utf8')); pkg.version = process.env.LOCAL_RNU_VERSION; fs.writeFileSync(file, JSON.stringify(pkg, null, 2) + '\n');" + ln -s ../.e2e-artifacts/local-react-native-update "$E2E_DIR/node_modules/react-native-update" + + - name: Install react-native-update-cli dependencies + if: inputs.checkout-cli == 'true' + shell: bash + working-directory: react-native-update-cli + run: bun install --frozen-lockfile && bun run build diff --git a/.github/workflows/e2e_android.yml b/.github/workflows/e2e_android.yml index 0a220f4b..7b5e1383 100644 --- a/.github/workflows/e2e_android.yml +++ b/.github/workflows/e2e_android.yml @@ -7,6 +7,7 @@ on: paths: &e2e-android-paths - '.github/workflows/e2e_android.yml' - '.github/actions/e2e-android-setup/**' + - '.github/actions/e2e-common/**' - '.gitmodules' # codegenConfig is copied into the linked package and drives codegen - 'package.json' @@ -26,6 +27,12 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +# Read by .detoxrc.js (detox build/test only): the ABI to build and the AVD +# the emulator steps below create. +env: + DETOX_ANDROID_ARCHS: x86_64 + DETOX_AVD_NAME: api34 + jobs: e2e-android: runs-on: ubuntu-latest @@ -40,9 +47,6 @@ jobs: uses: ./.github/actions/e2e-android-setup - name: Detox build (android.emu.release) - env: - DETOX_ANDROID_ARCHS: x86_64 - DETOX_AVD_NAME: api34 run: cd Example/e2etest && E2E_PLATFORM=android bunx detox build --configuration android.emu.release - name: Prepare local update artifacts @@ -54,11 +58,18 @@ jobs: - name: Detox test (android.emu.release) uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 env: - DETOX_ANDROID_ARCHS: x86_64 RNU_CLI_ROOT: ${{ github.workspace }}/react-native-update-cli RNU_E2E_SKIP_PREPARE: 'true' - DETOX_AVD_NAME: api34 - with: + # 每条命令自带 cd:android-emulator-runner 逐行起独立 shell, + # 单独一行的 cd 不会保持到下一条(实测 detox 会在仓库根找不到 jest) + EMULATOR_SCRIPT: | + cd Example/e2etest && E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all --retries 1 + # 原生冷启动检测有自己的 runner 配置(见 e2e/native/jest.config.js); + # Android 这个 job 有预算,不必像 iOS 那样拆成独立 job + cd Example/e2etest && E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all --retries 1 --config e2e/native/jest.config.js + # Shared by every emulator step in this workflow; each step supplies + # its own commands through the EMULATOR_SCRIPT env var. + with: &emulator api-level: 34 target: google_apis arch: x86_64 @@ -71,13 +82,7 @@ jobs: # worker. Unpin once a run proves 37.x stable. emulator-build: 15507667 emulator-boot-timeout: 900 - # 每条命令自带 cd:android-emulator-runner 逐行起独立 shell, - # 单独一行的 cd 不会保持到下一条(实测 detox 会在仓库根找不到 jest) - script: | - cd Example/e2etest && E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all --retries 1 - # 原生冷启动检测有自己的 runner 配置(见 e2e/native/jest.config.js); - # Android 这个 job 有预算,不必像 iOS 那样拆成独立 job - cd Example/e2etest && E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all --retries 1 --config e2e/native/jest.config.js + script: ${{ env.EMULATOR_SCRIPT }} - name: Upload Detox artifacts # cancelled() 覆盖 job 超时被杀的场景,否则超时时拿不到现场 @@ -108,9 +113,6 @@ jobs: checkout-cli: 'false' - name: Detox build (android.emu.debug) - env: - DETOX_ANDROID_ARCHS: x86_64 - DETOX_AVD_NAME: api34 run: cd Example/e2etest && bunx detox build --configuration android.emu.debug - name: Start Metro in background @@ -125,22 +127,13 @@ jobs: - name: Detox test (android.emu.debug boot smoke) uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 env: - DETOX_ANDROID_ARCHS: x86_64 - DETOX_AVD_NAME: api34 - with: - api-level: 34 - target: google_apis - arch: x86_64 - avd-name: api34 - # Pinned while stabilizing e2e; see the e2e-android job. - emulator-build: 15507667 - emulator-boot-timeout: 900 # 注意:emulator-runner 把 script 的每一行单独用 sh -c 执行, # cd 不会跨行生效,所以每行必须自包含。 - script: | + EMULATOR_SCRIPT: | for i in $(seq 1 90); do curl -sf http://localhost:8081/status >/dev/null && break; sleep 2; done curl -sf --max-time 420 -o /dev/null "http://localhost:8081/index.bundle?platform=android&dev=true&minify=false" || { echo '::error::Metro dev bundle failed to build'; tail -n 100 Example/e2etest/.e2e-artifacts/metro.log; exit 1; } cd Example/e2etest && bunx detox test --configuration android.emu.debug --config e2e/smoke/jest.config.js --no-start --headless --record-logs all --retries 1 + with: *emulator - name: Upload Detox artifacts # cancelled() 覆盖 job 超时被杀的场景,否则超时时拿不到现场 @@ -183,9 +176,6 @@ jobs: frozen-lockfile: 'false' - name: Detox build (RN 0.77.3 old arch android.emu.release) - env: - DETOX_ANDROID_ARCHS: x86_64 - DETOX_AVD_NAME: api34 run: cd .e2e-rn077-oldarch/AwesomeProject && E2E_PLATFORM=android bunx detox build --configuration android.emu.release - name: Prepare local update artifacts @@ -197,22 +187,13 @@ jobs: - name: Detox test (RN 0.77.3 old arch android.emu.release) uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 env: - DETOX_ANDROID_ARCHS: x86_64 RNU_CLI_ROOT: ${{ github.workspace }}/react-native-update-cli RNU_E2E_SKIP_PREPARE: 'true' - DETOX_AVD_NAME: api34 - with: - api-level: 34 - target: google_apis - arch: x86_64 - avd-name: api34 - # Pinned while stabilizing e2e; see the e2e-android job. - emulator-build: 15507667 - emulator-boot-timeout: 900 # 每条命令自带 cd,理由同上 - script: | + EMULATOR_SCRIPT: | cd .e2e-rn077-oldarch/AwesomeProject && E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all --retries 1 cd .e2e-rn077-oldarch/AwesomeProject && E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all --retries 1 --config e2e/native/jest.config.js + with: *emulator - name: Upload Detox artifacts # cancelled() 覆盖 job 超时被杀的场景,否则超时时拿不到现场 diff --git a/.github/workflows/e2e_ios.yml b/.github/workflows/e2e_ios.yml index 433d937d..0a4d7200 100644 --- a/.github/workflows/e2e_ios.yml +++ b/.github/workflows/e2e_ios.yml @@ -7,6 +7,7 @@ on: paths: &e2e-ios-paths - '.github/workflows/e2e_ios.yml' - '.github/actions/e2e-cache/**' + - '.github/actions/e2e-common/**' - '.gitmodules' # codegenConfig is copied into the linked package and drives codegen - 'package.json' @@ -27,6 +28,10 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +env: + # Also part of every cache key/scope below: a new Xcode means new binaries. + XCODE_VERSION: '26.4' + jobs: # Builds the release .app once (pod install + xcodebuild, or the cached app # with a refreshed JS bundle) and prepares the local update artifacts; both @@ -41,34 +46,10 @@ jobs: with: submodules: recursive - - name: Checkout react-native-update-cli - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: reactnativecn/react-native-update-cli - path: react-native-update-cli - - - name: Setup Node.js - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version: 22 - - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version: 1.4.0 - - - name: Cache Bun dependencies - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-bun-e2e-${{ hashFiles('Example/e2etest/bun.lock', 'react-native-update-cli/bun.lock') }} - restore-keys: | - ${{ runner.os }}-bun-e2e- - - name: Setup Xcode uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 with: - xcode-version: '26.4' + xcode-version: ${{ env.XCODE_VERSION }} - name: Cache Homebrew downloads # Bottle content addressing still controls reuse; only this job's @@ -78,7 +59,7 @@ jobs: uses: ./.github/actions/e2e-cache with: path: ~/Library/Caches/Homebrew - scope: brew-xcode-26.4 + scope: brew-xcode-${{ env.XCODE_VERSION }} write: 'true' - name: Install applesimutils and ccache @@ -90,36 +71,20 @@ jobs: uses: ./.github/actions/e2e-cache with: path: ~/Library/Caches/ccache - scope: ccache-xcode-26.4 + scope: ccache-xcode-${{ env.XCODE_VERSION }} write: 'true' - - name: Install e2etest dependencies - run: cd Example/e2etest && bun install --frozen-lockfile - - - name: Link local react-native-update - # Mirrors what an npm install ships (package.json `files`), plus cpp/ - # for the native build; scripts/ is dev-only and unused by any build - # step. - run: | - set -euo pipefail - export LOCAL_RNU_VERSION="$(node -p "require('./Example/e2etest/node_modules/react-native-update/package.json').version")" - LOCAL_RNU_DIR="$PWD/Example/e2etest/.e2e-artifacts/local-react-native-update" - rm -rf "$LOCAL_RNU_DIR" Example/e2etest/node_modules/react-native-update - mkdir -p "$LOCAL_RNU_DIR" - cp package.json react-native-update.podspec react-native.config.js expo-module.config.json "$LOCAL_RNU_DIR"/ - rsync -a --exclude='.git' src ios android cpp "$LOCAL_RNU_DIR"/ - node -e "const fs = require('fs'); const file = 'Example/e2etest/.e2e-artifacts/local-react-native-update/package.json'; const pkg = JSON.parse(fs.readFileSync(file, 'utf8')); pkg.version = process.env.LOCAL_RNU_VERSION; fs.writeFileSync(file, JSON.stringify(pkg, null, 2) + '\n');" - ln -s ../.e2e-artifacts/local-react-native-update Example/e2etest/node_modules/react-native-update - - - name: Install react-native-update-cli dependencies - run: cd react-native-update-cli && bun install --frozen-lockfile && bun run build + - name: Set up e2e environment + # Node/Bun, e2etest dependencies, the local react-native-update link + # and react-native-update-cli (shared with the Android jobs). + uses: ./.github/actions/e2e-common - name: Cache Metro transform cache # Metro validates its own content keys; stale snapshots only recompile. uses: ./.github/actions/e2e-cache with: path: Example/e2etest/.metro-cache - scope: metro-xcode-26.4 + scope: metro-xcode-${{ env.XCODE_VERSION }} write: 'true' - name: Prepare local update artifacts @@ -149,7 +114,7 @@ jobs: path: | Example/e2etest/ios/build/Build/Products/Release-iphonesimulator/AwesomeProject.app Example/e2etest/ios/build/hermesc - key: ${{ runner.os }}-e2e-app-xcode-26.4-${{ hashFiles('Example/e2etest/ios/**', 'Example/e2etest/package.json', 'Example/e2etest/bun.lock', 'react-native-update.podspec', 'react-native.config.js', 'expo-module.config.json', 'ios/**', 'cpp/**', 'android/jni/**', 'src/Native*.ts', 'package.json') }} + key: ${{ runner.os }}-e2e-app-xcode-${{ env.XCODE_VERSION }}-${{ hashFiles('Example/e2etest/ios/**', 'Example/e2etest/package.json', 'Example/e2etest/bun.lock', 'react-native-update.podspec', 'react-native.config.js', 'expo-module.config.json', 'ios/**', 'cpp/**', 'android/jni/**', 'src/Native*.ts', 'package.json') }} - name: Install CocoaPods dependencies if: steps.cache-ios-app.outputs.cache-hit != 'true' @@ -215,7 +180,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - node-version: 22 + node-version: 24 - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 @@ -233,7 +198,7 @@ jobs: - name: Setup Xcode uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 with: - xcode-version: '26.4' + xcode-version: ${{ env.XCODE_VERSION }} - name: Boot iOS simulator in background # 模拟器冷启动要 2-5 分钟,提前异步 boot,让它和依赖安装/下载并行。 @@ -253,7 +218,7 @@ jobs: uses: ./.github/actions/e2e-cache with: path: ~/Library/Caches/Homebrew - scope: brew-xcode-26.4 + scope: brew-xcode-${{ env.XCODE_VERSION }} - name: Install applesimutils run: brew trust wix/brew && HOMEBREW_NO_AUTO_UPDATE=1 brew install wix/brew/applesimutils @@ -274,9 +239,9 @@ jobs: uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/Library/Detox - key: ${{ runner.os }}-detox-ios-framework-xcode-26.4-${{ hashFiles('Example/e2etest/bun.lock') }} + key: ${{ runner.os }}-detox-ios-framework-xcode-${{ env.XCODE_VERSION }}-${{ hashFiles('Example/e2etest/bun.lock') }} restore-keys: | - ${{ runner.os }}-detox-ios-framework-xcode-26.4- + ${{ runner.os }}-detox-ios-framework-xcode-${{ env.XCODE_VERSION }}- - name: Build Detox iOS framework cache if: steps.cache-detox-ios-framework.outputs.cache-hit != 'true' From 21d07ea01f365f8cfd0227026c64e282c4fb0847 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:53:56 +0800 Subject: [PATCH 033/104] ci(publish): move the shared publish steps into a composite action publish_with_harmony and publish_without_harmony ran the same toolchain, native-library, verification and npm publish steps and differed only in where harmony/pushy.har comes from. .github/actions/publish-npm runs them with harmony-har: build | previous; both jobs stay (the HAR build needs the HarmonyOS container). The release tag now reaches the publish script via env instead of inline expression interpolation. Co-Authored-By: Claude Opus 5.5 --- .github/actions/publish-npm/action.yml | 118 +++++++++++++++++++++++++ .github/workflows/publish.yml | 104 +++------------------- 2 files changed, 128 insertions(+), 94 deletions(-) create mode 100644 .github/actions/publish-npm/action.yml diff --git a/.github/actions/publish-npm/action.yml b/.github/actions/publish-npm/action.yml new file mode 100644 index 00000000..27886876 --- /dev/null +++ b/.github/actions/publish-npm/action.yml @@ -0,0 +1,118 @@ +name: Publish to npm +description: >- + Shared body of the two publish jobs in publish.yml: toolchains, the CI-built + Android native libraries, the Harmony HAR (rebuilt, or reused from the + previous published release), the pre-publish checks and npm publish. Runs + after the react-native-update checkout (this action lives in it). +inputs: + harmony-har: + description: "'build' rebuilds harmony/pushy.har from source; 'previous' reuses the one in the previous-tag release on npm." + required: true + previous-tag: + description: Last release tag published to npm (with harmony-har 'previous'). + default: '' +runs: + using: composite + steps: + - name: Check inputs + shell: bash + env: + HARMONY_HAR: ${{ inputs.harmony-har }} + PREVIOUS_TAG: ${{ inputs.previous-tag }} + run: | + case "$HARMONY_HAR" in + build) ;; + previous) + if [ -z "$PREVIOUS_TAG" ]; then + echo "::error::harmony-har 'previous' needs previous-tag" + exit 1 + fi + ;; + *) echo "::error::unknown harmony-har '$HARMONY_HAR'"; exit 1 ;; + esac + + - name: Configure git safe.directory + shell: bash + run: | + git config --global --add safe.directory "$GITHUB_WORKSPACE" + # $toplevel/$sm_path are expanded by git submodule foreach, not here. + # shellcheck disable=SC2016 + git submodule foreach --recursive 'git config --global --add safe.directory "$toplevel/$sm_path"' + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.4.0 + + # Setup .npmrc file to publish to npm + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + registry-url: 'https://registry.npmjs.org' + + - name: Install dependencies + shell: bash + run: bun install --frozen-lockfile + + # Drop the committed binaries first: the artifact must fully replace + # android/lib, so any layout regression makes the verify step fail loudly + # instead of silently passing against stale committed .so files. + - name: Remove committed Android native libraries + shell: bash + run: rm -rf android/lib + + - name: Use CI-built Android native libraries + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: android-so + path: android/lib + + - name: Verify Android native libraries + shell: bash + run: node scripts/verify-android-so.js + + - name: Build Harmony HAR + if: inputs.harmony-har == 'build' + shell: bash + run: npm run build:harmony-har -- --build-mode release + + # Fail closed: the package must always ship harmony/pushy.har + # (package.json `files` lists it and check-packlist.js --release + # requires it). If the previous release can't be fetched, fix the cause + # or re-run — never publish a HAR-less package. + - name: Fetch previous Harmony HAR + if: inputs.harmony-har == 'previous' + shell: bash + env: + PREVIOUS_TAG: ${{ inputs.previous-tag }} + run: | + PREV_VERSION="${PREVIOUS_TAG#v}" + echo "Fetching harmony/pushy.har from react-native-update@$PREV_VERSION" + npm pack "react-native-update@$PREV_VERSION" + tar -xzf "react-native-update-${PREV_VERSION}.tgz" package/harmony/pushy.har + mkdir -p harmony + cp package/harmony/pushy.har harmony/pushy.har + # Clean up temporary pack and extraction files to prevent publishing them + rm -rf "react-native-update-${PREV_VERSION}.tgz" package + + - name: Verify Harmony HAR artifact + shell: bash + run: test -f harmony/pushy.har + + - name: Verify release tag matches package version + shell: bash + run: node scripts/check-release-version.js + + - name: Verify publish file list + shell: bash + run: node scripts/check-packlist.js --release + + - name: Publish to npm + shell: bash + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + if [[ "$RELEASE_TAG" == *"beta"* ]]; then + npm publish --provenance --access public --tag beta + else + npm publish --provenance --access public + fi diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 04da5855..a1ecda61 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -115,6 +115,9 @@ jobs: path: android/lib/ if-no-files-found: error + # The two publish jobs differ only in where harmony/pushy.har comes from; + # everything else lives in .github/actions/publish-npm. They stay separate + # jobs: the HAR build needs the HarmonyOS container, the reuse path does not. publish_with_harmony: needs: [check_changes, build_android_so] if: needs.check_changes.outputs.harmony_changed == 'true' @@ -126,47 +129,10 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: recursive - - name: Configure git safe.directory - run: | - git config --global --add safe.directory "$GITHUB_WORKSPACE" - git submodule foreach --recursive 'git config --global --add safe.directory "$toplevel/$sm_path"' - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version: 1.4.0 - # Setup .npmrc file to publish to npm - - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version: 24 - registry-url: 'https://registry.npmjs.org' - - run: bun install --frozen-lockfile - # Drop the committed binaries first: the artifact must fully replace - # android/lib, so any layout regression makes the verify step fail loudly - # instead of silently passing against stale committed .so files. - - name: Remove committed Android native libraries - run: rm -rf android/lib - - name: Use CI-built Android native libraries - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + - name: Build Harmony HAR and publish + uses: ./.github/actions/publish-npm with: - name: android-so - path: android/lib - - name: Verify Android native libraries - run: node scripts/verify-android-so.js - - name: Build Harmony HAR - run: npm run build:harmony-har -- --build-mode release - - name: Verify Harmony HAR artifact - run: test -f harmony/pushy.har - - name: Verify release tag matches package version - run: node scripts/check-release-version.js - - name: Verify publish file list - run: node scripts/check-packlist.js --release - - name: Publish to npm - shell: bash - run: | - if [[ "${{ github.event.release.tag_name }}" == *"beta"* ]]; then - npm publish --provenance --access public --tag beta - else - npm publish --provenance --access public - fi + harmony-har: build publish_without_harmony: needs: [check_changes, build_android_so] @@ -177,58 +143,8 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: recursive - - name: Configure git safe.directory - run: | - git config --global --add safe.directory "$GITHUB_WORKSPACE" - git submodule foreach --recursive 'git config --global --add safe.directory "$toplevel/$sm_path"' - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + - name: Reuse the previous Harmony HAR and publish + uses: ./.github/actions/publish-npm with: - bun-version: 1.4.0 - # Setup .npmrc file to publish to npm - - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version: 24 - registry-url: 'https://registry.npmjs.org' - - run: bun install --frozen-lockfile - # Drop the committed binaries first: the artifact must fully replace - # android/lib, so any layout regression makes the verify step fail loudly - # instead of silently passing against stale committed .so files. - - name: Remove committed Android native libraries - run: rm -rf android/lib - - name: Use CI-built Android native libraries - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - name: android-so - path: android/lib - - name: Verify Android native libraries - run: node scripts/verify-android-so.js - # Fail closed: the package must always ship harmony/pushy.har - # (package.json `files` lists it and check-packlist.js --release - # requires it). If the previous release can't - # be fetched, fix the cause or re-run — never publish a HAR-less package. - - name: Fetch previous Harmony HAR - shell: bash - run: | - PREV_VERSION="${{ needs.check_changes.outputs.prev_tag }}" - PREV_VERSION="${PREV_VERSION#v}" - echo "Fetching harmony/pushy.har from react-native-update@$PREV_VERSION" - npm pack "react-native-update@$PREV_VERSION" - tar -xzf "react-native-update-${PREV_VERSION}.tgz" package/harmony/pushy.har - mkdir -p harmony - cp package/harmony/pushy.har harmony/pushy.har - # Clean up temporary pack and extraction files to prevent publishing them - rm -rf "react-native-update-${PREV_VERSION}.tgz" package - - name: Verify Harmony HAR artifact - run: test -f harmony/pushy.har - - name: Verify release tag matches package version - run: node scripts/check-release-version.js - - name: Verify publish file list - run: node scripts/check-packlist.js --release - - name: Publish to npm - shell: bash - run: | - if [[ "${{ github.event.release.tag_name }}" == *"beta"* ]]; then - npm publish --provenance --access public --tag beta - else - npm publish --provenance --access public - fi + harmony-har: previous + previous-tag: ${{ needs.check_changes.outputs.prev_tag }} From 287933723427b5cd0a74f122d6182ee271e8544d Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:54:45 +0800 Subject: [PATCH 034/104] chore(scripts): drop the stale debug e2e npm scripts test:ios-debug / test:android-debug ran the update-flow suites against a __DEV__ build, where the client skips update checks, and build:ios-debug only fed them; nothing (CI, docs, Example) references the three. The debug boot smoke pair (build:android-debug, smoke:android-debug) stays. Co-Authored-By: Claude Opus 5.5 --- package.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/package.json b/package.json index b770b02d..7498826e 100644 --- a/package.json +++ b/package.json @@ -41,14 +41,11 @@ "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", - "build:ios-debug": "cd Example/e2etest && bun && bunx detox build --configuration ios.sim.debug", "build:ios-release": "cd Example/e2etest && bun && bunx detox build --configuration ios.sim.release", - "test:ios-debug": "cd Example/e2etest && E2E_PLATFORM=ios bunx detox test --configuration ios.sim.debug", "test:ios-release": "cd Example/e2etest && E2E_PLATFORM=ios bunx detox test --configuration ios.sim.release", "build:android-debug": "cd Example/e2etest && bun && bunx detox build --configuration android.emu.debug", "build:android-release": "cd Example/e2etest && bun && bunx detox build --configuration android.emu.release", "test:android-release": "cd Example/e2etest && E2E_PLATFORM=android bunx detox test --configuration android.emu.release --headless --record-logs all", - "test:android-debug": "cd Example/e2etest && E2E_PLATFORM=android bunx detox test --configuration android.emu.debug --headless --record-logs all", "smoke:android-debug": "cd Example/e2etest && bunx detox test --configuration android.emu.debug --config e2e/smoke/jest.config.js", "e2e:ios": "bun build:ios-release && bun test:ios-release", "e2e:android": "bun build:android-release && bun test:android-release" From c198d72bdbd973bbfd383c4285eccd0c330105a6 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:55:46 +0800 Subject: [PATCH 035/104] refactor(android): lambdas and one thread helper Threads.single(name, daemon) replaces the four hand-written executor factories and Threads.startDaemon the three hand-rolled daemon threads. UiThreadRunner folds into StateSerialRunner.run(Executor, ...) with a UI_THREAD executor; its promise == null branches were dead (every caller passes one). Anonymous single-method classes become lambdas. Co-Authored-By: Claude Opus 5.5 --- .../reactnative/modules/update/CrashHold.java | 52 +++++------ .../modules/update/DownloadTask.java | 35 +++----- .../modules/update/PushyRuntime.java | 90 ++++++------------- .../modules/update/StateSerialRunner.java | 61 +++++-------- .../modules/update/SyncCoordinator.java | 44 ++++----- .../reactnative/modules/update/Threads.java | 35 ++++++++ .../modules/update/UiThreadRunner.java | 37 -------- .../modules/update/UpdateContext.java | 32 +++---- .../modules/update/UpdateModule.java | 82 ++++++----------- .../modules/update/UpdatePackage.java | 33 ++++--- 10 files changed, 193 insertions(+), 308 deletions(-) create mode 100644 android/src/main/java/cn/reactnative/modules/update/Threads.java delete mode 100644 android/src/main/java/cn/reactnative/modules/update/UiThreadRunner.java diff --git a/android/src/main/java/cn/reactnative/modules/update/CrashHold.java b/android/src/main/java/cn/reactnative/modules/update/CrashHold.java index 0113f8cb..52d785c7 100644 --- a/android/src/main/java/cn/reactnative/modules/update/CrashHold.java +++ b/android/src/main/java/cn/reactnative/modules/update/CrashHold.java @@ -43,23 +43,20 @@ static void install() { installedAtElapsedRealtime = SystemClock.elapsedRealtime(); final Thread.UncaughtExceptionHandler previous = Thread.getDefaultUncaughtExceptionHandler(); - Thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() { - @Override - public void uncaughtException(Thread thread, Throwable error) { - try { - maybeHold(thread); - } catch (Throwable ignored) { - // The dying process owes the previous handler its turn no - // matter what the rescue did. - } - if (previous != null) { - previous.uncaughtException(thread, error); - } else { - // Mirror RuntimeInit's default behaviour so the process - // still dies when no other handler was registered. - Process.killProcess(Process.myPid()); - System.exit(10); - } + Thread.setDefaultUncaughtExceptionHandler((thread, error) -> { + try { + maybeHold(thread); + } catch (Throwable ignored) { + // The dying process owes the previous handler its turn no + // matter what the rescue did. + } + if (previous != null) { + previous.uncaughtException(thread, error); + } else { + // Mirror RuntimeInit's default behaviour so the process + // still dies when no other handler was registered. + Process.killProcess(Process.myPid()); + System.exit(10); } }); } @@ -90,20 +87,15 @@ private static void maybeHold(Thread crashedThread) { + budgetMillis + "ms (uptime " + uptimeMillis + "ms)"); final CountDownLatch done = new CountDownLatch(1); - Thread worker = new Thread(new Runnable() { - @Override - public void run() { - try { - SyncCoordinator.runHoldRound(deadlineNanos); - } catch (Throwable e) { - Log.w(Texts.LOG_TAG, "crash hold failed: " + e); - } finally { - done.countDown(); - } + Threads.startDaemon("pushy-crash-hold", () -> { + try { + SyncCoordinator.runHoldRound(deadlineNanos); + } catch (Throwable e) { + Log.w(Texts.LOG_TAG, "crash hold failed: " + e); + } finally { + done.countDown(); } - }, "pushy-crash-hold"); - worker.setDaemon(true); - worker.start(); + }); try { if (!done.await(budgetMillis, TimeUnit.MILLISECONDS)) { Log.w(Texts.LOG_TAG, "crash hold: budget exhausted, letting go"); 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 432536df..3377d0b6 100644 --- a/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +++ b/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java @@ -120,15 +120,12 @@ private void postProgress(final long received, final long total) { // Cross-platform progress contract: unknown length is reported as // total=0, never a raw -1 (OkHttp's contentLength for chunked/gzip). final long normalizedTotal = total > 0 ? total : 0; - mainHandler.post(new Runnable() { - @Override - public void run() { - WritableMap progress = Arguments.createMap(); - progress.putDouble("received", received); - progress.putDouble("total", normalizedTotal); - progress.putString("hash", hash); - UpdateEventEmitter.sendEvent("RCTPushyDownloadProgress", progress); - } + mainHandler.post(() -> { + WritableMap progress = Arguments.createMap(); + progress.putDouble("received", received); + progress.putDouble("total", normalizedTotal); + progress.putString("hash", hash); + UpdateEventEmitter.sendEvent("RCTPushyDownloadProgress", progress); }); } @@ -766,19 +763,13 @@ private void doCleanUp() { // Phase 1, under the version-files lock: only renames doomed entries // into the trash, so activation is never blocked behind a recursive // delete of several version directories. - UpdateContext.getInstance(context).runCleanupWithLatestState( - new UpdateContext.CleanupAction() { - @Override - public void run(String[] keepNames) { - cleanupOldEntries( - params.unzipDirectory.getAbsolutePath(), - keepNames, - params.maxAgeDays, - trash.getAbsolutePath() - ); - } - } - ); + UpdateContext.getInstance(context).runCleanupWithLatestState(keepNames -> + cleanupOldEntries( + params.unzipDirectory.getAbsolutePath(), + keepNames, + params.maxAgeDays, + trash.getAbsolutePath() + )); // Phase 2, lock released: nothing in the trash is reachable as a // version any more. Also purges leftovers of an interrupted run. // Best effort: phase 1 already succeeded, and a leftover the native diff --git a/android/src/main/java/cn/reactnative/modules/update/PushyRuntime.java b/android/src/main/java/cn/reactnative/modules/update/PushyRuntime.java index 38123909..6e10653e 100644 --- a/android/src/main/java/cn/reactnative/modules/update/PushyRuntime.java +++ b/android/src/main/java/cn/reactnative/modules/update/PushyRuntime.java @@ -7,8 +7,6 @@ import androidx.annotation.Nullable; import org.json.JSONObject; import java.util.concurrent.Executor; -import java.util.concurrent.Executors; -import java.util.concurrent.ThreadFactory; /** Bridge-free native configuration and update APIs. */ public final class PushyRuntime { @@ -19,14 +17,7 @@ public interface Callback { // A single waiting worker, not one thread per caller. The actual round is // shared with cold start and crash rescue by SyncCoordinator. - private static final Executor WORKER = Executors.newSingleThreadExecutor(new ThreadFactory() { - @Override - public Thread newThread(Runnable runnable) { - Thread thread = new Thread(runnable, "pushy-host-sync"); - thread.setDaemon(true); - return thread; - } - }); + private static final Executor WORKER = Threads.single("pushy-host-sync", true); public interface ConfigurationCallback { /** Main thread; null means configuration was persisted successfully. */ @@ -34,14 +25,7 @@ public interface ConfigurationCallback { } // Configuration must not wait behind a network round that it invalidates. - private static final Executor CONFIG_WORKER = Executors.newSingleThreadExecutor(new ThreadFactory() { - @Override - public Thread newThread(Runnable runnable) { - Thread thread = new Thread(runnable, "pushy-host-config"); - thread.setDaemon(true); - return thread; - } - }); + private static final Executor CONFIG_WORKER = Threads.single("pushy-host-config", true); /** * Validate and persist a complete configuration, even before JS or bundle @@ -56,26 +40,18 @@ public static void configure(Context context, JSONObject options, final Configur final Context applicationContext = context.getApplicationContext(); // Snapshot caller-owned JSON before dispatch, not minutes later on a worker. final String snapshot = options.toString(); - CONFIG_WORKER.execute(new Runnable() { - @Override - public void run() { - Exception failure = null; - try { - String config = PushyConfiguration.normalize(snapshot); - UpdateContext.getInstance(applicationContext).setNativeConfig(config); - } catch (Exception e) { - failure = e; - } catch (LinkageError e) { - failure = new IllegalStateException("Native configuration failed", e); - } - final Exception error = failure; - new Handler(Looper.getMainLooper()).post(new Runnable() { - @Override - public void run() { - callback.onComplete(error); - } - }); + CONFIG_WORKER.execute(() -> { + Exception failure = null; + try { + String config = PushyConfiguration.normalize(snapshot); + UpdateContext.getInstance(applicationContext).setNativeConfig(config); + } catch (Exception e) { + failure = e; + } catch (LinkageError e) { + failure = new IllegalStateException("Native configuration failed", e); } + final Exception error = failure; + new Handler(Looper.getMainLooper()).post(() -> callback.onComplete(error)); }); } @@ -96,32 +72,24 @@ public static void prepareBundle(Context context, final Callback callback) { throw new IllegalArgumentException("context and callback are required"); } final Context applicationContext = context.getApplicationContext(); - WORKER.execute(new Runnable() { - @Override - public void run() { - BundlePreparationResult outcome; - try { - if (BuildConfig.DEBUG) { - outcome = BundlePreparationResult.of(BundlePreparationResult.SKIPPED, "debug"); - } else { - outcome = SyncCoordinator.prepareBundle( - UpdateContext.getInstance(applicationContext)); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - outcome = BundlePreparationResult.of(BundlePreparationResult.CANCELLED, "interrupted"); - } catch (Exception | LinkageError e) { - Log.w(Texts.LOG_TAG, "native host sync failed", e); - outcome = BundlePreparationResult.of(BundlePreparationResult.FAILED, "internal_error"); + WORKER.execute(() -> { + BundlePreparationResult outcome; + try { + if (BuildConfig.DEBUG) { + outcome = BundlePreparationResult.of(BundlePreparationResult.SKIPPED, "debug"); + } else { + outcome = SyncCoordinator.prepareBundle( + UpdateContext.getInstance(applicationContext)); } - final BundlePreparationResult result = outcome; - new Handler(Looper.getMainLooper()).post(new Runnable() { - @Override - public void run() { - callback.onComplete(result); - } - }); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + outcome = BundlePreparationResult.of(BundlePreparationResult.CANCELLED, "interrupted"); + } catch (Exception | LinkageError e) { + Log.w(Texts.LOG_TAG, "native host sync failed", e); + outcome = BundlePreparationResult.of(BundlePreparationResult.FAILED, "internal_error"); } + final BundlePreparationResult result = outcome; + new Handler(Looper.getMainLooper()).post(() -> callback.onComplete(result)); }); } } diff --git a/android/src/main/java/cn/reactnative/modules/update/StateSerialRunner.java b/android/src/main/java/cn/reactnative/modules/update/StateSerialRunner.java index d00a91cb..9d0a7263 100644 --- a/android/src/main/java/cn/reactnative/modules/update/StateSerialRunner.java +++ b/android/src/main/java/cn/reactnative/modules/update/StateSerialRunner.java @@ -1,64 +1,47 @@ package cn.reactnative.modules.update; -import android.util.Log; -import androidx.annotation.Nullable; import com.facebook.react.bridge.Promise; - +import com.facebook.react.bridge.UiThreadUtil; import java.util.concurrent.Executor; -import java.util.concurrent.Executors; -import java.util.concurrent.ThreadFactory; /** - * Runs state-persistence operations (switchVersion / markSuccess / setUuid / - * setLocalHashInfo) on a dedicated single background thread. - * - * These operations only read/modify SharedPreferences via a synchronous - * commit(); they were previously dispatched to the UI thread purely to - * serialize them. markSuccess in particular runs on every cold start, so doing - * its blocking disk write on the main thread caused jank/ANR on low-end - * devices. A single-thread executor preserves the same serialization guarantee - * while keeping the disk I/O off the UI thread. + * Runs a module operation on a given executor and rejects its promise with + * the operation's error code when it throws; the operation resolves the + * promise itself. * - * Note: reload/restart operations must still run on the UI thread and therefore - * keep using {@link UiThreadRunner}. + * State-persistence operations (switchVersion / markSuccess / setUuid / + * setLocalHashInfo / reset / config) go to {@link #STATE_SERIAL}, a single + * background thread: they only read/modify SharedPreferences via a + * synchronous commit(), and were once dispatched to the UI thread purely to + * serialize them. markSuccess runs on every cold start, so its blocking disk + * write on the main thread caused jank/ANR on low-end devices; one worker + * keeps the serialization in submission order without that. Only the React + * reload itself still hops to {@link #UI_THREAD}. */ final class StateSerialRunner { interface Operation { void run() throws Throwable; } - // Single worker thread -> operations stay serialized in submission order, - // matching the previous UI-thread behavior. The thread is named so it is - // identifiable in thread dumps / ANR traces when diagnosing persistence. - private static final Executor EXECUTOR = Executors.newSingleThreadExecutor( - new ThreadFactory() { - @Override - public Thread newThread(Runnable r) { - return new Thread(r, "pushy-state-serial"); - } - }); + // Named so it is identifiable in thread dumps / ANR traces. + static final Executor STATE_SERIAL = Threads.single("pushy-state-serial", false); + static final Executor UI_THREAD = UiThreadUtil::runOnUiThread; private StateSerialRunner() { } static void run( - @Nullable final Promise promise, + Executor executor, + final Promise promise, final String errorCode, final String operationName, final Operation operation ) { - EXECUTOR.execute(new Runnable() { - @Override - public void run() { - try { - operation.run(); - } catch (Throwable error) { - if (promise != null) { - promise.reject(errorCode, operationName + " failed", error); - } else { - Log.e(Texts.LOG_TAG, operationName + " failed", error); - } - } + executor.execute(() -> { + try { + operation.run(); + } catch (Throwable error) { + promise.reject(errorCode, operationName + " failed", error); } }); } diff --git a/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java b/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java index 43bc14b7..707edb98 100644 --- a/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java +++ b/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java @@ -200,33 +200,27 @@ static void schedule(final UpdateContext context, final String launchRolledBackV if (context.getKv(KEY_CONFIG) != null) { CrashHold.install(); } - Thread thread = new Thread(new Runnable() { - @Override - public void run() { - try { - // Keep the check away from the cold-start critical path - // (§7 R5) — unless the previous process died mid-round, - // in which case every launch second counts (§11.4). - if (context.getKv(KEY_ROUND_INCOMPLETE) == null) { - Thread.sleep(5000); - } - if (hasJsRound(context)) { - // Not consuming the round: a later crash rescue may - // still need it. - Log.i(Texts.LOG_TAG, - "native sync skipped: JS already queried in this process"); - return; - } - startRound(0); - } catch (Throwable e) { - // The rescue path must never take the app down with it. - Log.w(Texts.LOG_TAG, "native sync failed: " + e); + Threads.startDaemon("pushy-native-sync", Thread.MIN_PRIORITY + 1, () -> { + try { + // Keep the check away from the cold-start critical path + // (§7 R5) — unless the previous process died mid-round, + // in which case every launch second counts (§11.4). + if (context.getKv(KEY_ROUND_INCOMPLETE) == null) { + Thread.sleep(5000); + } + if (hasJsRound(context)) { + // Not consuming the round: a later crash rescue may + // still need it. + Log.i(Texts.LOG_TAG, + "native sync skipped: JS already queried in this process"); + return; } + startRound(0); + } catch (Throwable e) { + // The rescue path must never take the app down with it. + Log.w(Texts.LOG_TAG, "native sync failed: " + e); } - }, "pushy-native-sync"); - thread.setPriority(Thread.MIN_PRIORITY + 1); - thread.setDaemon(true); - thread.start(); + }); } static boolean isRoundInFlight() { diff --git a/android/src/main/java/cn/reactnative/modules/update/Threads.java b/android/src/main/java/cn/reactnative/modules/update/Threads.java new file mode 100644 index 00000000..dfe615b9 --- /dev/null +++ b/android/src/main/java/cn/reactnative/modules/update/Threads.java @@ -0,0 +1,35 @@ +package cn.reactnative.modules.update; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** The SDK's named worker threads, so they are identifiable in thread dumps / ANR traces. */ +final class Threads { + private Threads() { + } + + /** A single-thread executor: tasks run serialized in submission order. */ + static ExecutorService single(final String name, final boolean daemon) { + return Executors.newSingleThreadExecutor(runnable -> { + Thread thread = new Thread(runnable, name); + thread.setDaemon(daemon); + return thread; + }); + } + + /** Starts a one-off daemon thread: it never keeps a dying process alive. */ + static Thread startDaemon(String name, Runnable task) { + return startDaemon(name, -1, task); + } + + /** As above, with an explicit priority (negative keeps the inherited one). */ + static Thread startDaemon(String name, int priority, Runnable task) { + Thread thread = new Thread(task, name); + if (priority >= 0) { + thread.setPriority(priority); + } + thread.setDaemon(true); + thread.start(); + return thread; + } +} diff --git a/android/src/main/java/cn/reactnative/modules/update/UiThreadRunner.java b/android/src/main/java/cn/reactnative/modules/update/UiThreadRunner.java deleted file mode 100644 index 7526296d..00000000 --- a/android/src/main/java/cn/reactnative/modules/update/UiThreadRunner.java +++ /dev/null @@ -1,37 +0,0 @@ -package cn.reactnative.modules.update; - -import android.util.Log; -import androidx.annotation.Nullable; -import com.facebook.react.bridge.Promise; -import com.facebook.react.bridge.UiThreadUtil; - -final class UiThreadRunner { - interface Operation { - void run() throws Throwable; - } - - private UiThreadRunner() { - } - - static void run( - @Nullable final Promise promise, - final String errorCode, - final String operationName, - final Operation operation - ) { - UiThreadUtil.runOnUiThread(new Runnable() { - @Override - public void run() { - try { - operation.run(); - } catch (Throwable error) { - if (promise != null) { - promise.reject(errorCode, operationName + " failed", error); - } else { - Log.e(Texts.LOG_TAG, operationName + " failed", error); - } - } - } - }); - } -} 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 50bbd2e2..b309e1f9 100644 --- a/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +++ b/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java @@ -13,8 +13,6 @@ import java.io.InputStream; import java.security.MessageDigest; import java.util.concurrent.Executor; -import java.util.concurrent.Executors; -import java.util.concurrent.ThreadFactory; public class UpdateContext { static { @@ -113,12 +111,7 @@ private static native StateCoreResult runStateCore( private UpdateContext(Context context) { this.context = context.getApplicationContext(); - this.executor = Executors.newSingleThreadExecutor(new ThreadFactory() { - @Override - public Thread newThread(Runnable r) { - return new Thread(r, "pushy-download"); - } - }); + this.executor = Threads.single("pushy-download", false); this.rootDir = new File(this.context.getFilesDir(), Texts.reveal("0502e4d5af9f6d")); @@ -258,21 +251,16 @@ private void startBundleHashComputation() { publishBundleHash(""); return; } - Thread thread = new Thread(new Runnable() { - @Override - public void run() { - String hash = ""; - try { - hash = loadOrHashBundledAsset(); - } catch (Throwable e) { - Log.w(TAG, "bundle hash failed: " + e); - } finally { - publishBundleHash(hash); - } + Threads.startDaemon("pushy-bundle-hash", () -> { + String hash = ""; + try { + hash = loadOrHashBundledAsset(); + } catch (Throwable e) { + Log.w(TAG, "bundle hash failed: " + e); + } finally { + publishBundleHash(hash); } - }, "pushy-bundle-hash"); - thread.setDaemon(true); - thread.start(); + }); } private void publishBundleHash(String hash) { diff --git a/android/src/main/java/cn/reactnative/modules/update/UpdateModule.java b/android/src/main/java/cn/reactnative/modules/update/UpdateModule.java index 9ba8ea59..d84ecf21 100644 --- a/android/src/main/java/cn/reactnative/modules/update/UpdateModule.java +++ b/android/src/main/java/cn/reactnative/modules/update/UpdateModule.java @@ -1,5 +1,8 @@ package cn.reactnative.modules.update; +import static cn.reactnative.modules.update.StateSerialRunner.STATE_SERIAL; +import static cn.reactnative.modules.update.StateSerialRunner.UI_THREAD; + import android.util.Log; import androidx.annotation.Nullable; import com.facebook.react.bridge.Promise; @@ -249,18 +252,12 @@ private void restart(@Nullable final String hash, final Promise promise) { // the launch resolution run on the state serial thread like every // other state write; only the React reload itself is posted to the // UI thread. A failure at either hop rejects with RESTART_FAILED. - StateSerialRunner.run(promise, ErrorCodes.RESTART_FAILED, "restartApp", new StateSerialRunner.Operation() { - @Override - public void run() throws Throwable { - final String bundlePath = ReactReloadManager.prepareRestart(updateContext, hash); - UiThreadRunner.run(promise, ErrorCodes.RESTART_FAILED, "restartApp", new UiThreadRunner.Operation() { - @Override - public void run() throws Throwable { - ReactReloadManager.reload(updateContext, reactContext, bundlePath); - promise.resolve(true); - } - }); - } + StateSerialRunner.run(STATE_SERIAL, promise, ErrorCodes.RESTART_FAILED, "restartApp", () -> { + final String bundlePath = ReactReloadManager.prepareRestart(updateContext, hash); + StateSerialRunner.run(UI_THREAD, promise, ErrorCodes.RESTART_FAILED, "restartApp", () -> { + ReactReloadManager.reload(updateContext, reactContext, bundlePath); + promise.resolve(true); + }); }); } @@ -271,24 +268,18 @@ public void setNeedUpdate(final ReadableMap options, final Promise promise) { if (hash == null) { return; } - StateSerialRunner.run(promise, ErrorCodes.SWITCH_VERSION_FAILED, "switchVersionLater", new StateSerialRunner.Operation() { - @Override - public void run() { - updateContext.switchVersion(hash); - promise.resolve(true); - } + StateSerialRunner.run(STATE_SERIAL, promise, ErrorCodes.SWITCH_VERSION_FAILED, "switchVersionLater", () -> { + updateContext.switchVersion(hash); + promise.resolve(true); }); } @ReactMethod @Override public void markSuccess(final Promise promise) { - StateSerialRunner.run(promise, ErrorCodes.MARK_SUCCESS_FAILED, "markSuccess", new StateSerialRunner.Operation() { - @Override - public void run() { - updateContext.markSuccess(); - promise.resolve(true); - } + StateSerialRunner.run(STATE_SERIAL, promise, ErrorCodes.MARK_SUCCESS_FAILED, "markSuccess", () -> { + updateContext.markSuccess(); + promise.resolve(true); }); } @@ -297,23 +288,15 @@ public void run() { public void getBundleHash(final Promise promise) { // Threading lives in UpdateContext (its bundle-hash thread); resolve is // thread-safe. Never rejects — empty string means "unknown". - updateContext.getBundleHash(new UpdateContext.BundleHashListener() { - @Override - public void onBundleHash(String hash) { - promise.resolve(hash); - } - }); + updateContext.getBundleHash(promise::resolve); } @ReactMethod @Override public void resetToPackagedBundle(final Promise promise) { - StateSerialRunner.run(promise, ErrorCodes.RESET_FAILED, "resetToPackagedBundle", new StateSerialRunner.Operation() { - @Override - public void run() { - updateContext.resetToPackagedBundle(); - promise.resolve(true); - } + StateSerialRunner.run(STATE_SERIAL, promise, ErrorCodes.RESET_FAILED, "resetToPackagedBundle", () -> { + updateContext.resetToPackagedBundle(); + promise.resolve(true); }); } @@ -362,12 +345,9 @@ public void syncNativeConfig(final String config, final Promise promise) { 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.setNativeConfig(config); - promise.resolve(true); - } + StateSerialRunner.run(STATE_SERIAL, promise, ErrorCodes.FILE_OPERATION_FAILED, "syncNativeConfig", () -> { + updateContext.setNativeConfig(config); + promise.resolve(true); }); } @@ -377,12 +357,9 @@ public void setUuid(final String uuid, final Promise promise) { if (requireNonEmpty(uuid, "uuid", promise) == null) { return; } - StateSerialRunner.run(promise, ErrorCodes.FILE_OPERATION_FAILED, "setUuid", new StateSerialRunner.Operation() { - @Override - public void run() { - updateContext.setKv("uuid", uuid); - promise.resolve(true); - } + StateSerialRunner.run(STATE_SERIAL, promise, ErrorCodes.FILE_OPERATION_FAILED, "setUuid", () -> { + updateContext.setKv("uuid", uuid); + promise.resolve(true); }); } @@ -398,12 +375,9 @@ public void setLocalHashInfo(final String hash, final String info, final Promise } // A state write that did not reach disk (SharedPreferences commit // false) is FILE_OPERATION_FAILED, distinct from malformed input. - StateSerialRunner.run(promise, ErrorCodes.FILE_OPERATION_FAILED, "setLocalHashInfo", new StateSerialRunner.Operation() { - @Override - public void run() { - updateContext.setKv("hash_" + hash, info); - promise.resolve(true); - } + StateSerialRunner.run(STATE_SERIAL, promise, ErrorCodes.FILE_OPERATION_FAILED, "setLocalHashInfo", () -> { + updateContext.setKv("hash_" + hash, info); + promise.resolve(true); }); } diff --git a/android/src/main/java/cn/reactnative/modules/update/UpdatePackage.java b/android/src/main/java/cn/reactnative/modules/update/UpdatePackage.java index 97dd5e87..02288a49 100644 --- a/android/src/main/java/cn/reactnative/modules/update/UpdatePackage.java +++ b/android/src/main/java/cn/reactnative/modules/update/UpdatePackage.java @@ -23,24 +23,21 @@ public NativeModule getModule(String name, ReactApplicationContext reactContext) @Override public ReactModuleInfoProvider getReactModuleInfoProvider() { - return new ReactModuleInfoProvider() { - @Override - public Map getReactModuleInfos() { - final Map moduleInfos = new HashMap<>(); - boolean isTurboModule = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED; - moduleInfos.put( - UpdateModule.NAME, - new ReactModuleInfo( - UpdateModule.NAME, - UpdateModule.NAME, - false, // canOverrideExistingModule - false, // needsEagerInit - true, // hasConstants - false, // isCxxModule - isTurboModule // isTurboModule - )); - return moduleInfos; - } + return () -> { + final Map moduleInfos = new HashMap<>(); + boolean isTurboModule = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED; + moduleInfos.put( + UpdateModule.NAME, + new ReactModuleInfo( + UpdateModule.NAME, + UpdateModule.NAME, + false, // canOverrideExistingModule + false, // needsEagerInit + true, // hasConstants + false, // isCxxModule + isTurboModule // isTurboModule + )); + return moduleInfos; }; } } From 4c96721e4dec163973dd56f41fd32addb62ed46b Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:56:33 +0800 Subject: [PATCH 036/104] build(harmony): compile shared native sources in place, drop the staging copy build-harmony-har.js copied android/jni, cpp/patch_core and cpp/update_flow_core into harmony/pushy/src/main/cpp/android-generated for CMakeLists.txt's STAGED fallback, then deleted it again. CMake always resolved the repo-root sources first, and the HAR only ships the compiled librnpushy.so (no C++ sources), so the copy was never used: a release HAR built without it has the same file list (minus OAT.xml), identical non-ArkTS files and the same exported symbols. Hosts compiling PushyTurboModule.cpp from node_modules are unaffected (npm ships android/jni and cpp/). Also: the six source checks are one table, and the eval-based JSON5 dependency probe is gone (the module always has RNOH to install, the wrapper project never has dependencies). Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 - harmony/pushy/src/main/cpp/CMakeLists.txt | 12 +- scripts/build-harmony-har.js | 191 ++++------------------ 3 files changed, 32 insertions(+), 172 deletions(-) diff --git a/.gitignore b/.gitignore index 2f08e9ad..b48aa296 100644 --- a/.gitignore +++ b/.gitignore @@ -72,7 +72,6 @@ Example/harmony_use_pushy/harmony/entry/src/main/resources/rawfile/meta.json **/.hvigor Example/harmony_use_pushy/harmony/entry/src/main/cpp/generated Example/testHotUpdate/.e2e-artifacts -harmony/pushy/src/main/cpp/android-generated **/ios/.xcode.env.local .bun-cache .ohpm-cache diff --git a/harmony/pushy/src/main/cpp/CMakeLists.txt b/harmony/pushy/src/main/cpp/CMakeLists.txt index 1e242470..471056e5 100644 --- a/harmony/pushy/src/main/cpp/CMakeLists.txt +++ b/harmony/pushy/src/main/cpp/CMakeLists.txt @@ -7,20 +7,12 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_VERBOSE_MAKEFILE on) +# The shared native sources are compiled in place from the repository root +# (or the npm package root, which ships android/jni and cpp/). set(REPO_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/../../../../../) -set(STAGED_NATIVE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/android-generated) set(ANDROID_JNI_DIR ${REPO_ROOT}/android/jni) -if(NOT EXISTS ${ANDROID_JNI_DIR}/hpatch.c) - set(ANDROID_JNI_DIR ${STAGED_NATIVE_DIR}/jni) -endif() 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//librnpushy.so missing a diff --git a/scripts/build-harmony-har.js b/scripts/build-harmony-har.js index cf7bf07f..b2c1e12e 100644 --- a/scripts/build-harmony-har.js +++ b/scripts/build-harmony-har.js @@ -10,18 +10,6 @@ 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( - harmonyModuleDir, - 'src', - 'main', - 'cpp', - 'android-generated' -); -const harmonyNativeStageJniDir = path.join(harmonyNativeStageDir, 'jni'); -const harmonyNativeStagePatchCoreDir = path.join( - harmonyNativeStageDir, - 'patch_core' -); const wrapperProjectDir = path.join(projectRoot, 'harmony', 'har-wrapper'); const defaultOutputPath = path.join(projectRoot, 'harmony', 'pushy.har'); const wrapperProjectFiles = [ @@ -31,6 +19,19 @@ const wrapperProjectFiles = [ path.join('AppScope', 'app.json5'), 'build-profile.json5', ]; +// The shared native sources harmony/pushy/src/main/cpp/CMakeLists.txt +// compiles in place from the repository root (submodules included). +const requiredNativeSources = [ + [path.join(androidJniDir, 'hpatch.c'), 'Android native source'], + [path.join(androidJniDir, 'hpatch.h'), 'Android native source'], + [path.join(androidJniDir, 'HDiffPatch'), 'Android native source directory'], + [path.join(androidJniDir, 'lzma', 'C'), 'Android native source directory'], + [path.join(patchCoreDir, 'patch_core.cpp'), 'shared patch core source'], + [ + path.join(updateFlowCoreDir, 'update_flow_core.cpp'), + 'shared update flow core source', + ], +]; const args = parseArgs(process.argv.slice(2)); const buildMode = normalizeBuildMode( @@ -59,36 +60,9 @@ try { } function main() { - let buildError = null; - - // Setup runs inside the guard too: a failed version sync or staging copy - // must still clean the android-generated staging directory (its own or a - // stale one from an earlier build) instead of leaving it behind. - try { - syncOhPackageVersion(); - syncHarmonyNativeSources(); - buildHar(); - } catch (error) { - buildError = error; - } - - try { - cleanupHarmonyNativeSources(); - } catch (error) { - if (!buildError) { - buildError = error; - } else { - console.warn( - `Warning: failed to clean staged Harmony native sources: ${ - error instanceof Error ? error.message : String(error) - }` - ); - } - } - - if (buildError) { - throw buildError; - } + syncOhPackageVersion(); + ensureNativeSources(); + buildHar(); } function buildHar() { @@ -136,22 +110,14 @@ function buildHar() { } } + // The wrapper project declares no dependencies of its own; only the + // module (RNOH) needs installing. if (!skipInstall) { - if (hasDependencies(harmonyModuleDir)) { - runCommand(ohpmPath, ['install'], { - cwd: harmonyModuleDir, - env, - label: 'Install Harmony dependencies', - }); - } - - if (hasDependencies(wrapperProjectDir)) { - runCommand(ohpmPath, ['install'], { - cwd: wrapperProjectDir, - env, - label: 'Install wrapper project dependencies', - }); - } + runCommand(ohpmPath, ['install'], { + cwd: harmonyModuleDir, + env, + label: 'Install Harmony dependencies', + }); } const hvigorArgs = ['assembleHar']; @@ -222,89 +188,14 @@ function syncOhPackageVersion() { ); } -function syncHarmonyNativeSources() { - ensureFileExists( - path.join(androidJniDir, 'hpatch.c'), - `Missing Android native source: ${relativeToProject( - path.join(androidJniDir, 'hpatch.c') - )}` - ); - ensureFileExists( - path.join(androidJniDir, 'hpatch.h'), - `Missing Android native source: ${relativeToProject( - path.join(androidJniDir, 'hpatch.h') - )}` - ); - ensureFileExists( - path.join(androidJniDir, 'HDiffPatch'), - `Missing Android native source directory: ${relativeToProject( - path.join(androidJniDir, 'HDiffPatch') - )}` - ); - ensureFileExists( - path.join(androidJniDir, 'lzma', 'C'), - `Missing Android native source directory: ${relativeToProject( - path.join(androidJniDir, 'lzma', 'C') - )}` - ); - ensureFileExists( - path.join(patchCoreDir, 'patch_core.cpp'), - `Missing shared patch core source: ${relativeToProject( - 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'), { - recursive: true, - }); - - copyPath( - path.join(androidJniDir, 'hpatch.c'), - path.join(harmonyNativeStageJniDir, 'hpatch.c') - ); - copyPath( - path.join(androidJniDir, 'hpatch.h'), - path.join(harmonyNativeStageJniDir, 'hpatch.h') - ); - copyPath( - path.join(androidJniDir, 'HDiffPatch'), - path.join(harmonyNativeStageJniDir, 'HDiffPatch') - ); - copyPath( - path.join(androidJniDir, 'lzma', 'C'), - path.join(harmonyNativeStageJniDir, 'lzma', 'C') - ); - copyPath(patchCoreDir, harmonyNativeStagePatchCoreDir); - copyPath( - updateFlowCoreDir, - path.join(harmonyNativeStageDir, 'update_flow_core') - ); -} - -function cleanupHarmonyNativeSources() { - fs.rmSync(harmonyNativeStageDir, { recursive: true, force: true }); -} - -function copyPath(sourcePath, targetPath) { - const stats = fs.statSync(sourcePath); - if (stats.isDirectory()) { - fs.cpSync(sourcePath, targetPath, { - recursive: true, - force: true, - filter: (entry) => path.basename(entry) !== '.git', - }); - return; +function ensureNativeSources() { + for (const [sourcePath, label] of requiredNativeSources) { + ensureFileExists( + sourcePath, + `Missing ${label}: ${relativeToProject(sourcePath)} ` + + '(run `git submodule update --init --recursive`)' + ); } - - fs.mkdirSync(path.dirname(targetPath), { recursive: true }); - fs.copyFileSync(sourcePath, targetPath); } function ensureWrapperProject() { @@ -492,25 +383,3 @@ function relativeToProject(filePath) { function fail(message) { throw new Error(message); } - -function hasDependencies(dir) { - try { - const pkgPath = path.join(dir, 'oh-package.json5'); - if (!fs.existsSync(pkgPath)) { - return false; - } - const pkgContent = fs.readFileSync(pkgPath, 'utf8'); - const cleanJson = pkgContent.replace( - /\/\*[\s\S]*?\*\/|([^\\:]|^)\/\/.*$/gm, - '' - ); - const pkg = eval('(' + cleanJson + ')'); - const hasDeps = - pkg.dependencies && Object.keys(pkg.dependencies).length > 0; - const hasDevDeps = - pkg.devDependencies && Object.keys(pkg.devDependencies).length > 0; - return !!(hasDeps || hasDevDeps); - } catch (e) { - return true; - } -} From 22af75b90de95f1fab494636b2139ebfa1b7654c Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:57:06 +0800 Subject: [PATCH 037/104] refactor(android): share install presence and activation checks InstallRecord.isInstalled (bundle + matching record) replaces UpdateContext.hasCompletedVersion's and DownloadTask.hasCompletedDelta- Directory's copies; InstallRecord.verifyInstalled (bundle + digest) backs both verifySwitchTarget and ensureNotReinstallingRunningVersion. isComplete and verifyForActivation share one schema/hash check, and the bundle file name is a constant. A switch to a version without a valid record now reports the record's own reason instead of 'is incomplete'. Co-Authored-By: Claude Opus 5.5 --- .../modules/update/DownloadTask.java | 31 ++++++-------- .../modules/update/InstallRecord.java | 40 +++++++++++++++---- .../modules/update/UpdateContext.java | 21 ++++------ .../modules/update/InstallRecordTest.java | 35 ++++++++++++++++ 4 files changed, 86 insertions(+), 41 deletions(-) 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 3377d0b6..e7aeaf4f 100644 --- a/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +++ b/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java @@ -73,7 +73,7 @@ private File stagingDirectory() { // transfer first hashes the prefix already on disk); null when this task // did not stream the whole file itself and it must be hashed from disk. private String streamedArchiveSha256; - // Digest of index.bundlejs computed while a full package was extracted; + // Digest of the bundle computed while a full package was extracted; // null when the bundle was produced some other way (patch tasks). private String stagedBundleSha256; @@ -589,7 +589,7 @@ private void doFullPatch() throws IOException { // bytes as they are extracted, instead of re-reading the bundle right // after. Any entry resolving to the bundle path counts; the last write // wins on disk and in the digest alike. - File bundleTarget = new File(work, "index.bundlejs").getCanonicalFile(); + File bundleTarget = InstallRecord.bundleFile(work).getCanonicalFile(); stagedBundleSha256 = null; try (SafeZipFile zipFile = new SafeZipFile(params.targetFile)) { ensureArchiveWithinLimits(params.targetFile, zipFile, work); @@ -630,7 +630,7 @@ private void promoteStaging() throws IOException, JSONException { UpdateFileUtils.removeDirectory(work); return; } - File bundle = new File(work, "index.bundlejs"); + File bundle = InstallRecord.bundleFile(work); if (!bundle.isFile()) { throw new IOException("bundle missing after install: " + bundle); } @@ -709,7 +709,7 @@ private void doPatchFromApk() throws IOException, JSONException { work.getAbsolutePath(), originBundleFile.getAbsolutePath(), new File(work, BUNDLE_DELTA_ENTRY).getAbsolutePath(), - new File(work, "index.bundlejs").getAbsolutePath(), + InstallRecord.bundleFile(work).getAbsolutePath(), "", false, new String[0], @@ -742,9 +742,9 @@ private void doPatchFromPpk() throws IOException, JSONException { applyDeltaFromSource( params.originDirectory.getAbsolutePath(), work.getAbsolutePath(), - new File(params.originDirectory, "index.bundlejs").getAbsolutePath(), + InstallRecord.bundleFile(params.originDirectory).getAbsolutePath(), new File(work, BUNDLE_DELTA_ENTRY).getAbsolutePath(), - new File(work, "index.bundlejs").getAbsolutePath(), + InstallRecord.bundleFile(work).getAbsolutePath(), plan.mergeSourceSubdir, plan.enableMerge, contents.copyFroms.toArray(new String[0]), @@ -844,8 +844,7 @@ private boolean isPatchTask(int taskType) { private boolean hasCompletedDeltaDirectory() { return params.unzipDirectory != null && params.hash != null - && new File(params.unzipDirectory, "index.bundlejs").isFile() - && InstallRecord.isComplete(params.unzipDirectory, params.hash); + && InstallRecord.isInstalled(params.unzipDirectory, params.hash); } // True when this task targets the version the process is running from. @@ -864,18 +863,12 @@ private boolean targetsRunningVersion() { * failure rather than overwritten. */ private void ensureNotReinstallingRunningVersion() throws IOException { - File bundle = new File(params.unzipDirectory, "index.bundlejs"); - if (bundle.isFile() && InstallRecord.isComplete(params.unzipDirectory, params.hash)) { - try { - InstallRecord.verifyForActivation(params.unzipDirectory, params.hash, bundle); - return; - } catch (IOException e) { - throw new FileOperationException("Running version " + params.hash - + " cannot be reinstalled in place: " + e.getMessage()); - } + try { + InstallRecord.verifyInstalled(params.unzipDirectory, params.hash); + } catch (IOException e) { + throw new FileOperationException("Running version " + params.hash + + " cannot be reinstalled in place: " + e.getMessage()); } - throw new FileOperationException("Running version " + params.hash - + " has no verifiable install record and cannot be reinstalled in place"); } @Override diff --git a/android/src/main/java/cn/reactnative/modules/update/InstallRecord.java b/android/src/main/java/cn/reactnative/modules/update/InstallRecord.java index a1cdae09..f2da07fe 100644 --- a/android/src/main/java/cn/reactnative/modules/update/InstallRecord.java +++ b/android/src/main/java/cn/reactnative/modules/update/InstallRecord.java @@ -19,10 +19,16 @@ final class InstallRecord { static final int SCHEMA = 1; static final String FILE_NAME = ".pushy-complete"; static final String STAGING_SUFFIX = ".staging"; + /** The JS bundle inside a version directory. */ + static final String BUNDLE_FILE_NAME = "index.bundlejs"; private InstallRecord() { } + static File bundleFile(File versionDir) { + return new File(versionDir, BUNDLE_FILE_NAME); + } + static File stagingDirectoryFor(File versionDir) { return new File(versionDir.getPath() + STAGING_SUFFIX); } @@ -117,16 +123,35 @@ static JSONObject read(File versionDir) { */ static boolean isComplete(File versionDir, String versionHash) { JSONObject record = read(versionDir); - if (record == null) { - return false; - } - if (record.length() == 0) { - return true; - } + return record != null && (record.length() == 0 || names(record, versionHash)); + } + + /** A schema-1 record for this version (the legacy empty marker has no fields). */ + private static boolean names(JSONObject record, String versionHash) { return record.optInt("schema", 0) == SCHEMA && versionHash.equals(record.optString("versionHash", "")); } + /** + * Launch / dedup presence check: the bundle exists and the record names + * this version. No digest work. + */ + static boolean isInstalled(File versionDir, String versionHash) { + return bundleFile(versionDir).isFile() && isComplete(versionDir, versionHash); + } + + /** + * Activation check of an installed version: the bundle exists and its + * bytes match the record's digest. Throws with the reason otherwise. + */ + static void verifyInstalled(File versionDir, String versionHash) throws IOException { + File bundle = bundleFile(versionDir); + if (!bundle.isFile()) { + throw new IOException("Bundle version " + versionHash + " not found."); + } + verifyForActivation(versionDir, versionHash, bundle); + } + /** * Activation check: re-hashes the bundle against the record's digest, * which schema 1 makes mandatory (only the legacy empty marker has none). @@ -141,8 +166,7 @@ static void verifyForActivation(File versionDir, String versionHash, File bundle if (record.length() == 0) { return; } - if (record.optInt("schema", 0) != SCHEMA - || !versionHash.equals(record.optString("versionHash", ""))) { + if (!names(record, versionHash)) { throw new IOException("Bundle version " + versionHash + " completion record mismatch."); } Object digest = record.opt("bundleSha256"); 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 b309e1f9..946c233c 100644 --- a/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +++ b/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java @@ -514,8 +514,7 @@ private void verifySwitchTarget(String hash) { throw new IllegalArgumentException("Invalid hash: " + hash); } File versionDir = new File(rootDir, hash); - File bundleFile = new File(versionDir, "index.bundlejs"); - if (!bundleFile.isFile()) { + if (!InstallRecord.bundleFile(versionDir).isFile()) { throw new IllegalStateException("Bundle version " + hash + " not found."); } StateCoreResult currentState = getStateSnapshot(); @@ -527,15 +526,11 @@ private void verifySwitchTarget(String hash) { // that does carry a record is verified like any other. return; } - if (!InstallRecord.isComplete(versionDir, hash)) { - // An arbitrary markerless directory may be a crash-left partial - // install. - throw new IllegalStateException("Bundle version " + hash + " is incomplete."); - } - // The record's bundle digest must match the bytes on disk before - // the next launch is pointed at them. + // An arbitrary markerless directory may be a crash-left partial + // install, and the record's bundle digest must match the bytes on + // disk before the next launch is pointed at them. try { - InstallRecord.verifyForActivation(versionDir, hash, bundleFile); + InstallRecord.verifyInstalled(versionDir, hash); } catch (IOException e) { throw new IllegalStateException(e.getMessage(), e); } @@ -801,7 +796,7 @@ public String getBundleUrl(String defaultAssetsUrl) { // 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"); + File bundleFile = InstallRecord.bundleFile(new File(rootDir, currentVersion)); if (!bundleFile.exists()) { Log.e(TAG, "Bundle version " + currentVersion + " not found."); currentVersion = this.rollBack(); @@ -977,9 +972,7 @@ boolean hasCompletedVersion(String hash) { if (!UpdateFileUtils.isSafePathComponent(hash)) { return false; } - File versionDir = new File(rootDir, hash); - return new File(versionDir, "index.bundlejs").isFile() - && InstallRecord.isComplete(versionDir, hash); + return InstallRecord.isInstalled(new File(rootDir, hash), hash); } private String rollBack() { diff --git a/android/src/test/java/cn/reactnative/modules/update/InstallRecordTest.java b/android/src/test/java/cn/reactnative/modules/update/InstallRecordTest.java index 6b9d1707..93e00744 100644 --- a/android/src/test/java/cn/reactnative/modules/update/InstallRecordTest.java +++ b/android/src/test/java/cn/reactnative/modules/update/InstallRecordTest.java @@ -196,6 +196,41 @@ public void verifyForActivationRejectsMissingOrForeignRecords() throws Exception } } + @Test + public void isInstalledNeedsBundleAndMatchingRecord() throws Exception { + File dir = temp.newFolder(); + InstallRecord.write(dir, InstallRecord.build(HASH, "bb", "")); + assertFalse(InstallRecord.isInstalled(dir, HASH)); + + writeFile(InstallRecord.bundleFile(dir), "x".getBytes(StandardCharsets.UTF_8)); + assertTrue(InstallRecord.isInstalled(dir, HASH)); + assertFalse(InstallRecord.isInstalled(dir, "someotherhash")); + } + + @Test + public void verifyInstalledChecksBundlePresenceThenDigest() throws Exception { + File dir = temp.newFolder(); + byte[] bundleBytes = "var a = 1;".getBytes(StandardCharsets.UTF_8); + InstallRecord.write(dir, InstallRecord.build(HASH, sha256Hex(bundleBytes), "")); + try { + InstallRecord.verifyInstalled(dir, HASH); + fail("a version without its bundle must not activate"); + } catch (IOException expected) { + assertTrue(expected.getMessage().contains("not found")); + } + + writeFile(InstallRecord.bundleFile(dir), bundleBytes); + InstallRecord.verifyInstalled(dir, HASH); + + writeFile(InstallRecord.bundleFile(dir), "var a = 2;".getBytes(StandardCharsets.UTF_8)); + try { + InstallRecord.verifyInstalled(dir, HASH); + fail("tampered bundle must not activate"); + } catch (IOException expected) { + assertTrue(expected.getMessage().contains("digest mismatch")); + } + } + @Test public void stagingDirectoryIsASibling() { File version = new File("/data/_update/" + HASH); From b6e4c41bf70cf582aba478ce404890fb820be7cc Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:57:59 +0800 Subject: [PATCH 038/104] refactor(android): tidy UpdateContext state ops, downloads and cleanup runStateOp folds the four snapshot -> state core calls without launch flags; the three no-deadline public download wrappers go (only the module called them; it now passes deadline 0) and every download shares one params builder; the two queued-cleanup blocks become cleanUp(maxAgeDays). Also drops the unused VERSION_COMPLETE_FILE, commented-out code, a stacked stale javadoc and the one-line applyEditor. Co-Authored-By: Claude Opus 5.5 --- .../modules/update/DownloadTaskParams.java | 4 +- .../modules/update/UpdateContext.java | 160 +++++++----------- .../modules/update/UpdateModule.java | 6 +- 3 files changed, 68 insertions(+), 102 deletions(-) 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 bbe4a885..f1c3408e 100644 --- a/android/src/main/java/cn/reactnative/modules/update/DownloadTaskParams.java +++ b/android/src/main/java/cn/reactnative/modules/update/DownloadTaskParams.java @@ -13,13 +13,15 @@ class DownloadTaskParams { static final int TASK_TYPE_PATCH_FROM_PPK = 3; static final int TASK_TYPE_PLAIN_DOWNLOAD = 4; + static final int DEFAULT_MAX_AGE_DAYS = 3; + int type; String url; String hash; String originHash; // TASK_TYPE_CLEANUP only: entries younger than this survive; 0 = delete all - int maxAgeDays = 3; + int maxAgeDays = DEFAULT_MAX_AGE_DAYS; // Absolute System.nanoTime deadline for orchestrated cold-start downloads; // 0 keeps the normal public API's 10-minute per-call timeout. long deadlineNanos; 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 946c233c..9c16ed4b 100644 --- a/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +++ b/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java @@ -66,7 +66,6 @@ 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 = InstallRecord.FILE_NAME; // 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 @@ -135,7 +134,7 @@ private UpdateContext(Context context) { if (nextState.changed) { // Execute cleanUp before clearing SharedPreferences to avoid race condition - this.cleanUp(); + cleanUp(DownloadTaskParams.DEFAULT_MAX_AGE_DAYS); SharedPreferences.Editor editor = this.sp.edit(); editor.clear(); applyState(editor, nextState); @@ -335,16 +334,30 @@ public interface DownloadFileListener { void onDownloadFailed(Throwable error); } - public void downloadFullUpdate(String url, String hash, DownloadFileListener listener) { - downloadFullUpdate(url, hash, listener, 0); + /** + * Params shared by every download task. deadlineNanos is an absolute + * System.nanoTime deadline for orchestrated (native cold-start) tasks; 0 + * keeps the per-call timeout of a JS-initiated download. + */ + private static DownloadTaskParams downloadParams( + int type, String url, String hash, DownloadFileListener listener, long deadlineNanos + ) { + DownloadTaskParams params = new DownloadTaskParams(); + params.type = type; + params.url = url; + params.hash = hash; + params.listener = listener; + params.deadlineNanos = deadlineNanos; + return params; } - /** - * The deadline-taking overloads (native cold-start check) return the - * queued task's params so the orchestrator can cancel the task when its - * phase budget expires (CODE_AUDIT 2.12); null when the request was - * rejected up front (the listener has already been told). + /* + * The patch downloads return the queued task's params so the native + * orchestrator can cancel the task when its phase budget expires + * (CODE_AUDIT 2.12); null when the request was rejected up front (the + * listener has already been told). */ + @Nullable DownloadTaskParams downloadFullUpdate( String url, String hash, DownloadFileListener listener, long deadlineNanos @@ -352,37 +365,25 @@ DownloadTaskParams downloadFullUpdate( if (rejectUnsafeComponent(hash, listener)) { return null; } - DownloadTaskParams params = new DownloadTaskParams(); - params.type = DownloadTaskParams.TASK_TYPE_PATCH_FULL; - params.url = url; - params.hash = hash; - params.listener = listener; - params.deadlineNanos = deadlineNanos; + DownloadTaskParams params = downloadParams( + DownloadTaskParams.TASK_TYPE_PATCH_FULL, url, hash, listener, deadlineNanos); params.targetFile = new File(rootDir, hash + ".ppk"); params.unzipDirectory = new File(rootDir, hash); enqueue(params); return params; } + /** A plain file download into the root dir; hash only keys progress events. */ public void downloadFile(String url, String hash, String fileName, DownloadFileListener listener) { if (rejectUnsafeComponent(fileName, listener)) { return; } - DownloadTaskParams params = new DownloadTaskParams(); - params.type = DownloadTaskParams.TASK_TYPE_PLAIN_DOWNLOAD; - params.url = url; - params.hash = hash; - params.listener = listener; - + DownloadTaskParams params = downloadParams( + DownloadTaskParams.TASK_TYPE_PLAIN_DOWNLOAD, url, hash, listener, 0); params.targetFile = new File(rootDir, fileName); -// params.unzipDirectory = new File(rootDir, hash); enqueue(params); } - public void downloadPatchFromApk(String url, String hash, DownloadFileListener listener) { - downloadPatchFromApk(url, hash, listener, 0); - } - @Nullable DownloadTaskParams downloadPatchFromApk( String url, String hash, DownloadFileListener listener, long deadlineNanos @@ -390,22 +391,14 @@ DownloadTaskParams downloadPatchFromApk( if (rejectUnsafeComponent(hash, listener)) { return null; } - DownloadTaskParams params = new DownloadTaskParams(); - params.type = DownloadTaskParams.TASK_TYPE_PATCH_FROM_APK; - params.url = url; - params.hash = hash; - params.listener = listener; - params.deadlineNanos = deadlineNanos; + DownloadTaskParams params = downloadParams( + DownloadTaskParams.TASK_TYPE_PATCH_FROM_APK, url, hash, listener, deadlineNanos); params.targetFile = new File(rootDir, hash + Texts.reveal("7416e4dae09b69512137")); params.unzipDirectory = new File(rootDir, hash); enqueue(params); return params; } - public void downloadPatchFromPpk(String url, String hash, String originHash, DownloadFileListener listener) { - downloadPatchFromPpk(url, hash, originHash, listener, 0); - } - @Nullable DownloadTaskParams downloadPatchFromPpk( String url, @@ -417,13 +410,9 @@ DownloadTaskParams downloadPatchFromPpk( if (rejectUnsafeComponent(hash, listener) || rejectUnsafeComponent(originHash, listener)) { return null; } - DownloadTaskParams params = new DownloadTaskParams(); - params.type = DownloadTaskParams.TASK_TYPE_PATCH_FROM_PPK; - params.url = url; - params.hash = hash; + DownloadTaskParams params = downloadParams( + DownloadTaskParams.TASK_TYPE_PATCH_FROM_PPK, url, hash, listener, deadlineNanos); params.originHash = originHash; - params.listener = listener; - params.deadlineNanos = deadlineNanos; params.targetFile = new File(rootDir, originHash + "-" + hash + Texts.reveal("7407e4dae09b69512137")); params.unzipDirectory = new File(rootDir, hash); params.originDirectory = new File(rootDir, originHash); @@ -480,16 +469,6 @@ private boolean persistEditor(SharedPreferences.Editor editor, String reason) { return true; } - /** - * Asynchronous variant for the getConstants path (JS thread): the - * in-memory state changes immediately, the disk write happens on the - * SharedPreferences writer. Losing it to a crash only re-reports the - * one-shot markers on the next launch. - */ - private static void applyEditor(SharedPreferences.Editor editor) { - editor.apply(); - } - /** * Persistence for operations whose result is promised to a caller * (switchVersion / markSuccess / setLocalHashInfo / the cold-start round): @@ -542,13 +521,12 @@ private void verifySwitchTarget(String hash) { * holds commitLock and has run verifySwitchTarget. */ private StateCoreResult computeSwitchState(String hash) { - return runStateCore( - STATE_OP_SWITCH_VERSION, - getStateSnapshot(), - hash, - false, - false - ); + return runStateOp(STATE_OP_SWITCH_VERSION, hash); + } + + /** One state-core operation on the current snapshot; caller holds commitLock. */ + private StateCoreResult runStateOp(int operation, @Nullable String hash) { + return runStateCore(operation, getStateSnapshot(), hash, false, false); } public void switchVersion(String hash) { @@ -614,21 +592,18 @@ LaunchMarkers consumeLaunchMarkers() { editor.remove(KEY_FIRST_LOAD_MARKED); } if (rolledBackVersion != null) { - StateCoreResult nextState = runStateCore( - STATE_OP_CLEAR_ROLLBACK_MARK, - getStateSnapshot(), - null, - false, - false - ); - applyState(editor, nextState); + applyState(editor, runStateOp(STATE_OP_CLEAR_ROLLBACK_MARK, null)); } - applyEditor(editor); + // Asynchronous on purpose: the in-memory state changes + // immediately, the disk write happens on the + // SharedPreferences writer. Losing it to a crash only + // re-reports the one-shot markers on the next launch. + editor.apply(); } } if (markers.rolledBackVersion != null) { // The rolled-back version's directory is now eligible for cleanup. - this.cleanUp(); + cleanUp(DownloadTaskParams.DEFAULT_MAX_AGE_DAYS); } return markers; } @@ -642,14 +617,7 @@ public void markSuccess() { return; } synchronized (commitLock) { - StateCoreResult currentState = getStateSnapshot(); - StateCoreResult nextState = runStateCore( - STATE_OP_MARK_SUCCESS, - currentState, - null, - false, - false - ); + StateCoreResult nextState = runStateOp(STATE_OP_MARK_SUCCESS, null); SharedPreferences.Editor editor = sp.edit(); applyState(editor, nextState); if (nextState.staleVersionToDelete != null) { @@ -658,7 +626,7 @@ public void markSuccess() { persistEditorOrThrow(editor, "mark success"); } - this.cleanUp(); + cleanUp(DownloadTaskParams.DEFAULT_MAX_AGE_DAYS); } /** @@ -700,14 +668,7 @@ private void resetToPackagedBundleLocked() { persistEditorOrThrow(editor, "reset to packaged bundle"); ignoreRollback = false; Log.i(TAG, "Reset to packaged bundle"); - - DownloadTaskParams params = new DownloadTaskParams(); - params.type = DownloadTaskParams.TASK_TYPE_CLEANUP; - params.maxAgeDays = 0; - // Keep names are resolved by the queued task at execution time, so a - // later activation is protected too. - params.unzipDirectory = rootDir; - enqueue(params); + cleanUp(0); } public static void setCustomInstanceManager(ReactInstanceManager instanceManager) { @@ -821,7 +782,6 @@ public String getBundleUrl(String defaultAssetsUrl) { } } - /** Sampled/compared by the native check orchestrator; see resetGeneration. */ /** Shared by JS and native hosts. Config replacement invalidates old native decisions. */ private static final java.util.concurrent.atomic.AtomicLong nativeConfigGeneration = new java.util.concurrent.atomic.AtomicLong(0); @@ -863,7 +823,11 @@ void setNativeConfig(String config) { SyncCoordinator.onConfigured(this); } - // Native-decision generation: bumped by reset AND configuration replacement. + /** + * Native-decision generation, sampled/compared by the native check + * orchestrator: bumped by reset AND configuration replacement (see + * resetGeneration). + */ static long getResetGeneration() { return resetGeneration.get(); } @@ -977,15 +941,9 @@ boolean hasCompletedVersion(String hash) { private String rollBack() { synchronized (commitLock) { - StateCoreResult currentState = getStateSnapshot(); - StateCoreResult nextState = runStateCore( - STATE_OP_ROLLBACK, - currentState, - null, - false, - false - ); - Log.e(TAG, "Rolling back version " + currentState.currentVersion + String rollingBack = getCurrentVersion(); + StateCoreResult nextState = runStateOp(STATE_OP_ROLLBACK, null); + Log.e(TAG, "Rolling back version " + rollingBack + " to " + nextState.currentVersion); SharedPreferences.Editor editor = sp.edit(); applyState(editor, nextState); @@ -1029,9 +987,15 @@ void runCleanupWithLatestState(CleanupAction action) { } } - private void cleanUp() { + /** + * Queues removal of every version entry older than maxAgeDays (0: all) + * except the keep names, which the queued task resolves at execution + * time so a later activation is protected too. + */ + private void cleanUp(int maxAgeDays) { DownloadTaskParams params = new DownloadTaskParams(); params.type = DownloadTaskParams.TASK_TYPE_CLEANUP; + params.maxAgeDays = maxAgeDays; params.unzipDirectory = rootDir; enqueue(params); } diff --git a/android/src/main/java/cn/reactnative/modules/update/UpdateModule.java b/android/src/main/java/cn/reactnative/modules/update/UpdateModule.java index d84ecf21..e4dcb5a3 100644 --- a/android/src/main/java/cn/reactnative/modules/update/UpdateModule.java +++ b/android/src/main/java/cn/reactnative/modules/update/UpdateModule.java @@ -150,7 +150,7 @@ public void downloadFullUpdate(final ReadableMap options, final Promise promise) if (hash == null) { return; } - updateContext.downloadFullUpdate(url, hash, downloadListener(promise)); + updateContext.downloadFullUpdate(url, hash, downloadListener(promise), 0); } @ReactMethod @@ -210,7 +210,7 @@ public void downloadPatchFromPackage(final ReadableMap options, final Promise pr if (hash == null) { return; } - updateContext.downloadPatchFromApk(url, hash, downloadListener(promise)); + updateContext.downloadPatchFromApk(url, hash, downloadListener(promise), 0); } @ReactMethod @@ -228,7 +228,7 @@ public void downloadPatchFromPpk(final ReadableMap options, final Promise promis if (originHash == null) { return; } - updateContext.downloadPatchFromPpk(url, hash, originHash, downloadListener(promise)); + updateContext.downloadPatchFromPpk(url, hash, originHash, downloadListener(promise), 0); } @ReactMethod From e66688aec578d4721ef660ec9b3a82e92925479f Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:58:31 +0800 Subject: [PATCH 039/104] build(harmony): move the CI SDK discovery into check-harmony-types.js The harmony-build type-check step carried ~20 lines of shell that walked the container's install roots for openharmony/ets/api. The script now does that walk itself (first, and only when HARMONY_TYPECHECK_REQUIRED=1, in the same root order and depth) and logs the SDK it uses. The oh_modules gate is gone: the type check only covers the .ts sources and resolves @rnoh and the NAPI module through harmony/types stubs. The header comment no longer claims the check skips on CI. Co-Authored-By: Claude Opus 5.5 --- scripts/check-harmony-types.js | 95 ++++++++++++++++++++++++++++------ 1 file changed, 80 insertions(+), 15 deletions(-) diff --git a/scripts/check-harmony-types.js b/scripts/check-harmony-types.js index 04264061..90234e67 100644 --- a/scripts/check-harmony-types.js +++ b/scripts/check-harmony-types.js @@ -1,13 +1,18 @@ #!/usr/bin/env node /** - * Type-checks the HarmonyOS TS sources (harmony/pushy/src/main/ets) with tsc. + * Type-checks the HarmonyOS TS sources (harmony/pushy/src/main/ets/*.ts) with + * a strict tsc pass. * * The root tsconfig excludes harmony/ because these files need the HarmonyOS - * SDK type declarations (@ohos.* / @kit.*) and the RNOH types from oh_modules. - * This script locates a locally installed DevEco SDK, generates a tsconfig - * with the right path mappings, and runs tsc against it. When no SDK is - * available (e.g. CI runners), the check is skipped with a notice instead of - * failing, so it never blocks environments that cannot have the SDK. + * SDK type declarations (@ohos.* / @kit.*); the RNOH surface and the NAPI + * module are stubbed in harmony/types, so oh_modules is not needed. This + * script locates an OpenHarmony SDK, generates a tsconfig with the right + * path mappings and runs tsc against it. + * + * Without an SDK (developer machines without DevEco) the check is skipped + * with a notice. With HARMONY_TYPECHECK_REQUIRED=1 (the harmony-build CI + * container) a missing SDK fails instead, and the search also walks the + * container's install roots. */ const fs = require('fs'); const path = require('path'); @@ -15,8 +20,58 @@ const { spawnSync } = require('child_process'); const repoRoot = path.resolve(__dirname, '..'); const harmonyDir = path.join(repoRoot, 'harmony'); -const ohModules = path.join(harmonyDir, 'pushy', 'oh_modules'); const generatedConfigPath = path.join(harmonyDir, '.tsconfig.harmony.json'); +const required = process.env.HARMONY_TYPECHECK_REQUIRED === '1'; + +// Install roots of the CI image, whose SDK is not laid out like a DevEco +// install. Only searched (first) when the check is required: walking them +// is slow. +const containerRoots = [ + process.env.DEVECO_SDK_HOME, + '/opt', + '/usr/local', + '/usr/lib', + '/root', + '/home', + '/github/home', +]; +const CONTAINER_SEARCH_DEPTH = 8; + +// Every `/openharmony/ets/api` directory under root, at most `depth` +// levels down (symlinks are not followed). The @ohos.*/@kit.* declarations +// live under the OpenHarmony SDK; the sibling hms/ets tree only holds HMS +// kits and must not win. +function findOpenHarmonyApiDirs(root, depth) { + const found = []; + const walk = (dir, level) => { + if (level > depth) { + return; + } + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch (e) { + return; + } + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + const child = path.join(dir, entry.name); + if ( + entry.name === 'api' && + path.basename(dir) === 'ets' && + path.basename(path.dirname(dir)) === 'openharmony' + ) { + found.push(child); + continue; + } + walk(child, level + 1); + } + }; + walk(root, 1); + return found.sort(); +} function findSdkEtsDir() { // CI containers lay the SDK out differently from DevEco; let them point @@ -33,6 +88,19 @@ function findSdkEtsDir() { 'ignoring it and probing the default SDK locations.' ); } + if (required) { + // The CI image: the first OpenHarmony declaration root under its + // install roots, in root order. + for (const root of containerRoots) { + if (!root || !fs.existsSync(root)) { + continue; + } + const [apiDir] = findOpenHarmonyApiDirs(root, CONTAINER_SEARCH_DEPTH); + if (apiDir) { + return path.dirname(apiDir); + } + } + } const bases = []; if (process.env.DEVECO_SDK_HOME) { bases.push(process.env.DEVECO_SDK_HOME); @@ -63,10 +131,8 @@ function findSdkEtsDir() { return null; } -// On developer machines without DevEco the check skips; in the HarmonyOS CI -// container (HARMONY_TYPECHECK_REQUIRED=1) a missing SDK or oh_modules is a -// misconfiguration and must fail instead of silently passing. -const required = process.env.HARMONY_TYPECHECK_REQUIRED === '1'; +// A missing SDK skips the check, except where it is required (CI), where it +// is a misconfiguration and must fail instead of silently passing. const skipOrFail = (reason) => { if (required) { console.error(`check-harmony-types: ${reason} (required in CI).`); @@ -79,12 +145,11 @@ const skipOrFail = (reason) => { const sdkEts = findSdkEtsDir(); if (!sdkEts) { skipOrFail( - 'no DevEco/OpenHarmony SDK found (set DEVECO_SDK_HOME to enable)' + 'no DevEco/OpenHarmony SDK found (set DEVECO_SDK_HOME or ' + + 'HARMONY_SDK_ETS_DIR to enable)' ); } -if (!fs.existsSync(ohModules)) { - skipOrFail('harmony/pushy/oh_modules not installed'); -} +console.log(`check-harmony-types: using SDK declarations in ${sdkEts}`); const config = { compilerOptions: { From 373a7d06cb148ba6ac9256039d17b090d2c47343 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:58:34 +0800 Subject: [PATCH 040/104] ci(harmony): dedupe trigger paths and drop checks bun lint already runs push and pull_request share one paths list through a YAML anchor. The JS <-> native spec parity step already runs in `bun lint` (lint.yml, every PR), and the constant-parity check moves into `bun lint` as well, so both steps and their src/NativePushy.ts / check-native-spec-parity.js path triggers go. The type-check step is a plain invocation now that the script finds the container SDK itself. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/harmony-build.yml | 57 +++++------------------------ 1 file changed, 10 insertions(+), 47 deletions(-) diff --git a/.github/workflows/harmony-build.yml b/.github/workflows/harmony-build.yml index bc166c13..1968a8ad 100644 --- a/.github/workflows/harmony-build.yml +++ b/.github/workflows/harmony-build.yml @@ -7,11 +7,14 @@ name: harmony-build # first seen when publishing. assembleHar runs the ArkTS compiler over the # .ets/.ts sources, which is the real type check; check-harmony-types.js # adds a strict tsc pass over the .ts sources and must not skip here. +# +# The JS <-> native method parity and constant-mirror checks run in +# `bun lint` (lint.yml) on every PR, so they are not repeated here. on: pull_request: branches: - master - paths: + paths: &harmony-paths - '.github/workflows/harmony-build.yml' - '.gitmodules' - 'package.json' @@ -21,23 +24,10 @@ on: - 'android/jni/**' - 'scripts/build-harmony-har.js' - 'scripts/check-harmony-types.js' - - 'scripts/check-native-spec-parity.js' - - 'src/NativePushy.ts' push: branches: - master - paths: - - '.github/workflows/harmony-build.yml' - - '.gitmodules' - - 'package.json' - - 'bun.lock' - - 'harmony/**' - - 'cpp/**' - - 'android/jni/**' - - 'scripts/build-harmony-har.js' - - 'scripts/check-harmony-types.js' - - 'scripts/check-native-spec-parity.js' - - 'src/NativePushy.ts' + paths: *harmony-paths concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -67,12 +57,6 @@ jobs: node-version: 24 - run: bun install --frozen-lockfile - - name: Verify JS <-> native method parity - run: node scripts/check-native-spec-parity.js - - - name: Verify constant mirrors match the C++ headers - run: node harmony/pushy/src/test/check-constant-parity.js - - name: Build Harmony HAR (release) run: npm run build:harmony-har -- --build-mode release @@ -88,30 +72,9 @@ jobs: test "${#libs[@]}" -gt 0 node scripts/check-binary-strings.js "${libs[@]}" + # Required: the script also searches the container's install roots for + # the OpenHarmony SDK declarations and fails if it finds none. - name: Type-check Harmony TS sources (required) - shell: bash - run: | - # The container's SDK is not laid out like a DevEco install, so - # locate the ets declaration root (the directory holding api/ and - # kits/) directly and hand it to the script. - ets_dir="${HARMONY_SDK_ETS_DIR:-}" - if [ -z "$ets_dir" ]; then - for root in "${DEVECO_SDK_HOME:-}" /opt /usr/local /usr/lib /root /home /github/home; do - [ -n "$root" ] && [ -d "$root" ] || continue - # The @ohos.*/@kit.* declarations live under the OpenHarmony SDK - # (sdk//openharmony/ets); the sibling hms/ets tree only - # holds HMS kits and must not win. - api_dir="$(find "$root" -maxdepth 8 -type d -path '*/openharmony/ets/api' 2>/dev/null \ - | sort | head -n 1)" - if [ -n "$api_dir" ]; then - ets_dir="$(dirname "$api_dir")" - break - fi - done - fi - echo "HARMONY_SDK_ETS_DIR=${ets_dir:-}" - if [ -z "$ets_dir" ]; then - echo "SDK candidates under /opt:"; ls -la /opt 2>/dev/null || true - fi - HARMONY_SDK_ETS_DIR="$ets_dir" HARMONY_TYPECHECK_REQUIRED=1 \ - node scripts/check-harmony-types.js + run: node scripts/check-harmony-types.js + env: + HARMONY_TYPECHECK_REQUIRED: '1' From 7eef0bb9eb4fca48d706511eade071ef17b8320e Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:58:59 +0800 Subject: [PATCH 041/104] refactor(android): share the patch-task prelude in DownloadTask doPatchFromApk/doPatchFromPpk share stagePatchArchive (download, stage, native plan) and PatchArchiveContents hands out arrays instead of seven inline toArray calls. discardPartial replaces three delete-or-throw blocks, the plain-download failure path reuses deleteConsumedArchive, and rejectProtocolDowngrade uses HttpUtils.isHttpsUrl. Co-Authored-By: Claude Opus 5.5 --- .../modules/update/DownloadTask.java | 157 +++++++++--------- 1 file changed, 79 insertions(+), 78 deletions(-) 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 e7aeaf4f..18862d61 100644 --- a/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +++ b/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java @@ -4,6 +4,7 @@ import android.os.Handler; import android.os.Looper; import android.util.Log; +import androidx.annotation.Nullable; import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.WritableMap; import java.io.ByteArrayOutputStream; @@ -77,14 +78,39 @@ private File stagingDirectory() { // null when the bundle was produced some other way (patch tasks). private String stagedBundleSha256; + /** What a staged patch archive declared, as the native planner consumes it. */ private static final class PatchArchiveContents { - final ArrayList entryNames = new ArrayList(); - final ArrayList copyFroms = new ArrayList(); - final ArrayList copyTos = new ArrayList(); - final ArrayList deletes = new ArrayList(); + final String[] entryNames; + final String[] copyFroms; + final String[] copyTos; + final String[] deletes; + // Maps a copy source path ("from") to the CRC32 of the file content, + // when provided by the manifest ("copiesCrc"). Lets the resource + // copier locate the file by content if the path is not present on + // device (APK baseline -> AAB install path shortening). + final HashMap copyCrcs; // __diff.json 的 hbcTransform 元数据(按 patch 条目名索引); // 为空对象/缺失时返回 ""(native 走现状路径) - JSONObject hbcTransform; + @Nullable + final JSONObject hbcTransform; + // Set by stagePatchArchive once the native planner accepted the archive. + ArchivePatchPlanResult plan; + + PatchArchiveContents( + ArrayList entryNames, + ArrayList copyFroms, + ArrayList copyTos, + ArrayList deletes, + HashMap copyCrcs, + @Nullable JSONObject hbcTransform + ) { + this.entryNames = entryNames.toArray(new String[0]); + this.copyFroms = copyFroms.toArray(new String[0]); + this.copyTos = copyTos.toArray(new String[0]); + this.deletes = deletes.toArray(new String[0]); + this.copyCrcs = copyCrcs; + this.hbcTransform = hbcTransform; + } String hbcTransformMetaFor(String patchEntryName) { if (hbcTransform == null) { @@ -93,11 +119,6 @@ String hbcTransformMetaFor(String patchEntryName) { JSONObject meta = hbcTransform.optJSONObject(patchEntryName); return meta != null ? meta.toString() : ""; } - // Maps a copy source path ("from") to the CRC32 of the file content, - // when provided by the manifest ("copiesCrc"). Lets the resource - // copier locate the file by content if the path is not present on - // device (APK baseline -> AAB install path shortening). - final HashMap copyCrcs = new HashMap(); } private final Context context; @@ -222,19 +243,13 @@ private void downloadFile() throws IOException { } } if (resumeOffset == 0) { - if (writePath.exists() && !writePath.delete()) { - throw new IOException("Failed to replace existing file: " + writePath); - } - deleteResumeSidecar(writePath); + discardPartial(writePath); } if (!transferArchive(url, writePath, sidecar, resumeMeta, resumeOffset)) { // The server rejected the range for a partial that no longer // matches (416): drop it and retry once from zero. - if (writePath.exists() && !writePath.delete()) { - throw new IOException("Failed to replace existing file: " + writePath); - } - deleteResumeSidecar(writePath); + discardPartial(writePath); if (!transferArchive(url, writePath, sidecar, null, 0)) { throw new IOException("Server rejected the download range for " + url); } @@ -242,6 +257,14 @@ private void downloadFile() throws IOException { downloadPhaseCompleted = true; } + /** Drops a partial download and its sidecar; throws when the bytes stay. */ + private static void discardPartial(File archive) throws IOException { + if (archive.exists() && !archive.delete()) { + throw new IOException("Failed to replace existing file: " + archive); + } + deleteResumeSidecar(archive); + } + /** * One HTTP transfer, appending from resumeOffset when the server honours * the range. Returns false only for the retryable stale-partial case @@ -350,9 +373,7 @@ private boolean transferArchive( // Destroy the old bytes before the sidecar can vouch for // them with the new validators (a crash between the two // writes must never leave a sidecar describing stale bytes). - if (writePath.exists() && !writePath.delete()) { - throw new IOException("Failed to replace existing file: " + writePath); - } + discardPartial(writePath); } if (encodedBody) { // No resume across an encoded transfer. @@ -487,7 +508,7 @@ private void appendManifestEntries( } copyFroms.add(from); copyTos.add(to); - if (copiesCrc != null && copyCrcs != null && copiesCrc.has(to)) { + if (copiesCrc != null && copiesCrc.has(to)) { // Same content => same crc, so grouping multiple "to" under // one "from" stays consistent. copyCrcs.put(from, copiesCrc.getLong(to)); @@ -543,14 +564,19 @@ private PatchArchiveContents extractPatchArchive(File archiveFile, File unzipDir UpdateFileUtils.removeDirectory(unzipDirectory); UpdateFileUtils.ensureDirectory(unzipDirectory); - PatchArchiveContents contents = new PatchArchiveContents(); + ArrayList entryNames = new ArrayList(); + ArrayList copyFroms = new ArrayList(); + ArrayList copyTos = new ArrayList(); + ArrayList deletes = new ArrayList(); + HashMap copyCrcs = new HashMap(); + JSONObject hbcTransform = null; try (SafeZipFile zipFile = new SafeZipFile(archiveFile)) { ensureArchiveWithinLimits(archiveFile, zipFile, unzipDirectory); Enumeration entries = zipFile.entries(); while (entries.hasMoreElements()) { ZipEntry entry = entries.nextElement(); String name = entry.getName(); - contents.entryNames.add(name); + entryNames.add(name); if (name.equals("__diff.json")) { if (entry.getSize() > ArchiveLimits.MAX_MANIFEST_BYTES) { @@ -560,20 +586,29 @@ private PatchArchiveContents extractPatchArchive(File archiveFile, File unzipDir zipFile.getInputStream(entry), ArchiveLimits.MAX_MANIFEST_BYTES); String json = new String(bytes, StandardCharsets.UTF_8); JSONObject manifest = (JSONObject) new JSONTokener(json).nextValue(); - appendManifestEntries( - manifest, - contents.copyFroms, - contents.copyTos, - contents.deletes, - contents.copyCrcs - ); - contents.hbcTransform = manifest.optJSONObject("hbcTransform"); + appendManifestEntries(manifest, copyFroms, copyTos, deletes, copyCrcs); + hbcTransform = manifest.optJSONObject("hbcTransform"); continue; } zipFile.unzipToPath(entry, unzipDirectory); } } + return new PatchArchiveContents( + entryNames, copyFroms, copyTos, deletes, copyCrcs, hbcTransform); + } + + /** + * Shared first half of the two patch tasks: download the archive, unpack + * it into the staging directory and have the native planner validate it. + */ + private PatchArchiveContents stagePatchArchive(int patchType) + throws IOException, JSONException { + downloadFile(); + artifactSha256 = archiveSha256(); + PatchArchiveContents contents = extractPatchArchive(params.targetFile, stagingDirectory()); + contents.plan = buildArchivePlan( + patchType, contents.entryNames, contents.copyFroms, contents.copyTos, contents.deletes); return contents; } @@ -680,26 +715,11 @@ private void deleteConsumedArchive() { } private void doPatchFromApk() throws IOException, JSONException { - downloadFile(); + PatchArchiveContents contents = + stagePatchArchive(DownloadTaskParams.TASK_TYPE_PATCH_FROM_APK); File work = stagingDirectory(); - artifactSha256 = archiveSha256(); - PatchArchiveContents contents = extractPatchArchive(params.targetFile, work); - - buildArchivePlan( - DownloadTaskParams.TASK_TYPE_PATCH_FROM_APK, - contents.entryNames.toArray(new String[0]), - contents.copyFroms.toArray(new String[0]), - contents.copyTos.toArray(new String[0]), - contents.deletes.toArray(new String[0]) - ); - HashMap> copyList = buildCopyList( - work, - buildCopyGroups( - contents.copyFroms.toArray(new String[0]), - contents.copyTos.toArray(new String[0]) - ) - ); + work, buildCopyGroups(contents.copyFroms, contents.copyTos)); File originBundleFile = new File(work, ".origin.bundle"); copyBundledAssetToFile("index.android.bundle", originBundleFile); @@ -726,30 +746,20 @@ private void doPatchFromApk() throws IOException, JSONException { } private void doPatchFromPpk() throws IOException, JSONException { - downloadFile(); + PatchArchiveContents contents = + stagePatchArchive(DownloadTaskParams.TASK_TYPE_PATCH_FROM_PPK); File work = stagingDirectory(); - artifactSha256 = archiveSha256(); - PatchArchiveContents contents = extractPatchArchive(params.targetFile, work); - - ArchivePatchPlanResult plan = buildArchivePlan( - DownloadTaskParams.TASK_TYPE_PATCH_FROM_PPK, - contents.entryNames.toArray(new String[0]), - contents.copyFroms.toArray(new String[0]), - contents.copyTos.toArray(new String[0]), - contents.deletes.toArray(new String[0]) - ); - applyDeltaFromSource( params.originDirectory.getAbsolutePath(), work.getAbsolutePath(), InstallRecord.bundleFile(params.originDirectory).getAbsolutePath(), new File(work, BUNDLE_DELTA_ENTRY).getAbsolutePath(), InstallRecord.bundleFile(work).getAbsolutePath(), - plan.mergeSourceSubdir, - plan.enableMerge, - contents.copyFroms.toArray(new String[0]), - contents.copyTos.toArray(new String[0]), - contents.deletes.toArray(new String[0]), + contents.plan.mergeSourceSubdir, + contents.plan.enableMerge, + contents.copyFroms, + contents.copyTos, + contents.deletes, contents.hbcTransformMetaFor(BUNDLE_DELTA_ENTRY) ); deleteConsumedArchive(); @@ -804,14 +814,7 @@ private void cleanUpAfterFailure(int taskType) { } break; case DownloadTaskParams.TASK_TYPE_PLAIN_DOWNLOAD: - if ( - params.targetFile.exists() - && !params.targetFile.delete() - && UpdateContext.DEBUG - ) { - Log.w(Texts.LOG_TAG, "Failed to clean partial download " + params.targetFile); - } - deleteResumeSidecar(params.targetFile); + deleteConsumedArchive(); break; default: break; @@ -826,9 +829,7 @@ private void cleanUpAfterFailure(int taskType) { */ static void rejectProtocolDowngrade(String requestedUrl, Response response) throws IOException { - if (requestedUrl != null - && requestedUrl.regionMatches(true, 0, "https:", 0, 6) - && !response.request().url().isHttps()) { + if (HttpUtils.isHttpsUrl(requestedUrl) && !response.request().url().isHttps()) { throw new IOException( "https download redirected to plaintext http: " + response.request().url()); From 3c0871fb12992f1ca20da23f2d4df01de4119eea Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:59:07 +0800 Subject: [PATCH 042/104] docs: condense the native-check followups and bundleHash migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NATIVE_CHECK_FOLLOWUPS.md: keep only the still-open items (Harmony native-check e2e, automated crash-on-launch e2e, Harmony real-device rescue, iOS main-thread hold budget) plus a short record of what was settled; the full four-round history stays in git. - BUNDLEHASH_MIGRATION.md: Phase 0-2 narration compressed to what shipped and the decisions that overturned the original design; Phase 3 (§5.1 SyncBinaryVersion migration, §5.2 digests) kept in full and marked not started (state_core.cpp still compares buildTime). - NATIVE_CHECKUPDATE_DESIGN.md: the status header now reflects the 10.51.0/10.52.0/10.52.1 releases and the current orchestrator names. - Dangling references to the never-committed BUNDLEHASH_DESIGN.md / REMOTE_RESET_DESIGN.md now say so. Co-Authored-By: Claude Opus 5.5 --- docs/BUNDLEHASH_MIGRATION.md | 346 +++++------------- docs/NATIVE_CHECKUPDATE_DESIGN.md | 21 +- docs/NATIVE_CHECK_FOLLOWUPS.md | 562 ++---------------------------- 3 files changed, 141 insertions(+), 788 deletions(-) diff --git a/docs/BUNDLEHASH_MIGRATION.md b/docs/BUNDLEHASH_MIGRATION.md index 4250f377..ab7ad871 100644 --- a/docs/BUNDLEHASH_MIGRATION.md +++ b/docs/BUNDLEHASH_MIGRATION.md @@ -1,14 +1,17 @@ # bundleHash 迁移执行方案 -> 状态:**Phase 0/1/2 已实现、已提交;两台服务端已完成生产迁移与部署(2026-07-29)** -> - DB:pushy 北京 RDS 与 cresc Cloud SQL 均已加 `packages.bundleHash` 列(幂等 runner 在 `scripts/migrate-add-package-bundle-hash.ts`;cresc 经一次性 Cloud Run Job 进 VPC 执行) -> - 服务:pushy `p`/`s1` @ `2026.7.29-a2ecac26`,cresc api/worker/dailyjob @ `2026.7.29-3aabff91`,线上带 `bundleHash` 冒烟均通过 -> - **`bundleHashJudge.enabled` 仍为关**——待 CLI/客户端发版、覆盖率起来后再开 -> - **客户端与 CLI 已发版(2026-07-30)**:`react-native-update@10.49.0`、`react-native-update-cli@2.20.3`。全链路(客户端上报 → CLI 注册 → 服务端判定基建)就位,仅待覆盖率与开开关 -> - **`bundleHashJudge.enabled` 已于 2026-08-03 双端开启**(pushy + cresc,redis `config_dynamic`,回退=写回 false)。同日:CLI 2.21.2 补全 copiesCrc(§4.2.1 第 2 条)并随两服务端部署;客户端三端 CRC 校验 + fallback 遥测(§4.2.1 第 3/4 条)已实现、待随下个客户端版本发布 -> - Phase 2 遗留见 §4.5,Phase 3 待观察期后 -> 依据:`BUNDLEHASH_DESIGN.md`(设计定稿)。本文只讲**怎么落地**,不重复论证为什么。 -> 下游:`NATIVE_CHECKUPDATE_DESIGN.md` 的协议下沉必须等本迁移完成,否则协议下沉后再改协议要返工。 +> 状态:**Phase 0/1/2 已全部上线,Phase 3 未开始(§5)** +> - 客户端 `react-native-update@10.49.0`(bundleHash 上报,da48a685)、 +> `10.50.0`(三端 copiesCrc 校验 + `downloadFallback` 遥测,29da9b7a); +> CLI `react-native-update-cli@2.20.3`(注册 bundleHash)、`2.21.2`(全量 copiesCrc) +> - 服务端:pushy / cresc 两端 `packages.bundleHash` 列已迁移并部署(2026-07-29); +> `bundleHashJudge.enabled` 已于 2026-08-03 双端开启(redis `config_dynamic`,回退=写回 false) +> - 仍开放:§5.1 `SyncBinaryVersion` 迁移、§5.2 `digests` 下发与校验 +> +> 依据:原设计稿 `BUNDLEHASH_DESIGN.md`(未入库)。本文只讲**怎么落地**,并记录落地时 +> 推翻原设计的几处(§2.2 缓存键、§4.1 服务端缓存键、§4.2.1 `touchesPackageAssets`)。 +> Phase 0–2 的逐步实施记录(服务端改动明细、cresc 两态矩阵移植、e2e 踩坑)已收敛,见 +> `git show 4ca8972e:docs/BUNDLEHASH_MIGRATION.md`。 --- @@ -22,239 +25,108 @@ | | 客户端哈希的对象 | pdiff 的源读取(哈希必须与之逐字对齐) | |---|---|---| -| iOS | `[RCTPushy binaryBundleURL]` 返回的文件(`mainBundle` 的 `main.jsbundle`) | 就是 pdiff 的 `fromBundle`(`RCTPushy.mm:727`) | -| Android | **硬编码 `index.android.bundle`**,`context.getAssets().open()` 读 | `DownloadTask.copyBundledAssetToFile("index.android.bundle", …)`(`DownloadTask.java:322`)——pdiff 源本来就是硬编码的,**不走** `getBundleAssetName()` 反射;自定义 bundle 名的用户 pdiff 今天就打不了、CLI 也传不上(`getApkInfo` 的正则同样硬编码),三处一致地退化到 buildTime 启发式 | -| Harmony | rawfile `bundle.harmony.js` | pdiff 的 `getRawFileContent('bundle.harmony.js')`(`DownloadTask.ts:559`) | -| CLI | 解包后的同名条目 | `getIpaInfo` / `getApkInfo` / `getAppInfo` **已经把 bundleFile 拿在手里了** | - -最后一行是这次调研最大的好消息,见 §3。 +| iOS | `[RCTPushy binaryBundleURL]` 返回的文件(`mainBundle` 的 `main.jsbundle`) | 就是 pdiff 的 `fromBundle` | +| Android | **硬编码 `index.android.bundle`**,`context.getAssets().open()` 读 | `DownloadTask.copyBundledAssetToFile("index.android.bundle", …)`——pdiff 源本来就是硬编码的,**不走** `getBundleAssetName()` 反射;自定义 bundle 名的用户 pdiff 打不了、CLI 也传不上(`getApkInfo` 的正则同样硬编码),三处一致地退化到 buildTime 启发式 | +| Harmony | rawfile `bundle.harmony.js` | pdiff 的 `getRawFileContent('bundle.harmony.js')` | +| CLI | 解包后的同名条目 | `getIpaInfo` / `getApkInfo` / `getAppInfo` 已经把 bundleFile 拿在手里 | --- -## 1. 实施前定稿(原设计 §11 的四个开放问题) +## 1. 实施前定稿(原设计的四个开放问题) | # | 问题 | 定稿 | |---|---|---| -| 1 | 字段名 | `bundleHash` / `bundleStatus` / `digests`,维持原议 | -| 2 | `digests` 用 sha256 还是复用 OSS md5 | **sha256**。OSS 的 etag 在分片上传下不是内容 md5,不能直接复用;且 §0 的 sha256 工具本来就要进 C++,复用同一套心智 | -| 3 | 控制台聚合口径 | 按 **(app, packageVersion)**。因为对应的开发者动作就是"把这个包传上来",按 app 聚合无法定位到具体动作 | -| 4 | `assetsHash` | **不做**。metro 把受管资源的内容 md5 嵌进 bundle,资源变则 bundle 字节必变,需求趋近于零。等真实反馈 | +| 1 | 字段名 | `bundleHash` / `bundleStatus` / `digests` | +| 2 | `digests` 用 sha256 还是复用 OSS md5 | **sha256**(OSS etag 在分片上传下不是内容 md5) | +| 3 | 控制台聚合口径 | 按 **(app, packageVersion)** | +| 4 | `assetsHash` | **不做**(metro 把受管资源内容 md5 嵌进 bundle,资源变则 bundle 字节必变) | --- -## 2. Phase 0 — 客户端(react-native-update) - -### 2.1 sha256 工具进 `cpp/patch_core`——但 Android 用 `MessageDigest` - -`cpp/patch_core/digest.{h,cpp}`,流式接口 + `Sha256File`,同时为 §8 的下载校验铺路: - -```cpp -namespace pushy::digest { - class Sha256 { void Update(const uint8_t*, size_t); std::string HexDigest(); }; - std::string Sha256File(const std::string& path); // iOS 用 -} -``` - -各端消费方式(审查后修订,原稿要求三端全走 C++): - -| 平台 | 实现 | 理由 | -|---|---|---| -| iOS | `pushy::digest`(podspec 已从源码编译 patch_core,加一个文件即可) | 免费共享 | -| Harmony | NAPI 包装 `pushy::digest`(CMakeLists 同样从源码编译) | 免费共享 | -| **Android** | **`java.security.MessageDigest("SHA-256")`** | `librnupdate.so` 是预编译产物,为这一个函数重编 4 ABI 不值;SHA-256 是标准算法,C++ 侧的 NIST 测试向量同时锚定两个实现 | - -**收益:Phase 0 完全不需要重编 `.so`**,原方案的 R4(ABI/对齐回归)风险在本阶段消失。digest.cpp 照常加进 `.so` 的构建脚本,下次因其他原因重编时自动带上,Phase 3 的 C++ 侧校验直接可用。 - -### 2.2 缓存键:设计文档这里是错的 - -原设计写"缓存键 `(packageVersion, bundle 文件 size, mtime)`"。**Android 和 Harmony 的 bundle 不是磁盘文件**——一个在 APK 的 assets 里,一个在 rawfile 里,都没有独立的 size/mtime 可读。 - -改为按平台取各自的"安装身份": - -| 平台 | 缓存键 | 读取方式 | -|---|---|---| -| iOS | `(packageVersion, 文件 size, mtime)` | `main.jsbundle` 是真实文件,原设计可用 | -| Android | `(packageVersion, PackageInfo.lastUpdateTime)` | 每次安装/覆盖安装都会变,含同版本重装 | -| Harmony | `(packageVersion, BundleInfo.updateTime)` | 已核对 `BundleInfo.d.ts:390`,字段存在 | - -键存 SharedPreferences / NSUserDefaults / preferences,与现有状态同一存储。 - -### 2.3 读取与计算 - -- **Android**:`context.getAssets().open("index.android.bundle")` 流式喂给 `MessageDigest`——**与 pdiff 源读取(`copyBundledAssetToFile`)完全同一 API、同一硬编码名**(§0)。资源不存在(debug / 自定义名 / 无嵌入 bundle)→ 返回空串。 -- **iOS**:`Sha256File([[RCTPushy binaryBundleURL] path])`;文件不存在 → 空串。 -- **Harmony**:`resourceManager.getRawFileContent('bundle.harmony.js')` 拿到 buffer 后经 NAPI 喂给 sha256(与 pdiff 读法完全一致)。 - -线程:Android `StateSerialRunner`,iOS `_fileQueue`,Harmony taskpool。几 MB 约 10-50ms。 - -### 2.4 暴露给 JS - -**异步 getter,不进 `getConstants`**(AN-6/IO-4 的教训): - -```ts -// NativePushy.ts -getBundleHash(): Promise; // 计算失败 / debug 构建返回空串 -``` - -### 2.5 JS 接入:预取 + 同步读,checkUpdate 零 await - -初稿在 checkUpdate 里做 200ms 超时 race,评审否决——没必要的复杂度。定稿: - -```ts -// core.ts 模块加载时 fire-and-forget 预取,settle 后存模块变量 -if (typeof PushyModule.getBundleHash === 'function') { - Promise.resolve(PushyModule.getBundleHash()).then((h) => { bundleHash = String(h || ''); }); -} -export const getBundleHash = (): string => bundleHash; - -// client.ts checkUpdate 内:同步读,没有就省略 -const bundleHash = __DEV__ ? '' : getBundleHash(); -``` - -拿不到就省略字段,服务端自动退回 buildTime 启发式(判定矩阵第 4 行),下一次检查自然带上。代价是每个进程的**第一次** checkUpdate 可能缺字段(原生缓存命中时预取通常几 ms 内 settle,实际很少缺)——一次退化完全可接受。 - -**顺带消掉一个曾踩过的坑**:初稿的 race `await` 插进了"5s 去重检查 → `lastRespJson` 赋值"的同步窗口,导致并发 checkUpdate 双发请求(JS2-1 测试抓住)。同步读方案下 checkUpdate 完全没有新增 await,这类问题在结构上不可能出现。 - -### 2.6 `bundleStatus` 消费(字段先行) - -服务端上线前不会有这个字段,先把消费侧写好: -- `unknownBundle` → `warn()`(新 i18n key `warn_unknown_bundle`)+ `report({ type: 'bundleMismatch' })` 进 logger -- `afterCheckUpdate` 的 `UpdateCheckState` 透传 `bundleStatus`(result 自带字段,无需额外代码) - -**修订原设计 §7 触达第 1 条**:\"`__DEV__` 下 console.warn 联调期拦截\"实际不可达——dev 构建不上报 bundleHash(metro bundle 无嵌入产物),服务端按判定矩阵第 4 行走 buildTime 启发式,永远不会返回 `unknownBundle`。warn 实际只出现在 release 日志里;开发者触达的主渠道是控制台聚合(触达第 3 条)与 CLI 前置拦截(第 4 条),不受影响。 +## 2. Phase 0 — 客户端(已发布,10.49.0) + +- **sha256**:`cpp/patch_core/digest.{h,cpp}`(流式 + `Sha256File`,NIST 向量测试锚定); + iOS 直接用、Harmony 经 NAPI;**Android 用 `MessageDigest("SHA-256")`**,免得为一个 + 函数重编 `.so`。 +- **缓存键(推翻原设计)**:Android/Harmony 的 bundle 不是磁盘文件,没有 size/mtime, + 改为各端"安装身份":iOS `(packageVersion, size, mtime)`、Android + `(packageVersion, PackageInfo.lastUpdateTime)`、Harmony `(packageVersion, BundleInfo.updateTime)`。 +- **读取**:与 pdiff 源读取同一 API、同一硬编码名(§0);找不到 → 空串。线程:Android + `StateSerialRunner`、iOS `_fileQueue`、Harmony taskpool。 +- **JS 接入**:异步 `getBundleHash()`(不进 `getConstants`),模块加载时预取、 + checkUpdate 同步读,没有就省略字段(服务端退回 buildTime 启发式)。checkUpdate 不新增 + await——初稿的 200ms race 曾在"5s 去重 → `lastRespJson` 赋值"窗口里造成并发双发。 +- **`bundleStatus`**:`unknownBundle` → `warn()` + `report({ type: 'bundleMismatch' })`。 + dev 构建不上报 bundleHash,所以不会在 `__DEV__` 下出现。 ### 2.7 验收 -- ✅ C++ sha256 NIST 向量测试(`bun test:patch-core`,26/26) -- ✅ `bun test`(122,含 5 条新 bundleHash/bundleStatus 用例)/ `bun lint`(biome + tsc + harmony tsc) -- ✅ `javac` 对 RN 0.85.2 AAR(main + oldarch)/ `clang -fsyntax-only` 对 e2etest Pods -- ✅ **iOS + Android 模拟器 e2e**(`Example/e2etest/e2e/bundle-hash.test.ts`,已入 detox 套件、随 e2e_ios / e2e_android CI 跑):release 构建、真机路径全链路,原生算出的值与宿主侧对包内 bundle 算的 sha256 **逐字节一致**—— - - iOS:JS 预取 → `_fileQueue` 懒计算 → C++ digest → NSUserDefaults;宿主 `simctl get_app_container` + `plutil` 直读容器 plist 比对 `sha256(main.jsbundle)`;含缓存命中路径 - - Android:`AssetManager.open` 解压字节 == APK zip 条目字节(bundle 压缩存储,这是 iOS 覆盖不到的独立风险点)、`MessageDigest` == 标准 sha256;宿主 `adb root` 读 `shared_prefs/update.xml` 比对 `unzip -p` 提取值;gradle 构建顺带完成 newarch codegen 对 `getBundleHash` 的真实编译验证 -- ⬜ Harmony 真机比对未做:NAPI 直连已过 NIST 向量的 C++ digest,读法与 pdiff 完全同源(`getRawFileContent`),风险最低 -- **e2e 踩坑记录**:`simctl spawn defaults read` 看不到 app 容器 defaults(必须 `get_app_container` + `plutil -extract`);detox jest 环境把全局 `expect` 换成元素断言版(普通值断言用显式比较);staged 目录为空壳时 gradle autolinking 缓存会记住"无 android 平台",重 stage 后须清 `android/build/generated/autolinking`;Android release 下 detox `launchApp` 的会话握手在本机 arm64 模拟器挂起——本用例不需要 UI 交互,Android 分支用 `adb shell monkey` 拉起绕开 +- C++ sha256 NIST 向量(`bun test:patch-core`)、JS 单测、lint 全过。 +- **iOS + Android 模拟器 e2e**:`Example/e2etest/e2e/bundle-hash.test.ts`(随 e2e_ios / + e2e_android CI 跑),release 构建下原生算出的值与宿主侧对包内 bundle 算的 sha256 + **逐字节一致**(iOS 直读容器 plist;Android 比对 `unzip -p` 提取值,顺带覆盖 bundle + 压缩存储这一独立风险点)。 +- ⬜ Harmony 真机比对未做:NAPI 直连已过 NIST 向量的 C++ digest,读法与 pdiff 同源,风险最低。 --- -## 3. Phase 1 — CLI(react-native-update-cli) - -**成本远低于设计文档的预期。** `getIpaInfo` / `getApkInfo` / `getAppInfo`(`src/utils/index.ts:145/187/230`)为了校验"包里有没有 bundle",**已经把 bundle 的 Buffer 读出来了**,而且缺失时已经报错。 - -所以每个函数加一行: - -```ts -return { versionName, buildTime, bundleHash: sha256(bundleFile), ...appCredential }; -``` - -再在 `uploadNativePackage`(`src/package.ts:170`)的 `package/create` 请求体里带上 `bundleHash`。 +## 3. Phase 1 — CLI(已发布,2.20.3) -另外两件事: -- AAB **不需要单独处理**:`uploadAab` 是先 `AabParser.extractApk` 转 APK 再委托 `uploadApk`(`package.ts:258`),自动经过 `getApkInfo` -- 上传时若同 `versionName` 已存在**不同** `bundleHash` 的包记录 → 提示"重打包且 JS 有变"(设计 §7 第 4 条)。这需要服务端先提供查询,**可以延后到 Phase 2 之后**,不阻塞 - -老服务端会忽略未知字段——已验证:`pushy-server` 的 `dto.ts:280` 注释明确"未知字段由 Elysia 默认 normalize 剥离,保留前向兼容"。Phase 1 可以独立发布。 +`getIpaInfo` / `getApkInfo` / `getAppInfo` 已读出 bundle Buffer,各加一行 +`bundleHash: sha256(bundleFile)`,随 `package/create` 上报;AAB 先转 APK 自动覆盖。 +老服务端剥离未知字段,可独立发布。 --- -## 4. Phase 2 — 服务端(pushy-server) - -### 4.1 最容易漏、漏了最严重的一条 - -**`buildCheckUpdateCacheKey` 必须加 `bundleHash`。**(`code/src/utils/checkUpdateCapabilities.ts:51`) - -现在的键是 `[appId, checkUpdateRevision, packageVersion, fromHash, buildTime, cacheBucket, toHash]`。一旦响应内容开始随 `bundleHash` 变化(给不给 pdiff、`bundleStatus` 是什么),不进键就是**跨设备串响应**:一个装了未注册二进制的设备可能拿到另一个设备的"给 pdiff"缓存,直接踩中 §0 那个最坏结果。 - -基数不会明显上升——`bundleHash` 与 `buildTime` 基本一一对应,Phase 3 删掉 buildTime 后反而回落。 - -### 4.2 数据与判定 - -- `packages` 表加 `bundleHash VARCHAR(64)`(`prisma/schema.prisma:60`),`getCachedCheckUpdatePackage` 的 select 加该字段 -- 判定矩阵(设计 §6)落在 `handleCheckUpdate.ts` 的 `checkAndGenerateTaskAndCdnUrl` 之前:决定"这次允不允许下发 pdiff" -- `digests` 下发 + `__diff.json` 的 copies 附摘要 - -### 4.2.1 不实现 `touchesPackageAssets`(推翻原设计 §6) - -原设计要求 pdiff 生成时预计算 `touchesPackageAssets`(manifest 是否含"从包内拷非 bundle 文件"),用于门控"bundleHash 命中但 buildTime 不符"这一行。**该标记不应实现**: - -查 CLI 的 pdiff 生成(`react-native-update-cli/src/diff.ts:695-710`): - -```js -// If same file. -if (originEntries[entry.fileName] === entry.crc32) { - copies[entry.fileName] = ''; // 同路径拷贝 - return; -} -const movedFrom = originMap[entry.crc32]; -if (movedFrom) { - copies[entry.fileName] = movedFrom; // 按内容定位拷贝 - return; -} -await addFileFromZipEntry(...); // 只有新文件才打进 patch 包 -``` - -**ppk 里每一个与二进制包 CRC32 相同的非 bundle 文件都会进 `copies`**。任何带图片/字体的真实应用,该标记恒为 true → "重打包但 JS 未变"这一行永远拿不到 pdiff。 - -而这一行正是设计 §1 点名的最常见场景、整个方案的头号收益来源。**这个机制会把它自己要解决的主场景关掉。** - -它防的场景设计文档自己也判定为 ≈ 不存在:metro 把受管资源的内容 hash 嵌进 bundle,资源变则 bundle 字节必变、bundleHash 不会命中,根本落不到这一行。 - -**代之以运行时保证**(设计 §8 的 apply 校验,已建好一半): - -1. 该行直接下发 pdiff -2. `copiesCrc`(`diff.ts:645`,目前只记 "moved" 条目,为 APK→AAB 路径变化而设)扩展到**全部** copies 条目 -3. 客户端拷贝时校验,不符 → fallback full。代价退化为一次 pdiff 下载,**永不装出坏包** -4. 给该 fallback 加遥测 - -**已全部落地(2026-08-03)**: -- 第 2 条:CLI 2.21.2(同路径分支补记 CRC;两服务端已 bump 部署,worker 新生成的 pdiff 均带全量 copiesCrc) -- 第 3 条(三端):Android `BundledResourceCopier` 路径命中也比 CRC,不符 → 内容定位 → 仍无 → 抛错;iOS/共享 C++ `CopyOperation.expected_crc` 在 `ApplyPatchFromFileSource` 拷贝前校验(`digest::Crc32File`,NIST/check-value 测试锚定);Harmony pdiff 拷贝声明 CRC 的条目改走字节路径经 NAPI `crc32` 校验。三端不符均使整次 pdiff 失败,JS 策略链自动落 full -- 第 4 条:新增本地事件 `downloadFallback`(增量策略失败但后续策略救回),失败码为 PATCH_FAILED 时映射服务端 `patch_fail`(网络噪音不上报)。为此补齐了 Android(`PatchFailedException`:下载完成后的失败 reject 为 PATCH_FAILED,此前一律 DOWNLOAD_FAILED)与 Harmony(patch 阶段 `error.code='PATCH_FAILED'`)的稳定码 -- 注意:存量已生成的 pdiff 只有 moved 条目的 CRC,客户端只校验有声明的条目——旧 pdiff 行为不变,随版本滚动自然收敛 +## 4. Phase 2 — 服务端(已上线,判定开关已开) -若遥测显示这一行的 pdiff 失败率显著,再引入**精确**判据(如"copies 含非 metro 资源"),而不是用恒为 true 的粗标记一刀切。 +### 4.1 缓存键必须含 bundleHash -### 4.3 开关与灰度 +响应内容随 `bundleHash` 变化(给不给 pdiff、`bundleStatus`),不进 +`buildCheckUpdateCacheKey` 就是**跨设备串响应**,直接踩中 §0 的最坏结果。已进键 +(`cu:v3→v4`,`cu:manifest:v1→v2`)。 -判定逻辑挂 `dynamicConfig` 开关,默认关。开启后异常可一键退回 buildTime 启发式——**这是 Phase 2 唯一的回退手段,必须先有开关再有逻辑**。 +### 4.2 判定 -### 4.4 实现状态与两处实施决策 +`packages.bundleHash` + 判定矩阵(`handleCheckUpdate`,`dynamicConfig.bundleHashJudge.enabled` +门控)。`unknownBundle` 不再 block:扣 pdiff、照发 full。cresc 无 buildTime 启发式, +矩阵塌缩为 `matched` / `unknownBundle` 两态,对 cresc 是第一道真正的 pdiff 门控。 -已落地(`pushy-server` 工作区,未提交,`bun test` 841 全绿): +### 4.2.1 不实现 `touchesPackageAssets`(推翻原设计) -- `packages.bundleHash VARCHAR(64) NULL`(schema + `migrations/add_package_bundle_hash.sql`);`package/create` 接收并存储(DTO 带 `^[0-9a-f]{64}$` 校验) -- `checkUpdateBodyJson` 接收 `bundleHash`;`buildCheckUpdateCacheKey` 进键并 bump `cu:v3→v4`;`cu:manifest` 也 bump `v1→v2`(select 加了字段,避免旧缓存形状被误判为"老 CLI 上传") -- 判定矩阵在 `handleCheckUpdate`,`dynamicConfig.bundleHashJudge.enabled` 门控(默认关);`unknownBundle` 的 pdiff 扣发复用 artifact 状态机的 `'empty'` 语义(天然做到"不下发、不入队") -- 测试:矩阵五行 + 开关关死回退 + 缓存键分裂,7 条新用例 +原设计要求 pdiff 生成时预计算 `touchesPackageAssets`(manifest 是否含"从包内拷非 bundle +文件"),用于门控"bundleHash 命中但 buildTime 不符"这一行。**不实现**:CLI 的 pdiff 生成把 +每一个与二进制包 CRC32 相同的非 bundle 文件都放进 `copies`,任何带图片/字体的应用该标记 +恒为 true,"重打包但 JS 未变"——方案头号收益场景——永远拿不到 pdiff。它防的场景(资源变 +而 bundle 不变)因 metro 把资源 hash 嵌进 bundle 而≈不存在。 -实施决策两处(文档此前未明确): +**代之以运行时保证(已全部落地)**: -1. **`unknownBundle` 不再 block**。今天 buildTime 不符是 6 小时 block(`ApiErrorWithOp`);矩阵命中后同样场景变为"扣 pdiff、照发 full",终端用户无感知。这是行为放宽,也是设计本意(§7"所有分支自动降级到正确路径")。 -2. **`ignoreBuildTime` 的 app 在开关开启后变严**:此前它无条件发 pdiff,现在内容不符会扣发。正确——内容不符的 pdiff 下载后 hpatch 源校验必败,本来就是白费流量。 +1. 该行直接下发 pdiff; +2. `copiesCrc` 扩展到**全部** copies 条目(CLI 2.21.2); +3. 客户端拷贝前校验,不符 → 整次 pdiff 失败、JS 策略链落 full,**永不装出坏包**: + Android `BundledResourceCopier`、iOS/共享 C++ `CopyOperation.expected_crc` + (`ApplyPatchFromFileSource`,`digest::Crc32File`)、Harmony 经 NAPI `crc32`; +4. 遥测:本地事件 `downloadFallback`(增量失败但后续策略救回),失败码为 PATCH_FAILED 时 + 映射服务端 `patch_fail`(为此 Android `PatchFailedException`、Harmony patch 阶段补齐了 + 稳定码)。 -### 4.4.1 cresc-server 同步(两态矩阵版) +存量 pdiff 只有 moved 条目的 CRC,客户端只校验有声明的条目,随版本滚动自然收敛。若遥测 +显示这一行的 pdiff 失败率显著,再引入**精确**判据,而不是恒为 true 的粗标记。 -cresc-server 是独立仓库(结构同构、历史独立,`handleCheckUpdate` 已漂移约 700 行),Phase 2 已手工移植(`bun test` 760 全绿)。关键差异: +### 4.3 开关与回退 -- **cresc 没有 buildTime 启发式**(连 `ignoreBuildTime` 都已删除,见其 `drop_apps_ignore_build_time.sql`),今天 pdiff 是**无条件信任**的——正是设计 §1 "过松"失效模式的现役实例:不匹配的二进制会白下载 pdiff、hpatch 源校验失败再回落 full。bundleHash 判定对 cresc 是**第一道真正的门控** -- 矩阵塌缩为**两态**:`matched` / `unknownBundle`(rebuiltSameJs 的定义依赖 buildTime 分歧,无 buildTime 则与 matched 不可区分,也无需区分——客户端对二者都静默) -- 回退语义不同:开关关闭 = 回到"无条件信任 pdiff"(pushy 端是回到 buildTime 启发式) -- 其余同构:`packages.bundleHash` 列 + migration、DTO、`cu:v3→v4`、`cu:manifest:v1→v2`、`'empty'` 语义扣发、`dynamicConfig.bundleHashJudge.enabled` 默认关 +判定逻辑挂 `dynamicConfig` 开关——Phase 2 唯一的回退手段。关闭 = pushy 回到 buildTime +启发式、cresc 回到无条件信任 pdiff。 -注:cresc-server 干净树上有两个**存量** TS 错误(`src/routers/user.ts:916/957`,email 变更通知的 audit extra 字段),与本次改动无关,未处理。 +### 4.5 Phase 2 遗留 -### 4.5 Phase 2 遗留(不阻塞开启开关) +- ~~控制台 unknownBundle 聚合~~ 已实现:请求侧合并进原生包面板(报了 bundleHash 记 + `packageVersion_bundleHash`,否则 `packageVersion_buildTime`)。 +- ~~CLI 上传冲突提示~~ 已实现(服务端 409 分支用 bundleHash 区分"内容一致/JS 有变")。 +- `digests` 下发 + `__diff.json` copies 附摘要——与客户端 §5.2 同波次做。 -- ~~控制台 unknownBundle 聚合面板~~ **已实现(2026-08-01,四端部署)**:最终形态不是独立的 bundleStatus"内容判定"维度(首版上线后即废弃——它依赖开关开启+双侧 hash+恰好有更新流动,观测窗口太窄、零数据),而是**请求侧合并进原生包面板**:客户端报了 bundleHash 就记 `packageVersion_bundleHash`(admin 图例把 64 位 hex 截 8 位展示),否则回退 `packageVersion_buildTime`,同一 attribute。全量流量可见、与开关/判定结果解耦;未注册人群在面板上直接带 packageVersion 上下文。代价是丢失 matched/rebuiltSameJs 区分——那只有教育用途,unknownBundle 占比(§4.6 的推进指标)不受影响。曾考虑挂在 `classifyCheckUpdateResult` 的 hitType 上,否决:hitType 是"下发了什么产物"维度,混入二进制身份维度不干净 -- `digests` 下发 + `__diff.json` copies 附摘要——与客户端 Phase 3 的消费侧同波次做 -- ~~CLI 上传冲突提示~~ **已实现(改判为无需动 CLI)**:`(appId, name)` 唯一约束下重复上传本来就 409,在两台服务器的冲突分支里用 bundleHash 增强语义——内容一致 →"无需重复上传"、内容不同 →"重打包且 JS 有变,请提升原生版本号"、任一侧无 hash → 通用文案不变。查询仅发生在冲突路径。cresc 3 条路由级用例覆盖;pushy 无路由级测试脚手架,靠同构代码 + cresc 用例 + tsc 兜底 - -另:cresc-server 顺手修了一个与本迁移无关的存量 bug——`/email/confirm` 的 `select` 漏了 `extra`/`name`(`user.ts:888`),导致改邮箱时 OAuth 凭据吊销实际不生效(安全相关)+ 通知邮件用户名恒为兜底文案。 - -### 4.6 推进门槛 - -Phase 2 的收益与 Phase 0/1 的覆盖率成正比,但**不需要等覆盖率**:判定矩阵第 4/5 行本来就规定了"没上报 bundleHash → 原样走 buildTime 启发式"。所以 Phase 2 可以在覆盖率很低时就上线,收益随客户端升级自然增长。 - -真正要看的指标:`unknownBundle` 占比。它高说明教育有效(发现了真问题),持续不降说明 §7 的触达链路没起作用。 +推进指标:`unknownBundle` 占比。持续不降说明触达链路没起作用。 --- @@ -271,6 +143,9 @@ Phase 2 的收益与 Phase 0/1 的覆盖率成正比,但**不需要等覆盖 > pdiff,但客户端 buildTime 一变仍清状态回内置包再白下载一遍;hash 优先后 > 同 JS 重打包做到真正无感。§5.2 的 digests/copies 摘要照旧待做;§5.3 的 > 降级序列与"删构建注入"**取消**。 +> +> 现状(2026-10):未开始——`cpp/patch_core/state_core.cpp` 的 `SyncBinaryVersion` +> 仍按 `(packageVersion, buildTime)` 比较。 ### 5.1 `SyncBinaryVersion` 迁移有个状态危险,必须处理 @@ -294,62 +169,29 @@ Phase 2 的收益与 Phase 0/1 的覆盖率成正比,但**不需要等覆盖 把当前 bundleHash 写入,stateSchemaVersion = 2 保持 currentVersion / firstTime 等原样 否则: - 正常比较 (packageVersion, bundleHash) + 正常比较 (packageVersion, bundleHash || buildTime) ``` -即"首次遇到旧格式状态时,认领它而不是否决它"。这是纯函数逻辑,落在 `cpp/patch_core/state_core.cpp`,可以直接单测覆盖。 +即"首次遇到旧格式状态时,认领它而不是否决它"。这是纯函数逻辑,落在 `cpp/patch_core/state_core.cpp`,可以直接单测覆盖。schema 版本号不可回退,需谨慎发布。 ### 5.2 其余收尾 - 下载产物 `digests` 校验(IO-6 本体) - apply 时 copies 摘要校验 → 不符即 fallback full,**永不装出坏包** -- 删除 `pushy_build_time` 构建注入:podspec 的 script_phase 写 node_modules(pnpm 下的老问题)、gradle 的 resValue、Harmony 的 meta.json 一并消失 - -### 5.3 buildTime 的降级顺序 - -不要一步到位。`bundleHash` 为空时(计算失败、老二进制)仍需要一个身份标识,所以: - -1. 先让 `SyncBinaryVersion` 变成 `(packageVersion, bundleHash || buildTime)` -2. 观察一个版本周期,确认 `bundleHash` 为空的比例足够低 -3. 再删构建期注入 - ---- +- ~~删除 `pushy_build_time` 构建注入~~ 取消(见上方定位修订) -## 6. 分阶段发布与回退 +### 5.3 ~~buildTime 的降级顺序~~ -| Phase | 仓库 | 可独立发布 | 回退方式 | -|---|---|---|---| -| 0 | react-native-update | ✅ | 老服务端忽略未知字段,天然无害 | -| 1 | react-native-update-cli | ✅ | 同上 | -| 2 | pushy-server | ✅ | `dynamicConfig` 开关(§4.3) | -| 3 | react-native-update | ❌ 依赖 Phase 2 稳定 | 各项独立回退;§5.1 的 schema 版本号不可回退,需谨慎发布 | - -任意新老组合的行为 = 两侧能力交集,最差退化为现状。 +取消(见上方定位修订)。 --- -## 7. 风险 +## 6. 风险(仍相关的) | # | 风险 | 处置 | |---|---|---| -| R1 | 客户端与 CLI 哈希了不同的文件 | §0:两侧都复用已有的 pdiff 源解析逻辑,不新写;验收里有手工比对 | -| R2 | 服务端缓存串响应 | §4.1,必须先改缓存键再上判定逻辑 | +| R1 | 客户端与 CLI 哈希了不同的文件 | §0:两侧都复用已有的 pdiff 源解析逻辑;e2e 逐字节比对(§2.7) | +| R2 | 服务端缓存串响应 | §4.1,缓存键已含 bundleHash | | R3 | Phase 3 的状态迁移抹掉全网热更 | §5.1 的 schema 版本号;纯函数,单测覆盖 | -| R4 | ~~`librnupdate.so` 重编引入对齐/ABI 回归~~ | **Phase 0 已消除**:Android 改用 MessageDigest,不动 `.so`(§2.1)。Phase 3 重编时 CI 的 `verify-android-so.js` 兜 16KB 对齐 | -| R5 | 自定义 bundle 名 / Expo / use_frameworks 下客户端定位不到文件 | 定位不到就返回空串 → 退回 buildTime 启发式,不比现状差。自定义名三处(客户端哈希 / pdiff 源 / CLI 提取)一致硬编码,退化行为对齐(§0) | -| R6 | 首次安装后第一次 checkUpdate 拿不到哈希 | §2.5 的 200ms 超时 + 省略字段,下次自然带上 | -| R7 | "重打包但 JS 未变"行下发的 pdiff 因包内资源漂移而 apply 失败 | §4.2.1:apply 时校验 + fallback full + 遥测。这是本方案里唯一被下调保护强度的地方,用运行时保证换回主场景的收益 | - ---- - -## 8. 建议的执行顺序 - -Phase 0 与 Phase 1 无依赖,**可并行**,且 Phase 1 只有几行、应当先合(先让平台开始积累 `bundleHash` 记录,Phase 2 上线时就有数据可判)。 - -``` -Phase 1 (CLI, 数行) ─┐ - ├─> Phase 2 (服务端, 带开关) ─> 观察 ─> Phase 3 (收尾) -Phase 0 (客户端) ─┘ -``` - -**Phase 3 之前不要动 `NATIVE_CHECKUPDATE_DESIGN.md` 的协议下沉**——协议此时还在变。 +| R5 | 自定义 bundle 名 / Expo / use_frameworks 下客户端定位不到文件 | 返回空串 → 退回 buildTime 启发式,不比现状差;三处一致硬编码(§0) | +| R7 | "重打包但 JS 未变"行下发的 pdiff 因包内资源漂移而 apply 失败 | §4.2.1:apply 时校验 + fallback full + 遥测 | diff --git a/docs/NATIVE_CHECKUPDATE_DESIGN.md b/docs/NATIVE_CHECKUPDATE_DESIGN.md index a8fc36df..be0a1e98 100644 --- a/docs/NATIVE_CHECKUPDATE_DESIGN.md +++ b/docs/NATIVE_CHECKUPDATE_DESIGN.md @@ -1,13 +1,16 @@ # 原生检测更新设计:让砖机也能被修好 -> 状态:**实现完成(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) +> 状态:**已发布**。§10 原生冷启动检测随 10.51.0 发布(#620 / 62290556, +> 含标定层 syncNativeConfig、§10.3 JS 响应缓存复用 +> getNativeCheckCache),§11 崩溃时刻救援 + 断点续传随 10.52.0 发布(2d7f2b38),鸿蒙 +> 方法表漏注册在 10.52.1 修复。现行三端编排:iOS `RCTPushyOrchestrator.mm`、 +> Android `SyncCoordinator.java`、Harmony `SyncCoordinator.ts`(b8bfe1fd 前叫 +> NativeCheckOrchestrator;下文的 `librnupdate.so` 即现在的 +> `librnpushy.so`)。README 已写明能力与覆盖边界;e2e +> `Example/e2etest/e2e/native/native-check.test.ts` 覆盖 Android/iOS 的 +> forceBoot 激活。仍开放的项(鸿蒙 native-check e2e、救砖自动化 e2e 等)见 +> `NATIVE_CHECK_FOLLOWUPS.md`。 +> 取代:本地启动熔断方案(原 `REMOTE_RESET_DESIGN.md`,未入库,已放弃,理由见 §1.2) > 前置:bundleHash 迁移 Phase 0/1/2 已上线且 buildTime 永久保留作 fallback 已定稿 > (2026-08-03)——checkUpdate 的 wire protocol 已稳定,协议前置解除 > 关联:[[native-update-reset-design]] 的 Phase 3 @@ -294,7 +297,7 @@ app 侧的 `client.ts` / `UpdateProvider` 仍然负责**交互**:更新提示 ## 9. 已写代码的处置 -`REMOTE_RESET_DESIGN.md` 那一版的本地熔断已经三端实现完毕(未提交)。按 §1.2 应当整体撤回。可留用的只有: +本地熔断那一版(原 `REMOTE_RESET_DESIGN.md`,未入库)曾三端实现完毕(未提交)。按 §1.2 应当整体撤回。可留用的只有: - `blockedVersions` —— 与本方案无关,可独立保留或一并撤回 - `autoReset` 遥测事件 —— 无 reset 动作后失去意义,撤回 diff --git a/docs/NATIVE_CHECK_FOLLOWUPS.md b/docs/NATIVE_CHECK_FOLLOWUPS.md index 5c313803..f418a23d 100644 --- a/docs/NATIVE_CHECK_FOLLOWUPS.md +++ b/docs/NATIVE_CHECK_FOLLOWUPS.md @@ -1,529 +1,37 @@ # 原生冷启动检测:遗留改进项 -> 来源:`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 - -## 2026-08-12 救砖实测:能救什么、救不了什么(Android 真机 + 生产服务端) - -两轮实测,同一套 forceBoot 通道,结论截然相反,差别只在崩溃时刻。 - -**能救(已验证)**:markSuccess 之后 10s 才崩的砖。 -时间线:proc_start → 9.4s 原生检测 commit `set for next launch` → 10.0s JS 崩溃 → -下次启动进入修复版并 markSuccess。救援仅领先崩溃点 567ms。 - -**救不了(已验证)**:markSuccess 后写 storage 标记、下次启动读到标记即崩的砖 -(用户提出的理想场景,崩溃点约 proc_start+230ms)。连续 4 次冷启动,原生检测 -一次都没能走到 commit,设备停留在 "keeps stopping"。 - -**根因(不是延迟参数问题)**:5s 固定延迟只是其一,检查+下载本身实测还需 ~4.4s。 -即使把延迟归零也远超 230ms。更本质的是原生检测线程与 JS 同进程,JS 崩溃时 -进程整体被杀,该轮下载进度全部丢弃,下次启动从零开始——无论重启多少次都无法收敛。 - -**可行方向(未实施,需三端评估)**: -1. 下载断点续传 + 跨启动累积:每次启动补几百毫秒,多次启动后凑齐并激活(治本,改动最大) -2. 原生检测移入独立进程:JS 崩溃不再连坐(Android 可行;iOS 无此模型) -3. 上次启动异常检测 → 本次启动同步阻塞式检查(牺牲启动速度换救援,需谨慎) - -在此之前,产品口径应明确:forceBoot 救的是"能活过启动阶段、之后才出问题"的砖, -不覆盖"启动即刻崩"的场景。文档与卖点文案不应过度承诺。 - -## 2026-08-12 缺口闭环(10.52.0):crash-handler hold + 断点续传 - -上面三个方向重新评审后全部改判,最终方案见设计文档 §11: - -- **主机制 = 崩溃时刻扣留进程**(维护者提出):uncaught handler 里 hold 住 - 垂死进程数秒完成检查+下载。崩溃即信号,零误判、零检测态、首崩即救, - 且修复在垂死会话末尾 commit,下次启动零阻塞直接进修复版。方向 2(独立 - 进程)与方向 3(启动阻塞)被它整体取代;方向 1(续传)保留为地基。 -- **断点续传三端落地**:sidecar(url/验证器/总长)+ Range/If-Range, - 206/200/416 全处理;归档与 sidecar 同生共死;下载阶段失败保留 partial, - patch 阶段失败判定归档有毒连 sidecar 一起删。 -- **round-incomplete 标记**:轮次开始落盘、结束清除;残留 = 上个进程死于 - 轮中,下次启动跳过 5s 延迟立即续传。 -- **救援轮强制激活** + hashInfo `crashRescue` 标记(与 forceBootRescue 对 - 称);本机 rolledBack 黑名单与 first_time 保护语义不变。 -- **边界**:只逮 exception 形态崩溃(native signal/ANR/OOM 逃逸);鸿蒙本 - 版无 crash hold(errorManager 同步回调驱动不了 ArkTS 异步 IO),只有续 - 传+零延迟。 - -- [x] 10.52.0 发版后按本节上方同一实验复测 crash-on-launch 砖(见下) - -### 2026-08-12 实测:crash-on-launch 砖被救回(Android 真机 emu + 生产服务端) - -10.52.0 发布到 npm,testHotUpdate 升级并出 release APK(rnu 10.52.0)。 -复刻 crash-on-launch 砖:砖包活过一次启动并 markSuccess(first_time 保护 -解除),之后每次启动读到标记即在 JS 线程抛错。fix 版本绑定到同一原生版本 -1.87.1,砖包磁盘上的 fix 预下载被删除,逼救援在崩溃窗口内完整走 -检查+下载+commit。 - -冷启动时序(同一进程 PID 11498): - -``` -22:59:52.365 JS 线程抛 BRICK(mqt_v_native),进程存活 122ms -22:59:52.367 crash rescue: holding process for up to 10000ms (uptime 122ms) -22:59:56.645 native check: downloaded -IxhPnHy(fix) and set for next launch -22:59:56.651 前任 handler 接管,进程被 SIG9 终止 -``` - -崩溃到 commit 全程 **4.28s**,落在 10s(后台线程)预算内。下次冷启动直接 -进 fix 版本(hot-fix-1052),不再崩溃;fix 立即把标记 disarm。 - -遥测闭环:fix 的 currentVersionInfo 带 `crashRescue: true`,markSuccess 后 -JS 紧跟着上报一条 `crashRescue` 回执(logcat 实测 23:00:16.180 markSuccess -→ 23:00:16.181 crashRescue)。 - -对比 2026-08-12 上午同一台设备的旧结论:230ms 砖连续 4 次冷启动不收敛。 -10.52.0 下**一次崩溃即救回**——崩溃本身触发 hold,进度不再随进程死亡丢弃。 -鸿蒙真机复测待补(只有续传+零延迟无 hold)。 - -### 2026-08-12 iOS 模拟器实测:同一场景救回(Release-iphonesimulator + 本地服务端) - -e2e 基建复用:本地 rnu 副本刷到 10.52.0 源码 → pod install → Release 模拟器 -构建(e2e-ios 的常态形态,`#if !DEBUG` 门控激活);Detox 逮不住 JS 起不来的 -app,绕开它直接 simctl + 文件系统/崩溃报告观察。实验脚手架已入库: -`e2e/entry.brick.ts` / `e2e/entry.fix.ts` / `scripts/ios-rescue-server.ts` -(端口 31337,链:base→brick(带 forceBoot 投放)→fix(**刻意不带 forceBoot**))。 - -砖状态与 Android 同构:brick 经 forceBoot 装上、首启健康 markSuccess -(isFirstTime 消费、isFirstLoadOK=true)、8s 后 arm 标记;磁盘上预下载的 -fix 版本目录删除,逼救援窗口真下载。 - -armed 冷启动结果(procLaunch 23:24:44.233,崩溃报告 .ips 为证): - -- JS fatal 重抛在 **com.meta.react.turbomodulemanager.queue**(后台线程 → - 10s 预算档;此前对"JS 崩落在哪个线程"的分析在 bridgeless 下得到证实) -- 救援窗口内完成整轮:服务端记录到 checkUpdate(hash=brick)→ 下发 fix → - **fix.ppk 真实下载** → commit;本地服务端下全程 <0.9s(captureTime - 23:24:45.156,远低于预算——4.4s 是生产网络的数字) -- **强制激活的独立证据**:fix 响应无 forceBoot、app 配置 - checkStrategy:null/afterDownload:none——常规路径绝不会激活,而 - currentVersion 已切到 fix 且 hashInfo 带 `crashRescue:true`,只能来自 - crashRescueActive -- 链式礼仪:hold 结束后前任 handler 照常执行,SIGABRT 崩溃报告正常生成 - (崩溃上报不丢) -- 下次启动进 fix、disarm 标记、markSuccess - -**主线程 3.5s 预算档不做人工实测,理由记录**:iOS 上落主线程的 RCTFatal 只有 -bundle 加载失败一族(RCTInstance 先 RCTExecuteOnMainQueue 再 RCTFatal),而 -加载失败的包永远跑不到 markSuccess,一律被既有 first_time 回滚接住,不构成 -真实砖形态;JS 运行时错误(砖的实际形态)实证落在后台线程。另:主线程档 -3.5s 是按 Android ANR 定的,iOS watchdog 上限 ~20s,未来可放宽。 - -**iOS 覆盖边界补记**:若宿主 app 调用了 `RCTSetFatalHandler`,RCTFatal 走 -handler 分支不再抛异常,crash-rescue 不会触发(Android 无对应抢占点)。 -文档口径需含此项。 - -## 2026-08-13 鸿蒙模拟器实测:两个实证 + 一个 10.51 以来的功能性断裂 - -DevEco api20 模拟器(HarmonyOS 6.0 / arm64)+ harmony_use_pushy(RN 0.72 + -RNOH 0.72.96)+ 本地 e2e 服务端。探针脚手架入库: -`harmony_use_pushy/e2e/entry.crashprobe.ts`(+1s 未捕获 JS 错误)、 -`entry.diag.ts`(TM 方法表诊断)。 - -**实证一(缺口 0 的分叉答案):RNOH 未捕获 JS 错误不杀进程。** +1s 抛错后 -进程存活 40s+ 无恙,错误只落 #RNOH_JS/#RNOH_ARK 日志。Hermes 在 RNOH 自管 -线程上,异常不重抛 ArkTS 主线程,errorManager 根本不参与。因此**鸿蒙不需要 -crash hold**:ets 编排器在 JS 死后照常跑完整轮。此前 §11.5 的 -errorManager/appRecovery 论述前提不成立,已改写。 - -**实证二(端到端救砖):** crashprobe 作基座(每次启动 JS +1s 崩),服务端 -forceBoot 下发 v1:+5s 轮次照常检查→下载 v1.ppk→激活(currentVersion 写 -入),重启直接运行 e2e-full-v1,砖消失。JS 崩溃对轮次零影响,无预算压力。 - -**功能性断裂(已修,severity 高):`PushyTurboModule.cpp` 的 C++ 方法表漏 -注册 10.50+ 的全部四个新方法**(syncNativeConfig / getNativeCheckCache / -getBundleHash / resetToPackagedBundle)。RNOH 的 ArkTS TM 只有经 C++ -methodMap 注册的方法才对 JS 可见,缺失 = JS 侧 undefined → feature-detect -判"老原生"静默跳过 → **原生冷启动检测在鸿蒙自 10.51.0 从未生效** -(nativeConfig 从未落盘,ets 编排器+本次的续传/零延迟全是死代码); -resetToPackagedBundle(10.50 Phase 1)在鸿蒙 JS 侧也一直不可用。诊断链: -健康启动 preferences 无 nativeConfig → entry.diag 打 typeof=undefined + -keys 只有 14 个老方法 → cpp 方法表对不上 ets 类。 - -**为什么 CI 没逮住**:harmony e2e 套件 testMatch 只含 `e2e/harmony/**` -(smoke + local-update),native-check.test.ts 从未在鸿蒙执行;而 JS 的 -feature-detect 把缺失当"老原生"优雅跳过——这是[[no-graceful-degradation]] -教训的又一实例:兼容旧原生的降级路径把新原生的接线断裂藏了三个版本。 - -- [x] cpp 方法表补注册(含"新增 spec 方法必须同步注册"的醒目注释) -- [ ] harmony e2e 补 native-check 场景(driver 是 hdc+uitest 非 detox, - 需移植;顺带把 `hdc rport` 写进用例基建——模拟器 127.0.0.1 不通宿主) -- [ ] 发版 10.52.1(鸿蒙 bridging 修复;Android/iOS 无变化) -- [ ] Logger.ts isDebug 硬编码 false,轮次全程无日志可观测,排障全靠副作用 - (preferences/服务端流量);考虑 error 级关键节点或可配置开关 - -## 2026-09-02 处理结论(全面审计后续,见 CODE_AUDIT.md §8) - -| 项 | 结论 | -|---|---| -| 第四轮"后续小版本"第 4 项(Harmony 超时不取消底层任务) | 已修:下载超时按剩余 deadline 设置 read/connect timeout,编排器与任务链共享绝对 deadline | -| 第 5 项(iOS/Harmony `switchVersion` 可激活半完成安装) | 已由 6e9ae76 的安装记录 digest 复验关闭;鸿蒙本轮在提交前先校验再写入 | -| 第 6 项(iOS deferred 异类型 waiter 进度串流) | 已修(同类型合流广播,异类型 deferred) | -| 第 7 项(`expired+update` 无 hash 畸形响应) | 已修:按 expired-only 处理 | -| 第 8 项(瞬时空 appKey/server 写入 `disabled`) | 已修:只有 `disableNativeCheck` 才持久化 disabled,瞬时无效配置沿用上一份 | -| 第 9 项(`noArtifact` 上报位置) | 已修:每个坏发布只报一次,静默策略由 client 报、弹窗策略由 provider 报 | -| 第 10 项(两处 cleanup) | 已修 | -| 鸿蒙 e2e 补 native-check 场景 | 未做(需 hdc/uitest 设备链路) | -| `Logger.ts isDebug` 硬编码 | 已修:由宿主 `isDebugModeEnabled` 在模块构造时打开(HAR 内无法引用 hvigor 生成的 `BuildProfile`,首次 CI 编译已证实),轮次里程碑提升到 info/warn | -| "P1 Android/Harmony 重复下载" 遗留的 launchVersion 保护 | 已修:三端清理都保留本进程启动版本;Android 在 keep 集合不含它时跳过本次清理 | +> 设计见 `NATIVE_CHECKUPDATE_DESIGN.md`(§10 冷启动检测、§11 崩溃救援)。 +> 本文只保留**仍开放**的项。四轮评审(2026-08-10/11)、2026-09-02 全面审计 +> 后续与 2026-08-12/13 三端救砖实测的完整记录(40 条发现的逐条问题描述、 +> 修法与实测时序)已收敛,见 git 历史: +> `git show 4ca8972e:docs/NATIVE_CHECK_FOLLOWUPS.md`。 + +## 已收敛结论(备查) + +- 四轮评审 40 条发现:除下文开放项外全部修复或显式拍板(P3 `.pushy-complete` + 无迁移 → 接受一次性重下;P4 bundleHash 未就绪窗口指纹失配 → 接受并在 + `readNativeCheckCache` 注明;iOS owner-only 进度事件 → 不采纳)。 +- 崩溃时刻救援(10.52.0,§11):Android 真机与 iOS Release 模拟器均实测 + "启动即崩"的砖一次崩溃即救回(Android 崩溃→commit 4.28s);鸿蒙 RNOH 未捕获 + JS 错误不杀进程,无需 crash hold,模拟器实测 forceBoot 端到端救回。 +- 2026-08-13 鸿蒙功能性断裂:`PushyTurboModule.cpp` 方法表漏注册 10.50+ 的 + 新方法,原生冷启动检测在鸿蒙自 10.51.0 起从未生效;已修(10.52.1),并由 + `scripts/check-native-spec-parity.js` 与 harmony-build CI 防回归。 +- 已知覆盖边界(README 已写明):crash hold 只逮 JS exception 形态的崩溃, + 不覆盖原生崩溃 / ANR / OOM 击杀,以及 iOS 上安装了自定义 + `RCTSetFatalHandler` 的应用。 + +## 开放项 + +- [ ] **鸿蒙 e2e 缺 native-check 场景**:`e2e/harmony.jest.config.js` 的 + testMatch 只含 `e2e/harmony/**`(smoke + local-update), + `e2e/native/native-check.test.ts` 只在 Android/iOS 跑。需把用例移植到 + hdc + uitest driver(模拟器 127.0.0.1 不通宿主,用 `hdc rport`)。 +- [ ] **救砖(crash-on-launch)无自动化 e2e**:§11 的三端实测都是手工的 + (Detox 逮不住 JS 起不来的 app)。现有 native-check 用例只覆盖 + "服务端 forceBoot → 原生激活",不覆盖"坏 bundle 崩溃 → 救援 → 下次 + 启动进修复版"。 +- [ ] **鸿蒙真机救砖复测**:目前只有 DevEco 模拟器实证。 +- [ ] (可选)iOS 主线程 crash hold 预算 3.5s 按 Android ANR 定,iOS watchdog + 上限约 20s,可放宽;主线程 RCTFatal 实际只有 bundle 加载失败一族 + (由 first_time 回滚接住),优先级低。 From b32291a8b577456be95e164d6e1623044fe9e73e Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:59:25 +0800 Subject: [PATCH 043/104] test: one loader for native sources and Harmony VM modules nativeHostApi, nativeConfiguration, configEquivalenceVectors and harmonyInstallRecord each stripped imports/exports from a Harmony .ts file, transpiled it and ran it in a VM; nativeHostApiNaming and nativeTextEncoding each defined root/source/shippedNativeSources/ stripComments. Both now come from nativeSources.ts (evalHarmonyModule caches the transpiled script per file). Co-Authored-By: Claude Opus 5.5 --- .../configEquivalenceVectors.test.ts | 20 +------ src/__tests__/harmonyInstallRecord.test.ts | 19 ++----- src/__tests__/nativeConfiguration.test.ts | 21 ++----- src/__tests__/nativeHostApi.test.ts | 19 ++----- src/__tests__/nativeHostApiNaming.test.ts | 26 +++------ src/__tests__/nativeSources.ts | 55 +++++++++++++++++++ src/__tests__/nativeTextEncoding.test.ts | 22 +------- 7 files changed, 78 insertions(+), 104 deletions(-) create mode 100644 src/__tests__/nativeSources.ts diff --git a/src/__tests__/configEquivalenceVectors.test.ts b/src/__tests__/configEquivalenceVectors.test.ts index d8e2a141..3505a5de 100644 --- a/src/__tests__/configEquivalenceVectors.test.ts +++ b/src/__tests__/configEquivalenceVectors.test.ts @@ -1,9 +1,7 @@ import { describe, expect, test } from 'bun:test'; -import { readFileSync } from 'node:fs'; -import { URL } from 'node:url'; -import { runInNewContext } from 'node:vm'; import vectorsFile from '../../cpp/update_flow_core/tests/config-equivalence-vectors.json'; import { nativeConfigsEquivalent } from '../updateFlowCore'; +import { evalHarmonyModule } from './nativeSources'; // Contract C1 (native config equivalence) is implemented four times: here, // Harmony UpdateContext.ts, Android ConfigEquivalence.java and iOS @@ -21,20 +19,8 @@ const vectors = vectorsFile.cases as Vector[]; // UpdateContext.ts imports @ohos modules, so it is evaluated with its imports // stripped (as in nativeConfiguration.test.ts); the C1 functions only depend // on the file's own source. -function harmonyEquivalence(): (a: string, b: string) => boolean { - const source = readFileSync( - new URL( - '../../harmony/pushy/src/main/ets/UpdateContext.ts', - import.meta.url - ), - 'utf8' - ) - .replace(/^import[\s\S]*?;\r?\n/gm, '') - .replace(/^export\s*\{[^}]*\}(?:\s*from\s*[^;]*)?;\r?\n/gm, '') - .replace(/^export /gm, ''); - const js = new Bun.Transpiler({ loader: 'ts' }).transformSync(source); - return runInNewContext(`${js}\nnativeConfigsEquivalent;`, {}); -} +const harmonyEquivalence = (): ((a: string, b: string) => boolean) => + evalHarmonyModule('UpdateContext.ts', 'nativeConfigsEquivalent;'); const implementations: Array<[string, (a: string, b: string) => boolean]> = [ ['js', nativeConfigsEquivalent], diff --git a/src/__tests__/harmonyInstallRecord.test.ts b/src/__tests__/harmonyInstallRecord.test.ts index 6a8ec39c..90de68f7 100644 --- a/src/__tests__/harmonyInstallRecord.test.ts +++ b/src/__tests__/harmonyInstallRecord.test.ts @@ -1,7 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { readFileSync } from 'node:fs'; -import { URL } from 'node:url'; -import { runInNewContext } from 'node:vm'; +import { evalHarmonyModule } from './nativeSources'; // Harmony InstallRecord.ts (ArkTS mirror of cpp/patch_core/install_record.h) // imports @ohos modules, so it is evaluated with its imports stripped and its @@ -38,16 +36,6 @@ function loadInstallRecord( files: Map, digests: Map ): InstallRecordModule { - const source = readFileSync( - new URL( - '../../harmony/pushy/src/main/ets/InstallRecord.ts', - import.meta.url - ), - 'utf8' - ) - .replace(/^import[\s\S]*?;\r?\n/gm, '') - .replace(/^export /gm, ''); - const js = new Bun.Transpiler({ loader: 'ts' }).transformSync(source); const fileIo = { accessSync: (path: string) => files.has(path), statSync: (path: string) => ({ size: files.get(path)?.length ?? 0 }), @@ -62,8 +50,9 @@ function loadInstallRecord( return digest; }, }; - return runInNewContext( - `${js}\n({ buildInstallRecord, expectedBundleSha256ForActivation, verifyInstallForActivation });`, + return evalHarmonyModule( + 'InstallRecord.ts', + '({ buildInstallRecord, expectedBundleSha256ForActivation, verifyInstallForActivation });', { fileIo, NativePatchCore, diff --git a/src/__tests__/nativeConfiguration.test.ts b/src/__tests__/nativeConfiguration.test.ts index 9a3390e1..63b67504 100644 --- a/src/__tests__/nativeConfiguration.test.ts +++ b/src/__tests__/nativeConfiguration.test.ts @@ -1,21 +1,7 @@ import { describe, expect, test } from 'bun:test'; -import { readFileSync } from 'node:fs'; -import { URL } from 'node:url'; -import { runInNewContext } from 'node:vm'; import type { PushyConfiguration } from '../../harmony/pushy/src/main/ets/PushyConfiguration'; import { normalizePushyConfiguration } from '../../harmony/pushy/src/main/ets/PushyConfiguration'; - -function runtimeSource(relativePath: string): string { - const source = readFileSync(new URL(relativePath, import.meta.url), 'utf8') - .replace(/^import[\s\S]*?;\r?\n/gm, '') - .replace(/^export\s*\{[^}]*\}(?:\s*from\s*[^;]*)?;\r?\n/gm, '') - .replace(/^export /gm, ''); - return new Bun.Transpiler({ loader: 'ts' }).transformSync(source); -} - -const storeSource = runtimeSource( - '../../harmony/pushy/src/main/ets/UpdateContext.ts' -); +import { evalHarmonyModule } from './nativeSources'; interface ConfigStore { setNativeConfig: (config: string) => Promise; @@ -43,8 +29,9 @@ function storeHarness() { if (shouldFail) throw new Error('storage unavailable'); }, }; - const store = runInNewContext( - `${storeSource}\nconst store = Object.create(UpdateContext.prototype); store.preferences = preferences; store.flushBatchDepth = 0; store;`, + const store = evalHarmonyModule( + 'UpdateContext.ts', + 'const store = Object.create(UpdateContext.prototype); store.preferences = preferences; store.flushBatchDepth = 0; store;', { preferences, util: { generateRandomUUID: () => 'native-installation-id' }, diff --git a/src/__tests__/nativeHostApi.test.ts b/src/__tests__/nativeHostApi.test.ts index 5c6f3b34..261c5772 100644 --- a/src/__tests__/nativeHostApi.test.ts +++ b/src/__tests__/nativeHostApi.test.ts @@ -1,27 +1,15 @@ import { describe, expect, test } from 'bun:test'; -import { readFileSync } from 'node:fs'; -import { URL } from 'node:url'; -import { runInNewContext } from 'node:vm'; import type { BundlePreparationResult } from '../../harmony/pushy/src/main/ets/BundlePreparationResult'; import { BundlePreparationRound, bundlePreparationResult, } from '../../harmony/pushy/src/main/ets/BundlePreparationResult'; import { STATUS_NONE } from '../../harmony/pushy/src/main/ets/Texts'; +import { evalHarmonyModule } from './nativeSources'; // Evaluate the actual Harmony orchestrator in an isolated VM per test. Only // platform imports, HTTP and download IO are substituted; entry points, // scheduling, configuration gates, result mapping and reset checks are real. -const source = readFileSync( - new URL( - '../../harmony/pushy/src/main/ets/SyncCoordinator.ts', - import.meta.url - ), - 'utf8' -) - .replace(/^import[\s\S]*?;\r?\n/gm, '') - .replace(/^export /gm, ''); -const javascript = new Bun.Transpiler({ loader: 'ts' }).transformSync(source); interface Decision { action: string; @@ -69,8 +57,9 @@ function harness() { return generation === state.generation && state.commitOK; }, }; - const runtime = runInNewContext( - `${javascript}\nrunQueryRequest = mockCheck;\nperformAttempts = mockDownload;\n({ check: prepareBundleNative, schedule: scheduleNativeSync });`, + const runtime = evalHarmonyModule( + 'SyncCoordinator.ts', + 'runQueryRequest = mockCheck;\nperformAttempts = mockDownload;\n({ check: prepareBundleNative, schedule: scheduleNativeSync });', { BundlePreparationRound, bundlePreparationResult, diff --git a/src/__tests__/nativeHostApiNaming.test.ts b/src/__tests__/nativeHostApiNaming.test.ts index 0439a351..8a1adb0f 100644 --- a/src/__tests__/nativeHostApiNaming.test.ts +++ b/src/__tests__/nativeHostApiNaming.test.ts @@ -1,11 +1,15 @@ import { describe, expect, test } from 'bun:test'; -import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { existsSync } from 'node:fs'; import { URL } from 'node:url'; +import { + root, + shippedNativeSources, + source, + stripComments, +} from './nativeSources'; -const root = new URL('../../', import.meta.url); const android = 'android/src/main/java/cn/reactnative/modules/update/'; const harmony = 'harmony/pushy/src/main/ets/'; -const source = (path: string) => readFileSync(new URL(path, root), 'utf8'); // These are forbidden host API identifiers, not backend paths or JS bridge names. const oldNames = @@ -18,22 +22,6 @@ const roundNames = /\b(?:NativeCheckOrchestrator|CrashRescue|updateflow|scheduleNativeCheck|isJsCheckCompleted|commitNativeCheckResult\w*|runCheckRequest|runRescue\w*|softReload|ReloadEventEmitter|\w*PatchInputs|FlowCheckInput|\w*(?:Check|Rescue)(?:Request|Response|Result|Budget|Deadline|Active|Attempted)\w*|pushyNativeCheckReady|nativeCheckRolledBackVersion)\b/; const roundWording = /native check|crash rescue|host-check|native-check|crash-rescue/i; -const stripComments = (text: string) => - text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:"'\\])\/\/.*$/gm, '$1'); - -const shippedNativeSources = (dir: string): string[] => - readdirSync(new URL(dir, root), { recursive: true, withFileTypes: true }) - .filter( - (entry) => - entry.isFile() && /\.(?:h|m|mm|java|kt|ts|ets|cpp)$/.test(entry.name) - ) - .map((entry) => - `${entry.parentPath}/${entry.name}`.slice( - new URL(dir, root).pathname.length - dir.length - ) - ) - .filter((path) => !/\/tests?\//.test(path)); - describe('native host API naming', () => { test('Android exposes prepareBundle through PushyRuntime', () => { const entry = source(`${android}PushyRuntime.java`); diff --git a/src/__tests__/nativeSources.ts b/src/__tests__/nativeSources.ts new file mode 100644 index 00000000..4515856e --- /dev/null +++ b/src/__tests__/nativeSources.ts @@ -0,0 +1,55 @@ +// Shared access to the repository's native sources for the tests that check +// or evaluate them directly. +import { readdirSync, readFileSync } from 'node:fs'; +import { URL } from 'node:url'; +import { runInNewContext } from 'node:vm'; + +/** The repository root. */ +export const root = new URL('../../', import.meta.url); + +/** A repository file's text, by its path from the root. */ +export const source = (path: string) => + readFileSync(new URL(path, root), 'utf8'); + +/** Drops block and line comments (not `//` inside URLs or strings). */ +export const stripComments = (text: string) => + text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:"'\\])\/\/.*$/gm, '$1'); + +/** Root-relative paths of the native source files shipped from `dir`. */ +export const shippedNativeSources = (dir: string): string[] => + readdirSync(new URL(dir, root), { recursive: true, withFileTypes: true }) + .filter( + (entry) => + entry.isFile() && /\.(?:h|m|mm|java|kt|ts|ets|cpp)$/.test(entry.name) + ) + .map((entry) => + `${entry.parentPath}/${entry.name}`.slice( + new URL(dir, root).pathname.length - dir.length + ) + ) + .filter((path) => !/\/tests?\//.test(path)); + +const harmonyScripts = new Map(); + +/** + * Evaluates a Harmony module (harmony/pushy/src/main/ets/) in an + * isolated VM context and returns the value of `expression` evaluated after + * it. Imports are stripped, so everything the code under test imports must + * be supplied through `context`. + */ +export const evalHarmonyModule = ( + file: string, + expression: string, + context: Record = {} +) => { + let js = harmonyScripts.get(file); + if (js === undefined) { + const ts = source(`harmony/pushy/src/main/ets/${file}`) + .replace(/^import[\s\S]*?;\r?\n/gm, '') + .replace(/^export\s*\{[^}]*\}(?:\s*from\s*[^;]*)?;\r?\n/gm, '') + .replace(/^export /gm, ''); + js = new Bun.Transpiler({ loader: 'ts' }).transformSync(ts); + harmonyScripts.set(file, js); + } + return runInNewContext(`${js}\n${expression}`, context); +}; diff --git a/src/__tests__/nativeTextEncoding.test.ts b/src/__tests__/nativeTextEncoding.test.ts index ebc04ba4..0ca946ed 100644 --- a/src/__tests__/nativeTextEncoding.test.ts +++ b/src/__tests__/nativeTextEncoding.test.ts @@ -1,15 +1,11 @@ import { describe, expect, test } from 'bun:test'; -import { readdirSync, readFileSync } from 'node:fs'; -import { URL } from 'node:url'; import { normalizePushyConfiguration } from '../../harmony/pushy/src/main/ets/PushyConfiguration'; import { QUERY_PATH, revealText } from '../../harmony/pushy/src/main/ets/Texts'; import { decodeNativeText, encodeNativeText, } from '../../scripts/encode-native-text'; - -const root = new URL('../../', import.meta.url); -const source = (path: string) => readFileSync(new URL(path, root), 'utf8'); +import { shippedNativeSources, source, stripComments } from './nativeSources'; // Service addresses and the request path must only appear encoded in shipped // native code (see scripts/encode-native-text.ts). @@ -21,19 +17,6 @@ const plainTexts = [ 'https://cdn.jsdelivr.net/gh/reactnativecn/react-native-update@master/endpoints.json', ]; -const shippedNativeSources = (dir: string): string[] => - readdirSync(new URL(dir, root), { recursive: true, withFileTypes: true }) - .filter( - (entry) => - entry.isFile() && /\.(?:h|m|mm|java|kt|ts|ets|cpp)$/.test(entry.name) - ) - .map((entry) => - `${entry.parentPath}/${entry.name}`.slice( - new URL(dir, root).pathname.length - dir.length - ) - ) - .filter((path) => !/\/tests?\//.test(path)); - const paths = [ 'ios/', 'android/src/main/', @@ -42,9 +25,6 @@ const paths = [ 'cpp/update_flow_core/', ].flatMap(shippedNativeSources); -const stripComments = (text: string) => - text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:"'\\])\/\/.*$/gm, '$1'); - const encodedIn = (text: string) => [ ...text.matchAll( From 725c3d54c1a39f7d7124b52af12fd7162661fd9f Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:59:32 +0800 Subject: [PATCH 044/104] test: shared client test helpers, real client in provider render tests - helpers.ts: createJsonResponse, flush, __DEV__ save/restore (setDevForFile / restoreDevAfterEach), mockPlatform, mockI18n, mockCore and importFreshClient/createClient with an auto-incrementing module key (no more hand-picked cache keys). - Every module mock now passes the real namespace through: mockCore spreads the real ../core (nativeMethod bound to the mocked native module), the react-native stand-in is setup.ts's baseReactNative with overrides, the download tests' ../utils mock spreads the real utils, and the file-scope partial mocks in utils.test.ts are gone. - client.test.ts: setupAndroidApkMocks folds into setupClientMocks ({ platform: 'android' }); 104 import+construct pairs become createClient; no-op resets of a fresh module's sharedState are dropped. - provider.render.test.tsx renders the real client with spied update-flow methods instead of a hand-written fake (provider.contract.test.tsx no longer has to keep that fake honest). Verified per file in isolation, in reversed/shuffled file order and with bun test --randomize. Co-Authored-By: Claude Opus 5.5 --- src/__tests__/client.test.ts | 635 +++++++---------------- src/__tests__/context.test.ts | 10 +- src/__tests__/core.test.ts | 20 +- src/__tests__/helpers.ts | 124 +++++ src/__tests__/metadata.test.ts | 2 + src/__tests__/provider.contract.test.tsx | 81 +-- src/__tests__/provider.render.test.tsx | 197 +++---- src/__tests__/setup.ts | 104 ++-- src/__tests__/utils.test.ts | 21 +- 9 files changed, 470 insertions(+), 724 deletions(-) create mode 100644 src/__tests__/helpers.ts diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index 8ba051ae..e21ae0bb 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -1,13 +1,20 @@ import { afterEach, describe, expect, mock, spyOn, test } from 'bun:test'; +import { + createClient, + createJsonResponse, + importFreshClient, + mockCore, + mockI18n, + mockPlatform, + restoreDevAfterEach, +} from './helpers'; +import { realProjectModules } from './setup'; -const importFreshClient = (cacheKey: string) => import(`../client?${cacheKey}`); - -const originalDev = (globalThis as any).__DEV__; const originalErrorUtils = (globalThis as any).ErrorUtils; // Module mocks and mock.restore() are handled globally in setup.ts. +restoreDevAfterEach(); afterEach(() => { - (globalThis as any).__DEV__ = originalDev; if (originalErrorUtils === undefined) { delete (globalThis as any).ErrorUtils; } else { @@ -15,14 +22,6 @@ afterEach(() => { } }); -const createJsonResponse = (payload: unknown) => - ({ - ok: true, - status: 200, - text: async () => JSON.stringify(payload), - json: async () => payload, - }) as Response; - const setupClientMocks = ({ isFirstTime = false, markSuccess = mock(() => {}), @@ -45,6 +44,8 @@ const setupClientMocks = ({ getNativeCheckCache = undefined, markJsCheckCompleted = undefined, setLocalHashInfo = mock(() => {}), + downloadAndInstallApk = mock(() => Promise.resolve()), + platform = 'ios', }: { isFirstTime?: boolean; markSuccess?: ReturnType; @@ -62,24 +63,13 @@ const setupClientMocks = ({ getNativeCheckCache?: ReturnType; markJsCheckCompleted?: ReturnType; setLocalHashInfo?: ReturnType; + downloadAndInstallApk?: ReturnType; + platform?: 'ios' | 'android'; } = {}) => { (globalThis as any).__DEV__ = false; - mock.module('react-native', () => ({ - Platform: { - OS: 'ios', - Version: 17, - }, - DeviceEventEmitter: { - addListener: mock(() => ({ remove: mock(() => {}) })), - }, - NativeEventEmitter: class { - addListener = mock(() => ({ remove: mock(() => {}) })); - removeAllListeners = mock(() => {}); - }, - })); - - mock.module('../core', () => ({ + mockPlatform({ OS: platform, Version: platform === 'ios' ? 17 : 30 }); + mockCore({ PushyModule: { markSuccess, reloadUpdate, @@ -87,99 +77,30 @@ const setupClientMocks = ({ downloadPatchFromPpk, downloadPatchFromPackage, downloadFullUpdate, - downloadAndInstallApk: mock(() => Promise.resolve()), + downloadAndInstallApk, restartApp, resetToPackagedBundle, ...(syncNativeConfig ? { syncNativeConfig } : {}), ...(getNativeCheckCache ? { getNativeCheckCache } : {}), ...(markJsCheckCompleted ? { markJsCheckCompleted } : {}), }, - buildTime: '2023-01-01', - cInfo: { - rnu: '10.0.0', - rn: '0.73.0', - os: 'ios', - uuid: 'uuid', - }, - currentVersion: 'hash', - currentVersionInfo: {}, + cInfo: { rnu: '10.0.0', rn: '0.73.0', os: platform, uuid: 'uuid' }, isFirstTime, - isRolledBack: false, - packageVersion: '1.0.0', pushyNativeEventEmitter: { addListener: addProgressListener, }, - rolledBackVersion: '', setLocalHashInfo, supportedDiffVersion, getBundleHash, - })); - - mock.module('../i18n', () => ({ - default: { - t: (key: string) => key, - setLocale: mock(() => {}), - }, - })); -}; - -const setupAndroidApkMocks = ( - downloadAndInstallApk: ReturnType -) => { - (globalThis as any).__DEV__ = false; - - mock.module('react-native', () => ({ - Platform: { - OS: 'android', - Version: 30, - }, - DeviceEventEmitter: { - addListener: mock(() => ({ remove: mock(() => {}) })), - }, - NativeEventEmitter: class { - addListener = mock(() => ({ remove: mock(() => {}) })); - removeAllListeners = mock(() => {}); - }, - })); - - mock.module('../core', () => ({ - PushyModule: { - markSuccess: mock(() => {}), - reloadUpdate: mock(() => Promise.resolve()), - setNeedUpdate: mock(() => Promise.resolve()), - downloadPatchFromPpk: mock(() => Promise.resolve()), - downloadPatchFromPackage: mock(() => Promise.resolve()), - downloadFullUpdate: mock(() => Promise.resolve()), - downloadAndInstallApk, - restartApp: mock(() => Promise.resolve()), - }, - buildTime: '2023-01-01', - cInfo: { rnu: '10.0.0', rn: '0.73.0', os: 'android', uuid: 'uuid' }, - currentVersion: 'hash', - currentVersionInfo: {}, - isFirstTime: false, - isRolledBack: false, - packageVersion: '1.0.0', - pushyNativeEventEmitter: { - addListener: mock(() => ({ remove: mock(() => {}) })), - }, - rolledBackVersion: '', - setLocalHashInfo: mock(() => {}), - supportedDiffVersion: 2, - getBundleHash: mock(() => ''), - })); - - mock.module('../i18n', () => ({ - default: { t: (key: string) => key, setLocale: mock(() => {}) }, - })); + }); + mockI18n(); }; describe('Pushy server config', () => { test('uses preset main endpoints directly as configured endpoints', async () => { setupClientMocks(); - const { Pushy } = await importFreshClient('preset-main'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); expect(client.getConfiguredCheckEndpoints()).toEqual([ 'https://update.react-native.cn/api', @@ -199,8 +120,7 @@ describe('Pushy server config', () => { ]) ); - const { Pushy } = await importFreshClient('remote-main-array'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); expect(await client.getRemoteEndpoints()).toEqual([ 'https://update.react-native.cn/api', @@ -213,8 +133,7 @@ describe('Pushy server config', () => { test('clones custom server config when setOptions overrides server', async () => { setupClientMocks(); - const { Pushy } = await importFreshClient('clone-server-config'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); const server = { main: ['https://a.example.com', 'https://b.example.com'], queryUrls: ['https://q.example.com'], @@ -235,8 +154,7 @@ describe('Pushy server config', () => { setupClientMocks(); const logger = mock(() => {}); - const { Pushy } = await importFreshClient('report-late-logger'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); const reportPromise = (client as any).report({ type: 'markSuccess' }); expect(logger).not.toHaveBeenCalled(); @@ -254,9 +172,7 @@ describe('Pushy server config', () => { const beforeCheckUpdate = mock(() => false); const afterCheckUpdate = mock(() => {}); - const { Pushy } = await importFreshClient('after-check-update-skipped'); - const client = new Pushy({ - appKey: 'demo-app', + const { client } = await createClient({ beforeCheckUpdate, afterCheckUpdate, }); @@ -280,9 +196,7 @@ describe('Pushy server config', () => { createJsonResponse(checkResult) ); - const { Pushy } = await importFreshClient('after-check-update-completed'); - const client = new Pushy({ - appKey: 'demo-app', + const { client } = await createClient({ afterCheckUpdate, }); @@ -298,8 +212,8 @@ describe('Pushy server config', () => { const fetchMock = mock(async () => createJsonResponse({ update: false })); (globalThis as any).fetch = fetchMock; - const { Pushy } = await importFreshClient('check-body-diffv'); - await new Pushy({ appKey: 'demo-app' }).checkUpdate(); + const { client } = await createClient(); + await client.checkUpdate(); const body = JSON.parse((fetchMock.mock.calls[0] as any[])[1].body); expect(body.diffV).toBe(2); @@ -310,8 +224,8 @@ describe('Pushy server config', () => { const fetchMock = mock(async () => createJsonResponse({ update: false })); (globalThis as any).fetch = fetchMock; - const { Pushy } = await importFreshClient('check-body-no-diffv'); - await new Pushy({ appKey: 'demo-app' }).checkUpdate(); + const { client } = await createClient(); + await client.checkUpdate(); const body = JSON.parse((fetchMock.mock.calls[0] as any[])[1].body); expect(body).not.toHaveProperty('diffV'); @@ -322,8 +236,8 @@ describe('Pushy server config', () => { const fetchMock = mock(async () => createJsonResponse({ update: false })); (globalThis as any).fetch = fetchMock; - const { Pushy } = await importFreshClient('check-body-bundlehash'); - await new Pushy({ appKey: 'demo-app' }).checkUpdate(); + const { client } = await createClient(); + await client.checkUpdate(); const body = JSON.parse((fetchMock.mock.calls[0] as any[])[1].body); expect(body.bundleHash).toBe('a'.repeat(64)); @@ -337,8 +251,8 @@ describe('Pushy server config', () => { const fetchMock = mock(async () => createJsonResponse({ update: false })); (globalThis as any).fetch = fetchMock; - const { Pushy } = await importFreshClient('check-body-no-bundlehash'); - await new Pushy({ appKey: 'demo-app' }).checkUpdate(); + const { client } = await createClient(); + await client.checkUpdate(); const body = JSON.parse((fetchMock.mock.calls[0] as any[])[1].body); expect(body).not.toHaveProperty('bundleHash'); @@ -350,10 +264,8 @@ describe('Pushy server config', () => { createJsonResponse({ upToDate: true, bundleStatus: 'unknownBundle' }) ); - const { Pushy } = await importFreshClient('check-unknown-bundle'); const events: any[] = []; - const client = new Pushy({ - appKey: 'demo-app', + const { client } = await createClient({ logger: (event: any) => events.push(event), }); const result = await client.checkUpdate(); @@ -370,10 +282,8 @@ describe('Pushy server config', () => { createJsonResponse({ upToDate: true, bundleStatus: 'matched' }) ); - const { Pushy } = await importFreshClient('check-matched-bundle'); const events: any[] = []; - const client = new Pushy({ - appKey: 'demo-app', + const { client } = await createClient({ logger: (event: any) => events.push(event), }); await client.checkUpdate(); @@ -389,9 +299,7 @@ describe('Pushy server config', () => { throw fetchError; }); - const { Pushy } = await importFreshClient('after-check-update-error'); - const client = new Pushy({ - appKey: 'demo-app', + const { client } = await createClient({ throwError: true, afterCheckUpdate, }); @@ -414,9 +322,7 @@ describe('Pushy server config', () => { downloadFullUpdate, }); - const { Pushy } = await importFreshClient('skip-current-hash-download'); - const client = new Pushy({ - appKey: 'demo-app', + const { client } = await createClient({ logger, }); @@ -453,11 +359,7 @@ describe('Pushy server config', () => { markSuccess: nativeMarkSuccess, }); - const { Pushy, sharedState } = await importFreshClient( - 'mark-success-awaits-native' - ); - const client = new Pushy({ - appKey: 'demo-app', + const { client, sharedState } = await createClient({ logger, }); @@ -487,10 +389,7 @@ describe('Pushy server config', () => { ); const logger = mock(() => {}); setupClientMocks({ isFirstTime: true, markSuccess: nativeMarkSuccess }); - const { Pushy, sharedState } = await importFreshClient( - 'mark-success-concurrent' - ); - const client = new Pushy({ appKey: 'demo-app', logger }); + const { client, sharedState } = await createClient({ logger }); const first = client.markSuccess(); const second = client.markSuccess(); @@ -511,9 +410,7 @@ describe('Pushy server config', () => { ++calls === 1 ? Promise.reject(Error('busy')) : Promise.resolve() ); setupClientMocks({ isFirstTime: true, markSuccess: nativeMarkSuccess }); - const { Pushy, sharedState } = - await importFreshClient('mark-success-retry'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client, sharedState } = await createClient(); const errors: any[] = []; client.onError((e: any) => { errors.push(e); @@ -548,14 +445,10 @@ describe('Pushy server config', () => { }); setupClientMocks({ reloadUpdate }); - const { Pushy, sharedState } = await importFreshClient( - 'before-reload-switch-version' - ); - sharedState.downloadedHash = 'next-hash'; - const client = new Pushy({ - appKey: 'demo-app', + const { client, sharedState } = await createClient({ beforeReload, }); + sharedState.downloadedHash = 'next-hash'; await client.switchVersion('next-hash'); @@ -569,14 +462,10 @@ describe('Pushy server config', () => { const beforeReload = mock(() => false); setupClientMocks({ reloadUpdate }); - const { Pushy, sharedState } = await importFreshClient( - 'before-reload-skip-switch' - ); - sharedState.downloadedHash = 'next-hash'; - const client = new Pushy({ - appKey: 'demo-app', + const { client, sharedState } = await createClient({ beforeReload, }); + sharedState.downloadedHash = 'next-hash'; await client.switchVersion('next-hash'); @@ -599,9 +488,7 @@ describe('Pushy server config', () => { }); setupClientMocks({ restartApp }); - const { Pushy } = await importFreshClient('before-reload-restart-app'); - const client = new Pushy({ - appKey: 'demo-app', + const { client } = await createClient({ beforeReload, }); @@ -623,8 +510,7 @@ describe('JS exception reporting', () => { } ); - const { Pushy } = await importFreshClient('js-exception-report'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); const error = Object.assign(new Error('boom'), { name: 'TypeError', stack: 'TypeError: boom\n at index.bundlejs:12:34', @@ -675,8 +561,7 @@ describe('JS exception reporting', () => { }, }; - const { Pushy } = await importFreshClient('js-exception-global'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); expect(current).not.toBe(previous); const cleanup = client.enableErrorReporting(); const error = new Error('fatal'); @@ -702,9 +587,7 @@ describe('JS exception reporting', () => { }, }; - const { Pushy } = await importFreshClient('js-exception-opt-out'); - const client = new Pushy({ - appKey: 'demo-app', + const { client } = await createClient({ disableErrorReporting: true, }); expect(current).toBe(previous); @@ -726,8 +609,7 @@ describe('JS exception reporting', () => { setupClientMocks(); const fetch = mock(async () => createJsonResponse({ ok: 1 })); (globalThis as any).fetch = fetch; - const { Pushy } = await importFreshClient('js-exception-disabled'); - const client = new Pushy({ appKey: 'demo-app', disableTelemetry: true }); + const { client } = await createClient({ disableTelemetry: true }); client.captureException(new Error('ignored')); await Promise.resolve(); expect(fetch).not.toHaveBeenCalled(); @@ -742,8 +624,7 @@ describe('error pipeline (onError + stable codes, EH-1/EH-2/EH-3)', () => { throw new Error('offline'); }); - const { Pushy } = await importFreshClient('pipeline-check-failed'); - const client = new Pushy({ appKey: 'demo-app', logger }); + const { client } = await createClient({ logger }); const seen: any[] = []; client.onError((e: any, eventType: string) => { seen.push({ e, eventType }); @@ -770,8 +651,7 @@ describe('error pipeline (onError + stable codes, EH-1/EH-2/EH-3)', () => { throw new Error('offline'); }); - const { Pushy } = await importFreshClient('pipeline-unsubscribe'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); const listener = mock(() => {}); const unsubscribe = client.onError(listener); unsubscribe(); @@ -785,12 +665,8 @@ describe('error pipeline (onError + stable codes, EH-1/EH-2/EH-3)', () => { const logger = mock(() => {}); setupClientMocks({ reloadUpdate }); - const { Pushy, sharedState } = await importFreshClient( - 'pipeline-switch-version-failed' - ); + const { client, sharedState } = await createClient({ logger }); sharedState.downloadedHash = 'next-hash'; - sharedState.applyingUpdate = false; - const client = new Pushy({ appKey: 'demo-app', logger }); const seen: any[] = []; client.onError((e: any, eventType: string) => { seen.push({ e, eventType }); @@ -824,12 +700,8 @@ describe('error pipeline (onError + stable codes, EH-1/EH-2/EH-3)', () => { const reloadUpdate = mock(() => Promise.reject(nativeError)); setupClientMocks({ reloadUpdate }); - const { Pushy, sharedState } = await importFreshClient( - 'pipeline-native-code-preserved' - ); + const { client, sharedState } = await createClient(); sharedState.downloadedHash = 'next-hash'; - sharedState.applyingUpdate = false; - const client = new Pushy({ appKey: 'demo-app' }); const seen: any[] = []; client.onError((e: any, eventType: string) => { seen.push({ e, eventType }); @@ -845,11 +717,8 @@ describe('error pipeline (onError + stable codes, EH-1/EH-2/EH-3)', () => { test('a beforeReload hook throw gets USER_HOOK_ERROR, distinct from pipeline failures (JS2-3)', async () => { setupClientMocks(); - const { Pushy, sharedState } = await importFreshClient( - 'pipeline-user-hook-error' - ); + const { Pushy, sharedState } = await importFreshClient(); sharedState.downloadedHash = 'next-hash'; - sharedState.applyingUpdate = false; const client = new Pushy({ appKey: 'demo-app', beforeReload: () => { @@ -878,7 +747,7 @@ describe('error pipeline (onError + stable codes, EH-1/EH-2/EH-3)', () => { (globalThis as any).fetch = mock(async () => { throw new Error('offline'); }); - const { Pushy } = await importFreshClient('pipeline-check-cache-error'); + const { Pushy } = await importFreshClient(); const states: any[] = []; const client = new Pushy({ appKey: 'demo-app', @@ -905,12 +774,12 @@ describe('error pipeline (onError + stable codes, EH-1/EH-2/EH-3)', () => { Promise.reject(Error('disk full')) ); const logger = mock(() => {}); - setupAndroidApkMocks(downloadAndInstallApk); + setupClientMocks({ + platform: 'android', + downloadAndInstallApk: downloadAndInstallApk, + }); - const { Pushy, sharedState } = - await importFreshClient('pipeline-apk-cause'); - sharedState.apkStatus = null; - const client = new Pushy({ appKey: 'demo-app', logger }); + const { client } = await createClient({ logger }); const seen: any[] = []; client.onError((e: any, eventType: string) => { seen.push({ e, eventType }); @@ -959,12 +828,8 @@ describe('switchVersion operation ownership', () => { }) as unknown as typeof clearTimeout; setupClientMocks({ reloadUpdate: mock(() => Promise.resolve()) }); - const { Pushy, sharedState } = await importFreshClient( - 'switch-watchdog-ownership' - ); const logger = mock((_event: any) => {}); - const client = new Pushy({ - appKey: 'demo-app', + const { client, sharedState } = await createClient({ logger, disableTelemetry: true, disableErrorReporting: true, @@ -1018,11 +883,7 @@ describe('switchVersion operation ownership', () => { }) ); setupClientMocks({ reloadUpdate }); - const { Pushy, sharedState } = await importFreshClient( - 'switch-rejection-ownership' - ); - const client = new Pushy({ - appKey: 'demo-app', + const { client, sharedState } = await createClient({ disableTelemetry: true, disableErrorReporting: true, }); @@ -1084,7 +945,7 @@ describe('downloadUpdate fallback chain', () => { // Mock testUrls to return urls directly (skip actual HEAD ping) mock.module('../utils', () => ({ - __esModule: true, + ...realProjectModules['../utils'], assertWeb: () => true, computeProgress: (received: number, total: number) => total > 0 @@ -1130,9 +991,7 @@ describe('downloadUpdate fallback chain', () => { test('uses diff when available', async () => { const { downloadPatchFromPpk } = setupDownloadMocks(); - const { Pushy, sharedState } = await importFreshClient('dl-diff-ok'); - sharedState.downloadedHash = undefined; - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); const hash = await client.downloadUpdate(updateInfo); @@ -1163,9 +1022,7 @@ describe('downloadUpdate fallback chain', () => { const { downloadPatchFromPackage, downloadFullUpdate } = setupDownloadMocks( { downloadPatchFromPpk } ); - const { Pushy, sharedState } = await importFreshClient('dl-mirror-next'); - sharedState.downloadedHash = undefined; - const client = new Pushy({ appKey: 'demo-app', maxRetries: 0 }); + const { client } = await createClient({ maxRetries: 0 }); expect(await client.downloadUpdate(twoMirrors)).toBe('new-hash'); expect(downloadPatchFromPpk).toHaveBeenCalledTimes(2); @@ -1186,9 +1043,7 @@ describe('downloadUpdate fallback chain', () => { const { downloadPatchFromPackage } = setupDownloadMocks({ downloadPatchFromPpk, }); - const { Pushy, sharedState } = await importFreshClient('dl-mirror-patch'); - sharedState.downloadedHash = undefined; - const client = new Pushy({ appKey: 'demo-app', maxRetries: 0 }); + const { client } = await createClient({ maxRetries: 0 }); expect(await client.downloadUpdate(twoMirrors)).toBe('new-hash'); // Same bytes on every mirror: one attempt, then straight to pdiff. @@ -1206,11 +1061,7 @@ describe('downloadUpdate fallback chain', () => { listener?.({ hash: 'new-hash', received: 50, total: 100 }); }); setupDownloadMocks({ downloadPatchFromPpk, addProgressListener }); - const { Pushy, sharedState } = await importFreshClient( - 'dl-progress-isolated' - ); - sharedState.downloadedHash = undefined; - const client = new Pushy({ appKey: 'demo-app', maxRetries: 0 }); + const { client } = await createClient({ maxRetries: 0 }); const seen: number[] = []; const first = client.downloadUpdate(updateInfo, () => { @@ -1229,9 +1080,7 @@ describe('downloadUpdate fallback chain', () => { test('a version is not "downloaded" until its hash info is persisted', async () => { const setLocalHashInfo = mock(() => Promise.reject(Error('commit failed'))); setupDownloadMocks({ setLocalHashInfo }); - const { Pushy, sharedState } = await importFreshClient('dl-hashinfo-fail'); - sharedState.downloadedHash = undefined; - const client = new Pushy({ appKey: 'demo-app', maxRetries: 0 }); + const { client, sharedState } = await createClient({ maxRetries: 0 }); const seen: any[] = []; client.onError((e: any, eventType: string) => { seen.push({ e, eventType }); @@ -1253,10 +1102,7 @@ describe('downloadUpdate fallback chain', () => { downloadFullUpdate, } = setupDownloadMocks(); const logger = mock(() => {}); - const { Pushy, sharedState } = await importFreshClient('dl-no-artifact'); - sharedState.downloadedHash = undefined; - const client = new Pushy({ - appKey: 'demo-app', + const { client } = await createClient({ logger, disableTelemetry: true, }); @@ -1305,9 +1151,7 @@ describe('downloadUpdate fallback chain', () => { }); }), }); - const { Pushy, sharedState } = await importFreshClient('dl-progress'); - sharedState.downloadedHash = undefined; - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); await client.downloadUpdate(updateInfo, onDownloadProgress); @@ -1324,9 +1168,7 @@ describe('downloadUpdate fallback chain', () => { setupDownloadMocks({ downloadPatchFromPpk: mock(() => Promise.reject(Error('diff fail'))), }); - const { Pushy, sharedState } = await importFreshClient('dl-fallback-pdiff'); - sharedState.downloadedHash = undefined; - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); const hash = await client.downloadUpdate(updateInfo); @@ -1344,9 +1186,7 @@ describe('downloadUpdate fallback chain', () => { downloadPatchFromPpk: mock(() => Promise.reject(Error('diff fail'))), downloadPatchFromPackage: mock(() => Promise.reject(Error('pdiff fail'))), }); - const { Pushy, sharedState } = await importFreshClient('dl-fallback-full'); - sharedState.downloadedHash = undefined; - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); const hash = await client.downloadUpdate(updateInfo); @@ -1362,9 +1202,7 @@ describe('downloadUpdate fallback chain', () => { downloadPatchFromPackage: mock(() => Promise.reject(Error('pdiff fail'))), downloadFullUpdate: mock(() => Promise.reject(Error('full fail'))), }); - const { Pushy, sharedState } = await importFreshClient('dl-all-fail'); - sharedState.downloadedHash = undefined; - const client = new Pushy({ appKey: 'demo-app', maxRetries: 0 }); + const { client } = await createClient({ maxRetries: 0 }); await expect(client.downloadUpdate(updateInfo)).rejects.toThrow( 'error_full_patch_failed' @@ -1377,11 +1215,7 @@ describe('downloadUpdate fallback chain', () => { downloadPatchFromPackage: mock(() => Promise.reject(Error('pdiff fail'))), downloadFullUpdate: mock(() => Promise.reject(Error('full fail'))), }); - const { Pushy, sharedState } = await importFreshClient( - 'dl-negative-retries' - ); - sharedState.downloadedHash = undefined; - const client = new Pushy({ appKey: 'demo-app', maxRetries: -1 }); + const { client } = await createClient({ maxRetries: -1 }); await expect(client.downloadUpdate(updateInfo)).rejects.toThrow( 'error_full_patch_failed' @@ -1402,9 +1236,7 @@ describe('downloadUpdate fallback chain', () => { return Promise.resolve(); }), }); - const { Pushy, sharedState } = await importFreshClient('dl-retry-ok'); - sharedState.downloadedHash = undefined; - const client = new Pushy({ appKey: 'demo-app', maxRetries: 2 }); + const { client } = await createClient({ maxRetries: 2 }); const hash = await client.downloadUpdate(updateInfo); @@ -1426,9 +1258,7 @@ describe('downloadUpdate fallback chain', () => { : Promise.resolve(); }), }); - const { Pushy, sharedState } = await importFreshClient('dl-rank-once'); - sharedState.downloadedHash = undefined; - const client = new Pushy({ appKey: 'demo-app', maxRetries: 3 }); + const { client } = await createClient({ maxRetries: 3 }); expect(await client.downloadUpdate(twoMirrors)).toBe('new-hash'); // diff, pdiff, full: one HEAD race each, not one per attempt. @@ -1447,10 +1277,7 @@ describe('downloadUpdate fallback chain', () => { downloadPatchFromPackage: mock(() => Promise.reject(Error('pdiff fail'))), downloadFullUpdate: mock(() => Promise.reject(Error('full fail'))), }); - const { Pushy, sharedState } = - await importFreshClient('dl-default-retries'); - sharedState.downloadedHash = undefined; - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); await expect(client.downloadUpdate(updateInfo)).rejects.toThrow(); // 1 initial + 3 retries = 4 calls @@ -1463,9 +1290,7 @@ describe('downloadUpdate fallback chain', () => { downloadPatchFromPackage: mock(() => Promise.reject(Error('pdiff fail'))), downloadFullUpdate: mock(() => Promise.reject(Error('full fail'))), }); - const { Pushy, sharedState } = await importFreshClient('dl-retry-exhaust'); - sharedState.downloadedHash = undefined; - const client = new Pushy({ appKey: 'demo-app', maxRetries: 2 }); + const { client } = await createClient({ maxRetries: 2 }); await expect(client.downloadUpdate(updateInfo)).rejects.toThrow( 'error_full_patch_failed' @@ -1478,9 +1303,7 @@ describe('downloadUpdate fallback chain', () => { downloadPatchFromPackage: mock(() => Promise.reject(Error('pdiff fail'))), downloadFullUpdate: mock(() => Promise.reject(Error('full fail'))), }); - const { Pushy, sharedState } = await importFreshClient('dl-failed-code'); - sharedState.downloadedHash = undefined; - const client = new Pushy({ appKey: 'demo-app', maxRetries: 0 }); + const { client } = await createClient({ maxRetries: 0 }); const seen: any[] = []; client.onError((e: any, eventType: string) => { seen.push({ e, eventType }); @@ -1507,11 +1330,7 @@ describe('downloadUpdate fallback chain', () => { downloadPatchFromPackage: mock(() => Promise.reject(Error('pdiff fail'))), downloadFullUpdate: mock(() => Promise.reject(patchError)), }); - const { Pushy, sharedState } = await importFreshClient( - 'dl-patch-failed-code' - ); - sharedState.downloadedHash = undefined; - const client = new Pushy({ appKey: 'demo-app', maxRetries: 0 }); + const { client } = await createClient({ maxRetries: 0 }); const seen: any[] = []; client.onError((e: any, eventType: string) => { seen.push({ e, eventType }); @@ -1538,10 +1357,6 @@ describe('downloadUpdate fallback chain', () => { }) ); setupDownloadMocks({ downloadFullUpdate }); - const { Pushy, sharedState } = await importFreshClient( - 'dl-concurrent-dedup' - ); - sharedState.downloadedHash = undefined; // Only a full URL so the full strategy is the one exercised. const fullOnlyInfo = { update: true as const, @@ -1553,7 +1368,7 @@ describe('downloadUpdate fallback chain', () => { }; // No onDownloadProgress passed — old code only deduped when a progress // handler was registered, so this exercises the new promise-table dedup. - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); const first = client.downloadUpdate(fullOnlyInfo); const second = client.downloadUpdate(fullOnlyInfo); @@ -1581,11 +1396,7 @@ describe('downloadUpdate fallback chain', () => { ), downloadFullUpdate: mock(() => Promise.reject(Error('full offline'))), }); - const { Pushy, sharedState } = await importFreshClient( - 'dl-exhausted-strategy' - ); - sharedState.downloadedHash = undefined; - const client = new Pushy({ appKey: 'demo-app', maxRetries: 2 }); + const { client } = await createClient({ maxRetries: 2 }); await expect(client.downloadUpdate(updateInfo)).rejects.toThrow(); @@ -1611,9 +1422,7 @@ describe('downloadUpdate fallback chain', () => { downloadPatchFromPackage: mock(reject), downloadFullUpdate: mock(reject), }); - const { Pushy, sharedState } = await importFreshClient('dl-all-exhausted'); - sharedState.downloadedHash = undefined; - const client = new Pushy({ appKey: 'demo-app', maxRetries: 3 }); + const { client } = await createClient({ maxRetries: 3 }); const err: any = await client .downloadUpdate(updateInfo) @@ -1635,11 +1444,10 @@ describe('downloadUpdate fallback chain', () => { ), }); const logger = mock(() => {}); - const { Pushy, sharedState } = await importFreshClient( - 'dl-fallback-patch-code' - ); - sharedState.downloadedHash = undefined; - const client = new Pushy({ appKey: 'demo-app', maxRetries: 0, logger }); + const { client } = await createClient({ + maxRetries: 0, + logger, + }); expect(await client.downloadUpdate(updateInfo)).toBe('new-hash'); @@ -1661,11 +1469,7 @@ describe('downloadUpdate fallback chain', () => { const { fetchWithTimeout } = setupDownloadMocks({ downloadPatchFromPpk: mock(() => Promise.reject(timeout)), }); - const { Pushy, sharedState } = await importFreshClient( - 'dl-fallback-transport-report' - ); - sharedState.downloadedHash = undefined; - const client = new Pushy({ appKey: 'demo-app', maxRetries: 0 }); + const { client } = await createClient({ maxRetries: 0 }); expect(await client.downloadUpdate(updateInfo)).toBe('new-hash'); @@ -1683,11 +1487,7 @@ describe('downloadUpdate fallback chain', () => { downloadPatchFromPpk: mock(() => Promise.reject(patchError)), downloadPatchFromPackage: mock(() => Promise.reject(timeout)), }); - const { Pushy, sharedState } = await importFreshClient( - 'dl-fallback-patch-report' - ); - sharedState.downloadedHash = undefined; - const client = new Pushy({ appKey: 'demo-app', maxRetries: 0 }); + const { client } = await createClient({ maxRetries: 0 }); expect(await client.downloadUpdate(updateInfo)).toBe('new-hash'); @@ -1710,11 +1510,10 @@ describe('downloadUpdate fallback chain', () => { downloadPatchFromPpk, }); const logger = mock(() => {}); - const { Pushy, sharedState } = await importFreshClient( - 'dl-prefixed-patch-code' - ); - sharedState.downloadedHash = undefined; - const client = new Pushy({ appKey: 'demo-app', maxRetries: 0, logger }); + const { client } = await createClient({ + maxRetries: 0, + logger, + }); expect(await client.downloadUpdate(twoMirrors)).toBe('new-hash'); @@ -1733,8 +1532,7 @@ describe('Cresc class', () => { test('uses Cresc server endpoints', async () => { setupClientMocks(); - const { Cresc } = await importFreshClient('cresc-endpoints'); - const client = new Cresc({ appKey: 'demo-app' }); + const { client } = await createClient({}, 'Cresc'); expect(client.getConfiguredCheckEndpoints()).toEqual([ 'https://api.cresc.dev', @@ -1746,14 +1544,9 @@ describe('Cresc class', () => { setupClientMocks(); // Override i18n mock AFTER setupClientMocks to avoid being overwritten const setLocale = mock(() => {}); - mock.module('../i18n', () => ({ - default: { - t: (key: string) => key, - setLocale, - }, - })); + mockI18n({ setLocale }); - const { Cresc } = await importFreshClient('cresc-locale'); + const { Cresc } = await importFreshClient(); const client = new Cresc({ appKey: 'demo-app' }); expect(client.clientType).toBe('Cresc'); @@ -1763,14 +1556,9 @@ describe('Cresc class', () => { test('explicit locale option overrides clientType default', async () => { setupClientMocks(); const setLocale = mock(() => {}); - mock.module('../i18n', () => ({ - default: { - t: (key: string) => key, - setLocale, - }, - })); + mockI18n({ setLocale }); - const { Pushy } = await importFreshClient('pushy-locale-override'); + const { Pushy } = await importFreshClient(); // Pushy defaults to 'zh'; an explicit 'en' must win. new Pushy({ appKey: 'demo-app', locale: 'en' }); @@ -1780,7 +1568,7 @@ describe('Cresc class', () => { test('Cresc is instance of Pushy', async () => { setupClientMocks(); - const { Cresc, Pushy } = await importFreshClient('cresc-instanceof'); + const { Cresc, Pushy } = await importFreshClient(); const client = new Cresc({ appKey: 'demo-app' }); expect(client).toBeInstanceOf(Pushy); @@ -1790,14 +1578,15 @@ describe('Cresc class', () => { test('Cresc custom server overrides default endpoints', async () => { setupClientMocks(); - const { Cresc } = await importFreshClient('cresc-custom-server'); - const client = new Cresc({ - appKey: 'demo-app', - server: { - main: ['https://custom.example.com'], - queryUrls: ['https://q.example.com'], + const { client } = await createClient( + { + server: { + main: ['https://custom.example.com'], + queryUrls: ['https://q.example.com'], + }, }, - }); + 'Cresc' + ); expect(client.getConfiguredCheckEndpoints()).toEqual([ 'https://custom.example.com', @@ -1811,10 +1600,9 @@ describe('resetToPackagedBundle', () => { const resetToPackagedBundle = mock(() => Promise.resolve()); const restartApp = mock(() => Promise.resolve()); setupClientMocks({ resetToPackagedBundle, restartApp }); - const { Pushy, sharedState } = await importFreshClient('reset-basic'); + const { client, sharedState } = await createClient(); sharedState.downloadedHash = 'stale-hash'; sharedState.marked = true; - const client = new Pushy({ appKey: 'demo-app' }); const result = await client.resetToPackagedBundle(); @@ -1830,8 +1618,7 @@ describe('resetToPackagedBundle', () => { const resetToPackagedBundle = mock(() => Promise.resolve()); const restartApp = mock(() => Promise.resolve()); setupClientMocks({ resetToPackagedBundle, restartApp }); - const { Pushy } = await importFreshClient('reset-restart'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); await client.resetToPackagedBundle({ restart: true }); @@ -1843,8 +1630,7 @@ describe('resetToPackagedBundle', () => { // Simulates new JS arriving via hot update onto an older binary. Like the // other update-flow APIs this must not throw by default. setupClientMocks({ resetToPackagedBundle: null }); - const { Pushy } = await importFreshClient('reset-unsupported'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); const seen: any[] = []; client.onError((err: any) => seen.push(err)); @@ -1857,8 +1643,7 @@ describe('resetToPackagedBundle', () => { test('throwError option makes an unsupported reset throw', async () => { setupClientMocks({ resetToPackagedBundle: null }); - const { Pushy } = await importFreshClient('reset-unsupported-throw'); - const client = new Pushy({ appKey: 'demo-app', throwError: true }); + const { client } = await createClient({ throwError: true }); await expect(client.resetToPackagedBundle()).rejects.toMatchObject({ code: 'RESET_FAILED', @@ -1870,9 +1655,8 @@ describe('resetToPackagedBundle', () => { Promise.reject(Error('disk full')) ); setupClientMocks({ resetToPackagedBundle }); - const { Pushy, sharedState } = await importFreshClient('reset-native-fail'); + const { client, sharedState } = await createClient(); sharedState.downloadedHash = 'stale-hash'; - const client = new Pushy({ appKey: 'demo-app' }); const seen: any[] = []; client.onError((err: any) => seen.push(err)); @@ -1898,10 +1682,11 @@ describe('downloadAndInstallApk apkStatus (JS-3)', () => { } return Promise.resolve(); }); - setupAndroidApkMocks(downloadAndInstallApk); - const { Pushy, sharedState } = await importFreshClient('apk-retry'); - sharedState.apkStatus = null; - const client = new Pushy({ appKey: 'demo-app' }); + setupClientMocks({ + platform: 'android', + downloadAndInstallApk: downloadAndInstallApk, + }); + const { client, sharedState } = await createClient(); await client.downloadAndInstallApk('https://example.com/app.apk'); // Failure must leave apkStatus reset (null), not stuck at 'downloaded'. @@ -1917,7 +1702,7 @@ describe('downloadAndInstallApk apkStatus (JS-3)', () => { describe('client singleton', () => { test('a second client with a different appKey throws SINGLETON_VIOLATION', async () => { setupClientMocks(); - const { Pushy } = await importFreshClient('singleton-different-appkey'); + const { Pushy } = await importFreshClient(); const client1 = new Pushy({ appKey: 'app-1' }); expect(client1.options.appKey).toBe('app-1'); @@ -1932,7 +1717,7 @@ describe('client singleton', () => { test('a Cresc client after a Pushy client throws SINGLETON_VIOLATION', async () => { setupClientMocks(); - const { Pushy, Cresc } = await importFreshClient('singleton-cross-type'); + const { Pushy, Cresc } = await importFreshClient(); new Pushy({ appKey: 'app-1' }); let error: any; @@ -1946,7 +1731,7 @@ describe('client singleton', () => { test('re-creating the same client is idempotent and applies the new options', async () => { setupClientMocks(); - const { Pushy } = await importFreshClient('singleton-idempotent'); + const { Pushy } = await importFreshClient(); const client1 = new Pushy({ appKey: 'app-1', debug: true }); // e.g. dev fast refresh re-running the module that builds the client const client2 = new Pushy({ @@ -1961,8 +1746,7 @@ describe('client singleton', () => { test('setOptions bumps optionsVersion and notifies subscribers', async () => { setupClientMocks(); - const { Pushy } = await importFreshClient('singleton-options-version'); - const client = new Pushy({ appKey: 'app-1' }); + const { client } = await createClient({ appKey: 'app-1' }); const initialVersion = client.optionsVersion; const listener = mock(() => {}); const unsubscribe = client.onOptionsChange(listener); @@ -1978,8 +1762,7 @@ describe('client singleton', () => { test('a second concurrent provider mount claim throws SINGLETON_VIOLATION', async () => { setupClientMocks(); - const { Pushy } = await importFreshClient('singleton-provider-claim'); - const client = new Pushy({ appKey: 'app-1' }); + const { client } = await createClient({ appKey: 'app-1' }); const release = client.claimProviderMount(); let error: any; @@ -2000,7 +1783,7 @@ 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'); + const { Pushy } = await importFreshClient(); new Pushy({ appKey: 'demo-app', updateStrategy: 'silentAndLater' }); expect(syncNativeConfig).toHaveBeenCalled(); @@ -2023,7 +1806,7 @@ describe('syncNativeConfig', () => { // only the server's explicit forceBoot may still activate. const syncNativeConfig = mock(() => Promise.resolve()); setupClientMocks({ syncNativeConfig }); - const { Pushy } = await importFreshClient('sync-config-no-autocheck'); + const { Pushy } = await importFreshClient(); new Pushy({ appKey: 'demo-app', updateStrategy: 'silentAndNow', @@ -2039,7 +1822,7 @@ describe('syncNativeConfig', () => { test('silent strategy with automatic checks still activates', async () => { const syncNativeConfig = mock(() => Promise.resolve()); setupClientMocks({ syncNativeConfig }); - const { Pushy } = await importFreshClient('sync-config-autocheck'); + const { Pushy } = await importFreshClient(); new Pushy({ appKey: 'demo-app', updateStrategy: 'silentAndNow', @@ -2055,7 +1838,7 @@ describe('syncNativeConfig', () => { test('disableNativeCheck writes a disabling config the natives bail on', async () => { const syncNativeConfig = mock(() => Promise.resolve()); setupClientMocks({ syncNativeConfig }); - const { Pushy } = await importFreshClient('sync-config-optout'); + const { Pushy } = await importFreshClient(); new Pushy({ appKey: 'demo-app', updateStrategy: 'silentAndNow', @@ -2074,7 +1857,7 @@ describe('syncNativeConfig', () => { test('alert strategies keep activation with JS (afterDownload none)', async () => { const syncNativeConfig = mock(() => Promise.resolve()); setupClientMocks({ syncNativeConfig }); - const { Pushy } = await importFreshClient('sync-config-2'); + const { Pushy } = await importFreshClient(); new Pushy({ appKey: 'demo-app' }); const config = JSON.parse( @@ -2086,8 +1869,7 @@ describe('syncNativeConfig', () => { 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' }); + const { client } = await createClient(); client.setOptions({ updateStrategy: 'silentAndNow' }); await Promise.resolve(); await Promise.resolve(); @@ -2106,8 +1888,7 @@ describe('syncNativeConfig', () => { // disableNativeCheck writes the disabled state. const syncNativeConfig = mock(() => Promise.resolve()); setupClientMocks({ syncNativeConfig }); - const { Pushy } = await importFreshClient('sync-config-transient'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); @@ -2142,9 +1923,7 @@ describe('syncNativeConfig', () => { 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', + const { client } = await createClient({ overridePackageVersion: '9.9.9', }); @@ -2158,8 +1937,7 @@ describe('syncNativeConfig', () => { 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' }); + const { client } = await createClient(); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); @@ -2178,8 +1956,7 @@ describe('syncNativeConfig', () => { }) ); setupClientMocks({ syncNativeConfig }); - const { Pushy } = await importFreshClient('sync-config-serialized'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); client.setOptions({ updateStrategy: 'silentAndNow' }); expect(syncNativeConfig).toHaveBeenCalledTimes(1); @@ -2205,8 +1982,7 @@ describe('syncNativeConfig', () => { }) ); setupClientMocks({ syncNativeConfig }); - const { Pushy } = await importFreshClient('sync-config-revert-race'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); // Finish the initial alert-strategy value (A). resolvers[0](); @@ -2242,8 +2018,7 @@ describe('syncNativeConfig', () => { : Promise.resolve(); }); setupClientMocks({ syncNativeConfig }); - const { Pushy } = await importFreshClient('sync-config-retry'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); @@ -2256,8 +2031,7 @@ describe('syncNativeConfig', () => { test('switching ownership to native and back writes the config again', async () => { const syncNativeConfig = mock(() => Promise.resolve()); setupClientMocks({ syncNativeConfig }); - const { Pushy } = await importFreshClient('sync-config-owner-roundtrip'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); @@ -2277,9 +2051,7 @@ describe('syncNativeConfig', () => { test('native ownership suppresses constructor and setOptions writes until handed back', async () => { const syncNativeConfig = mock(() => Promise.resolve()); setupClientMocks({ syncNativeConfig }); - const { Pushy } = await importFreshClient('sync-config-native-owner'); - const client = new Pushy({ - appKey: 'demo-app', + const { client } = await createClient({ nativeConfigSource: 'native', }); client.setOptions({ updateStrategy: 'silentAndLater' }); @@ -2292,7 +2064,7 @@ describe('syncNativeConfig', () => { test('an older native module without the method is skipped silently', async () => { setupClientMocks(); - const { Pushy } = await importFreshClient('sync-config-4'); + const { Pushy } = await importFreshClient(); // Must not throw even though PushyModule lacks syncNativeConfig. new Pushy({ appKey: 'demo-app' }); }); @@ -2332,8 +2104,7 @@ describe('native check cache reuse', () => { 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' }); + const { client } = await createClient(); expect(await client.checkUpdate()).toEqual(cachedResult); expect(getNativeCheckCache).toHaveBeenCalled(); @@ -2360,8 +2131,7 @@ describe('native check cache reuse', () => { (globalThis as any).fetch = mock(async () => createJsonResponse(networkResult) ); - const { Pushy } = await importFreshClient('native-cache-stale'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); expect(await client.checkUpdate()).toEqual(networkResult); }); @@ -2374,8 +2144,7 @@ describe('native check cache reuse', () => { (globalThis as any).fetch = mock(async () => createJsonResponse(networkResult) ); - const { Pushy } = await importFreshClient('native-cache-bad'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); expect(await client.checkUpdate()).toEqual(networkResult); }); @@ -2401,8 +2170,7 @@ describe('native check cache reuse', () => { (globalThis as any).fetch = mock(async () => createJsonResponse(networkResult) ); - const { Pushy } = await importFreshClient('native-cache-request-key'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); expect(await client.checkUpdate({ toHash: 'requested-hash' })).toEqual( networkResult @@ -2438,8 +2206,7 @@ describe('native check cache reuse', () => { ) ); setupClientMocks({ getNativeCheckCache, syncNativeConfig }); - const { Pushy } = await importFreshClient('native-cache-key-order'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); expect(await client.checkUpdate()).toEqual(cachedResult); }); @@ -2462,8 +2229,7 @@ describe('native check cache reuse', () => { ) ); setupClientMocks({ getNativeCheckCache, syncNativeConfig }); - const { Pushy } = await importFreshClient('native-cache-undefined-extra'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); expect(await client.checkUpdate({ omitted: undefined })).toEqual( cachedResult @@ -2487,8 +2253,7 @@ describe('native check cache reuse', () => { (globalThis as any).fetch = mock(async () => createJsonResponse(networkResult) ); - const { Pushy } = await importFreshClient('native-cache-config-key'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); expect(await client.checkUpdate()).toEqual(networkResult); }); @@ -2511,8 +2276,7 @@ describe('native check cache reuse', () => { (globalThis as any).fetch = mock(async () => createJsonResponse(networkResult) ); - const { Pushy } = await importFreshClient('native-cache-expired-once'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); const parse = spyOn(JSON, 'parse'); let parsedCacheFields: unknown[][] = []; @@ -2541,8 +2305,7 @@ describe('native check cache reuse', () => { (globalThis as any).fetch = mock(async () => createJsonResponse(networkResult) ); - const { Pushy } = await importFreshClient('native-cache-absent-once'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); await client.checkUpdate(); await client.checkUpdate({ toHash: 'other' }); @@ -2576,8 +2339,7 @@ describe('native check cache reuse', () => { ); }); setupClientMocks({ getNativeCheckCache, syncNativeConfig }); - const { Pushy } = await importFreshClient('native-cache-canonical-config'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); expect(await client.checkUpdate()).toEqual(cachedResult); }); @@ -2592,8 +2354,7 @@ describe('native check cache reuse', () => { (globalThis as any).fetch = mock(async () => createJsonResponse({ upToDate: true }) ); - const { Pushy } = await importFreshClient('mark-js-check-once'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); const flushAll = async () => { for (let i = 0; i < 5; i++) { await Promise.resolve(); @@ -2640,8 +2401,7 @@ describe('native check cache reuse', () => { (globalThis as any).fetch = mock(async () => createJsonResponse(networkResult) ); - const { Pushy } = await importFreshClient('native-cache-future'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); expect(await client.checkUpdate()).toEqual(networkResult); }); @@ -2657,8 +2417,7 @@ describe('checkUpdate request identity', () => { setupClientMocks(); const fetchMock = mock(async () => createJsonResponse({ upToDate: true })); (globalThis as any).fetch = fetchMock; - const { Pushy } = await importFreshClient('check-identity-same'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); await client.checkUpdate({ channel: 'beta' }); await client.checkUpdate({ channel: 'beta' }); @@ -2673,8 +2432,7 @@ describe('checkUpdate request identity', () => { return createJsonResponse({ update: true, hash: `hash-${body.channel}` }); }); (globalThis as any).fetch = fetchMock; - const { Pushy } = await importFreshClient('check-identity-extra'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); const a = await client.checkUpdate({ channel: 'a' }); const b = await client.checkUpdate({ channel: 'b' }); @@ -2692,8 +2450,7 @@ describe('checkUpdate request identity', () => { return createJsonResponse({ upToDate: true }); }); (globalThis as any).fetch = fetchMock; - const { Pushy } = await importFreshClient('check-identity-forge'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); await client.checkUpdate({ packageVersion: '9.9.9', hash: 'forged' }); @@ -2712,8 +2469,7 @@ describe('checkUpdate request identity', () => { return createJsonResponse({ update: true, hash: 'old-good' }); }); (globalThis as any).fetch = fetchMock; - const { Pushy } = await importFreshClient('check-identity-stale'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); expect(((await client.checkUpdate()) as any).hash).toBe('old-good'); @@ -2739,8 +2495,7 @@ describe('JS -> native check completion signal', () => { (globalThis as any).fetch = mock(async () => createJsonResponse({ upToDate: true }) ); - const { Pushy } = await importFreshClient('js-check-completed'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); await client.checkUpdate(); await Promise.resolve(); @@ -2759,8 +2514,7 @@ describe('JS -> native check completion signal', () => { (globalThis as any).fetch = mock(async () => { throw new Error('offline'); }); - const { Pushy } = await importFreshClient('js-check-failed'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); expect(await client.checkUpdate()).toBeUndefined(); expect(markJsCheckCompleted).not.toHaveBeenCalled(); @@ -2772,12 +2526,12 @@ describe('JS -> native check completion signal', () => { (globalThis as any).fetch = mock(async () => createJsonResponse({ upToDate: true }) ); - const { Pushy } = await importFreshClient('js-check-reject'); + const { Pushy } = await importFreshClient(); const client = new Pushy({ appKey: 'demo-app' }); expect(await client.checkUpdate()).toEqual({ upToDate: true }); setupClientMocks(); - const { Pushy: OldPushy } = await importFreshClient('js-check-old-native'); + const { Pushy: OldPushy } = await importFreshClient(); const old = new OldPushy({ appKey: 'demo-app' }); expect(await old.checkUpdate()).toEqual({ upToDate: true }); }); @@ -2796,8 +2550,7 @@ describe('check response schema gate', () => { } return createJsonResponse([]); }); - const { Pushy } = await importFreshClient('schema-gate-fallback'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); expect(await client.checkUpdate()).toEqual({ upToDate: true }); expect(checks).toBeGreaterThanOrEqual(2); @@ -2810,8 +2563,7 @@ describe('check response schema gate', () => { String(url).includes('/checkUpdate/') ? { error: 'internal' } : [] ) ); - const { Pushy } = await importFreshClient('schema-gate-all'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); const seen: any[] = []; client.onError((e: any) => { seen.push(e); @@ -2828,11 +2580,8 @@ describe('setOptions (1.3 / 1.11)', () => { test('applies a locale change at runtime', async () => { const setLocale = mock(() => {}); setupClientMocks(); - mock.module('../i18n', () => ({ - default: { t: (key: string) => key, setLocale }, - })); - const { Pushy } = await importFreshClient('set-options-locale'); - const client = new Pushy({ appKey: 'demo-app', locale: 'en' }); + mockI18n({ setLocale }); + const { client } = await createClient({ locale: 'en' }); expect(setLocale).toHaveBeenLastCalledWith('en'); client.setOptions({ locale: 'zh' }); @@ -2841,7 +2590,7 @@ describe('setOptions (1.3 / 1.11)', () => { test('debug gates the verbose log output in release builds', async () => { setupClientMocks(); - const { Pushy } = await importFreshClient('set-options-debug'); + const { Pushy } = await importFreshClient(); const { log } = await import('../utils'); const logSpy = spyOn(console, 'log').mockImplementation(() => {}); try { @@ -2880,8 +2629,7 @@ describe('getRemoteEndpoints (1.4 / 6.8)', () => { await new Promise((resolve) => setTimeout(resolve, 10)); return createJsonResponse(['https://edge.example.com']); }); - const { Pushy } = await importFreshClient('remote-endpoints-ok-gate'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); expect(await client.getRemoteEndpoints()).toEqual([ 'https://edge.example.com', @@ -2900,8 +2648,7 @@ describe('getRemoteEndpoints (1.4 / 6.8)', () => { null, ]) ); - const { Pushy } = await importFreshClient('remote-endpoints-https-only'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); expect(await client.getRemoteEndpoints()).toEqual([ 'https://edge.example.com', @@ -2914,8 +2661,7 @@ describe('getRemoteEndpoints (1.4 / 6.8)', () => { (globalThis as any).fetch = mock(async () => createJsonResponse({ endpoints: ['https://edge.example.com'] }) ); - const { Pushy } = await importFreshClient('remote-endpoints-not-list'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); expect(await client.getRemoteEndpoints()).toEqual([]); }); @@ -2948,8 +2694,7 @@ describe('native check cache schema gate (1.5)', () => { setupClientMocks({ getNativeCheckCache, syncNativeConfig }); const fetchMock = mock(async () => createJsonResponse({ upToDate: true })); (globalThis as any).fetch = fetchMock; - const { Pushy } = await importFreshClient('native-cache-schema-gate'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); expect(await client.checkUpdate()).toEqual({ upToDate: true }); expect(getNativeCheckCache).toHaveBeenCalled(); @@ -2970,10 +2715,8 @@ describe('switchVersion result and reload watchdog (1.7)', () => { globalThis.setTimeout = ((fn: (...args: any[]) => void) => realSetTimeout(fn, 0)) as unknown as typeof setTimeout; const logger = mock(() => {}); - const { Pushy, sharedState } = await importFreshClient('switch-watchdog'); + const { client, sharedState } = await createClient({ logger }); sharedState.downloadedHash = 'next-hash'; - sharedState.applyingUpdate = false; - const client = new Pushy({ appKey: 'demo-app', logger }); expect(await client.switchVersion('next-hash')).toBe(true); expect(sharedState.applyingUpdate).toBe(true); @@ -2997,10 +2740,7 @@ describe('switchVersion result and reload watchdog (1.7)', () => { test('resolves false when there is nothing to switch to', async () => { setupClientMocks(); - const { Pushy, sharedState } = await importFreshClient('switch-nothing'); - sharedState.downloadedHash = undefined; - sharedState.applyingUpdate = false; - const client = new Pushy({ appKey: 'demo-app' }); + const { client, sharedState } = await createClient(); expect(await client.switchVersion('next-hash')).toBe(false); expect(sharedState.applyingUpdate).toBe(false); @@ -3011,12 +2751,8 @@ describe('switchVersion result and reload watchdog (1.7)', () => { Promise.reject(new Error('[INVALID_OPTIONS] empty hash')) ); setupClientMocks({ reloadUpdate }); - const { Pushy, sharedState } = await importFreshClient( - 'switch-prefixed-code' - ); + const { client, sharedState } = await createClient(); sharedState.downloadedHash = 'next-hash'; - sharedState.applyingUpdate = false; - const client = new Pushy({ appKey: 'demo-app' }); const seen: any[] = []; client.onError((e: any) => { seen.push(e); @@ -3047,8 +2783,7 @@ describe('check dedup slot ownership (P4)', () => { } return createJsonResponse({ upToDate: true }); }); - const { Pushy } = await importFreshClient('dedup-slot'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); const first = client.checkUpdate({ toHash: 'slow-fail' }); const second = client.checkUpdate({ toHash: 'fast-ok' }); @@ -3068,13 +2803,7 @@ describe('check dedup slot ownership (P4)', () => { describe('non-2xx check responses (P4)', () => { test('the error text is truncated to 200 characters', async () => { setupClientMocks(); - mock.module('../i18n', () => ({ - default: { - t: (key: string, params?: Record) => - params ? `${key}${JSON.stringify(params)}` : key, - setLocale: mock(() => {}), - }, - })); + mockI18n({ withParams: true }); (globalThis as any).fetch = mock(async (url: string) => { if (!url.includes('/checkUpdate/')) { throw new Error('no mirrors'); @@ -3086,8 +2815,7 @@ describe('non-2xx check responses (P4)', () => { json: async () => ({}), }; }); - const { Pushy } = await importFreshClient('http-error-truncated'); - const client = new Pushy({ appKey: 'demo-app' }); + const { client } = await createClient(); const seen: any[] = []; client.onError((e: any) => { seen.push(e); @@ -3104,21 +2832,12 @@ describe('non-2xx check responses (P4)', () => { describe('appKey requirement (P4)', () => { const setupPlatform = (os: string) => { setupClientMocks(); - mock.module('react-native', () => ({ - Platform: { OS: os, Version: 1 }, - DeviceEventEmitter: { - addListener: mock(() => ({ remove: mock(() => {}) })), - }, - NativeEventEmitter: class { - addListener = mock(() => ({ remove: mock(() => {}) })); - removeAllListeners = mock(() => {}); - }, - })); + mockPlatform({ OS: os, Version: 1 }); }; test('harmony without an appKey fails fast with APPKEY_REQUIRED', async () => { setupPlatform('harmony'); - const { Pushy } = await importFreshClient('appkey-harmony'); + const { Pushy } = await importFreshClient(); let error: any; try { @@ -3133,7 +2852,7 @@ describe('appKey requirement (P4)', () => { setupPlatform('web'); const consoleWarn = spyOn(console, 'warn').mockImplementation(() => {}); try { - const { Pushy } = await importFreshClient('appkey-web'); + const { Pushy } = await importFreshClient(); expect(() => new Pushy({ appKey: '' })).not.toThrow(); } finally { consoleWarn.mockRestore(); diff --git a/src/__tests__/context.test.ts b/src/__tests__/context.test.ts index 21a09c81..2e4b0717 100644 --- a/src/__tests__/context.test.ts +++ b/src/__tests__/context.test.ts @@ -1,10 +1,10 @@ -import { afterAll, describe, expect, test } from 'bun:test'; +import { describe, expect, test } from 'bun:test'; import React from 'react'; import TestRenderer from 'react-test-renderer'; +import { setDevForFile } from './helpers'; // Must set __DEV__ before importing context.ts -const _origDEV = (globalThis as any).__DEV__; -(globalThis as any).__DEV__ = true; +setDevForFile(true); const { useUpdate, UpdateContext } = await import('../context'); const { default: i18n } = await import('../i18n'); @@ -22,10 +22,6 @@ const renderHook = (hook: () => T) => { }; describe('context', () => { - afterAll(() => { - (globalThis as any).__DEV__ = _origDEV; - }); - test('useUpdate throws error when used outside UpdateProvider in __DEV__', () => { expect(() => renderHook(() => useUpdate())).toThrow( i18n.t('error_use_update_outside_provider') diff --git a/src/__tests__/core.test.ts b/src/__tests__/core.test.ts index e9489624..580a2972 100644 --- a/src/__tests__/core.test.ts +++ b/src/__tests__/core.test.ts @@ -1,4 +1,6 @@ -import { afterEach, describe, expect, mock, spyOn, test } from 'bun:test'; +import { describe, expect, mock, spyOn, test } from 'bun:test'; +import { importFreshClient, mockI18n, restoreDevAfterEach } from './helpers'; +import { baseReactNative } from './setup'; // In Bun, top-level imports are cached. // We can use mock.module to change the implementation of a module, @@ -8,13 +10,13 @@ import { afterEach, describe, expect, mock, spyOn, test } from 'bun:test'; // Actually, bun test runs each file in its own environment usually, // BUT if we run multiple test files in one process, they might share the cache. const importFreshCore = (cacheKey: string) => import(`../core?${cacheKey}`); -const importFreshClient = (cacheKey: string) => import(`../client?${cacheKey}`); describe('core info parsing', () => { test('should call error when currentVersionInfo is invalid JSON', async () => { const consoleError = spyOn(console, 'error').mockImplementation(() => {}); mock.module('react-native', () => ({ + ...baseReactNative(), Platform: { OS: 'ios', Version: 13, @@ -63,6 +65,7 @@ describe('core info parsing', () => { const mockSetLocalHashInfo = mock(() => {}); mock.module('react-native', () => ({ + ...baseReactNative(), Platform: { OS: 'ios', Version: 13, @@ -104,6 +107,7 @@ describe('core info parsing', () => { describe('client os label', () => { const mockPlatform = (platform: Record) => { mock.module('react-native', () => ({ + ...baseReactNative(), Platform: platform, DeviceEventEmitter: { addListener: mock(() => ({ remove: mock(() => {}) })), @@ -144,10 +148,7 @@ describe('client os label', () => { }); describe('web platform', () => { - const origDev = (globalThis as any).__DEV__; - afterEach(() => { - (globalThis as any).__DEV__ = origDev; - }); + restoreDevAfterEach(); test('constants are explicit empties, no native calls, no rollback telemetry (1.1)', async () => { // On web PushyModule is a Proxy of noops; reading the constants off it @@ -156,6 +157,7 @@ describe('web platform', () => { const setUuid = mock(() => {}); const getBundleHash = mock(() => Promise.resolve('sha')); mock.module('react-native', () => ({ + ...baseReactNative(), Platform: { OS: 'web', Version: undefined, @@ -199,14 +201,12 @@ describe('web platform', () => { // fix every page load POSTed a fake rollback to /report/{appKey}. (globalThis as any).__DEV__ = false; mock.module('../core', () => ({ ...core })); - mock.module('../i18n', () => ({ - default: { t: (key: string) => key, setLocale: () => {} }, - })); + mockI18n(); const fetchMock = mock(async () => { throw new Error('unexpected network request'); }); (globalThis as any).fetch = fetchMock; - const { Pushy } = await importFreshClient('web-constants'); + const { Pushy } = await importFreshClient(); const client = new Pushy({ appKey: 'web-app' }); await new Promise((resolve) => setTimeout(resolve, 0)); diff --git a/src/__tests__/helpers.ts b/src/__tests__/helpers.ts new file mode 100644 index 00000000..05ddb199 --- /dev/null +++ b/src/__tests__/helpers.ts @@ -0,0 +1,124 @@ +// Shared test scaffolding for the JS client/provider tests. Module mocks +// installed here follow setup.ts's rule (they live for one test) and always +// pass the real module namespace through, so a mock that outlives its test +// can never leave a module with missing exports behind. +import { afterAll, afterEach, mock } from 'bun:test'; +import { baseReactNative, realProjectModules } from './setup'; + +export const createJsonResponse = (payload: unknown) => + ({ + ok: true, + status: 200, + text: async () => JSON.stringify(payload), + json: async () => payload, + }) as Response; + +/** Lets pending promise callbacks and zero-delay timers run. */ +export const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +const devGlobal = globalThis as { __DEV__?: boolean }; + +/** + * Sets __DEV__ for the calling test file (call at its top level, before the + * modules under test are imported) and restores it after the file's tests. + */ +export const setDevForFile = (value: boolean) => { + const original = devGlobal.__DEV__; + devGlobal.__DEV__ = value; + afterAll(() => { + devGlobal.__DEV__ = original; + }); +}; + +/** Restores __DEV__ after every test of the calling file. */ +export const restoreDevAfterEach = () => { + const original = devGlobal.__DEV__; + afterEach(() => { + devGlobal.__DEV__ = original; + }); +}; + +/** setup.ts's react-native stand-in with another Platform. */ +export const mockPlatform = (Platform: Record) => { + mock.module('react-native', () => ({ ...baseReactNative(), Platform })); +}; + +/** + * i18n whose `t` returns the key, plus the JSON-encoded params when + * `withParams` is set. Returns the setLocale mock. + */ +export const mockI18n = ({ + withParams = false, + setLocale = mock(() => {}), +}: { + withParams?: boolean; + setLocale?: ReturnType; +} = {}) => { + mock.module('../i18n', () => ({ + default: { + t: (key: string, params?: Record) => + withParams && params ? `${key}${JSON.stringify(params)}` : key, + setLocale, + }, + })); + return setLocale; +}; + +/** + * ../core for a client under test: the real module namespace with the native + * module, the native constants and nativeMethod (bound to that native + * module) replaced. `overrides` replace any other export. + */ +export const mockCore = ({ + PushyModule = {}, + ...overrides +}: { PushyModule?: Record } & Record< + string, + unknown +> = {}) => { + const realCore = realProjectModules['../core']; + mock.module('../core', () => ({ + ...realCore, + buildTime: '2023-01-01', + cInfo: { rnu: '10.0.0', rn: '0.73.0', os: 'ios', uuid: 'uuid' }, + currentVersion: 'hash', + currentVersionInfo: {}, + isFirstTime: false, + isRolledBack: false, + packageVersion: '1.0.0', + pushyNativeEventEmitter: { + addListener: mock(() => ({ remove: mock(() => {}) })), + }, + rolledBackVersion: '', + setLocalHashInfo: mock(() => {}), + supportedDiffVersion: 2, + getBundleHash: mock(() => ''), + getCurrentVersionInfo: async () => ({}), + ...overrides, + PushyModule, + UpdateModule: PushyModule, + nativeMethod: (name: string) => + realCore.bindNativeMethod(PushyModule, name), + })); +}; + +let freshImports = 0; + +/** + * A fresh client module per call: the client is a process-level singleton + * (module state), so every test gets its own instance of it and of + * sharedState. Picks up the module mocks installed before the call. Typed + * loosely on purpose: tests reach into private members. + */ +export const importFreshClient = (): Promise => + import(`../client?fresh-${++freshImports}`); + +/** A client from a fresh client module; appKey defaults to 'demo-app'. */ +export const createClient = async ( + options: Record = {}, + type: 'Pushy' | 'Cresc' = 'Pushy' +) => { + const module = await importFreshClient(); + const client = new module[type]({ appKey: 'demo-app', ...options }); + return { client, sharedState: module.sharedState, module }; +}; diff --git a/src/__tests__/metadata.test.ts b/src/__tests__/metadata.test.ts index bc0b45ca..1bc08314 100644 --- a/src/__tests__/metadata.test.ts +++ b/src/__tests__/metadata.test.ts @@ -1,10 +1,12 @@ import { describe, expect, mock, test } from 'bun:test'; +import { realProjectModules } from './setup'; const importFreshMetadata = (cacheKey: string) => import(`../metadata?${cacheKey}`); const mockCore = (overrides: Record = {}) => { mock.module('../core', () => ({ + ...realProjectModules['../core'], buildTime: '1719999999', currentBundleSha256: 'c'.repeat(64), cInfo: { rnu: '10.53.1', rn: '0.85.2', os: 'ios 17.5', uuid: 'u-1' }, diff --git a/src/__tests__/provider.contract.test.tsx b/src/__tests__/provider.contract.test.tsx index 63f9f73f..0556713a 100644 --- a/src/__tests__/provider.contract.test.tsx +++ b/src/__tests__/provider.contract.test.tsx @@ -1,50 +1,40 @@ /** * Provider <-> client contract, exercised with the REAL Pushy client (only - * the native module / core constants are mocked, as in client.test.ts). The - * render tests next door use a hand-written fake client; this file is what - * keeps that fake honest about the members the provider actually relies on. + * the native module / core constants are mocked, as in client.test.ts): the + * provider's lifecycle (mount claim, AppState, timers, option changes) + * against the client's own option and singleton machinery. */ -import { - afterAll, - beforeEach, - describe, - expect, - mock, - spyOn, - test, -} from 'bun:test'; +import { beforeEach, describe, expect, mock, spyOn, test } from 'bun:test'; import type React from 'react'; import TestRenderer from 'react-test-renderer'; +import { + createClient, + createJsonResponse, + flush, + mockCore, + mockI18n, + setDevForFile, +} from './helpers'; import { emitAppStateChange, mockAlert } from './setup'; // Release code paths (assertDebug allows checks, default strategies apply). -const _origDEV = (globalThis as any).__DEV__; -(globalThis as any).__DEV__ = false; +setDevForFile(false); const { useUpdate } = await import('../context'); -const createJsonResponse = (payload: unknown) => - ({ - ok: true, - status: 200, - text: async () => JSON.stringify(payload), - json: async () => payload, - }) as Response; - -const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); let importSeq = 0; -// A fresh client module per test: the client is a process-level singleton -// (module state), so every test gets its own module instance instead of -// inheriting the previous test's options, listeners and dedup window. +// A fresh client (and provider) module per test: the client is a +// process-level singleton (module state), so every test gets its own module +// instance instead of inheriting the previous test's options, listeners and +// dedup window. const setupRealClient = async ( options: Record = {}, native: { markSuccess?: ReturnType; isFirstTime?: boolean } = {} ) => { - const seq = ++importSeq; - mock.module('../core', () => ({ + mockCore({ PushyModule: { markSuccess: native.markSuccess ?? mock(() => Promise.resolve()), reloadUpdate: mock(() => Promise.resolve()), @@ -56,32 +46,13 @@ const setupRealClient = async ( restartApp: mock(() => Promise.resolve()), resetToPackagedBundle: mock(() => Promise.resolve()), }, - buildTime: '2023-01-01', - cInfo: { rnu: '10.0.0', rn: '0.73.0', os: 'ios', uuid: 'uuid' }, - currentVersion: 'hash', - currentVersionInfo: {}, isFirstTime: native.isFirstTime ?? false, - isRolledBack: false, - packageVersion: '1.0.0', - pushyNativeEventEmitter: { - addListener: mock(() => ({ remove: mock(() => {}) })), - }, - rolledBackVersion: '', - setLocalHashInfo: mock(() => {}), - supportedDiffVersion: 2, - getBundleHash: mock(() => ''), - getCurrentVersionInfo: async () => ({}), - })); - mock.module('../i18n', () => ({ - default: { - t: (key: string, params?: Record) => - params ? `${key}${JSON.stringify(params)}` : key, - setLocale: mock(() => {}), - }, - })); - const { Pushy } = await import(`../client?contract-${seq}`); - const { UpdateProvider } = await import(`../provider?contract-${seq}`); - const client = new Pushy({ appKey: 'contract-app', ...options }); + }); + mockI18n({ withParams: true }); + const { client } = await createClient({ appKey: 'contract-app', ...options }); + const { UpdateProvider } = await import( + `../provider?contract-${++importSeq}` + ); // Count the provider's calls into the client without changing what they do. const realCheckUpdate = client.checkUpdate; const checkUpdate = mock((extra?: Record) => @@ -114,10 +85,6 @@ describe('UpdateProvider <-> Pushy contract', () => { ); }); - afterAll(() => { - (globalThis as any).__DEV__ = _origDEV; - }); - test('a second concurrently mounted provider throws SINGLETON_VIOLATION', async () => { const { client, UpdateProvider } = await setupRealClient({ checkStrategy: null, diff --git a/src/__tests__/provider.render.test.tsx b/src/__tests__/provider.render.test.tsx index eea8efcb..54c7563a 100644 --- a/src/__tests__/provider.render.test.tsx +++ b/src/__tests__/provider.render.test.tsx @@ -1,20 +1,24 @@ -import { afterAll, beforeEach, describe, expect, test } from 'bun:test'; +import { beforeEach, describe, expect, mock, spyOn, test } from 'bun:test'; import type React from 'react'; import { useContext } from 'react'; import TestRenderer from 'react-test-renderer'; +import type { CheckResult, ProgressData } from '../type'; +import { + createClient, + flush, + mockCore, + mockI18n, + setDevForFile, +} from './helpers'; import { emitAppStateChange, mockAlert } from './setup'; // Render tests exercise the release code paths (assertDebug allows checks). -const _origDEV = (globalThis as any).__DEV__; -(globalThis as any).__DEV__ = false; +setDevForFile(false); const { UpdateProvider } = await import('../provider'); const { UpdateContext, useUpdate, useUpdateProgress } = await import( '../context' ); -const { mock } = await import('bun:test'); - -import type { CheckResult, ProgressData } from '../type'; const updateResult: CheckResult = { update: true, @@ -25,99 +29,44 @@ const updateResult: CheckResult = { paths: ['https://cdn.example.com'], }; -const createClient = (options: Record = {}) => { +// The real client (a fresh module per call) with the calls the provider makes +// into the update flow spied and stubbed: checks answer `updateResult`, +// downloads deliver 'next-hash' and keep the progress callback for +// emitProgress. Options, onError/wasEmitted, the mount claim and the +// remaining methods are the client's own. +const createSpiedClient = async (options: Record = {}) => { + mockCore({ rolledBackVersion: 'rolled-back-hash' }); + mockI18n({ withParams: true }); + const { client } = await createClient({ + updateStrategy: 'alwaysAlert', + checkStrategy: 'onAppStart', + autoMarkSuccess: false, + ...options, + }); let progressCallback: ((data: ProgressData) => void) | undefined; - const errorListeners = new Set<(e: Error, eventType?: string) => void>(); - const emittedErrors = new WeakSet(); - const optionsListeners = new Set<() => void>(); - let providerMounted = false; - const client = { - options: { - updateStrategy: 'alwaysAlert', - checkStrategy: 'onAppStart', - autoMarkSuccess: false, - ...options, - }, - // The provider's option-change contract (see Pushy.setOptions): options - // are mutated in place, a version is bumped and subscribers notified. - optionsVersion: 0, - onOptionsChange: (listener: () => void) => { - optionsListeners.add(listener); - return () => { - optionsListeners.delete(listener); - }; - }, - setOptions: (next: Record) => { - Object.assign(client.options, next); - client.optionsVersion++; - optionsListeners.forEach((listener) => { - listener(); - }); - }, - claimProviderMount: () => { - if (providerMounted) { - throw new Error('error_provider_singleton'); - } - providerMounted = true; - return () => { - providerMounted = false; - }; - }, - 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 () => {}), - downloadUpdate: mock( - async (_info: CheckResult, onProgress?: (data: ProgressData) => void) => { - progressCallback = onProgress; - return 'next-hash'; - } - ), - downloadAndInstallApk: mock(async () => {}), - restartApp: mock(async () => {}), - t: (key: string, params?: Record) => - params ? `${key}${JSON.stringify(params)}` : key, - onError: mock((listener: (e: Error, eventType?: string) => void) => { - errorListeners.add(listener); - return () => { - errorListeners.delete(listener); - }; - }), - // Simulates the real client contract: errors are emitted to onError - // listeners (report + lastError/Alert path) regardless of throwError, - // and wasEmitted() answers whether an error object went through the - // pipeline (the provider uses it to avoid double-surfacing). - emitError: (e: Error, eventType = 'errorChecking') => { - emittedErrors.add(e); - errorListeners.forEach((listener) => { - listener(e, eventType); - }); - }, - wasEmitted: (e: unknown) => e instanceof Error && emittedErrors.has(e), + spyOn(client, 'checkUpdate').mockImplementation( + async (): Promise => ({ ...updateResult }) + ); + spyOn(client, 'downloadUpdate').mockImplementation( + async (_info: CheckResult, onProgress?: (data: ProgressData) => void) => { + progressCallback = onProgress; + return 'next-hash'; + } + ); + spyOn(client, 'switchVersion').mockImplementation(async () => true); + spyOn(client, 'switchVersionLater').mockImplementation(async () => {}); + spyOn(client, 'markSuccess').mockImplementation(async () => {}); + spyOn(client, 'reportInvalidUpdateOnce'); + return Object.assign(client, { emitProgress: (data: ProgressData) => progressCallback?.(data), - }; - return client; + }); }; -const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); - -const renderProvider = async ( - client: ReturnType, - children?: React.ReactElement -) => { +const renderProvider = async (client: any, children?: React.ReactElement) => { let renderer: TestRenderer.ReactTestRenderer; await TestRenderer.act(async () => { renderer = TestRenderer.create( - - {children ?? <>} - + {children ?? <>} ); await flush(); }); @@ -129,12 +78,8 @@ describe('UpdateProvider rendering', () => { mockAlert.mockClear(); }); - afterAll(() => { - (globalThis as any).__DEV__ = _origDEV; - }); - test('alwaysAlert strategy alerts when an update is found and downloads on confirm', async () => { - const client = createClient({ updateStrategy: 'alwaysAlert' }); + const client = await createSpiedClient({ updateStrategy: 'alwaysAlert' }); await renderProvider(client); expect(client.checkUpdate).toHaveBeenCalledTimes(1); @@ -161,7 +106,7 @@ describe('UpdateProvider rendering', () => { }); test('a hash-less update is reported but never shown as an actionable alert', async () => { - const client = createClient({ updateStrategy: 'alwaysAlert' }); + const client = await createSpiedClient({ updateStrategy: 'alwaysAlert' }); client.checkUpdate.mockImplementation(async () => ({ update: true, name: 'broken rollout entry', @@ -175,7 +120,7 @@ describe('UpdateProvider rendering', () => { }); test('an expired app package without a bundle hash keeps its download action', async () => { - const client = createClient({ updateStrategy: 'alwaysAlert' }); + const client = await createSpiedClient({ updateStrategy: 'alwaysAlert' }); client.checkUpdate.mockImplementation(async () => ({ expired: true, update: true, @@ -192,7 +137,7 @@ describe('UpdateProvider rendering', () => { }); test('an update without a downloadable artifact is never shown as actionable', async () => { - const client = createClient({ updateStrategy: 'alwaysAlert' }); + const client = await createSpiedClient({ updateStrategy: 'alwaysAlert' }); client.checkUpdate.mockImplementation(async () => ({ update: true, hash: 'broken-artifact-hash', @@ -211,7 +156,7 @@ describe('UpdateProvider rendering', () => { }); test('a rolled-back update is never shown as actionable', async () => { - const client = createClient({ updateStrategy: 'alwaysAlert' }); + const client = await createSpiedClient({ updateStrategy: 'alwaysAlert' }); client.checkUpdate.mockImplementation(async () => ({ update: true, hash: 'rolled-back-hash', @@ -228,7 +173,7 @@ describe('UpdateProvider rendering', () => { }); test('silentAndNow strategy downloads and switches without alerts', async () => { - const client = createClient({ updateStrategy: 'silentAndNow' }); + const client = await createSpiedClient({ updateStrategy: 'silentAndNow' }); await renderProvider(client); expect(client.downloadUpdate).toHaveBeenCalledTimes(1); @@ -237,7 +182,9 @@ describe('UpdateProvider rendering', () => { }); test('silentAndLater strategy downloads and defers the switch', async () => { - const client = createClient({ updateStrategy: 'silentAndLater' }); + const client = await createSpiedClient({ + updateStrategy: 'silentAndLater', + }); await renderProvider(client); expect(client.downloadUpdate).toHaveBeenCalledTimes(1); @@ -247,7 +194,7 @@ describe('UpdateProvider rendering', () => { }); test('check failure sets lastError and alerts under alwaysAlert', async () => { - const client = createClient({ updateStrategy: 'alwaysAlert' }); + const client = await createSpiedClient({ updateStrategy: 'alwaysAlert' }); const checkError = new Error('offline'); // Real client contract under the default throwError:false — the error is // emitted to onError listeners and checkUpdate resolves undefined. @@ -271,7 +218,7 @@ describe('UpdateProvider rendering', () => { }); test('a user-hook error carrying a foreign code is still surfaced (JS2-4)', async () => { - const client = createClient({ updateStrategy: 'alwaysAlert' }); + const client = await createSpiedClient({ updateStrategy: 'alwaysAlert' }); const hookError: any = new Error('axios network error'); // axios-style code on an error that never entered the client pipeline; // the old `!e?.code` heuristic wrongly treated it as already surfaced. @@ -292,7 +239,7 @@ describe('UpdateProvider rendering', () => { }); test('alertUpdateAndIgnoreError suppresses the error alert but keeps lastError', async () => { - const client = createClient({ + const client = await createSpiedClient({ updateStrategy: 'alertUpdateAndIgnoreError', }); const checkError = new Error('offline'); @@ -313,7 +260,7 @@ describe('UpdateProvider rendering', () => { }); test('dismissErrorAfter clears lastError automatically (JS-4 regression)', async () => { - const client = createClient({ + const client = await createSpiedClient({ updateStrategy: 'alertUpdateAndIgnoreError', dismissErrorAfter: 20, }); @@ -337,7 +284,7 @@ describe('UpdateProvider rendering', () => { }); test('autoMarkSuccessDelayMs delays the automatic markSuccess', async () => { - const client = createClient({ + const client = await createSpiedClient({ updateStrategy: 'silentAndLater', autoMarkSuccess: true, autoMarkSuccessDelayMs: 40, @@ -352,7 +299,7 @@ describe('UpdateProvider rendering', () => { }); test('healthCheck returning false or throwing skips the automatic markSuccess', async () => { - const unhealthy = createClient({ + const unhealthy = await createSpiedClient({ updateStrategy: 'silentAndLater', autoMarkSuccess: true, autoMarkSuccessDelayMs: 0, @@ -364,7 +311,7 @@ describe('UpdateProvider rendering', () => { }); expect(unhealthy.markSuccess).not.toHaveBeenCalled(); - const throwing = createClient({ + const throwing = await createSpiedClient({ updateStrategy: 'silentAndLater', autoMarkSuccess: true, autoMarkSuccessDelayMs: 0, @@ -378,7 +325,7 @@ describe('UpdateProvider rendering', () => { }); expect(throwing.markSuccess).not.toHaveBeenCalled(); - const healthy = createClient({ + const healthy = await createSpiedClient({ updateStrategy: 'silentAndLater', autoMarkSuccess: true, autoMarkSuccessDelayMs: 0, @@ -392,7 +339,7 @@ describe('UpdateProvider rendering', () => { }); test('test-channel payloads are refused when testChannel is false', async () => { - const client = createClient({ + const client = await createSpiedClient({ updateStrategy: 'silentAndLater', checkStrategy: null, testChannel: false, @@ -417,7 +364,7 @@ describe('UpdateProvider rendering', () => { test('a test-channel check passes toHash without swapping the logger', async () => { const logger = mock(() => {}); - const client = createClient({ + const client = await createSpiedClient({ updateStrategy: 'silentAndLater', checkStrategy: null, logger, @@ -443,7 +390,7 @@ describe('UpdateProvider rendering', () => { }); test('onAppResume strategy checks when the app becomes active', async () => { - const client = createClient({ + const client = await createSpiedClient({ updateStrategy: 'silentAndLater', checkStrategy: 'onAppResume', }); @@ -458,7 +405,9 @@ describe('UpdateProvider rendering', () => { }); test('progress ticks re-render progress consumers but not static context consumers (JS-7)', async () => { - const client = createClient({ updateStrategy: 'silentAndLater' }); + const client = await createSpiedClient({ + updateStrategy: 'silentAndLater', + }); let staticRenders = 0; const StaticProbe = () => { @@ -500,7 +449,7 @@ describe('UpdateProvider rendering', () => { }); test('setOptions on a mounted provider re-runs neither the start check nor the auto-mark timer (1.2)', async () => { - const client = createClient({ + const client = await createSpiedClient({ updateStrategy: 'silentAndLater', autoMarkSuccess: true, autoMarkSuccessDelayMs: 40, @@ -523,7 +472,7 @@ describe('UpdateProvider rendering', () => { }); test('enabling automatic checks after mount runs the start check once', async () => { - const client = createClient({ + const client = await createSpiedClient({ updateStrategy: 'silentAndLater', checkStrategy: null, }); @@ -544,7 +493,7 @@ describe('UpdateProvider rendering', () => { }); test('a strategy change re-subscribes AppState without another start check', async () => { - const client = createClient({ + const client = await createSpiedClient({ updateStrategy: 'silentAndLater', checkStrategy: 'onAppStart', }); @@ -571,7 +520,7 @@ describe('UpdateProvider rendering', () => { }); test('an expired response with a stray hash-less update is presented as expired-only', async () => { - const client = createClient({ updateStrategy: 'alwaysAlert' }); + const client = await createSpiedClient({ updateStrategy: 'alwaysAlert' }); client.checkUpdate.mockImplementation(async () => ({ expired: true, update: true, @@ -594,7 +543,9 @@ describe('UpdateProvider rendering', () => { }); test('a silent strategy hands an artifact-less release to the client, which reports it (once)', async () => { - const client = createClient({ updateStrategy: 'silentAndLater' }); + const client = await createSpiedClient({ + updateStrategy: 'silentAndLater', + }); client.checkUpdate.mockImplementation(async () => ({ update: true, hash: 'broken-artifact-hash', @@ -622,7 +573,7 @@ describe('UpdateProvider rendering', () => { }); test('a release without a name leaves no {{name}} placeholder in the alert', async () => { - const client = createClient({ updateStrategy: 'alwaysAlert' }); + const client = await createSpiedClient({ updateStrategy: 'alwaysAlert' }); client.checkUpdate.mockImplementation(async () => ({ update: true, hash: 'next-hash', @@ -638,7 +589,7 @@ describe('UpdateProvider rendering', () => { }); test('an expired download url the system cannot open becomes lastError, not an unhandled rejection (1.8)', async () => { - const client = createClient({ updateStrategy: 'silentAndNow' }); + const client = await createSpiedClient({ updateStrategy: 'silentAndNow' }); client.checkUpdate.mockImplementation(async () => ({ expired: true, downloadUrl: 'market://details?id=app', @@ -666,7 +617,7 @@ describe('UpdateProvider rendering', () => { Promise.resolve('app://open?type=__rnPushyVersionHash&data=test-hash') ); (Linking as any).getInitialURL = getInitialURL; - const client = createClient({ + const client = await createSpiedClient({ updateStrategy: 'alwaysAlert', checkStrategy: null, }); diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts index 4602ccd8..86297607 100644 --- a/src/__tests__/setup.ts +++ b/src/__tests__/setup.ts @@ -19,62 +19,66 @@ export const emitAppStateChange = (state: string) => { }); }; -const installBaseMocks = () => { - mock.module('react-native', () => { - return { - Platform: { - OS: 'ios', - Version: 13, - }, - Alert: { - alert: mockAlert, - }, - AppState: { - currentState: 'active', - addEventListener: (_type: string, handler: (state: string) => void) => { - appStateListeners.add(handler); - return { - remove: () => { - appStateListeners.delete(handler); - }, - }; - }, - }, - Linking: { - openURL: mock(() => Promise.resolve()), - getInitialURL: () => Promise.resolve(null), - addEventListener: () => ({ remove: () => {} }), - }, - DeviceEventEmitter: { - addListener: () => ({ remove: () => {} }), - }, - NativeModules: { - Pushy: { - currentVersionInfo: '{}', - downloadRootDir: '/tmp', - packageVersion: '1.0.0', - currentVersion: 'hash', - isFirstTime: false, - rolledBackVersion: 'rolled-back-hash', - buildTime: '2023-01-01', - uuid: 'uuid', - setLocalHashInfo: () => {}, - getLocalHashInfo: () => Promise.resolve('{}'), - setUuid: () => {}, +// The react-native stand-in every test starts from (the real package cannot +// load outside an app). Tests that need another platform spread it and +// override, so a leaked mock still has every export. +export const baseReactNative = () => ({ + Platform: { + OS: 'ios', + Version: 13, + }, + Alert: { + alert: mockAlert, + }, + AppState: { + currentState: 'active', + addEventListener: (_type: string, handler: (state: string) => void) => { + appStateListeners.add(handler); + return { + remove: () => { + appStateListeners.delete(handler); }, - }, - NativeEventEmitter: class { - addListener = () => ({ remove: () => {} }); - removeAllListeners = () => {}; - }, - }; - }); + }; + }, + }, + Linking: { + openURL: mock(() => Promise.resolve()), + getInitialURL: () => Promise.resolve(null), + addEventListener: () => ({ remove: () => {} }), + }, + DeviceEventEmitter: { + addListener: () => ({ remove: () => {} }), + }, + NativeModules: { + Pushy: { + currentVersionInfo: '{}', + downloadRootDir: '/tmp', + packageVersion: '1.0.0', + currentVersion: 'hash', + isFirstTime: false, + rolledBackVersion: 'rolled-back-hash', + buildTime: '2023-01-01', + uuid: 'uuid', + setLocalHashInfo: () => {}, + getLocalHashInfo: () => Promise.resolve('{}'), + setUuid: () => {}, + }, + }, + NativeEventEmitter: class { + addListener = () => ({ remove: () => {} }); + removeAllListeners = () => {}; + }, +}); + +const installBaseMocks = () => { + mock.module('react-native', baseReactNative); mock.module('../i18n', () => { return { default: { t: (key: string, params?: any) => `${key}${params ? JSON.stringify(params) : ''}`, + setLocale: () => {}, }, }; }); @@ -92,7 +96,7 @@ installBaseMocks(); // doesn't have to know to add it. (The modules under test — client, provider, // context — are absent on purpose: tests import those through cache-busting // specifiers, which mock.module() does not touch.) -const realProjectModules: Record> = { +export const realProjectModules: Record> = { '../core': { ...(await import('../core')) }, '../endpoint': { ...(await import('../endpoint')) }, '../error': { ...(await import('../error')) }, diff --git a/src/__tests__/utils.test.ts b/src/__tests__/utils.test.ts index f46c29d3..855967bc 100644 --- a/src/__tests__/utils.test.ts +++ b/src/__tests__/utils.test.ts @@ -1,21 +1,4 @@ import { afterEach, describe, expect, mock, spyOn, test } from 'bun:test'; - -mock.module('react-native', () => { - return { - Platform: { - OS: 'ios', - }, - }; -}); - -mock.module('../i18n', () => { - return { - default: { - t: (key: string) => key, - }, - }; -}); - import { joinUrls } from '../updateFlowCore'; import { computeProgress, @@ -31,6 +14,7 @@ import { testUrls, warn, } from '../utils'; +import { restoreDevAfterEach } from './helpers'; const originalFetch = globalThis.fetch; @@ -397,9 +381,8 @@ describe('parseQueryParams', () => { }); describe('log gating (1.11)', () => { - const origDev = (globalThis as any).__DEV__; + restoreDevAfterEach(); afterEach(() => { - (globalThis as any).__DEV__ = origDev; setDebugLogging(false); }); From 63b2c0c8fcdc488f7755ee563083a43b301e01cc Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 14:59:53 +0800 Subject: [PATCH 045/104] style(client): keep UpdateErrorListener's doc comment on its type The ReportEvent interface had been inserted between them. Co-Authored-By: Claude Opus 5.5 --- src/client.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/client.ts b/src/client.ts index 4a6252da..6faa2663 100644 --- a/src/client.ts +++ b/src/client.ts @@ -70,11 +70,6 @@ import { warn, } from './utils'; -/** - * Receives every error the client reports, alongside the report event type. - * The UpdateProvider subscribes to surface errors as lastError/Alert; user - * code can subscribe too. Listeners run before any throwError rethrow. - */ /** One report() event: the logger payload and the source of server telemetry. */ interface ReportEvent { type: EventType; @@ -83,6 +78,11 @@ interface ReportEvent { data?: Record; } +/** + * Receives every error the client reports, alongside the report event type. + * The UpdateProvider subscribes to surface errors as lastError/Alert; user + * code can subscribe too. Listeners run before any throwError rethrow. + */ export type UpdateErrorListener = ( error: UpdateError, eventType: EventType From a9e9242f8b546496ece24110518482c31d904d19 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 15:00:23 +0800 Subject: [PATCH 046/104] refactor(android): one set of IO and HTTP helpers HttpClients.withDeadline replaces the deadline -> callTimeout code duplicated in DownloadTask and SyncCoordinator, and hosts rejectProtocolDowngrade (SyncCoordinator no longer reaches into DownloadTask for it). UpdateFileUtils gains readBytes/readSmallUtf8 (resume sidecar, install record), sha256Hex(InputStream) (bundled asset hash), a public copy used by ApkInstaller, and the single 64KB BUFFER_SIZE; SafeZipFile creates parents via ensureParentDirectory. Co-Authored-By: Claude Opus 5.5 --- .../modules/update/ApkInstaller.java | 7 +- .../modules/update/DownloadTask.java | 71 +++---------------- .../modules/update/HttpClients.java | 42 +++++++++++ .../modules/update/InstallRecord.java | 19 ++--- .../modules/update/SafeZipFile.java | 11 +-- .../modules/update/SyncCoordinator.java | 20 ++---- .../modules/update/UpdateContext.java | 9 +-- .../modules/update/UpdateFileUtils.java | 68 ++++++++++++++---- 8 files changed, 119 insertions(+), 128 deletions(-) diff --git a/android/src/main/java/cn/reactnative/modules/update/ApkInstaller.java b/android/src/main/java/cn/reactnative/modules/update/ApkInstaller.java index 219a4258..dd2a62d9 100644 --- a/android/src/main/java/cn/reactnative/modules/update/ApkInstaller.java +++ b/android/src/main/java/cn/reactnative/modules/update/ApkInstaller.java @@ -20,7 +20,6 @@ import java.util.concurrent.ConcurrentHashMap; final class ApkInstaller { - private static final int COPY_BUFFER_SIZE = 64 * 1024; private static final String SESSION_ID_EXTRA = Texts.reveal("3919bac3ab8a6b512c3e08f0c0b6de60452311edfbc8f680624b2d1de38da5a58e65557f3ecaeb8ea3b859661f3dc3f98bab48646c042bd0e19ca546681c2939"); private static final ConcurrentHashMap pendingPromises = @@ -177,11 +176,7 @@ static void install( FileInputStream input = new FileInputStream(apkFile); OutputStream output = session.openWrite("base.apk", 0, apkFile.length()) ) { - byte[] buffer = new byte[COPY_BUFFER_SIZE]; - int count; - while ((count = input.read(buffer)) != -1) { - output.write(buffer, 0, count); - } + UpdateFileUtils.copy(input, output); session.fsync(output); } 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 18862d61..20bcfe73 100644 --- a/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +++ b/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java @@ -7,7 +7,6 @@ import androidx.annotation.Nullable; import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.WritableMap; -import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; @@ -37,9 +36,8 @@ class DownloadTask implements Runnable { private static final String BUNDLE_DELTA_ENTRY = Texts.reveal("3319f0d4b6c56a502c3b10fcdca0de7d4b3307e9"); - // Read size per loop iteration (and the buffer for small in-memory - // reads); progress events are throttled separately. - private static final int DOWNLOAD_CHUNK_SIZE = 64 * 1024; + // Resume sidecars are a few hundred bytes of JSON. + private static final long MAX_SIDECAR_BYTES = 64 * 1024; // When the server does not report Content-Length we cannot key progress // events on percentage change, so throttle by bytes to avoid flooding the // bridge (e.g. a 20MB chunked download would otherwise emit ~5000 events). @@ -124,7 +122,6 @@ String hbcTransformMetaFor(String patchEntryName) { private final Context context; private final DownloadTaskParams params; private final Handler mainHandler = new Handler(Looper.getMainLooper()); - private final byte[] buffer = new byte[DOWNLOAD_CHUNK_SIZE]; private final BundledResourceCopier bundledResourceCopier; private String hash; // Set once downloadFile() returns: failures after this point in a patch @@ -170,9 +167,8 @@ private JSONObject readResumeMeta(File sidecar, String url) { return null; } try { - byte[] bytes = readBytes(new java.io.FileInputStream(sidecar)); JSONObject meta = (JSONObject) new JSONTokener( - new String(bytes, StandardCharsets.UTF_8)).nextValue(); + UpdateFileUtils.readSmallUtf8(sidecar, MAX_SIDECAR_BYTES)).nextValue(); if (url.equals(meta.optString("url"))) { return meta; } @@ -296,27 +292,13 @@ private boolean transferArchive( } } - 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(); - } - - Call call = requestClient.newCall(builder.build()); + Call call = HttpClients.withDeadline(HTTP_CLIENT, params.deadlineNanos) + .newCall(builder.build()); // Registered so an orchestrated phase timeout can cancel it // (CODE_AUDIT 2.12); cleared once the transfer is over. params.attachCall(call); try (Response response = call.execute()) { - rejectProtocolDowngrade(url, response); + HttpClients.rejectProtocolDowngrade(url, response); if (response.code() == 416) { long total = resumeMeta == null ? 0 : resumeMeta.optLong("total", 0); if (total > 0 && writePath.length() == total) { @@ -406,7 +388,7 @@ private boolean transferArchive( BufferedSink sink = Okio.buffer(Okio.sink(new DigestOutputStream( new FileOutputStream(writePath, append), digest))) ) { - while ((bytesRead = source.read(sink.buffer(), DOWNLOAD_CHUNK_SIZE)) != -1) { + while ((bytesRead = source.read(sink.buffer(), UpdateFileUtils.BUFFER_SIZE)) != -1) { received += bytesRead; if (totalAll <= 0 && received > freeSpaceReservedUntil) { long reserve = Math.max( @@ -466,28 +448,6 @@ private boolean transferArchive( return true; } - private byte[] readBytes(InputStream input) throws IOException { - return readBytes(input, Long.MAX_VALUE); - } - - private byte[] readBytes(InputStream input, long maxBytes) throws IOException { - try ( - InputStream in = input; - ByteArrayOutputStream out = new ByteArrayOutputStream() - ) { - int count; - long total = 0; - while ((count = in.read(buffer)) != -1) { - total += count; - if (total > maxBytes) { - throw new IOException("content exceeds " + maxBytes + " bytes"); - } - out.write(buffer, 0, count); - } - return out.toByteArray(); - } - } - private void appendManifestEntries( JSONObject manifest, ArrayList copyFroms, @@ -582,7 +542,7 @@ private PatchArchiveContents extractPatchArchive(File archiveFile, File unzipDir if (entry.getSize() > ArchiveLimits.MAX_MANIFEST_BYTES) { throw new IOException("delta manifest too large: " + entry.getSize()); } - byte[] bytes = readBytes( + byte[] bytes = UpdateFileUtils.readBytes( zipFile.getInputStream(entry), ArchiveLimits.MAX_MANIFEST_BYTES); String json = new String(bytes, StandardCharsets.UTF_8); JSONObject manifest = (JSONObject) new JSONTokener(json).nextValue(); @@ -821,21 +781,6 @@ private void cleanUpAfterFailure(int taskType) { } } - /** - * An https artifact URL must stay on https through every redirect: the - * package is the supply-chain boundary and TLS is what authenticates - * it. OkHttp follows cross-scheme redirects by default, so the final - * request is checked here (the redirected bytes are discarded). - */ - static void rejectProtocolDowngrade(String requestedUrl, Response response) - throws IOException { - if (HttpUtils.isHttpsUrl(requestedUrl) && !response.request().url().isHttps()) { - throw new IOException( - "https download redirected to plaintext http: " - + response.request().url()); - } - } - private boolean isPatchTask(int taskType) { return taskType == DownloadTaskParams.TASK_TYPE_PATCH_FULL || taskType == DownloadTaskParams.TASK_TYPE_PATCH_FROM_APK diff --git a/android/src/main/java/cn/reactnative/modules/update/HttpClients.java b/android/src/main/java/cn/reactnative/modules/update/HttpClients.java index 0c935bcf..6db8e945 100644 --- a/android/src/main/java/cn/reactnative/modules/update/HttpClients.java +++ b/android/src/main/java/cn/reactnative/modules/update/HttpClients.java @@ -1,6 +1,9 @@ package cn.reactnative.modules.update; +import java.io.IOException; +import java.util.concurrent.TimeUnit; import okhttp3.OkHttpClient; +import okhttp3.Response; /** * The one OkHttpClient the SDK owns. Callers derive their timeout variants @@ -18,4 +21,43 @@ private static final class Holder { static OkHttpClient base() { return Holder.BASE; } + + /** + * {@code client} with its call timeout capped to the time left until the + * absolute System.nanoTime {@code deadlineNanos} (0: no deadline, the + * client as is), so an orchestrated request never outlives its budget. + * Throws when the deadline already passed. + */ + static OkHttpClient withDeadline(OkHttpClient client, long deadlineNanos) + throws IOException { + if (deadlineNanos <= 0) { + return client; + } + long remainingNanos = deadlineNanos - System.nanoTime(); + if (remainingNanos <= 0) { + throw new IOException("Request deadline expired before start"); + } + long remainingMillis = Math.max(1L, TimeUnit.NANOSECONDS.toMillis(remainingNanos)); + int callTimeoutMillis = client.callTimeoutMillis(); + if (callTimeoutMillis > 0 && callTimeoutMillis <= remainingMillis) { + return client; + } + return client.newBuilder() + .callTimeout(remainingMillis, TimeUnit.MILLISECONDS) + .build(); + } + + /** + * An https URL must stay on https through every redirect: TLS is what + * authenticates the package and the check response. OkHttp follows + * cross-scheme redirects by default, so the final request is checked + * here (the redirected bytes are discarded). + */ + static void rejectProtocolDowngrade(String requestedUrl, Response response) + throws IOException { + if (HttpUtils.isHttpsUrl(requestedUrl) && !response.request().url().isHttps()) { + throw new IOException( + "https request redirected to plaintext http: " + response.request().url()); + } + } } diff --git a/android/src/main/java/cn/reactnative/modules/update/InstallRecord.java b/android/src/main/java/cn/reactnative/modules/update/InstallRecord.java index f2da07fe..fcd17806 100644 --- a/android/src/main/java/cn/reactnative/modules/update/InstallRecord.java +++ b/android/src/main/java/cn/reactnative/modules/update/InstallRecord.java @@ -3,7 +3,6 @@ import android.os.Build; import android.util.Log; import java.io.File; -import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -19,6 +18,8 @@ final class InstallRecord { static final int SCHEMA = 1; static final String FILE_NAME = ".pushy-complete"; static final String STAGING_SUFFIX = ".staging"; + // A record is a few hundred bytes; anything larger is not one. + private static final long MAX_RECORD_BYTES = 64 * 1024; /** The JS bundle inside a version directory. */ static final String BUNDLE_FILE_NAME = "index.bundlejs"; @@ -98,20 +99,8 @@ static JSONObject read(File versionDir) { if (file.length() == 0) { return new JSONObject(); } - if (file.length() > 64 * 1024) { - return null; - } - try (FileInputStream in = new FileInputStream(file)) { - byte[] bytes = new byte[(int) file.length()]; - int offset = 0; - while (offset < bytes.length) { - int read = in.read(bytes, offset, bytes.length - offset); - if (read < 0) { - break; - } - offset += read; - } - return new JSONObject(new String(bytes, 0, offset, StandardCharsets.UTF_8)); + try { + return new JSONObject(UpdateFileUtils.readSmallUtf8(file, MAX_RECORD_BYTES)); } catch (IOException | JSONException e) { return null; } diff --git a/android/src/main/java/cn/reactnative/modules/update/SafeZipFile.java b/android/src/main/java/cn/reactnative/modules/update/SafeZipFile.java index a6ff9906..0c1ea49e 100644 --- a/android/src/main/java/cn/reactnative/modules/update/SafeZipFile.java +++ b/android/src/main/java/cn/reactnative/modules/update/SafeZipFile.java @@ -18,8 +18,6 @@ public SafeZipFile(File file) throws IOException { super(file); } - private static final int BUFFER_SIZE = 64 * 1024; - // Bytes written by unzipToFile across the archive: the sum of the central // directory's declared sizes is checked up front (inspect), the actual // stream is re-checked here — headers can lie. @@ -155,10 +153,7 @@ public void unzipToFile(ZipEntry ze, File target) throws IOException { } private void unzipToFile(ZipEntry ze, File target, MessageDigest digest) throws IOException { - File parent = target.getParentFile(); - if (parent != null && !parent.exists() && !parent.mkdirs() && !parent.exists()) { - throw new IOException("Failed to create parent dir for " + target); - } + UpdateFileUtils.ensureParentDirectory(target); // The declared size is the budget when known; otherwise the global // per-entry cap. Either way the stream may not exceed it. @@ -173,9 +168,9 @@ private void unzipToFile(ZipEntry ze, File target, MessageDigest digest) throws try (OutputStream output = digest == null ? new FileOutputStream(target) : new DigestOutputStream(new FileOutputStream(target), digest)) { - byte[] buffer = new byte[BUFFER_SIZE]; + byte[] buffer = new byte[UpdateFileUtils.BUFFER_SIZE]; int n; - while ((n = input.read(buffer, 0, BUFFER_SIZE)) >= 0) { + while ((n = input.read(buffer, 0, buffer.length)) >= 0) { written += n; totalWritten += n; if (written > budget) { diff --git a/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java b/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java index 707edb98..b4cc802a 100644 --- a/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java +++ b/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java @@ -572,25 +572,13 @@ private static String httpRequest(String url, String postBody, long deadlineNano builder.post(RequestBody.create( postBody, MediaType.parse("application/json; charset=utf-8"))); } - OkHttpClient client = httpClient; - if (deadlineNanos > 0) { - // Crash-rescue budget: never let a single request outlive the - // handler's hold window. - long remainingMillis = TimeUnit.NANOSECONDS.toMillis( - deadlineNanos - System.nanoTime()); - if (remainingMillis <= 0) { - return null; - } - if (remainingMillis < 15000) { - client = httpClient.newBuilder() - .callTimeout(remainingMillis, TimeUnit.MILLISECONDS) - .build(); - } - } + // Crash-rescue budget: never let a single request outlive the + // handler's hold window. + OkHttpClient client = HttpClients.withDeadline(httpClient, deadlineNanos); try (Response response = client.newCall(builder.build()).execute()) { // Same rule as the artifact download: an https endpoint that // redirects to plaintext http is a failed endpoint. - DownloadTask.rejectProtocolDowngrade(url, response); + HttpClients.rejectProtocolDowngrade(url, response); ResponseBody body = response.body(); if (!response.isSuccessful() || body == null) { return null; 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 9c16ed4b..cff46875 100644 --- a/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +++ b/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java @@ -11,7 +11,6 @@ import org.json.JSONObject; import java.io.IOException; import java.io.InputStream; -import java.security.MessageDigest; import java.util.concurrent.Executor; public class UpdateContext { @@ -302,13 +301,7 @@ private long getPackageLastUpdateTime() { private String sha256OfBundledAsset(String assetName) { try (InputStream in = context.getAssets().open(assetName)) { - MessageDigest md = UpdateFileUtils.newSha256(); - byte[] buffer = new byte[64 * 1024]; - int read; - while ((read = in.read(buffer)) != -1) { - md.update(buffer, 0, read); - } - return UpdateFileUtils.toHex(md.digest()); + return UpdateFileUtils.sha256Hex(in); } catch (IOException e) { // Expected when there is no embedded bundle (custom name, no // release bundle); "unknown" is a valid answer, not an error. diff --git a/android/src/main/java/cn/reactnative/modules/update/UpdateFileUtils.java b/android/src/main/java/cn/reactnative/modules/update/UpdateFileUtils.java index f285b1a4..8f771c1b 100644 --- a/android/src/main/java/cn/reactnative/modules/update/UpdateFileUtils.java +++ b/android/src/main/java/cn/reactnative/modules/update/UpdateFileUtils.java @@ -1,13 +1,18 @@ package cn.reactnative.modules.update; +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; final class UpdateFileUtils { - private static final int BUFFER_SIZE = 64 * 1024; + /** The one IO block size: stream copies, digests, downloads, unzip. */ + static final int BUFFER_SIZE = 64 * 1024; private UpdateFileUtils() { } @@ -57,22 +62,27 @@ static void removeDirectory(File file) throws IOException { } } - static java.security.MessageDigest newSha256() throws IOException { + static MessageDigest newSha256() throws IOException { try { - return java.security.MessageDigest.getInstance("SHA-256"); + return MessageDigest.getInstance("SHA-256"); } catch (java.security.NoSuchAlgorithmException e) { throw new IOException("SHA-256 unavailable", e); } } /** Feeds the whole file into digest (streaming, bounded memory). */ - static void updateDigest(java.security.MessageDigest digest, File file) throws IOException { + static void updateDigest(MessageDigest digest, File file) throws IOException { try (InputStream in = new FileInputStream(file)) { - byte[] buffer = new byte[BUFFER_SIZE]; - int read; - while ((read = in.read(buffer)) != -1) { - digest.update(buffer, 0, read); - } + updateDigest(digest, in); + } + } + + /** Feeds the rest of the stream into digest; the caller closes it. */ + static void updateDigest(MessageDigest digest, InputStream in) throws IOException { + byte[] buffer = new byte[BUFFER_SIZE]; + int read; + while ((read = in.read(buffer)) != -1) { + digest.update(buffer, 0, read); } } @@ -87,8 +97,15 @@ static String toHex(byte[] digest) { /** Streaming SHA-256 of a file, lowercase hex. */ static String sha256Hex(File file) throws IOException { - java.security.MessageDigest md = newSha256(); - updateDigest(md, file); + try (InputStream in = new FileInputStream(file)) { + return sha256Hex(in); + } + } + + /** Streaming SHA-256 of the rest of the stream, lowercase hex; the caller closes it. */ + static String sha256Hex(InputStream in) throws IOException { + MessageDigest md = newSha256(); + updateDigest(md, in); return toHex(md.digest()); } @@ -109,11 +126,38 @@ static void copyInputStreamToFile(InputStream input, File destination) throws IO } } - private static void copy(InputStream in, FileOutputStream out) throws IOException { + /** Copies the rest of the stream; the caller closes both ends. */ + static void copy(InputStream in, OutputStream out) throws IOException { byte[] buffer = new byte[BUFFER_SIZE]; int count; while ((count = in.read(buffer)) != -1) { out.write(buffer, 0, count); } } + + /** + * Reads the whole stream (and closes it), failing once it exceeds + * maxBytes: small server- or archive-provided documents must not be + * able to exhaust memory. + */ + static byte[] readBytes(InputStream input, long maxBytes) throws IOException { + try (InputStream in = input; ByteArrayOutputStream out = new ByteArrayOutputStream()) { + byte[] buffer = new byte[BUFFER_SIZE]; + int count; + long total = 0; + while ((count = in.read(buffer)) != -1) { + total += count; + if (total > maxBytes) { + throw new IOException("content exceeds " + maxBytes + " bytes"); + } + out.write(buffer, 0, count); + } + return out.toByteArray(); + } + } + + /** A small UTF-8 text file (a record, a sidecar), at most maxBytes long. */ + static String readSmallUtf8(File file, long maxBytes) throws IOException { + return new String(readBytes(new FileInputStream(file), maxBytes), StandardCharsets.UTF_8); + } } From 3ed50e376b2dae4342487531e623de740017d11e Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 15:00:29 +0800 Subject: [PATCH 047/104] ci(lint): mention the constant parity check in the lint step comment Co-Authored-By: Claude Opus 5.5 --- .github/workflows/lint.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 1b4ad709..0d090c94 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -28,9 +28,11 @@ jobs: - name: Install Dependency run: bun install --frozen-lockfile - # `bun lint` ends with check-native-spec-parity.js: every + # `bun lint` ends with check-native-spec-parity.js (every # src/NativePushy.ts method must be exported by all four native - # bindings, with the right sync/async kind on Harmony. + # bindings, with the right sync/async kind on Harmony) and + # check-constant-parity.js (the C++ header constants must match their + # Java/ArkTS/JS mirrors). - name: Run lint run: bun lint From 92186a31dccc78d86d0c6239f044613cb9a185c0 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 15:00:55 +0800 Subject: [PATCH 048/104] style: biome-format the harmony sync tests Co-Authored-By: Claude Opus 5.5 --- harmony/pushy/src/test/ArchiveLimits.test.ets | 104 ----------- harmony/pushy/src/test/ContentRange.test.ets | 54 ------ .../src/test/DownloadTaskParams.test.ets | 42 ----- harmony/pushy/src/test/ErrorCodes.test.ets | 48 ----- harmony/pushy/src/test/EventHub.test.ets | 56 ------ harmony/pushy/src/test/InstallRecord.test.ets | 172 ------------------ harmony/pushy/src/test/List.test.ets | 21 --- .../pushy/src/test/ManifestParsing.test.ets | 107 ----------- harmony/pushy/src/test/NativeConfig.test.ets | 59 ------ harmony/pushy/src/test/Validation.test.ets | 115 ------------ src/__tests__/harmonySyncAttempts.test.ts | 39 ++-- src/__tests__/harmonySyncQuery.test.ts | 6 +- 12 files changed, 25 insertions(+), 798 deletions(-) delete mode 100644 harmony/pushy/src/test/ArchiveLimits.test.ets delete mode 100644 harmony/pushy/src/test/ContentRange.test.ets delete mode 100644 harmony/pushy/src/test/DownloadTaskParams.test.ets delete mode 100644 harmony/pushy/src/test/ErrorCodes.test.ets delete mode 100644 harmony/pushy/src/test/EventHub.test.ets delete mode 100644 harmony/pushy/src/test/InstallRecord.test.ets delete mode 100644 harmony/pushy/src/test/List.test.ets delete mode 100644 harmony/pushy/src/test/ManifestParsing.test.ets delete mode 100644 harmony/pushy/src/test/NativeConfig.test.ets delete mode 100644 harmony/pushy/src/test/Validation.test.ets diff --git a/harmony/pushy/src/test/ArchiveLimits.test.ets b/harmony/pushy/src/test/ArchiveLimits.test.ets deleted file mode 100644 index 02c9b2e7..00000000 --- a/harmony/pushy/src/test/ArchiveLimits.test.ets +++ /dev/null @@ -1,104 +0,0 @@ -import { describe, it, expect } from '@ohos/hypium'; -import { - ORIGINAL_SIZE_UNAVAILABLE, - UNKNOWN_LENGTH_FREE_SPACE_PROBE_BYTES, - checkOriginalSize, - planUnknownLengthFreeSpaceProbe, -} from '../main/ets/ArchiveLimits'; -import { ERROR_DELTA_FAILED, PushyError } from '../main/ets/ErrorCodes'; - -const PROBE = UNKNOWN_LENGTH_FREE_SPACE_PROBE_BYTES; - -function refusal(run: () => number): PushyError | null { - try { - run(); - } catch (e) { - return e as PushyError; - } - return null; -} - -export default function archiveLimitsTest() { - // 未知长度下载的磁盘探测:任何写入都不得越过已探测预留的位置。 - describe('planUnknownLengthFreeSpaceProbe', () => { - it('never probes when the total length is known', 0, () => { - const plan = planUnknownLengthFreeSpaceProbe(1000, 0, 4096, 0); - expect(plan.probe).assertFalse(); - expect(plan.reservedUntil).assertEqual(0); - }); - - it('probes the first chunk and reserves PROBE bytes ahead', 0, () => { - const plan = planUnknownLengthFreeSpaceProbe(0, 0, 4096, 0); - expect(plan.probe).assertTrue(); - expect(plan.reserve).assertEqual(PROBE); - expect(plan.reservedUntil).assertEqual(PROBE); - }); - - it('skips chunks that stay inside the reservation', 0, () => { - const plan = planUnknownLengthFreeSpaceProbe(0, 4096, 4096, PROBE); - expect(plan.probe).assertFalse(); - expect(plan.reservedUntil).assertEqual(PROBE); - // Ending exactly on the reserved boundary is still covered. - const edge = planUnknownLengthFreeSpaceProbe(0, PROBE - 10, 10, PROBE); - expect(edge.probe).assertFalse(); - }); - - it('re-probes a chunk that would cross the reservation', 0, () => { - const plan = planUnknownLengthFreeSpaceProbe(0, PROBE - 10, 11, PROBE); - expect(plan.probe).assertTrue(); - expect(plan.reservedUntil).assertEqual(PROBE - 10 + PROBE); - }); - - it('reserves a chunk larger than PROBE by its own size', 0, () => { - const big = PROBE * 2 + 1; - const plan = planUnknownLengthFreeSpaceProbe(0, 100, big, 0); - expect(plan.probe).assertTrue(); - expect(plan.reserve).assertEqual(big); - expect(plan.reservedUntil).assertEqual(100 + big); - }); - - it('walking a stream never writes past a probed reservation', 0, () => { - let reservedUntil = 0; - let written = 0; - let probes = 0; - const chunk = 300 * 1024; - for (let i = 0; i < 100; i++) { - const plan = planUnknownLengthFreeSpaceProbe(0, written, chunk, reservedUntil); - if (plan.probe) { - probes += 1; - } - reservedUntil = plan.reservedUntil; - written += chunk; - expect(written <= reservedUntil).assertTrue(); - } - expect(probes < 100).assertTrue(); - }); - }); - - // getOriginalSize 读不到:拒绝(不放行),且带专属错误细节可诊断。 - describe('checkOriginalSize', () => { - it('passes a finite non-negative size through', 0, () => { - expect(checkOriginalSize(0)).assertEqual(0); - expect(checkOriginalSize(12345)).assertEqual(12345); - }); - - it('refuses when getOriginalSize threw, with its own detail', 0, () => { - const error = refusal(() => checkOriginalSize(undefined, 'boom')); - expect(error !== null).assertTrue(); - expect(error!.code).assertEqual(ERROR_DELTA_FAILED); - expect(error!.message.includes(ORIGINAL_SIZE_UNAVAILABLE)).assertTrue(); - expect(error!.message.includes('getOriginalSize failed: boom')).assertTrue(); - }); - - it('refuses an unusable result, distinct from the thrown case', 0, () => { - const sizes: (number | undefined)[] = [undefined, NaN, -1, Infinity]; - for (const size of sizes) { - const error = refusal(() => checkOriginalSize(size)); - expect(error !== null).assertTrue(); - expect(error!.code).assertEqual(ERROR_DELTA_FAILED); - expect(error!.message.includes(ORIGINAL_SIZE_UNAVAILABLE)).assertTrue(); - expect(error!.message.includes('getOriginalSize returned')).assertTrue(); - } - }); - }); -} diff --git a/harmony/pushy/src/test/ContentRange.test.ets b/harmony/pushy/src/test/ContentRange.test.ets deleted file mode 100644 index b6d7a62f..00000000 --- a/harmony/pushy/src/test/ContentRange.test.ets +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, it, expect } from '@ohos/hypium'; -import { parseContentRangeTotal } from '../main/ets/DownloadTask'; - -// 续传响应头解析:206 的 Content-Range 决定追加的字节是否可信。 -export default function contentRangeTest() { - describe('parseContentRangeTotal', () => { - it('returns the total for a well-formed range at the expected start', 0, () => { - expect(parseContentRangeTotal('bytes 100-199/200', 100)).assertEqual(200); - expect(parseContentRangeTotal('bytes 0-9/10', 0)).assertEqual(10); - }); - - it('tolerates surrounding whitespace', 0, () => { - expect(parseContentRangeTotal('bytes 100-199 / 200 ', 100)).assertEqual(200); - }); - - it('returns 0 for an unknown total (*)', 0, () => { - expect(parseContentRangeTotal('bytes 100-199/*', 100)).assertEqual(0); - }); - - it('rejects a start that does not match the local partial', 0, () => { - expect(parseContentRangeTotal('bytes 50-199/200', 100)).assertEqual(-1); - expect(parseContentRangeTotal('bytes 0-199/200', 100)).assertEqual(-1); - }); - - it('rejects missing or foreign units', 0, () => { - expect(parseContentRangeTotal('', 0)).assertEqual(-1); - expect(parseContentRangeTotal('100-199/200', 100)).assertEqual(-1); - expect(parseContentRangeTotal('items 100-199/200', 100)).assertEqual(-1); - }); - - it('rejects malformed ranges', 0, () => { - expect(parseContentRangeTotal('bytes 100-199', 100)).assertEqual(-1); - expect(parseContentRangeTotal('bytes 100/200', 100)).assertEqual(-1); - expect(parseContentRangeTotal('bytes */200', 0)).assertEqual(-1); - expect(parseContentRangeTotal('bytes abc-199/200', 100)).assertEqual(-1); - }); - - it('rejects non-positive or non-numeric totals', 0, () => { - expect(parseContentRangeTotal('bytes 100-199/0', 100)).assertEqual(-1); - expect(parseContentRangeTotal('bytes 100-199/-5', 100)).assertEqual(-1); - expect(parseContentRangeTotal('bytes 100-199/abc', 100)).assertEqual(-1); - }); - - // 数值 total 必须大于 end(RFC 9110 §14.4);end 不能在 start 之前。 - it('rejects a range that contradicts its total', 0, () => { - expect(parseContentRangeTotal('bytes 100-199/199', 100)).assertEqual(-1); - expect(parseContentRangeTotal('bytes 100-199/150', 100)).assertEqual(-1); - expect(parseContentRangeTotal('bytes 100-99/200', 100)).assertEqual(-1); - expect(parseContentRangeTotal('bytes 100-99/*', 100)).assertEqual(-1); - expect(parseContentRangeTotal('bytes 100-x/200', 100)).assertEqual(-1); - expect(parseContentRangeTotal('bytes 100-100/200', 100)).assertEqual(200); - }); - }); -} diff --git a/harmony/pushy/src/test/DownloadTaskParams.test.ets b/harmony/pushy/src/test/DownloadTaskParams.test.ets deleted file mode 100644 index 784cad22..00000000 --- a/harmony/pushy/src/test/DownloadTaskParams.test.ets +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, it, expect } from '@ohos/hypium'; -import { DownloadTaskParams } from '../main/ets/DownloadTaskParams'; - -export default function downloadTaskParamsTest() { - describe('DownloadTaskParams', () => { - it('has correct task type constants', 0, () => { - expect(DownloadTaskParams.TASK_TYPE_PATCH_FULL).assertEqual(1); - expect(DownloadTaskParams.TASK_TYPE_PATCH_FROM_APP).assertEqual(2); - expect(DownloadTaskParams.TASK_TYPE_PATCH_FROM_PPK).assertEqual(3); - }); - - it('has correct default values', 0, () => { - const params = new DownloadTaskParams(); - expect(params.type).assertEqual(DownloadTaskParams.TASK_TYPE_PATCH_FULL); - expect(params.url).assertEqual(''); - expect(params.hash).assertEqual(''); - expect(params.originHash).assertEqual(''); - expect(params.targetFile).assertEqual(''); - expect(params.unzipDirectory).assertEqual(''); - expect(params.originDirectory).assertEqual(''); - }); - - it('allows setting all fields', 0, () => { - const params = new DownloadTaskParams(); - params.type = DownloadTaskParams.TASK_TYPE_PATCH_FROM_PPK; - params.url = 'https://example.com/patch.ppk'; - params.hash = 'abc123'; - params.originHash = 'def456'; - params.targetFile = '/data/patch.ppk'; - params.unzipDirectory = '/data/abc123'; - params.originDirectory = '/data/def456'; - - expect(params.type).assertEqual(3); - expect(params.url).assertEqual('https://example.com/patch.ppk'); - expect(params.hash).assertEqual('abc123'); - expect(params.originHash).assertEqual('def456'); - expect(params.targetFile).assertEqual('/data/patch.ppk'); - expect(params.unzipDirectory).assertEqual('/data/abc123'); - expect(params.originDirectory).assertEqual('/data/def456'); - }); - }); -} diff --git a/harmony/pushy/src/test/ErrorCodes.test.ets b/harmony/pushy/src/test/ErrorCodes.test.ets deleted file mode 100644 index 1f0a858f..00000000 --- a/harmony/pushy/src/test/ErrorCodes.test.ets +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, it, expect } from '@ohos/hypium'; -import { - ERROR_DOWNLOAD_FAILED, - ERROR_DELTA_FAILED, - PushyError, - createPushyError, - getErrorMessage, - toPushyError, -} from '../main/ets/ErrorCodes'; - -// 稳定错误码:`code` 属性 + `[CODE] ` 消息前缀(RNOH 桥丢属性时 JS 解析前缀)。 -export default function errorCodesTest() { - describe('ErrorCodes.createPushyError', () => { - it('sets the code and prefixes the message', 0, () => { - const error = createPushyError(ERROR_DELTA_FAILED, 'boom'); - expect(error instanceof Error).assertTrue(); - expect(error.code).assertEqual(ERROR_DELTA_FAILED); - expect(error.message).assertEqual('[PATCH_FAILED] boom'); - }); - }); - - describe('ErrorCodes.toPushyError', () => { - it('keeps an existing code and never double-prefixes', 0, () => { - const original = createPushyError(ERROR_DELTA_FAILED, 'boom'); - const wrapped = toPushyError(original, ERROR_DOWNLOAD_FAILED); - expect(wrapped === original).assertTrue(); - expect(wrapped.message).assertEqual('[PATCH_FAILED] boom'); - }); - - it('assigns the default code to plain errors and values', 0, () => { - const fromError = toPushyError(new Error('plain'), ERROR_DOWNLOAD_FAILED); - expect(fromError.code).assertEqual(ERROR_DOWNLOAD_FAILED); - expect(fromError.message).assertEqual('[DOWNLOAD_FAILED] plain'); - const fromString = toPushyError('raw', ERROR_DOWNLOAD_FAILED); - expect(fromString instanceof PushyError).assertTrue(); - expect(fromString.message).assertEqual('[DOWNLOAD_FAILED] raw'); - }); - }); - - describe('ErrorCodes.getErrorMessage', () => { - it('reads messages from errors, objects and primitives', 0, () => { - expect(getErrorMessage(new Error('e'))).assertEqual('e'); - expect(getErrorMessage({ message: 'm' })).assertEqual('m'); - expect(getErrorMessage('s')).assertEqual('s'); - expect(getErrorMessage(42)).assertEqual('42'); - }); - }); -} diff --git a/harmony/pushy/src/test/EventHub.test.ets b/harmony/pushy/src/test/EventHub.test.ets deleted file mode 100644 index 3527bb77..00000000 --- a/harmony/pushy/src/test/EventHub.test.ets +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, it, expect } from '@ohos/hypium'; -import { EventHub } from '../main/ets/EventHub'; - -export default function eventHubTest() { - describe('EventHub', () => { - it('is a singleton', 0, () => { - const a = EventHub.getInstance(); - const b = EventHub.getInstance(); - expect(a === b).assertEqual(true); - }); - - it('does not throw when emitting without rnInstance', 0, () => { - const hub = EventHub.getInstance(); - hub.setRNInstance(undefined); - let threw = false; - try { - hub.emit('test', { foo: 'bar' }); - } catch { - threw = true; - } - expect(threw).assertEqual(false); - }); - - it('calls emitDeviceEvent on rnInstance when set', 0, () => { - const hub = EventHub.getInstance(); - let capturedEvent = ''; - let capturedData: Record = {}; - const mockRnInstance = { - emitDeviceEvent(event: string, data: Object) { - capturedEvent = event; - capturedData = data as Record; - }, - }; - hub.setRNInstance(mockRnInstance); - hub.emit('RCTPushyDownloadProgress', { received: 100, total: 200 }); - expect(capturedEvent).assertEqual('RCTPushyDownloadProgress'); - expect(capturedData.received).assertEqual(100); - expect(capturedData.total).assertEqual(200); - hub.setRNInstance(undefined); - }); - - it('clearRNInstance only drops the instance it is given', 0, () => { - const hub = EventHub.getInstance(); - let calls = 0; - const current = { emitDeviceEvent(_event: string, _data: Object) { calls++; } }; - const stale = { emitDeviceEvent(_event: string, _data: Object) { calls++; } }; - hub.setRNInstance(current); - hub.clearRNInstance(stale); - hub.emit('RCTPushyDownloadProgress', { received: 1, total: 2 }); - expect(calls).assertEqual(1); - hub.clearRNInstance(current); - hub.emit('RCTPushyDownloadProgress', { received: 2, total: 2 }); - expect(calls).assertEqual(1); - }); - }); -} diff --git a/harmony/pushy/src/test/InstallRecord.test.ets b/harmony/pushy/src/test/InstallRecord.test.ets deleted file mode 100644 index a025942d..00000000 --- a/harmony/pushy/src/test/InstallRecord.test.ets +++ /dev/null @@ -1,172 +0,0 @@ -import { describe, it, expect } from '@ohos/hypium'; -import { - INSTALL_RECORD_FILE_NAME, - INSTALL_RECORD_SCHEMA, - InstallRecordData, - STAGING_SUFFIX, - buildInstallRecord, - expectedBundleSha256ForActivation, - isRecordForVersion, - isReservedEntryName, - parseInstallRecord, - stagingDirectoryFor, -} from '../main/ets/InstallRecord'; -import { - ERROR_DELTA_FAILED, - ERROR_SWITCH_VERSION_FAILED, - PushyError, -} from '../main/ets/ErrorCodes'; - -// 两阶段安装的完成记录(cpp/patch_core/install_record.h 的 ArkTS 镜像): -// 纯逻辑部分,不碰磁盘。 -export default function installRecordTest() { - describe('InstallRecord.buildInstallRecord', () => { - it('serializes schema, version and digests', 0, () => { - const record = parseInstallRecord( - buildInstallRecord('abc', 'BUNDLE', 'ARTIFACT'), - ); - expect(record !== null).assertTrue(); - expect(record!.schema).assertEqual(INSTALL_RECORD_SCHEMA); - expect(record!.versionHash).assertEqual('abc'); - expect(record!.bundleSha256).assertEqual('BUNDLE'); - expect(record!.artifactSha256).assertEqual('ARTIFACT'); - }); - - it('omits an empty artifact digest', 0, () => { - const record = parseInstallRecord(buildInstallRecord('abc', 'BUNDLE', '')); - expect(record !== null).assertTrue(); - expect(record!.bundleSha256).assertEqual('BUNDLE'); - expect(record!.artifactSha256 === undefined).assertTrue(); - }); - - it('refuses to build a record without a bundle digest', 0, () => { - let caught: PushyError | null = null; - try { - buildInstallRecord('abc', '', 'ARTIFACT'); - } catch (e) { - caught = e as PushyError; - } - expect(caught !== null).assertTrue(); - expect(caught!.code).assertEqual(ERROR_DELTA_FAILED); - }); - - it('derives the staging directory from the version directory', 0, () => { - expect(stagingDirectoryFor('/data/_update/abc')).assertEqual( - `/data/_update/abc${STAGING_SUFFIX}`, - ); - }); - }); - - describe('InstallRecord.parseInstallRecord', () => { - it('treats an empty file as a legacy record', 0, () => { - const record = parseInstallRecord(''); - expect(record !== null).assertTrue(); - expect(Object.keys(record!).length).assertEqual(0); - }); - - it('parses a JSON object', 0, () => { - const record = parseInstallRecord('{"schema":1,"versionHash":"abc"}'); - expect(record !== null).assertTrue(); - expect(record!.schema).assertEqual(1); - expect(record!.versionHash).assertEqual('abc'); - }); - - it('rejects malformed or non-object content', 0, () => { - expect(parseInstallRecord('{oops')).assertNull(); - expect(parseInstallRecord('[1,2]')).assertNull(); - expect(parseInstallRecord('"text"')).assertNull(); - expect(parseInstallRecord('null')).assertNull(); - }); - }); - - describe('InstallRecord.isRecordForVersion', () => { - it('is false without a record', 0, () => { - expect(isRecordForVersion(null, 'abc')).assertFalse(); - }); - - it('trusts a legacy empty record', 0, () => { - expect(isRecordForVersion({}, 'abc')).assertTrue(); - }); - - it('requires the schema and version to match', 0, () => { - const record = parseInstallRecord(buildInstallRecord('abc', 'DIGEST', '')); - expect(isRecordForVersion(record, 'abc')).assertTrue(); - expect(isRecordForVersion(record, 'def')).assertFalse(); - expect( - isRecordForVersion({ schema: INSTALL_RECORD_SCHEMA + 1, versionHash: 'abc' }, 'abc'), - ).assertFalse(); - }); - }); - - describe('InstallRecord.expectedBundleSha256ForActivation', () => { - it('throws SWITCH_VERSION_FAILED without a record', 0, () => { - let caught: PushyError | null = null; - try { - expectedBundleSha256ForActivation(null, 'abc'); - } catch (e) { - caught = e as PushyError; - } - expect(caught !== null).assertTrue(); - expect(caught!.code).assertEqual(ERROR_SWITCH_VERSION_FAILED); - expect(caught!.message.startsWith(`[${ERROR_SWITCH_VERSION_FAILED}] `)).assertTrue(); - }); - - it('has nothing to verify for a legacy empty record', 0, () => { - expect(expectedBundleSha256ForActivation({}, 'abc')).assertEqual(''); - }); - - it('rejects a schema 1 record without a bundle digest', 0, () => { - const records: InstallRecordData[] = [ - { schema: INSTALL_RECORD_SCHEMA, versionHash: 'abc' }, - { schema: INSTALL_RECORD_SCHEMA, versionHash: 'abc', bundleSha256: '' }, - { schema: INSTALL_RECORD_SCHEMA, versionHash: 'abc', artifactSha256: 'A' }, - ]; - for (const record of records) { - let caught: PushyError | null = null; - try { - expectedBundleSha256ForActivation(record, 'abc'); - } catch (e) { - caught = e as PushyError; - } - expect(caught !== null).assertTrue(); - expect(caught!.code).assertEqual(ERROR_SWITCH_VERSION_FAILED); - expect(caught!.message.includes('record mismatch')).assertTrue(); - } - }); - - it('returns the recorded digest for a matching record', 0, () => { - expect( - expectedBundleSha256ForActivation( - parseInstallRecord(buildInstallRecord('abc', 'DIGEST', '')), - 'abc', - ), - ).assertEqual('DIGEST'); - }); - - it('throws on a record for another version', 0, () => { - let threw = false; - try { - expectedBundleSha256ForActivation( - parseInstallRecord(buildInstallRecord('def', 'DIGEST', '')), - 'abc', - ); - } catch (e) { - threw = true; - } - expect(threw).assertTrue(); - }); - }); - - describe('InstallRecord.isReservedEntryName', () => { - it('reserves every .pushy- prefixed name', 0, () => { - expect(isReservedEntryName(INSTALL_RECORD_FILE_NAME)).assertTrue(); - expect(isReservedEntryName('.pushy-anything')).assertTrue(); - }); - - it('allows ordinary entries', 0, () => { - expect(isReservedEntryName('bundle.harmony.js')).assertFalse(); - expect(isReservedEntryName('.pushyx')).assertFalse(); - expect(isReservedEntryName('pushy-complete')).assertFalse(); - }); - }); -} diff --git a/harmony/pushy/src/test/List.test.ets b/harmony/pushy/src/test/List.test.ets deleted file mode 100644 index d55166f9..00000000 --- a/harmony/pushy/src/test/List.test.ets +++ /dev/null @@ -1,21 +0,0 @@ -import manifestParsingTest from './ManifestParsing.test'; -import validationTest from './Validation.test'; -import eventHubTest from './EventHub.test'; -import downloadTaskParamsTest from './DownloadTaskParams.test'; -import contentRangeTest from './ContentRange.test'; -import installRecordTest from './InstallRecord.test'; -import errorCodesTest from './ErrorCodes.test'; -import archiveLimitsTest from './ArchiveLimits.test'; -import nativeConfigTest from './NativeConfig.test'; - -export default function testsuite() { - manifestParsingTest(); - validationTest(); - eventHubTest(); - downloadTaskParamsTest(); - contentRangeTest(); - installRecordTest(); - errorCodesTest(); - archiveLimitsTest(); - nativeConfigTest(); -} diff --git a/harmony/pushy/src/test/ManifestParsing.test.ets b/harmony/pushy/src/test/ManifestParsing.test.ets deleted file mode 100644 index 17ccb893..00000000 --- a/harmony/pushy/src/test/ManifestParsing.test.ets +++ /dev/null @@ -1,107 +0,0 @@ -import { describe, it, expect } from '@ohos/hypium'; -import { parseManifestToArrays } from '../main/ets/DownloadTask'; - -export default function manifestParsingTest() { - describe('parseManifestToArrays', () => { - it('returns empty arrays for empty manifest', 0, () => { - const result = parseManifestToArrays({}, false); - expect(result.copyFroms.length).assertEqual(0); - expect(result.copyTos.length).assertEqual(0); - expect(result.deletes.length).assertEqual(0); - }); - - it('parses copies and deletes array', 0, () => { - const manifest = { - copies: { - 'assets/icon.png': 'assets/original.png', - 'assets/logo.png': 'assets/brand.png', - }, - deletes: ['assets/old.png', 'assets/removed.txt'], - }; - const result = parseManifestToArrays(manifest, false); - expect(result.copyFroms.length).assertEqual(2); - expect(result.copyTos.length).assertEqual(2); - expect(result.copyFroms[0]).assertEqual('assets/original.png'); - expect(result.copyTos[0]).assertEqual('assets/icon.png'); - expect(result.copyFroms[1]).assertEqual('assets/brand.png'); - expect(result.copyTos[1]).assertEqual('assets/logo.png'); - expect(result.deletes.length).assertEqual(2); - expect(result.deletes[0]).assertEqual('assets/old.png'); - expect(result.deletes[1]).assertEqual('assets/removed.txt'); - }); - - it('parses deletes as object keys', 0, () => { - const manifest = { - deletes: { - 'assets/old.png': true, - 'assets/removed.txt': true, - }, - }; - const result = parseManifestToArrays(manifest, false); - expect(result.deletes.length).assertEqual(2); - expect(result.deletes[0]).assertEqual('assets/old.png'); - expect(result.deletes[1]).assertEqual('assets/removed.txt'); - }); - - it('handles missing copies gracefully', 0, () => { - const manifest = { - deletes: ['a.txt'], - }; - const result = parseManifestToArrays(manifest, false); - expect(result.copyFroms.length).assertEqual(0); - expect(result.copyTos.length).assertEqual(0); - expect(result.deletes.length).assertEqual(1); - }); - - it('handles missing deletes gracefully', 0, () => { - const manifest = { - copies: { 'a.png': 'b.png' }, - }; - const result = parseManifestToArrays(manifest, false); - expect(result.deletes.length).assertEqual(0); - expect(result.copyFroms.length).assertEqual(1); - }); - - it('normalizes resource copies when enabled', 0, () => { - const manifest = { - copies: { - 'assets/icon.png': 'resources/rawfile/assets/icon.png', - }, - }; - const result = parseManifestToArrays(manifest, true); - expect(result.copyFroms[0]).assertEqual('assets/icon.png'); - expect(result.copyTos[0]).assertEqual('assets/icon.png'); - }); - - it('falls back to key when normalized from is empty', 0, () => { - const manifest = { - copies: { - 'assets/icon.png': 'resources/rawfile/', - }, - }; - const result = parseManifestToArrays(manifest, true); - expect(result.copyFroms[0]).assertEqual('assets/icon.png'); - }); - - it('does not normalize when disabled', 0, () => { - const manifest = { - copies: { - 'assets/icon.png': 'resources/rawfile/assets/icon.png', - }, - }; - const result = parseManifestToArrays(manifest, false); - expect(result.copyFroms[0]).assertEqual('resources/rawfile/assets/icon.png'); - }); - - it('handles null-ish from values', 0, () => { - const manifest = { - copies: { - 'assets/icon.png': null, - }, - }; - const result = parseManifestToArrays(manifest as any, false); - expect(result.copyFroms[0]).assertEqual(''); - expect(result.copyTos[0]).assertEqual('assets/icon.png'); - }); - }); -} diff --git a/harmony/pushy/src/test/NativeConfig.test.ets b/harmony/pushy/src/test/NativeConfig.test.ets deleted file mode 100644 index a727d43e..00000000 --- a/harmony/pushy/src/test/NativeConfig.test.ets +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, it, expect } from '@ohos/hypium'; -import { canonicalNativeConfig, nativeConfigsEquivalent } from '../main/ets/UpdateContext'; - -// C1:跨平台配置等价规则(与 JS/Android/iOS 一致)。 -export default function nativeConfigTest() { - describe('nativeConfigsEquivalent', () => { - it('ignores key order', 0, () => { - expect(nativeConfigsEquivalent( - '{"appKey":"k","endpoints":["https://a"]}', - '{"endpoints":["https://a"],"appKey":"k"}', - )).assertTrue(); - }); - - it('fills absent keys with their defaults', 0, () => { - expect(nativeConfigsEquivalent( - '{"appKey":"k"}', - '{"appKey":"k","disabled":false,"queryUrls":[],"rnu":"","rn":"","afterDownload":"none"}', - )).assertTrue(); - expect(nativeConfigsEquivalent('{"appKey":"k"}', '{"appKey":"k","disabled":true}')) - .assertFalse(); - expect(nativeConfigsEquivalent('{"appKey":"k"}', '{"appKey":"k","rn":"0.82"}')) - .assertFalse(); - }); - - it('strips trailing slashes and dedupes URL lists, keeping first order', 0, () => { - expect(nativeConfigsEquivalent( - '{"appKey":"k","endpoints":["https://a/","https://b","https://a"],"queryUrls":["https://q/"]}', - '{"appKey":"k","endpoints":["https://a","https://b"],"queryUrls":["https://q"]}', - )).assertTrue(); - expect(nativeConfigsEquivalent( - '{"appKey":"k","endpoints":["https://a","https://b"]}', - '{"appKey":"k","endpoints":["https://b","https://a"]}', - )).assertFalse(); - // Every trailing slash goes, not just one. - expect(nativeConfigsEquivalent( - '{"appKey":"k","endpoints":["https://b.example.com//"]}', - '{"appKey":"k","endpoints":["https://b.example.com"]}', - )).assertTrue(); - }); - - it('treats unparseable or non-object JSON as not equivalent', 0, () => { - expect(nativeConfigsEquivalent('{', '{"appKey":"k"}')).assertFalse(); - expect(nativeConfigsEquivalent('[]', '{}')).assertFalse(); - // Not even against itself: only JSON objects have a canonical form. - expect(nativeConfigsEquivalent('{', '{')).assertFalse(); - expect(nativeConfigsEquivalent('"x"', '"x"')).assertFalse(); - expect(nativeConfigsEquivalent(undefined, '{"appKey":"k"}')).assertFalse(); - expect(canonicalNativeConfig('null') === undefined).assertTrue(); - }); - - it('compares every other field exactly', 0, () => { - expect(nativeConfigsEquivalent('{"appKey":"k"}', '{"appKey":"k2"}')).assertFalse(); - expect(nativeConfigsEquivalent( - '{"appKey":"k","packageVersion":"1"}', - '{"appKey":"k"}', - )).assertFalse(); - }); - }); -} diff --git a/harmony/pushy/src/test/Validation.test.ets b/harmony/pushy/src/test/Validation.test.ets deleted file mode 100644 index 1a6ace49..00000000 --- a/harmony/pushy/src/test/Validation.test.ets +++ /dev/null @@ -1,115 +0,0 @@ -import { describe, it, expect } from '@ohos/hypium'; -import { validateHashInfo, getErrorMessage } from '../main/ets/PushyTurboModule'; -import { isSafePathComponent } from '../main/ets/UpdateContext'; - -export default function validationTest() { - describe('isSafePathComponent', () => { - it('accepts a plain hash-like name', 0, () => { - expect(isSafePathComponent('a1b2c3d4')).assertEqual(true); - expect(isSafePathComponent('origin_hash-1.ppk')).assertEqual(true); - }); - - it('rejects empty and dot names', 0, () => { - expect(isSafePathComponent('')).assertEqual(false); - expect(isSafePathComponent('.')).assertEqual(false); - expect(isSafePathComponent('..')).assertEqual(false); - }); - - it('rejects names containing path separators', 0, () => { - expect(isSafePathComponent('../etc')).assertEqual(false); - expect(isSafePathComponent('a/b')).assertEqual(false); - expect(isSafePathComponent('a\\b')).assertEqual(false); - expect(isSafePathComponent('a\0b')).assertEqual(false); - }); - }); - - describe('validateHashInfo', () => { - it('accepts valid JSON object', 0, () => { - let threw = false; - try { - validateHashInfo('{"name":"v1"}'); - } catch { - threw = true; - } - expect(threw).assertEqual(false); - }); - - it('accepts empty JSON object', 0, () => { - let threw = false; - try { - validateHashInfo('{}'); - } catch { - threw = true; - } - expect(threw).assertEqual(false); - }); - - it('rejects JSON array', 0, () => { - let threw = false; - try { - validateHashInfo('[1,2,3]'); - } catch { - threw = true; - } - expect(threw).assertEqual(true); - }); - - it('rejects plain string', 0, () => { - let threw = false; - try { - validateHashInfo('"hello"'); - } catch { - threw = true; - } - expect(threw).assertEqual(true); - }); - - it('rejects invalid JSON', 0, () => { - let threw = false; - try { - validateHashInfo('{invalid}'); - } catch { - threw = true; - } - expect(threw).assertEqual(true); - }); - - it('rejects null JSON', 0, () => { - let threw = false; - try { - validateHashInfo('null'); - } catch { - threw = true; - } - expect(threw).assertEqual(true); - }); - }); - - describe('getErrorMessage', () => { - it('extracts message from Error object', 0, () => { - const err = new Error('something broke'); - expect(getErrorMessage(err)).assertEqual('something broke'); - }); - - it('extracts message from plain object with message property', 0, () => { - const err = { message: 'custom error', code: 42 }; - expect(getErrorMessage(err)).assertEqual('custom error'); - }); - - it('converts string to string', 0, () => { - expect(getErrorMessage('raw string')).assertEqual('raw string'); - }); - - it('converts number to string', 0, () => { - expect(getErrorMessage(404)).assertEqual('404'); - }); - - it('converts null to string', 0, () => { - expect(getErrorMessage(null)).assertEqual('null'); - }); - - it('converts undefined to string', 0, () => { - expect(getErrorMessage(undefined)).assertEqual('undefined'); - }); - }); -} diff --git a/src/__tests__/harmonySyncAttempts.test.ts b/src/__tests__/harmonySyncAttempts.test.ts index 6cb42e64..dff2e400 100644 --- a/src/__tests__/harmonySyncAttempts.test.ts +++ b/src/__tests__/harmonySyncAttempts.test.ts @@ -3,10 +3,10 @@ import { readFileSync } from 'node:fs'; import { URL } from 'node:url'; import { runInNewContext } from 'node:vm'; import { + createPushyError, ERROR_DELTA_FAILED, ERROR_DOWNLOAD_FAILED, PushyError, - createPushyError, } from '../../harmony/pushy/src/main/ets/ErrorCodes'; // The download strategy loop of the Harmony cold-start orchestrator @@ -47,22 +47,19 @@ function harness(failures: Map) { downloadPatchFromPackage: (url: string) => fail(url), downloadFullUpdate: (url: string) => fail(url), }; - const performAttempts = runInNewContext( - `${javascript}\nperformAttempts;`, - { - PushyError, - ERROR_DELTA_FAILED, - ERROR_DOWNLOAD_FAILED, - createPushyError, - getErrorMessage: (error: unknown) => String(error), - logger: { info() {}, warn() {}, error() {} }, - monotonicNowMs: () => performance.now(), - setTimeout, - clearTimeout, - BundlePreparationRound: class {}, - bundlePreparationResult: () => ({}), - } - ) as ( + const performAttempts = runInNewContext(`${javascript}\nperformAttempts;`, { + PushyError, + ERROR_DELTA_FAILED, + ERROR_DOWNLOAD_FAILED, + createPushyError, + getErrorMessage: (error: unknown) => String(error), + logger: { info() {}, warn() {}, error() {} }, + monotonicNowMs: () => performance.now(), + setTimeout, + clearTimeout, + BundlePreparationRound: class {}, + bundlePreparationResult: () => ({}), + }) as ( ctx: typeof context, attempts: Attempt[], hash: string, @@ -103,7 +100,9 @@ describe('harmony download attempts', () => { }); test('an uncoded error is retryable', async () => { - const h = harness(new Map([['https://a/full', new Error('socket closed')]])); + const h = harness( + new Map([['https://a/full', new Error('socket closed')]]) + ); const ok = await h.run([ { type: 'full', urls: ['https://a/full', 'https://b/full'] }, ]); @@ -119,7 +118,9 @@ describe('harmony download attempts', () => { ]) ); expect( - await h.run([{ type: 'full', urls: ['https://a/full', 'https://b/full'] }]) + await h.run([ + { type: 'full', urls: ['https://a/full', 'https://b/full'] }, + ]) ).toBe(false); expect(h.calls).toEqual(['https://a/full']); }); diff --git a/src/__tests__/harmonySyncQuery.test.ts b/src/__tests__/harmonySyncQuery.test.ts index 70738b50..d7df365f 100644 --- a/src/__tests__/harmonySyncQuery.test.ts +++ b/src/__tests__/harmonySyncQuery.test.ts @@ -130,7 +130,11 @@ describe('harmony query endpoint fallback', () => { const { result, requests } = await run( { endpoints, queryUrls: ['https://list'] }, { - 'https://list': JSON.stringify(['https://r0', 'https://r1', 'https://r2']), + 'https://list': JSON.stringify([ + 'https://r0', + 'https://r1', + 'https://r2', + ]), 'https://r2/q/key': VALID, } ); From 8080818ee38fbe0b34f00c09c4926d3c550cd270 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 15:01:03 +0800 Subject: [PATCH 049/104] test(harmony): run the pure-logic tests under bun, drop the hypium suite The hypium suite in harmony/pushy/src/test was run by no workflow. Its pure-logic cases now run in `bun test` (every PR), evaluating the real ArkTS sources the same way harmonyInstallRecord.test.ts does (direct import where a module has no @ohos imports, otherwise imports stripped and the source evaluated in a VM): - harmonyDownloadTask: Content-Range parsing, __diff.json manifest parsing (plus copiesCrc / hbcTransform), DownloadTaskParams defaults - harmonyInstallRecordLogic: record build/parse/match/activation rules - harmonyArchiveLimits: free-space probe plan, original-size and uncompressed-size checks - harmonyErrorCodes, harmonyValidation: error codes, path components, hash info JSON, EventHub NativeConfig.test.ets duplicated configEquivalenceVectors.test.ts and is dropped. The @ohos/hypium devDependency goes with the suite; check-constant-parity.js stays where it is. Co-Authored-By: Claude Opus 5.5 --- harmony/pushy/oh-package.json5 | 3 - src/__tests__/harmonyArchiveLimits.test.ts | 201 ++++++++++++++++ src/__tests__/harmonyDownloadTask.test.ts | 222 ++++++++++++++++++ src/__tests__/harmonyErrorCodes.test.ts | 45 ++++ .../harmonyInstallRecordLogic.test.ts | 187 +++++++++++++++ src/__tests__/harmonyValidation.test.ts | 120 ++++++++++ 6 files changed, 775 insertions(+), 3 deletions(-) create mode 100644 src/__tests__/harmonyArchiveLimits.test.ts create mode 100644 src/__tests__/harmonyDownloadTask.test.ts create mode 100644 src/__tests__/harmonyErrorCodes.test.ts create mode 100644 src/__tests__/harmonyInstallRecordLogic.test.ts create mode 100644 src/__tests__/harmonyValidation.test.ts diff --git a/harmony/pushy/oh-package.json5 b/harmony/pushy/oh-package.json5 index bc3f7710..d4fcfe70 100644 --- a/harmony/pushy/oh-package.json5 +++ b/harmony/pushy/oh-package.json5 @@ -1,9 +1,6 @@ { license: 'MIT', types: '', - devDependencies: { - '@ohos/hypium': '1.0.19', - }, name: 'pushy', description: '', main: 'index.ets', diff --git a/src/__tests__/harmonyArchiveLimits.test.ts b/src/__tests__/harmonyArchiveLimits.test.ts new file mode 100644 index 00000000..6df95ffc --- /dev/null +++ b/src/__tests__/harmonyArchiveLimits.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { URL } from 'node:url'; +import { runInNewContext } from 'node:vm'; +import { + createPushyError, + ERROR_DELTA_FAILED, + ERROR_FILE_OPERATION_FAILED, + type PushyError, +} from '../../harmony/pushy/src/main/ets/ErrorCodes'; + +// The pure limit logic of Harmony ArchiveLimits.ts (ArkTS mirror of +// cpp/patch_core/archive_limits.h), evaluated from source with its file +// system imports stripped. +interface FreeSpaceProbePlan { + probe: boolean; + reserve: number; + reservedUntil: number; +} + +interface ArchiveLimits { + UNKNOWN_LENGTH_FREE_SPACE_PROBE_BYTES: number; + MAX_TOTAL_UNCOMPRESSED_BYTES: number; + MAX_COMPRESSION_RATIO: number; + RATIO_CHECK_MIN_BYTES: number; + ORIGINAL_SIZE_UNAVAILABLE: string; + checkOriginalSize(size: number | undefined, failure?: string): number; + checkUncompressedSize(archiveBytes: number, uncompressedBytes: number): void; + planUnknownLengthFreeSpaceProbe( + knownTotal: number, + writtenBefore: number, + chunkBytes: number, + reservedUntil: number + ): FreeSpaceProbePlan; +} + +const source = readFileSync( + new URL('../../harmony/pushy/src/main/ets/ArchiveLimits.ts', import.meta.url), + 'utf8' +) + .replace(/^import[\s\S]*?;\r?\n/gm, '') + .replace(/^export /gm, ''); +const limits = runInNewContext( + `${new Bun.Transpiler({ loader: 'ts' }).transformSync(source)} +({ UNKNOWN_LENGTH_FREE_SPACE_PROBE_BYTES, MAX_TOTAL_UNCOMPRESSED_BYTES, + MAX_COMPRESSION_RATIO, RATIO_CHECK_MIN_BYTES, ORIGINAL_SIZE_UNAVAILABLE, + checkOriginalSize, checkUncompressedSize, planUnknownLengthFreeSpaceProbe });`, + { + ERROR_DELTA_FAILED, + ERROR_FILE_OPERATION_FAILED, + createPushyError, + isReservedEntryName: (name: string) => name.startsWith('.pushy-'), + } +) as ArchiveLimits; + +const PROBE = limits.UNKNOWN_LENGTH_FREE_SPACE_PROBE_BYTES; + +function refusal(run: () => unknown): PushyError | null { + try { + run(); + } catch (e) { + return e as PushyError; + } + return null; +} + +// Unknown-length downloads: no write may pass a probed reservation. +describe('harmony planUnknownLengthFreeSpaceProbe', () => { + test('never probes when the total length is known', () => { + const plan = limits.planUnknownLengthFreeSpaceProbe(1000, 0, 4096, 0); + expect(plan.probe).toBe(false); + expect(plan.reservedUntil).toBe(0); + }); + + test('probes the first chunk and reserves PROBE bytes ahead', () => { + const plan = limits.planUnknownLengthFreeSpaceProbe(0, 0, 4096, 0); + expect({ ...plan }).toEqual({ + probe: true, + reserve: PROBE, + reservedUntil: PROBE, + }); + }); + + test('skips chunks that stay inside the reservation, edge included', () => { + const plan = limits.planUnknownLengthFreeSpaceProbe(0, 4096, 4096, PROBE); + expect(plan.probe).toBe(false); + expect(plan.reservedUntil).toBe(PROBE); + expect( + limits.planUnknownLengthFreeSpaceProbe(0, PROBE - 10, 10, PROBE).probe + ).toBe(false); + }); + + test('re-probes a chunk that would cross the reservation', () => { + const plan = limits.planUnknownLengthFreeSpaceProbe( + 0, + PROBE - 10, + 11, + PROBE + ); + expect(plan.probe).toBe(true); + expect(plan.reservedUntil).toBe(PROBE - 10 + PROBE); + }); + + test('reserves a chunk larger than PROBE by its own size', () => { + const big = PROBE * 2 + 1; + const plan = limits.planUnknownLengthFreeSpaceProbe(0, 100, big, 0); + expect({ ...plan }).toEqual({ + probe: true, + reserve: big, + reservedUntil: 100 + big, + }); + }); + + test('walking a stream never writes past a probed reservation', () => { + let reservedUntil = 0; + let written = 0; + let probes = 0; + const chunk = 300 * 1024; + for (let i = 0; i < 100; i++) { + const plan = limits.planUnknownLengthFreeSpaceProbe( + 0, + written, + chunk, + reservedUntil + ); + if (plan.probe) { + probes += 1; + } + reservedUntil = plan.reservedUntil; + written += chunk; + expect(written).toBeLessThanOrEqual(reservedUntil); + } + expect(probes).toBeLessThan(100); + }); +}); + +// getOriginalSize unavailable: refuse (never pass through), with its own detail. +describe('harmony checkOriginalSize', () => { + test('passes a finite non-negative size through', () => { + expect(limits.checkOriginalSize(0)).toBe(0); + expect(limits.checkOriginalSize(12345)).toBe(12345); + }); + + test('refuses when getOriginalSize threw, with its own detail', () => { + const error = refusal(() => limits.checkOriginalSize(undefined, 'boom')); + expect(error?.code).toBe(ERROR_DELTA_FAILED); + expect(error?.message).toContain(limits.ORIGINAL_SIZE_UNAVAILABLE); + expect(error?.message).toContain('getOriginalSize failed: boom'); + }); + + test('refuses an unusable result, distinct from the thrown case', () => { + for (const size of [undefined, Number.NaN, -1, Number.POSITIVE_INFINITY]) { + const error = refusal(() => limits.checkOriginalSize(size)); + expect(error?.code).toBe(ERROR_DELTA_FAILED); + expect(error?.message).toContain(limits.ORIGINAL_SIZE_UNAVAILABLE); + expect(error?.message).toContain('getOriginalSize returned'); + } + }); +}); + +describe('harmony checkUncompressedSize', () => { + test('accepts sizes within the total and ratio limits', () => { + expect(refusal(() => limits.checkUncompressedSize(1024, 4096))).toBeNull(); + // Below RATIO_CHECK_MIN_BYTES the ratio is not checked. + expect( + refusal(() => + limits.checkUncompressedSize(1, limits.RATIO_CHECK_MIN_BYTES) + ) + ).toBeNull(); + }); + + test('refuses an archive that expands beyond the total limit', () => { + const error = refusal(() => + limits.checkUncompressedSize( + limits.MAX_TOTAL_UNCOMPRESSED_BYTES, + limits.MAX_TOTAL_UNCOMPRESSED_BYTES + 1 + ) + ); + expect(error?.code).toBe(ERROR_DELTA_FAILED); + }); + + test('refuses a whole-archive ratio above the limit', () => { + const archive = limits.RATIO_CHECK_MIN_BYTES; + expect( + refusal(() => + limits.checkUncompressedSize( + archive, + archive * limits.MAX_COMPRESSION_RATIO + ) + ) + ).toBeNull(); + expect( + refusal(() => + limits.checkUncompressedSize( + archive, + archive * limits.MAX_COMPRESSION_RATIO + 1 + ) + )?.code + ).toBe(ERROR_DELTA_FAILED); + }); +}); diff --git a/src/__tests__/harmonyDownloadTask.test.ts b/src/__tests__/harmonyDownloadTask.test.ts new file mode 100644 index 00000000..119be2cc --- /dev/null +++ b/src/__tests__/harmonyDownloadTask.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { URL } from 'node:url'; +import { runInNewContext } from 'node:vm'; +import { DownloadTaskParams } from '../../harmony/pushy/src/main/ets/DownloadTaskParams'; +import { BUNDLE_DELTA_ENTRY } from '../../harmony/pushy/src/main/ets/Texts'; + +// The pure parsers of Harmony DownloadTask.ts, evaluated from source with its +// platform imports stripped (the download/patch class itself needs a device). +// InstallRecord.ts imports @ohos modules, so its bundle name is inlined. +const HARMONY_BUNDLE_FILE_NAME = 'bundle.harmony.js'; +interface PatchManifestArrays { + copyFroms: string[]; + copyTos: string[]; + copyCrcs: (number | null)[]; + deletes: string[]; + hbcTransformMeta: string; +} + +const source = readFileSync( + new URL('../../harmony/pushy/src/main/ets/DownloadTask.ts', import.meta.url), + 'utf8' +) + .replace(/^import[\s\S]*?;\r?\n/gm, '') + .replace(/^export /gm, ''); +const { parseContentRangeTotal, parseManifestToArrays } = runInNewContext( + `${new Bun.Transpiler({ loader: 'ts' }).transformSync(source)}\n({ parseContentRangeTotal, parseManifestToArrays });`, + { HARMONY_BUNDLE_FILE_NAME, BUNDLE_DELTA_ENTRY } +) as { + parseContentRangeTotal(header: string, expectedStart: number): number; + parseManifestToArrays( + manifest: Record, + normalizeResourceCopies: boolean + ): PatchManifestArrays; +}; + +// A 206's Content-Range decides whether appended bytes can be trusted. +describe('harmony parseContentRangeTotal', () => { + test('returns the total for a well-formed range at the expected start', () => { + expect(parseContentRangeTotal('bytes 100-199/200', 100)).toBe(200); + expect(parseContentRangeTotal('bytes 0-9/10', 0)).toBe(10); + expect(parseContentRangeTotal('bytes 100-100/200', 100)).toBe(200); + }); + + test('tolerates surrounding whitespace', () => { + expect(parseContentRangeTotal('bytes 100-199 / 200 ', 100)).toBe(200); + }); + + test('returns 0 for an unknown total (*)', () => { + expect(parseContentRangeTotal('bytes 100-199/*', 100)).toBe(0); + }); + + test('rejects a start that does not match the local partial', () => { + expect(parseContentRangeTotal('bytes 50-199/200', 100)).toBe(-1); + expect(parseContentRangeTotal('bytes 0-199/200', 100)).toBe(-1); + }); + + test('rejects missing or foreign units', () => { + expect(parseContentRangeTotal('', 0)).toBe(-1); + expect(parseContentRangeTotal('100-199/200', 100)).toBe(-1); + expect(parseContentRangeTotal('items 100-199/200', 100)).toBe(-1); + }); + + test('rejects malformed ranges', () => { + expect(parseContentRangeTotal('bytes 100-199', 100)).toBe(-1); + expect(parseContentRangeTotal('bytes 100/200', 100)).toBe(-1); + expect(parseContentRangeTotal('bytes */200', 0)).toBe(-1); + expect(parseContentRangeTotal('bytes abc-199/200', 100)).toBe(-1); + }); + + test('rejects non-positive or non-numeric totals', () => { + expect(parseContentRangeTotal('bytes 100-199/0', 100)).toBe(-1); + expect(parseContentRangeTotal('bytes 100-199/-5', 100)).toBe(-1); + expect(parseContentRangeTotal('bytes 100-199/abc', 100)).toBe(-1); + }); + + // A numeric total must exceed end (RFC 9110 §14.4); end cannot precede start. + test('rejects a range that contradicts its total', () => { + expect(parseContentRangeTotal('bytes 100-199/199', 100)).toBe(-1); + expect(parseContentRangeTotal('bytes 100-199/150', 100)).toBe(-1); + expect(parseContentRangeTotal('bytes 100-99/200', 100)).toBe(-1); + expect(parseContentRangeTotal('bytes 100-99/*', 100)).toBe(-1); + expect(parseContentRangeTotal('bytes 100-x/200', 100)).toBe(-1); + }); +}); + +describe('harmony parseManifestToArrays', () => { + test('returns empty arrays for an empty manifest', () => { + // toEqual across the VM boundary compares plain values. + expect( + JSON.parse(JSON.stringify(parseManifestToArrays({}, false))) + ).toEqual({ + copyFroms: [], + copyTos: [], + copyCrcs: [], + deletes: [], + hbcTransformMeta: '', + }); + }); + + test('parses copies and a deletes array', () => { + const result = parseManifestToArrays( + { + copies: { + 'assets/icon.png': 'assets/original.png', + 'assets/logo.png': 'assets/brand.png', + }, + deletes: ['assets/old.png', 'assets/removed.txt'], + }, + false + ); + expect([...result.copyFroms]).toEqual([ + 'assets/original.png', + 'assets/brand.png', + ]); + expect([...result.copyTos]).toEqual(['assets/icon.png', 'assets/logo.png']); + expect([...result.deletes]).toEqual([ + 'assets/old.png', + 'assets/removed.txt', + ]); + }); + + test('parses deletes given as object keys', () => { + const result = parseManifestToArrays( + { deletes: { 'assets/old.png': true, 'assets/removed.txt': true } }, + false + ); + expect([...result.deletes]).toEqual([ + 'assets/old.png', + 'assets/removed.txt', + ]); + }); + + test('handles missing copies or deletes', () => { + const noCopies = parseManifestToArrays({ deletes: ['a.txt'] }, false); + expect(noCopies.copyFroms.length).toBe(0); + expect(noCopies.deletes.length).toBe(1); + const noDeletes = parseManifestToArrays( + { copies: { 'a.png': 'b.png' } }, + false + ); + expect(noDeletes.deletes.length).toBe(0); + expect(noDeletes.copyFroms.length).toBe(1); + }); + + test('normalizes rawfile resource copies only when enabled', () => { + const manifest = { + copies: { 'assets/icon.png': 'resources/rawfile/assets/icon.png' }, + }; + expect(parseManifestToArrays(manifest, true).copyFroms[0]).toBe( + 'assets/icon.png' + ); + expect(parseManifestToArrays(manifest, false).copyFroms[0]).toBe( + 'resources/rawfile/assets/icon.png' + ); + }); + + test('falls back to the key when the normalized source is empty', () => { + const result = parseManifestToArrays( + { copies: { 'assets/icon.png': 'resources/rawfile/' } }, + true + ); + expect(result.copyFroms[0]).toBe('assets/icon.png'); + }); + + test('treats a null source as empty', () => { + const result = parseManifestToArrays( + { copies: { 'assets/icon.png': null } }, + false + ); + expect(result.copyFroms[0]).toBe(''); + expect(result.copyTos[0]).toBe('assets/icon.png'); + }); + + test('aligns copiesCrc with the copies and keeps only finite numbers', () => { + const result = parseManifestToArrays( + { + copies: { a: 'x', b: 'y', c: 'z' }, + copiesCrc: { a: 123, c: 'nope' }, + }, + false + ); + expect([...result.copyCrcs]).toEqual([123, null, null]); + }); + + test('extracts the bundle entry hbcTransform metadata', () => { + const meta = { version: 1, base: 'abc' }; + const result = parseManifestToArrays( + { hbcTransform: { [BUNDLE_DELTA_ENTRY]: meta, other: { x: 1 } } }, + false + ); + expect(JSON.parse(result.hbcTransformMeta)).toEqual(meta); + expect( + parseManifestToArrays({ hbcTransform: { other: {} } }, false) + .hbcTransformMeta + ).toBe(''); + }); +}); + +describe('harmony DownloadTaskParams', () => { + test('defaults to a full-package task with empty paths', () => { + const params = new DownloadTaskParams(); + expect(params.type).toBe(DownloadTaskParams.TASK_TYPE_PATCH_FULL); + expect(params.url).toBe(''); + expect(params.hash).toBe(''); + expect(params.originHash).toBe(''); + expect(params.targetFile).toBe(''); + expect(params.unzipDirectory).toBe(''); + expect(params.originDirectory).toBe(''); + expect(params.deadlineUptimeMs).toBe(0); + }); + + test('task types are distinct', () => { + expect( + new Set([ + DownloadTaskParams.TASK_TYPE_PATCH_FULL, + DownloadTaskParams.TASK_TYPE_PATCH_FROM_APP, + DownloadTaskParams.TASK_TYPE_PATCH_FROM_PPK, + ]).size + ).toBe(3); + }); +}); diff --git a/src/__tests__/harmonyErrorCodes.test.ts b/src/__tests__/harmonyErrorCodes.test.ts new file mode 100644 index 00000000..e58530f8 --- /dev/null +++ b/src/__tests__/harmonyErrorCodes.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from 'bun:test'; +import { + createPushyError, + ERROR_DELTA_FAILED, + ERROR_DOWNLOAD_FAILED, + getErrorMessage, + PushyError, + toPushyError, +} from '../../harmony/pushy/src/main/ets/ErrorCodes'; + +// Stable Harmony error codes: a `code` property plus a `[CODE] ` message +// prefix (the RNOH bridge may drop the property; JS parses the prefix). +describe('harmony ErrorCodes', () => { + test('createPushyError sets the code and prefixes the message', () => { + const error = createPushyError(ERROR_DELTA_FAILED, 'boom'); + expect(error).toBeInstanceOf(Error); + expect(error.code).toBe(ERROR_DELTA_FAILED); + expect(error.message).toBe('[PATCH_FAILED] boom'); + }); + + test('toPushyError keeps an existing code and never double-prefixes', () => { + const original = createPushyError(ERROR_DELTA_FAILED, 'boom'); + const wrapped = toPushyError(original, ERROR_DOWNLOAD_FAILED); + expect(wrapped).toBe(original); + expect(wrapped.message).toBe('[PATCH_FAILED] boom'); + }); + + test('toPushyError assigns the default code to plain errors and values', () => { + const fromError = toPushyError(new Error('plain'), ERROR_DOWNLOAD_FAILED); + expect(fromError.code).toBe(ERROR_DOWNLOAD_FAILED); + expect(fromError.message).toBe('[DOWNLOAD_FAILED] plain'); + const fromString = toPushyError('raw', ERROR_DOWNLOAD_FAILED); + expect(fromString).toBeInstanceOf(PushyError); + expect(fromString.message).toBe('[DOWNLOAD_FAILED] raw'); + }); + + test('getErrorMessage reads errors, message objects and primitives', () => { + expect(getErrorMessage(new Error('e'))).toBe('e'); + expect(getErrorMessage({ message: 'm', code: 42 })).toBe('m'); + expect(getErrorMessage('s')).toBe('s'); + expect(getErrorMessage(42)).toBe('42'); + expect(getErrorMessage(null)).toBe('null'); + expect(getErrorMessage(undefined)).toBe('undefined'); + }); +}); diff --git a/src/__tests__/harmonyInstallRecordLogic.test.ts b/src/__tests__/harmonyInstallRecordLogic.test.ts new file mode 100644 index 00000000..61d8d2a8 --- /dev/null +++ b/src/__tests__/harmonyInstallRecordLogic.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { URL } from 'node:url'; +import { runInNewContext } from 'node:vm'; +import { + createPushyError, + ERROR_DELTA_FAILED, + ERROR_SWITCH_VERSION_FAILED, + type PushyError, +} from '../../harmony/pushy/src/main/ets/ErrorCodes'; + +// The pure record logic of Harmony InstallRecord.ts (ArkTS mirror of +// cpp/patch_core/install_record.h): parsing, version matching and the +// activation digest rule. The disk-backed activation path is covered by +// harmonyInstallRecord.test.ts. +interface InstallRecordData { + schema?: number; + versionHash?: string; + bundleSha256?: unknown; + artifactSha256?: string; +} + +interface InstallRecordLogic { + INSTALL_RECORD_SCHEMA: number; + INSTALL_RECORD_FILE_NAME: string; + STAGING_SUFFIX: string; + buildInstallRecord(hash: string, bundle: string, artifact: string): string; + parseInstallRecord(text: string): InstallRecordData | null; + isRecordForVersion(record: InstallRecordData | null, hash: string): boolean; + expectedBundleSha256ForActivation( + record: InstallRecordData | null, + hash: string + ): string; + stagingDirectoryFor(versionDir: string): string; + isReservedEntryName(name: string): boolean; +} + +const source = readFileSync( + new URL('../../harmony/pushy/src/main/ets/InstallRecord.ts', import.meta.url), + 'utf8' +) + .replace(/^import[\s\S]*?;\r?\n/gm, '') + .replace(/^export /gm, ''); +const record = runInNewContext( + `${new Bun.Transpiler({ loader: 'ts' }).transformSync(source)} +({ INSTALL_RECORD_SCHEMA, INSTALL_RECORD_FILE_NAME, STAGING_SUFFIX, + buildInstallRecord, parseInstallRecord, isRecordForVersion, + expectedBundleSha256ForActivation, stagingDirectoryFor, + isReservedEntryName });`, + { ERROR_DELTA_FAILED, ERROR_SWITCH_VERSION_FAILED, createPushyError } +) as InstallRecordLogic; + +function thrown(fn: () => unknown): PushyError { + try { + fn(); + } catch (e) { + return e as PushyError; + } + throw new Error('expected a throw'); +} + +describe('harmony install record writer', () => { + test('serializes schema, version and digests', () => { + const parsed = record.parseInstallRecord( + record.buildInstallRecord('abc', 'BUNDLE', 'ARTIFACT') + ); + expect({ ...parsed }).toEqual({ + schema: record.INSTALL_RECORD_SCHEMA, + versionHash: 'abc', + bundleSha256: 'BUNDLE', + artifactSha256: 'ARTIFACT', + }); + }); + + test('omits an empty artifact digest', () => { + const parsed = record.parseInstallRecord( + record.buildInstallRecord('abc', 'BUNDLE', '') + ); + expect(parsed?.artifactSha256).toBeUndefined(); + }); + + test('refuses a record without a bundle digest', () => { + expect(thrown(() => record.buildInstallRecord('abc', '', 'A')).code).toBe( + ERROR_DELTA_FAILED + ); + }); + + test('derives the staging directory from the version directory', () => { + expect(record.stagingDirectoryFor('/data/_update/abc')).toBe( + `/data/_update/abc${record.STAGING_SUFFIX}` + ); + }); +}); + +describe('harmony install record parsing', () => { + test('an empty file is a legacy record', () => { + expect(Object.keys(record.parseInstallRecord('') ?? { x: 1 })).toEqual([]); + }); + + test('parses a JSON object', () => { + expect({ + ...record.parseInstallRecord('{"schema":1,"versionHash":"abc"}'), + }).toEqual({ schema: 1, versionHash: 'abc' }); + }); + + test('rejects malformed or non-object content', () => { + for (const text of ['{oops', '[1,2]', '"text"', 'null']) { + expect(record.parseInstallRecord(text)).toBeNull(); + } + }); +}); + +describe('harmony install record matching', () => { + test('presence: no record, legacy record, schema and version', () => { + expect(record.isRecordForVersion(null, 'abc')).toBe(false); + expect(record.isRecordForVersion({}, 'abc')).toBe(true); + const current = record.parseInstallRecord( + record.buildInstallRecord('abc', 'DIGEST', '') + ); + expect(record.isRecordForVersion(current, 'abc')).toBe(true); + expect(record.isRecordForVersion(current, 'def')).toBe(false); + expect( + record.isRecordForVersion( + { schema: record.INSTALL_RECORD_SCHEMA + 1, versionHash: 'abc' }, + 'abc' + ) + ).toBe(false); + }); + + test('activation: no record is SWITCH_VERSION_FAILED with a code prefix', () => { + const error = thrown(() => + record.expectedBundleSha256ForActivation(null, 'abc') + ); + expect(error.code).toBe(ERROR_SWITCH_VERSION_FAILED); + expect(error.message.startsWith(`[${ERROR_SWITCH_VERSION_FAILED}] `)).toBe( + true + ); + expect(error.message).toContain('no valid completion record'); + }); + + test('activation: a legacy record has nothing to verify', () => { + expect(record.expectedBundleSha256ForActivation({}, 'abc')).toBe(''); + }); + + test('activation: returns the recorded digest of a matching record', () => { + expect( + record.expectedBundleSha256ForActivation( + record.parseInstallRecord(record.buildInstallRecord('abc', 'D', '')), + 'abc' + ) + ).toBe('D'); + }); + + test('activation: rejects digest-less, foreign and future records', () => { + const schema = record.INSTALL_RECORD_SCHEMA; + const records: InstallRecordData[] = [ + { schema, versionHash: 'abc' }, + { schema, versionHash: 'abc', bundleSha256: '' }, + { schema, versionHash: 'abc', bundleSha256: 7 }, + { schema, versionHash: 'abc', artifactSha256: 'A' }, + { schema, versionHash: 'def', bundleSha256: 'D' }, + { schema: schema + 1, versionHash: 'abc', bundleSha256: 'D' }, + ]; + for (const candidate of records) { + const error = thrown(() => + record.expectedBundleSha256ForActivation(candidate, 'abc') + ); + expect(error.code).toBe(ERROR_SWITCH_VERSION_FAILED); + expect(error.message).toContain('record mismatch'); + } + }); +}); + +describe('harmony reserved archive entries', () => { + test('every .pushy- prefixed name is reserved', () => { + expect(record.isReservedEntryName(record.INSTALL_RECORD_FILE_NAME)).toBe( + true + ); + expect(record.isReservedEntryName('.pushy-anything')).toBe(true); + }); + + test('ordinary entries are allowed', () => { + for (const name of ['bundle.harmony.js', '.pushyx', 'pushy-complete']) { + expect(record.isReservedEntryName(name)).toBe(false); + } + }); +}); diff --git a/src/__tests__/harmonyValidation.test.ts b/src/__tests__/harmonyValidation.test.ts new file mode 100644 index 00000000..aeec5fd9 --- /dev/null +++ b/src/__tests__/harmonyValidation.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { URL } from 'node:url'; +import { runInNewContext } from 'node:vm'; +import { + createPushyError, + ERROR_INVALID_HASH_INFO, + type PushyError, +} from '../../harmony/pushy/src/main/ets/ErrorCodes'; +import { EventHub } from '../../harmony/pushy/src/main/ets/EventHub'; +import { + assertSafePathComponent, + isSafePathComponent, +} from '../../harmony/pushy/src/main/ets/PathUtils'; + +// Harmony input validation (path components, hash info JSON) and the +// download-progress event hub. + +// validateHashInfo lives in PushyTurboModule.ts, which extends RNOH's +// UITurboModule: evaluate it with its imports stripped and a stub base class. +const turboModuleSource = readFileSync( + new URL( + '../../harmony/pushy/src/main/ets/PushyTurboModule.ts', + import.meta.url + ), + 'utf8' +) + .replace(/^import[\s\S]*?;\r?\n/gm, '') + .replace(/^export\s*\{[^}]*\}(?:\s*from\s*[^;]*)?;\r?\n/gm, '') + .replace(/^export /gm, ''); +const validateHashInfo = runInNewContext( + `${new Bun.Transpiler({ loader: 'ts' }).transformSync(turboModuleSource)}\nvalidateHashInfo;`, + { + UITurboModule: class {}, + ERROR_INVALID_HASH_INFO, + createPushyError, + } +) as (info: string) => void; + +function refusal(run: () => unknown): PushyError | null { + try { + run(); + } catch (e) { + return e as PushyError; + } + return null; +} + +describe('harmony isSafePathComponent', () => { + test('accepts plain hash-like names', () => { + expect(isSafePathComponent('a1b2c3d4')).toBe(true); + expect(isSafePathComponent('origin_hash-1.ppk')).toBe(true); + expect(assertSafePathComponent('a1b2c3d4')).toBe('a1b2c3d4'); + }); + + test('rejects empty, dot and separator-bearing names', () => { + for (const name of ['', '.', '..', '../etc', 'a/b', 'a\\b', 'a\0b']) { + expect(isSafePathComponent(name)).toBe(false); + expect(refusal(() => assertSafePathComponent(name))?.code).toBe( + 'INVALID_OPTIONS' + ); + } + }); +}); + +describe('harmony validateHashInfo', () => { + test('accepts JSON objects', () => { + expect(refusal(() => validateHashInfo('{"name":"v1"}'))).toBeNull(); + expect(refusal(() => validateHashInfo('{}'))).toBeNull(); + }); + + test('rejects arrays, primitives, null and invalid JSON', () => { + for (const info of ['[1,2,3]', '"hello"', 'null', '{invalid}']) { + expect(refusal(() => validateHashInfo(info))?.code).toBe( + ERROR_INVALID_HASH_INFO + ); + } + }); +}); + +describe('harmony EventHub', () => { + test('is a singleton', () => { + expect(EventHub.getInstance()).toBe(EventHub.getInstance()); + }); + + test('emitting without an RN instance is a no-op', () => { + const hub = EventHub.getInstance(); + hub.setRNInstance(undefined); + expect(() => hub.emit('test', { foo: 'bar' })).not.toThrow(); + }); + + test('forwards events to the RN instance', () => { + const hub = EventHub.getInstance(); + const events: Array<[string, object]> = []; + hub.setRNInstance({ + emitDeviceEvent: (event: string, data: object) => { + events.push([event, data]); + }, + }); + hub.emit('RCTPushyDownloadProgress', { received: 100, total: 200 }); + expect(events).toEqual([ + ['RCTPushyDownloadProgress', { received: 100, total: 200 }], + ]); + hub.setRNInstance(undefined); + }); + + test('clearRNInstance only drops the instance it is given', () => { + const hub = EventHub.getInstance(); + let calls = 0; + const current = { emitDeviceEvent: () => calls++ }; + const stale = { emitDeviceEvent: () => calls++ }; + hub.setRNInstance(current); + hub.clearRNInstance(stale); + hub.emit('RCTPushyDownloadProgress', { received: 1, total: 2 }); + expect(calls).toBe(1); + hub.clearRNInstance(current); + hub.emit('RCTPushyDownloadProgress', { received: 2, total: 2 }); + expect(calls).toBe(1); + }); +}); From 620f6007526c79ce9c353868a4d5541e14a74e56 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 15:01:23 +0800 Subject: [PATCH 050/104] refactor(android): one endpoint attempt path in SyncCoordinator QueryRun.tryEndpoint carries the normalize -> dedupe -> https filter -> budget -> request -> validate sequence both endpoint loops repeated. The response cache entry is built once instead of in three branches, the startRound javadoc sits on startRound again, and HttpUtils. stripTrailingSlashes is the single trailing-slash rule for endpoint bases, ConfigEquivalence and PushyConfiguration. Co-Authored-By: Claude Opus 5.5 --- .../modules/update/ConfigEquivalence.java | 10 +- .../reactnative/modules/update/HttpUtils.java | 14 ++- .../modules/update/PushyConfiguration.java | 2 +- .../modules/update/SyncCoordinator.java | 107 +++++++++++------- .../modules/update/HttpUtilsTest.java | 12 +- 5 files changed, 84 insertions(+), 61 deletions(-) diff --git a/android/src/main/java/cn/reactnative/modules/update/ConfigEquivalence.java b/android/src/main/java/cn/reactnative/modules/update/ConfigEquivalence.java index a91e97db..8ff267ad 100644 --- a/android/src/main/java/cn/reactnative/modules/update/ConfigEquivalence.java +++ b/android/src/main/java/cn/reactnative/modules/update/ConfigEquivalence.java @@ -86,19 +86,11 @@ private static void normalizeUrls(Map map, String key) { } LinkedHashSet unique = new LinkedHashSet<>(); for (Object item : (List) value) { - unique.add(item instanceof String ? stripTrailingSlashes((String) item) : item); + unique.add(item instanceof String ? HttpUtils.stripTrailingSlashes((String) item) : item); } map.put(key, new ArrayList(unique)); } - private static String stripTrailingSlashes(String url) { - int end = url.length(); - while (end > 0 && url.charAt(end - 1) == '/') { - end--; - } - return url.substring(0, end); - } - // JSONObject/JSONArray have identity equals; convert to Map/List with // value semantics. Numbers compare by value (1 == 1.0). private static Object toCanonical(Object value) { diff --git a/android/src/main/java/cn/reactnative/modules/update/HttpUtils.java b/android/src/main/java/cn/reactnative/modules/update/HttpUtils.java index d4129848..cdc3f9fe 100644 --- a/android/src/main/java/cn/reactnative/modules/update/HttpUtils.java +++ b/android/src/main/java/cn/reactnative/modules/update/HttpUtils.java @@ -23,12 +23,16 @@ static boolean isCleartextUrl(String url) { /** Request path appended to an endpoint base, kept out of the plain string table. */ static final String QUERY_PATH = Texts.reveal("7514fcd4ad805d55263e08fc99"); - /** Strips every trailing slash so "" never doubles one. */ - static String normalizeEndpointBase(String base) { - while (base.endsWith("/")) { - base = base.substring(0, base.length() - 1); + /** + * Strips every trailing slash: endpoint bases (so " + * " never doubles one) and the canonical config URL lists. + */ + static String stripTrailingSlashes(String url) { + int end = url.length(); + while (end > 0 && url.charAt(end - 1) == '/') { + end--; } - return base; + return url.substring(0, end); } // "bytes -/". Returns the total (0 when "*"), or -1 diff --git a/android/src/main/java/cn/reactnative/modules/update/PushyConfiguration.java b/android/src/main/java/cn/reactnative/modules/update/PushyConfiguration.java index e125a16d..88dba6ae 100644 --- a/android/src/main/java/cn/reactnative/modules/update/PushyConfiguration.java +++ b/android/src/main/java/cn/reactnative/modules/update/PushyConfiguration.java @@ -90,7 +90,7 @@ private static JSONArray urls(Object value, String name, boolean base) throws JS throw invalid(name + " requires absolute HTTP(S) URLs without credentials" + (base ? ", queries or fragments" : "")); } - String normalized = base ? url.replaceAll("/+$", "") : url; + String normalized = base ? HttpUtils.stripTrailingSlashes(url) : url; if (seen.add(normalized)) { result.put(normalized); } diff --git a/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java b/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java index b4cc802a..b190789c 100644 --- a/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java +++ b/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java @@ -227,11 +227,6 @@ static boolean isRoundInFlight() { return roundStarted.get() && !roundCompleted; } - /** - * Runs the process's single round on the calling thread if nobody has - * started it yet. deadlineNanos > 0 (crash rescue) caps every HTTP call - * and download phase to the remaining budget. - */ private static boolean hasRunnableConfig(UpdateContext context) { return context != null && parseRunnableConfig(context.getKv(KEY_CONFIG)).rejection == null; @@ -243,6 +238,11 @@ static void onConfigured(UpdateContext context) { } } + /** + * Runs the process's single round on the calling thread if nobody has + * started it yet. deadlineNanos > 0 (crash rescue) caps every HTTP call + * and download phase to the remaining budget. + */ private static void startRound(long deadlineNanos) { // An automatic check before first-run provisioning must not consume // the process's only round. Hosts may configure later in this launch. @@ -437,6 +437,9 @@ private static void runConfiguredRound( // 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; + // Built once; every commit below caches it for JS. + final String responseCacheJson = + buildResponseCacheJson(configJson, body, responseText, responseAtSeconds); String decisionJson = FlowBridge.handleResponse( responseText, identity.toString(), config.optString("afterDownload", "")); @@ -447,8 +450,7 @@ private static void runConfiguredRound( JSONObject decision = new JSONObject(decisionJson); if (!"download".equals(decision.optString("action"))) { boolean committed = context.commitSyncResult( - resetGeneration, null, null, false, - buildResponseCacheJson(configJson, body, responseText, responseAtSeconds)); + resetGeneration, null, null, false, responseCacheJson); roundResult = committed ? BundlePreparationResult.of(BundlePreparationResult.NO_UPDATE, decision.optString("reason")) : BundlePreparationResult.of(BundlePreparationResult.CANCELLED, "reset"); @@ -472,8 +474,7 @@ private static void runConfiguredRound( // The native attempt has finished, so JS may safely reuse the // response and retry through its own strategy chain. boolean committed = context.commitSyncResult( - resetGeneration, null, null, false, - buildResponseCacheJson(configJson, body, responseText, responseAtSeconds)); + resetGeneration, null, null, false, responseCacheJson); roundResult = BundlePreparationResult.of( committed ? BundlePreparationResult.FAILED : BundlePreparationResult.CANCELLED, committed ? "download_failed" : "reset"); @@ -519,7 +520,7 @@ private static void runConfiguredRound( hash, hashInfoJson, activate, - buildResponseCacheJson(configJson, body, responseText, responseAtSeconds)); + responseCacheJson); } catch (Exception e) { Log.w(Texts.LOG_TAG, "native sync: commit failed: " + e); roundResult = BundlePreparationResult.of(BundlePreparationResult.FAILED, "commit_failed"); @@ -642,19 +643,10 @@ private static String runQueryRequest( } catch (JSONException e) { return null; } - HashSet tried = new HashSet<>(); - int httpAttempts = 0; + QueryRun run = new QueryRun(appKey, body, deadlineNanos); for (int i = 0; i < ordered.length(); i++) { - String base = HttpUtils.normalizeEndpointBase(ordered.optString(i, "")); - if (base.isEmpty() || !tried.add(base)) { - continue; - } - if (httpAttempts++ >= MAX_QUERY_HTTP_ATTEMPTS) { - return null; - } - String response = httpRequest( - base + HttpUtils.QUERY_PATH + appKey, body, deadlineNanos); - if (isValidResponse(response)) { + String response = run.tryEndpoint(ordered.optString(i, ""), false); + if (response != null || run.exhausted) { return response; } } @@ -667,7 +659,7 @@ private static String runQueryRequest( if (listUrl.isEmpty()) { continue; } - if (httpAttempts++ >= MAX_QUERY_HTTP_ATTEMPTS) { + if (!run.spendAttempt()) { return null; } String listText = httpRequest(listUrl, null, deadlineNanos); @@ -681,23 +673,10 @@ private static String runQueryRequest( continue; } for (int j = 0; j < remote.length(); j++) { - String base = HttpUtils.normalizeEndpointBase(remote.optString(j, "")); - if (base.isEmpty() || tried.contains(base)) { - continue; - } - if (httpsOnly && !HttpUtils.isHttpsUrl(base)) { - // A remotely injected endpoint must not downgrade an - // https configuration to plaintext. - Log.w(Texts.LOG_TAG, "native sync: ignoring non-https remote endpoint " + base); - continue; - } - if (httpAttempts++ >= MAX_QUERY_HTTP_ATTEMPTS) { - return null; - } - tried.add(base); - String response = httpRequest( - base + HttpUtils.QUERY_PATH + appKey, body, deadlineNanos); - if (isValidResponse(response)) { + // A remotely injected endpoint must not downgrade an https + // configuration to plaintext. + String response = run.tryEndpoint(remote.optString(j, ""), httpsOnly); + if (response != null || run.exhausted) { return response; } } @@ -707,6 +686,54 @@ private static String runQueryRequest( return null; } + /** One query's shared state: the bases already tried and the HTTP budget. */ + private static final class QueryRun { + private final String appKey; + private final String body; + private final long deadlineNanos; + private final HashSet tried = new HashSet<>(); + private int httpAttempts; + // Set once MAX_QUERY_HTTP_ATTEMPTS is spent: the whole query gives up. + boolean exhausted; + + QueryRun(String appKey, String body, long deadlineNanos) { + this.appKey = appKey; + this.body = body; + this.deadlineNanos = deadlineNanos; + } + + boolean spendAttempt() { + if (httpAttempts++ >= MAX_QUERY_HTTP_ATTEMPTS) { + exhausted = true; + return false; + } + return true; + } + + /** + * Queries one endpoint base unless it is empty, already tried or (with + * httpsOnly) not https. Returns the valid response, or null to move on + * (check {@link #exhausted}). + */ + @Nullable + String tryEndpoint(String rawBase, boolean httpsOnly) { + String base = HttpUtils.stripTrailingSlashes(rawBase); + if (base.isEmpty() || tried.contains(base)) { + return null; + } + if (httpsOnly && !HttpUtils.isHttpsUrl(base)) { + Log.w(Texts.LOG_TAG, "native sync: ignoring non-https remote endpoint " + base); + return null; + } + if (!spendAttempt()) { + return null; + } + tried.add(base); + String response = httpRequest(base + HttpUtils.QUERY_PATH + appKey, body, deadlineNanos); + return isValidResponse(response) ? response : null; + } + } + private static long capToHoldBudget(long phaseDeadlineNanos, long holdDeadlineNanos) { if (holdDeadlineNanos <= 0) { return phaseDeadlineNanos; diff --git a/android/src/test/java/cn/reactnative/modules/update/HttpUtilsTest.java b/android/src/test/java/cn/reactnative/modules/update/HttpUtilsTest.java index 725f50ee..40d83f51 100644 --- a/android/src/test/java/cn/reactnative/modules/update/HttpUtilsTest.java +++ b/android/src/test/java/cn/reactnative/modules/update/HttpUtilsTest.java @@ -54,12 +54,12 @@ public void parseContentRangeRejectsMalformedHeaders() { } @Test - public void normalizeEndpointBaseStripsTrailingSlashes() { - assertEquals("https://update.example.com", HttpUtils.normalizeEndpointBase("https://update.example.com/")); - assertEquals("https://update.example.com", HttpUtils.normalizeEndpointBase("https://update.example.com///")); - assertEquals("https://update.example.com/api", HttpUtils.normalizeEndpointBase("https://update.example.com/api")); - assertEquals("", HttpUtils.normalizeEndpointBase("")); - assertEquals("", HttpUtils.normalizeEndpointBase("/")); + public void stripTrailingSlashesStripsEveryTrailingSlash() { + assertEquals("https://update.example.com", HttpUtils.stripTrailingSlashes("https://update.example.com/")); + assertEquals("https://update.example.com", HttpUtils.stripTrailingSlashes("https://update.example.com///")); + assertEquals("https://update.example.com/api", HttpUtils.stripTrailingSlashes("https://update.example.com/api")); + assertEquals("", HttpUtils.stripTrailingSlashes("")); + assertEquals("", HttpUtils.stripTrailingSlashes("/")); } @Test From fa7dfac4c85a3916afe0b0aaa1eb4431c2211cd7 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 15:02:10 +0800 Subject: [PATCH 051/104] refactor(android): one entry map in BundledResourceCopier The parallel entry/archive maps become HashMap, which makes the baseZipFile fallback (never reached: every indexed entry has its archive) go away. collectApkPaths reads the split APKs from context.getApplicationInfo() instead of a PackageManager round trip. Map.putIfAbsent (API 24+, below the module's minSdk 21) is replaced by containsKey checks. Co-Authored-By: Claude Opus 5.5 --- .../modules/update/BundledResourceCopier.java | 74 ++++++++----------- 1 file changed, 29 insertions(+), 45 deletions(-) diff --git a/android/src/main/java/cn/reactnative/modules/update/BundledResourceCopier.java b/android/src/main/java/cn/reactnative/modules/update/BundledResourceCopier.java index 1a8c9caf..6cd2821b 100644 --- a/android/src/main/java/cn/reactnative/modules/update/BundledResourceCopier.java +++ b/android/src/main/java/cn/reactnative/modules/update/BundledResourceCopier.java @@ -1,8 +1,6 @@ package cn.reactnative.modules.update; import android.content.Context; -import android.content.pm.ApplicationInfo; -import android.content.pm.PackageManager; import android.content.res.Resources; import android.util.DisplayMetrics; import android.util.Log; @@ -32,9 +30,9 @@ private static final class ResolvedResourceSource { } } - // Holds the exact archive a CRC32 match came from, so the fallback copy - // reads from that archive even if another APK exposes the same entry name - // with different bytes. + // An entry together with the exact archive it came from, so a copy reads + // from that archive even if another APK exposes the same entry name with + // different bytes. private static final class ZipSource { final ZipEntry entry; final SafeZipFile zipFile; @@ -54,9 +52,9 @@ void copyFromResource( HashMap crcByFrom ) throws IOException { ArrayList apkPaths = collectApkPaths(); - HashMap availableEntries = new HashMap(); - HashMap zipFileMap = new HashMap(); - HashMap entryToZipFileMap = new HashMap(); + ArrayList zipFiles = new ArrayList(); + // Entry name -> first archive (base APK first) that carries it. + HashMap availableEntries = new HashMap(); // Content checksum index: CRC32 -> matched archive source. Lets us // locate a file by content when its origin path is not present verbatim // on device (e.g. APK baseline diff applied on an AAB/split-apk install @@ -66,18 +64,18 @@ void copyFromResource( try { for (String apkPath : apkPaths) { SafeZipFile zipFile = new SafeZipFile(new File(apkPath)); - zipFileMap.put(apkPath, zipFile); + zipFiles.add(zipFile); Enumeration entries = zipFile.entries(); while (entries.hasMoreElements()) { ZipEntry ze = entries.nextElement(); - String entryName = ze.getName(); - if (!availableEntries.containsKey(entryName)) { - availableEntries.put(entryName, ze); - entryToZipFileMap.put(entryName, zipFile); + ZipSource source = new ZipSource(ze, zipFile); + // No Map.putIfAbsent: it is API 24+ and minSdk is 21. + if (!availableEntries.containsKey(ze.getName())) { + availableEntries.put(ze.getName(), source); } long crc = ze.getCrc(); if (crc != -1L && !crcToEntry.containsKey(crc)) { - crcToEntry.put(crc, new ZipSource(ze, zipFile)); + crcToEntry.put(crc, source); } } } @@ -85,10 +83,11 @@ void copyFromResource( HashMap normalizedEntryMap = new HashMap(); for (String entryName : availableEntries.keySet()) { String normalized = normalizeResPath(entryName); - normalizedEntryMap.putIfAbsent(normalized, entryName); + if (!normalizedEntryMap.containsKey(normalized)) { + normalizedEntryMap.put(normalized, entryName); + } } - SafeZipFile baseZipFile = zipFileMap.get(context.getPackageResourcePath()); HashMap> remainingFiles = new HashMap>(resToCopy); // Track copies that were located but failed to write. These are real @@ -109,9 +108,8 @@ void copyFromResource( continue; } - ZipEntry entry = availableEntries.get(fromPath); + ZipSource entry = availableEntries.get(fromPath); String actualSourcePath = fromPath; - SafeZipFile matchedZipFile = null; ResolvedResourceSource resolvedResource = null; Long wantedCrc = crcByFrom != null ? crcByFrom.get(fromPath) : null; @@ -130,7 +128,8 @@ void copyFromResource( // locate the expected content elsewhere. An entry with an // unknown CRC (-1) cannot be verified and is accepted as-is. if (entry != null && wantedCrc != null - && entry.getCrc() != -1L && entry.getCrc() != wantedCrc.longValue()) { + && entry.entry.getCrc() != -1L + && entry.entry.getCrc() != wantedCrc.longValue()) { entry = null; actualSourcePath = fromPath; } @@ -141,8 +140,7 @@ void copyFromResource( if (entry == null && wantedCrc != null) { ZipSource matched = crcToEntry.get(wantedCrc); if (matched != null) { - entry = matched.entry; - matchedZipFile = matched.zipFile; + entry = matched; actualSourcePath = matched.entry.getName(); } } @@ -166,7 +164,7 @@ void copyFromResource( // archives so the right variant is used. (openRawResource // would re-resolve the id at the current configuration // density and ignore the requested one.) - ZipEntry resolvedEntry = availableEntries.get(actualSourcePath); + ZipSource resolvedEntry = availableEntries.get(actualSourcePath); if (resolvedEntry != null) { entry = resolvedEntry; resolvedResource = null; @@ -184,13 +182,7 @@ void copyFromResource( if (lastTarget != null) { UpdateFileUtils.copyFile(lastTarget, target); } else if (entry != null) { - SafeZipFile sourceZipFile = matchedZipFile != null - ? matchedZipFile - : entryToZipFileMap.get(actualSourcePath); - if (sourceZipFile == null) { - sourceZipFile = baseZipFile; - } - sourceZipFile.unzipToFile(entry, target); + entry.zipFile.unzipToFile(entry.entry, target); } else { InputStream in = openResolvedResourceStream(resolvedResource); UpdateFileUtils.copyInputStreamToFile(in, target); @@ -235,7 +227,7 @@ void copyFromResource( ); } } finally { - closeZipFiles(zipFileMap); + closeZipFiles(zipFiles); } } @@ -354,29 +346,21 @@ private InputStream openResolvedResourceStream(ResolvedResourceSource source) th } } + // The base APK first, then the split APKs of an AAB install. private ArrayList collectApkPaths() { ArrayList apkPaths = new ArrayList(); apkPaths.add(context.getPackageResourcePath()); - - try { - ApplicationInfo appInfo = - context.getPackageManager().getApplicationInfo(context.getPackageName(), 0); - if (appInfo.splitSourceDirs != null) { - for (String splitPath : appInfo.splitSourceDirs) { - apkPaths.add(splitPath); - } - } - } catch (PackageManager.NameNotFoundException e) { - if (UpdateContext.DEBUG) { - Log.w(Texts.LOG_TAG, "Failed to get application info: " + e.getMessage()); + String[] splitSourceDirs = context.getApplicationInfo().splitSourceDirs; + if (splitSourceDirs != null) { + for (String splitPath : splitSourceDirs) { + apkPaths.add(splitPath); } } - return apkPaths; } - private void closeZipFiles(HashMap zipFileMap) { - for (SafeZipFile zipFile : zipFileMap.values()) { + private void closeZipFiles(ArrayList zipFiles) { + for (SafeZipFile zipFile : zipFiles) { try { zipFile.close(); } catch (IOException e) { From 02ba2b5d33a1e0fd9b1821055189033b322baa6d Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 15:03:11 +0800 Subject: [PATCH 052/104] refactor(android): small cleanups across the module - the three hardcoded "pushy" log tags use Texts.LOG_TAG - BuildConfig.DEBUG everywhere; UpdateContext.DEBUG was the same value and reading it from another class forced UpdateContext's native load - UpdateContext's static initializer is the only native library load: JNI_OnLoad registers every class's natives, and DownloadTask, FlowBridge and supportedDiffVersion are only reached through an UpdateContext instance - ReactReloadManager.prepareRestart is inlined into the restart hop - the ReactHostDelegate bundle loader field is found by trying jsBundleLoader, then Expo's _jsBundleLoader, instead of matching the Expo class name Co-Authored-By: Claude Opus 5.5 --- .../modules/update/BundledResourceCopier.java | 2 +- .../modules/update/DownloadTask.java | 6 +--- .../modules/update/FlowBridge.java | 4 --- .../modules/update/InstallRecord.java | 2 +- .../modules/update/NativeCore.java | 8 ++++- .../modules/update/ReactReloadManager.java | 31 +++++-------------- .../modules/update/SyncCoordinator.java | 4 +-- .../modules/update/UpdateContext.java | 4 +-- .../modules/update/UpdateEventEmitter.java | 2 +- .../modules/update/UpdateModule.java | 13 +++++--- 10 files changed, 32 insertions(+), 44 deletions(-) diff --git a/android/src/main/java/cn/reactnative/modules/update/BundledResourceCopier.java b/android/src/main/java/cn/reactnative/modules/update/BundledResourceCopier.java index 6cd2821b..76f372ee 100644 --- a/android/src/main/java/cn/reactnative/modules/update/BundledResourceCopier.java +++ b/android/src/main/java/cn/reactnative/modules/update/BundledResourceCopier.java @@ -364,7 +364,7 @@ private void closeZipFiles(ArrayList zipFiles) { try { zipFile.close(); } catch (IOException e) { - if (UpdateContext.DEBUG) { + if (BuildConfig.DEBUG) { Log.w(Texts.LOG_TAG, "Failed to close zip file", e); } } 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 20bcfe73..945f3452 100644 --- a/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +++ b/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java @@ -53,10 +53,6 @@ class DownloadTask implements Runnable { .callTimeout(10, java.util.concurrent.TimeUnit.MINUTES) .build(); - static { - NativeCore.ensureLoaded(); - } - // Two-phase install (cpp/patch_core/install_record.h): all unpack/patch // work happens in .staging; the final directory only ever // appears through an atomic rename after the completion record was @@ -157,7 +153,7 @@ private static File resumeSidecarFile(File archive) { static void deleteResumeSidecar(File archive) { File sidecar = resumeSidecarFile(archive); - if (sidecar.exists() && !sidecar.delete() && UpdateContext.DEBUG) { + if (sidecar.exists() && !sidecar.delete() && BuildConfig.DEBUG) { Log.w(Texts.LOG_TAG, "Failed to delete resume sidecar " + sidecar); } } diff --git a/android/src/main/java/cn/reactnative/modules/update/FlowBridge.java b/android/src/main/java/cn/reactnative/modules/update/FlowBridge.java index b04e8d5e..091f5bde 100644 --- a/android/src/main/java/cn/reactnative/modules/update/FlowBridge.java +++ b/android/src/main/java/cn/reactnative/modules/update/FlowBridge.java @@ -7,10 +7,6 @@ * A null return means the input did not parse; callers skip the check round. */ final class FlowBridge { - static { - NativeCore.ensureLoaded(); - } - private FlowBridge() { } diff --git a/android/src/main/java/cn/reactnative/modules/update/InstallRecord.java b/android/src/main/java/cn/reactnative/modules/update/InstallRecord.java index fcd17806..7dc7c129 100644 --- a/android/src/main/java/cn/reactnative/modules/update/InstallRecord.java +++ b/android/src/main/java/cn/reactnative/modules/update/InstallRecord.java @@ -83,7 +83,7 @@ static void syncDirectory(File dir) { dir.toPath(), java.nio.file.StandardOpenOption.READ)) { channel.force(true); } catch (Throwable e) { - Log.w("pushy", "Directory fsync skipped for " + dir + ": " + e); + Log.w(Texts.LOG_TAG, "Directory fsync skipped for " + dir + ": " + e); } } diff --git a/android/src/main/java/cn/reactnative/modules/update/NativeCore.java b/android/src/main/java/cn/reactnative/modules/update/NativeCore.java index 2b7cf48f..fdbb76ec 100644 --- a/android/src/main/java/cn/reactnative/modules/update/NativeCore.java +++ b/android/src/main/java/cn/reactnative/modules/update/NativeCore.java @@ -1,5 +1,12 @@ package cn.reactnative.modules.update; +/** + * Loads librnpushy.so. Its JNI_OnLoad registers the natives of every class + * (cpp/patch_core/jni_registration.cpp), so the library only has to be + * loaded before the first native call: UpdateContext's static initializer + * does that, and DownloadTask, FlowBridge and supportedDiffVersion are only + * ever reached through an UpdateContext instance. + */ final class NativeCore { private static boolean loaded = false; @@ -30,7 +37,6 @@ static synchronized void ensureLoaded() { * 服务端按能力门控下发。 */ static int supportedDiffVersion() { - ensureLoaded(); return getSupportedDiffVersion(); } diff --git a/android/src/main/java/cn/reactnative/modules/update/ReactReloadManager.java b/android/src/main/java/cn/reactnative/modules/update/ReactReloadManager.java index fdabed1e..24369acb 100644 --- a/android/src/main/java/cn/reactnative/modules/update/ReactReloadManager.java +++ b/android/src/main/java/cn/reactnative/modules/update/ReactReloadManager.java @@ -19,22 +19,7 @@ private ReactReloadManager() { } /** - * First half of a restart, for the state serial thread: activate the - * requested version (bundle digest + commit) and resolve the bundle to - * load. Nothing here touches React, so nothing here needs the UI thread - * (CODE_AUDIT 2.2). Returns the bundle path, or null for the packaged - * bundle. - */ - @Nullable - static String prepareRestart(UpdateContext updateContext, @Nullable String hash) { - if (hash != null) { - updateContext.switchVersion(hash); - } - return updateContext.getBundleUrl(); - } - - /** - * Second half, for the UI thread: point the running React instance at + * For the UI thread: point the running React instance at * {@code updateBundlePath} and reload it. Throws when no supported * injection worked — the caller rejects RESTART_FAILED. There is * deliberately no Activity.recreate() fallback: it would reload the @@ -241,14 +226,14 @@ private static void reloadReactHost(Object reactHost, JSBundleLoader loader) thr reactHostDelegateField.setAccessible(true); Object reactHostDelegate = reactHostDelegateField.get(reactHost); - String bundleFieldName = "jsBundleLoader"; - if ("expo.modules.ExpoReactHostFactory.ExpoReactHostDelegate".equals( - reactHostDelegate.getClass().getCanonicalName() - )) { - bundleFieldName = "_jsBundleLoader"; + // DefaultReactHostDelegate stores the loader in jsBundleLoader; Expo's + // ExpoReactHostDelegate computes that property from _jsBundleLoader. + Field jsBundleLoaderField; + try { + jsBundleLoaderField = reactHostDelegate.getClass().getDeclaredField("jsBundleLoader"); + } catch (NoSuchFieldException e) { + jsBundleLoaderField = reactHostDelegate.getClass().getDeclaredField("_jsBundleLoader"); } - - Field jsBundleLoaderField = reactHostDelegate.getClass().getDeclaredField(bundleFieldName); jsBundleLoaderField.setAccessible(true); jsBundleLoaderField.set(reactHostDelegate, loader); diff --git a/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java b/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java index b190789c..0d1fa05c 100644 --- a/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java +++ b/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java @@ -87,7 +87,7 @@ final class SyncCoordinator { /** Blocking only on the host API's worker; never call on the UI thread. */ static BundlePreparationResult prepareBundle(UpdateContext context) throws InterruptedException { - if (UpdateContext.DEBUG) { + if (BuildConfig.DEBUG) { return BundlePreparationResult.of(BundlePreparationResult.SKIPPED, "debug"); } if (!nativeReady || sContext != context || !context.getIsUsingBundleUrl()) { @@ -186,7 +186,7 @@ private SyncCoordinator() { } static void schedule(final UpdateContext context, final String launchRolledBackVersion) { - if (UpdateContext.DEBUG) { + if (BuildConfig.DEBUG) { return; } if (!scheduled.compareAndSet(false, true)) { 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 cff46875..d982f86b 100644 --- a/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +++ b/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java @@ -14,12 +14,12 @@ import java.util.concurrent.Executor; public class UpdateContext { + // The one place that loads librnpushy.so (see NativeCore). static { NativeCore.ensureLoaded(); } static final String TAG = Texts.LOG_TAG; - static final boolean DEBUG = BuildConfig.DEBUG; private final Context context; private final File rootDir; @@ -244,7 +244,7 @@ String computeBundleHash() { } private void startBundleHashComputation() { - if (DEBUG) { + if (BuildConfig.DEBUG) { // Metro serves the bundle in debug; mirror the dev buildTime behaviour. publishBundleHash(""); return; diff --git a/android/src/main/java/cn/reactnative/modules/update/UpdateEventEmitter.java b/android/src/main/java/cn/reactnative/modules/update/UpdateEventEmitter.java index 7b8d5740..d7d752e0 100644 --- a/android/src/main/java/cn/reactnative/modules/update/UpdateEventEmitter.java +++ b/android/src/main/java/cn/reactnative/modules/update/UpdateEventEmitter.java @@ -39,7 +39,7 @@ static void sendEvent(String eventName, WritableMap params) { // emit (this SDK itself triggers reloads); sendEvent runs on the // main thread, so a throw here would crash the app for a lost // progress tick. - Log.w("pushy", "sendEvent " + eventName + " failed: " + e.getMessage()); + Log.w(Texts.LOG_TAG, "sendEvent " + eventName + " failed: " + e.getMessage()); } } diff --git a/android/src/main/java/cn/reactnative/modules/update/UpdateModule.java b/android/src/main/java/cn/reactnative/modules/update/UpdateModule.java index e4dcb5a3..3b8e1df7 100644 --- a/android/src/main/java/cn/reactnative/modules/update/UpdateModule.java +++ b/android/src/main/java/cn/reactnative/modules/update/UpdateModule.java @@ -70,7 +70,7 @@ protected Map getTypedExportedConstants() { // A mismatched librnpushy.so (stale manual copy / build cache) // must not crash startup via getConstants; 0 simply means "no v2 // diff track" and the server degrades gracefully. - Log.e("pushy", "supportedDiffVersion missing from the native library", e); + Log.e(Texts.LOG_TAG, "supportedDiffVersion missing from the native library", e); } constants.put("supportedDiffVersion", supportedDiffVersion); return constants; @@ -250,10 +250,15 @@ public void restartApp(Promise promise) { private void restart(@Nullable final String hash, final Promise promise) { // Two hops (CODE_AUDIT 2.2): the switch (bundle digest + commit) and // the launch resolution run on the state serial thread like every - // other state write; only the React reload itself is posted to the - // UI thread. A failure at either hop rejects with RESTART_FAILED. + // other state write — nothing there touches React; only the React + // reload itself is posted to the UI thread. A failure at either hop + // rejects with RESTART_FAILED. StateSerialRunner.run(STATE_SERIAL, promise, ErrorCodes.RESTART_FAILED, "restartApp", () -> { - final String bundlePath = ReactReloadManager.prepareRestart(updateContext, hash); + if (hash != null) { + updateContext.switchVersion(hash); + } + // The bundle to load; null for the packaged one. + final String bundlePath = updateContext.getBundleUrl(); StateSerialRunner.run(UI_THREAD, promise, ErrorCodes.RESTART_FAILED, "restartApp", () -> { ReactReloadManager.reload(updateContext, reactContext, bundlePath); promise.resolve(true); From d6b2201679b2780696788550b274ac5b3d74a63b Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 15:03:27 +0800 Subject: [PATCH 053/104] docs(audit): compress fixed CODE_AUDIT items, keep the open ones in full MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every item was re-checked against the current code. Fixed items shrink to one line with the master commit that closed them (section numbers kept: the Android sources cite CODE_AUDIT 2.x); partially fixed or open items (1.10, 2.6, 2.13, 3.10, 3.13, 3.14, 4.4, 4.12, the Harmony NAPI null-field P4, 5.7, 5.8, 5.15, 6.4) keep a full description of what remains. The executed §7 ordering and the §8 status table fold into the header. Co-Authored-By: Claude Opus 5.5 --- CODE_AUDIT.md | 435 +++++++++++++++++--------------------------------- 1 file changed, 149 insertions(+), 286 deletions(-) diff --git a/CODE_AUDIT.md b/CODE_AUDIT.md index b2823a8b..f11f551a 100644 --- a/CODE_AUDIT.md +++ b/CODE_AUDIT.md @@ -1,332 +1,195 @@ # 全面审计(2026-09-02,基线 10.55.1 / b15223c) -> 范围:JS/TS 层、Android、iOS、共享 C++ 内核、鸿蒙、CI/发布/打包。 -> 上一轮安全加固(6e9ae76,CODE_AUDIT §13)已落地的项不再重复;本文只记录**仍有改进空间**的项。 -> 每条都经源码核实,标注文件:行号;严重级别 P1 真实缺陷 / P2 很可能的缺陷或明显风险 / P3 质量与性能 / P4 小项。 -> 本地基线:JS 单测 222 通过,biome/tsc 通过,patch-core 29 项 + flow-core 93 向量(含 ASan/UBSan)通过。 - -## 0. 总览:优先处理的 12 项 - -| # | 级别 | 层 | 问题 | 位置 | -|---|---|---|---|---| -| 1 | P1 | C++ | `IsSafeRelativePath` 不拒绝 NUL/控制字节,鸿蒙 manifest 路径可逃出 staging 目录 | `cpp/patch_core/patch_core.cpp:638` | -| 2 | P1 | 发布 | 只改 `cpp/` 或 `android/jni/` 时 publish 复用上一版 HAR,鸿蒙带着旧内核发版 | `.github/workflows/publish.yml:48-54` | -| 3 | P1 | 鸿蒙 | `setUuid`/`setNeedUpdate`/`markSuccess` 在 C++ 方法表注册为 SYNC,ArkTS 实现却是 async,拒绝到不了 JS | `harmony/pushy/src/main/cpp/PushyTurboModule.cpp:51-53` | -| 4 | P2 | JS | web 平台所有"常量"是函数:`isRolledBack===true`,每次加载上报一条假 rollback | `src/core.ts:15-37,76-79` | -| 5 | P2 | Android | 状态持久化四个写入方无统一锁,原生轮次的激活可被 JS `markSuccess` 覆盖回滚 | `UpdateContext.java:439,485,654,712` | -| 6 | P2 | Android | 旧架构 `setNeedUpdate/markSuccess/setUuid/setLocalHashInfo` 无 Promise,持久化失败静默 | `android/src/oldarch/.../UpdateModule.java:69-109` | -| 7 | P2 | CI | `patch_core` 测试(处理不可信补丁字节的代码)在 CI 里完全不跑;鸿蒙在 PR 上既不编译也不做类型检查 | `.github/workflows/test.yml` | -| 8 | P2 | C++ | 递归删目录/合并目录无深度上限,深嵌套归档可栈溢出崩溃 | `patch_core.cpp:235-274,453-512` | -| 9 | P2 | JS | 每次 `setOptions` 都重新触发检查、重置 autoMarkSuccess 计时器、重订阅 AppState | `src/provider.tsx:339-398` | -| 10 | P2 | Android | APK 安装路径无完整性校验,`hash` 只是进度键 | `UpdateModuleImpl.java:70-78`、`DownloadTask.java:797` | -| 11 | P2 | iOS | 无 PrivacyInfo.xcprivacy,且 10.53+ 新增了 DiskSpace required-reason API 调用,集成方上传会遇 ITMS-91053 | `ios/RCTPushy/RCTPushy.mm:186-195`、podspec | -| 12 | P2 | iOS | SSZipArchive 会物化归档里的 symlink 条目,可写到沙箱内任意位置;畸形 `__diff.json` 直接崩 app | `RCTPushy.mm:1714-1723,518-552` | - -其余按层展开如下。 +> 范围:JS/TS 层、Android、iOS、共享 C++ 内核、鸿蒙、CI/发布/打包。严重级别 P1 真实缺陷 / +> P2 很可能的缺陷或明显风险 / P3 质量与性能 / P4 小项。代码注释按编号引用本文(如 +> `CODE_AUDIT 2.12`),**编号保持不变**。 +> +> **处理状态(2026-10-12 按当前代码复核)**:修复随 10.56.0 发布,master 提交为 +> `a60809b3`(JS,§1/6.3/6.8)、`092f544a`(Android,§2)、`77b5488c`(iOS,§3)、 +> `90f8d58e`(C++,§4)、`6a7f4163`(鸿蒙,§5)、`1e46ee06`(CI/发布,§6),评审跟进 +> `5b0b9d29`。已修项每条压缩为一行;**仍开放 / 部分修复的项保留完整描述**,汇总如下: +> +> | 项 | 状态 | +> |---|---| +> | 1.10 | 部分:`require('../package.json')` 仍在 `src/core.ts`(收益小,有意保留) | +> | 2.6 | 部分:https-only + 安装后删除已做;APK sha256 校验未做 | +> | 2.13 / 单测建议 | 部分:两份 `UpdateModule.java`、`buildToolsVersion`;若干纯函数未覆盖 | +> | 3.10 | 部分:构建脚本仍写 pod 源码目录 | +> | 3.13 / 3.14 | 部分 / 未做:`RCTPushy.mm` 拆分、纯决策下沉 C++ + XCTest | +> | 4.4 | 部分:Android 已兜住;鸿蒙 NAPI 与 iOS 入口无 C++ catch | +> | 4.12 | 部分:`buildCheckFingerprint` 无 C++ 移植 | +> | §4 P4 | 开放:鸿蒙 NAPI 结果对象省略空串字段(Android 设 null) | +> | 5.7 | 部分:RCP 重定向控制(HMS-only)未做 | +> | 5.8 | 部分:hypium 测试无 CI;`e2e_harmony` 不在门内 | +> | 5.15 / §5 P4 | 部分:若干纯函数未覆盖;`transferArchive` 未拆;`codelinter` 未接入 | +> | 6.4 | 未做(流程决策):`.so` 仍提交在 `android/lib/` | +> +> 本文提到的 `librnupdate.so` 现名 `librnpushy.so`;`NativeCheckOrchestrator` 现名 +> `SyncCoordinator`(b8bfe1fd)。原文第 7 节"建议的执行顺序"已全部执行,删去。 --- ## 1. JS / TS 层(src/) -### P2 - -**1.1 web 平台常量全是函数,产生假 rollback 上报** -`src/core.ts:15-37,76-79`、`src/utils.ts:46-51`、`src/client.ts:239-246`。 -web 上 `PushyModule = emptyModule`(Proxy,任何属性都返回 `noop`),`PushyConstants` 就是同一个 Proxy,所以 `packageVersion / currentVersion / isFirstTime / rolledBackVersion / buildTime / uuid / currentVersionInfo` 全是 `() => {}`。实测(mock `Platform.OS='web'` 导入 core):`isRolledBack === true`、`isFirstTime` 是函数、`cInfo.uuid` 是函数、`JSON.parse(noop)` 抛错并打印 `error_parse_version_info`。后果:构造函数每次都 `report({type:'rollback'})`,生产 web 每次加载向 `/report/{appKey}` POST 一条 `hash:"() => {}"` 的 rollback;`attachToSentry` 打的 tag 也是函数字符串。 -修法:`core.ts` 里 `Platform.OS === 'web'` 时用显式常量对象(空串/false),不要从 Proxy 上取;补一个 web 用例断言 `isRolledBack===false` 且无 telemetry 请求。 - -**1.2 `setOptions` 触发整套副作用重跑** -`src/provider.tsx:339-398`。主 effect 依赖 `optionsVersion`(每次 `setOptions` 自增),重跑时无条件执行 `checkUpdate()`(`onAppStart/both`)、清掉并重新 arm `markSuccessTimer`、重订阅 AppState。README 里的 `client.setOptions({disableErrorReporting:true})`、`setOptions({logger})`、示例 app 的策略切换,每次都多一次网络检查(客户端去重只覆盖 5s 同指纹),`alwaysAlert` 下会立即再弹一次"发现新版本";反复改 options 会无限推迟 `markSuccess/healthCheck`。 -修法:拆成三个 effect,各自只依赖读到的原始值:AppState 订阅依赖 `[client, options.checkStrategy]`;计时器依赖 `[client, options.autoMarkSuccess, options.autoMarkSuccessDelayMs]`;启动检查只在挂载时跑一次(`useRef` 标记)。补测试:挂载后 `setOptions` 一次,断言 `checkUpdate` 只调用一次。 - -**1.3 `setOptions({locale})` 被静默忽略** -`src/client.ts:225-227,269-291`。`i18n.setLocale` 只在构造函数里调用;fast-refresh 的"同 client 重建"路径也走 `setOptions`,语言切换永远不生效。 -修法:`setOptions` 里 `key==='locale' && value` 时调用 `i18n.setLocale`,加单测。 - -### P3 - -**1.4 `getRemoteEndpoints` 取的是第一个"settled"而非第一个"成功"** -`src/client.ts:847-874`、`src/utils.ts:23-42,188-206`。`promiseAny(queryUrls.map(fetchWithTimeout))` 在任一 fetch resolve 时就返回,而 `fetch` 对 404/403/5xx 也 resolve,`enhancedFetch` 不检查 `ok`。Pushy 预设里 gitee 与 jsdelivr 竞速:gitee 秒回 404/反爬 HTML 时 `resp.json()` 抛错,好的 jsdelivr 列表被丢弃——而这正是主端点挂掉时才会走的路径。 -修法:每个查询 fetch 先 `if(!r.ok) throw` 再 `r.json()`,让 `promiseAny` 竞速解析后的数组;补一个 non-ok + ok 的用例。 - -**1.5 原生检查缓存复用绕过了响应 schema 门** -`src/client.ts:980-985`。`readNativeCheckCache` 只判 `typeof result==='object'`,网络路径却强制 `isValidCheckResult`。JS 可热更到旧原生上(10.51–10.53 的原生有 `getNativeCheckCache` 但没有 R2 schema 门),缓存里的 `{"error":...}` 200 会变成"无更新"并触发 `markJsCheckCompleted`。 -修法:加一行 `if(!isValidCheckResult(result)) return undefined`。 - -**1.6 重试循环会重复下载并重新应用已经确定性失败(`PATCH_FAILED`)的策略** -`src/client.ts:1386-1449`、`src/updateFlowCore.ts:258-260`。`isMirrorRetryableCode` 只跳出镜像循环;外层 `attempt`(默认 3 次重试)把整个 `attempts` 再跑一遍,坏 diff 最多下载并打补丁 4 次、每次间隔退避,才轮到 full。 -修法:记录已以不可重试码结束的策略集合,后续 attempt 跳过;补测试断言 `downloadPatchFromPpk` 在 `PATCH_FAILED` 后只调用一次。 - -**1.7 `applyingUpdate` 在 `reloadUpdate` 成功 resolve 后永不复位** -`src/client.ts:1020-1056,1618-1662`。`switchVersion` 在 `applyingUpdate` 为真时静默返回(无日志、返回 undefined)。89c638e 记录过 iOS `reloadUpdate` resolve 但没重启的真实案例;这种情况下后续所有 `switchVersion` 都被无声吞掉。`resetToPackagedBundle` 也不清 `applyingUpdate/apkStatus`。 -修法:至少在跳过时打日志、在 `resetToPackagedBundle` 里复位;更好的是 resolve 后 arm 一个几秒的计时器,进程仍活着就复位并上报 `errorSwitchVersion`;返回 boolean 区分"已切换"与"被忽略"。 - -**1.8 `Linking.openURL(downloadUrl)` 未 catch** -`src/provider.tsx:281,292`。`silentAndNow` 分支和弹窗按钮都直接调用,URL 由服务端控制,无处理器/格式错误时是 unhandled rejection。修法:抽出 `openExpiredDownload(url)`(两处是逐字重复的 apk/openURL 分支),失败走 `setLastError/log`。 - -**1.9 类型与导出契约** -- `src/index.ts` 没有从 `./type` 导出任何类型:`ClientOptions / CheckResult / ProgressData / EventType / EventData / UpdateEventsLogger / UpdateCheckState / UpdateServerConfig / BeforeReloadContext / UpdateTestPayload / VersionInfo`,用户只能深层 import `react-native-update/src/type`。 -- `src/context.ts:26-55` 与 provider 实际塞进去的值不一致:`markSuccess` 实际返回 Promise;`checkUpdate` 隐藏了 `{extra}` 参数;`switchVersion/switchVersionLater/downloadUpdate` 隐藏了可选 `info` 参数;`parseTestQrCode` 实际接受 `string | UpdateTestPayload`;`progress` 不在类型里但 `useUpdate()` 返回它。 -修法:`index.ts` 补 `export type {...} from './type'`;context 类型从 provider 的 value 推导。 - -**1.10 依赖与包体** -- `react-native-url-polyfill` 只为 `provider.tsx:500-504` 两个 `searchParams.get()`,却把 `whatwg-url-without-unicode`(84KB)+ `buffer` polyfill 带进每个 app 的 bundle,是这个 SDK 给宿主增加的最大一块。用 10 行 query 解析替换并删除依赖。 -- `src/core.ts:140` `require('../package.json')` 把整个 manifest(scripts、devDependencies)打进 bundle,prepublish 时生成 `version.ts` 即可。 -- `nanoid/non-secure` 仅用于 5 行随机 id,可内联。 - -**1.11 release 下无条件 `console.log`,包含完整检查请求体(含 uuid)与响应** -`src/utils.ts:4-18`、`src/client.ts:656,1157-1160,1176`。`options.debug` 存在但不管日志。修法:`log/info` 以 `__DEV__ || options.debug` 门控(保留 warn/error),或走用户 logger。 - -**1.12 测试基建让 provider↔client 契约无覆盖** -`src/__tests__/provider.render.test.tsx:28-79` 用手写假 client(没有 `optionsVersion / onOptionsChange / claimProviderMount`),provider 里的 `client.claimProviderMount?.()`、`client.optionsVersion ?? 0` 等 `?.` 纯粹是为了让假 client 活着。因此未覆盖:1.2 的重跑行为、单例 claim、卸载清理(AppState/计时器)、`checkStrategy:'both'`、深链解析、expired+openURL、`dismissErrorAfter` 重排。`setupDownloadMocks` 整体替换 `../utils`,`rankDownloadUrls` 的排序从未真正执行。 -修法:用真实 `Pushy`(mock `../core`)渲染 provider 做契约套件;去掉 `?.`;用 `emitAppStateChange` 断言卸载。 - -### P4 - -- `src/client.ts:1191-1195`:失败的检查无条件清空 `lastRespJson`,会清掉另一个在途检查的去重槽;只在 `this.lastRespJson === respJsonPromise` 时清。 -- `src/client.ts:1460-1471`:`downloadFallback` 的 `code` 取最后一个失败(diff→PATCH_FAILED、pdiff→DOWNLOAD_FAILED、full 成功 ⇒ 记成 DOWNLOAD_FAILED),patch-health 信号丢失;单独跟踪"任一 attempt 以 PATCH_FAILED 结束"。 -- 死代码:`EventType` 的 `rejectStoragePermission/errorStoragePermission` 与 `STORAGE_PERMISSION_*` 错误码全仓库无发射点;18 个 locale key 从未引用;`utils.ts:44 emptyObj`;`updateFlowCore.ts:358` 与其测试名仍说 devNoop 是"立即成功"(9ad95fb 已改为返回 undefined)。 -- `src/client.ts:1424-1426` catch 里 `e.message` 没有 `?.`,非 Error 拒绝会逃出 catch 并泄漏进度监听。 -- `src/client.ts:887-895` 非 2xx 的完整 `resp.text()`(常是 HTML 错误页)直接成为弹窗文案;截断到 ~200 字。 -- `src/utils.ts:153` 挂在调用方 signal 上的 abort 监听从不移除;`client.ts:664-667` 没有 logger 时每次 `report()` 都留一个未清理的 10s 计时器。 -- `src/client.ts:229-236` appKey 检查只在 ios/android 生效;鸿蒙空 appKey 得到 `/checkUpdate/` 404(HTTP_STATUS)而非 `APPKEY_REQUIRED`,且 `getNativeConfig` 会持久化 `{"disabled":true}`。 -- `src/endpoint.ts:149` `'No endpoints configured'` 未走 i18n。 -- `src/provider.tsx:308-311` 服务端不带 `name` 时弹窗里留着字面 `{{name}}`。 -- NATIVE_CHECK_FOLLOWUPS 第四轮"后续小版本"第 7/8/9 项在代码中仍未变(`!info.expired` 守卫、`?? NATIVE_CONFIG_DISABLED_JSON`、`noArtifact` 上报位置);"JS 配置同步回退竞态"实际已修,文档可关闭。 +- **1.1(P2)** web 平台常量全是 Proxy 函数、每次加载上报假 rollback — 已修 a60809b3 +- **1.2(P2)** `setOptions` 触发整套副作用重跑 — 已修 a60809b3(拆成三个 effect,启动检查只跑一次) +- **1.3(P2)** `setOptions({locale})` 被忽略 — 已修 a60809b3 +- **1.4(P3)** `getRemoteEndpoints` 取第一个 settled 而非第一个成功 — 已修 a60809b3 +- **1.5(P3)** 原生检查缓存复用绕过响应 schema 门 — 已修 a60809b3 +- **1.6(P3)** 重试循环重复下载已确定性失败(`PATCH_FAILED`)的策略 — 已修 a60809b3 +- **1.7(P3)** `applyingUpdate` 在 reload resolve 后永不复位 — 已修 a60809b3(跳过时打日志、reload 看门狗、reset 复位) +- **1.8(P3)** `Linking.openURL` 未 catch — 已修 a60809b3(`openExpiredDownload`) +- **1.9(P3)** 类型未导出、context 类型与实际不符 — 已修 a60809b3 +- **1.11(P3)** release 下无条件 `console.log` — 已修 a60809b3(`__DEV__ || options.debug` 门控) +- **1.12(P3)** provider↔client 契约无测试 — 已修 a60809b3(`provider.contract.test.tsx` 用真实 client) +- **P4**(`lastRespJson` 去重槽、`downloadFallback` 失败码、死代码/死 locale key、非 2xx 文案截断、abort 监听与计时器泄漏、鸿蒙 appKey 检查、i18n、`{{name}}` 占位)— 全部已修 a60809b3 + +### 仍开放 + +**1.10(P3)依赖与包体**(部分修复) +已做:`react-native-url-polyfill` 换成 `parseQueryParams`,`nanoid` 换成内联 `createNonSecureId`,运行时依赖清零。 +剩余:`src/core.ts` 的 `require('../package.json')` 把整个 manifest 打进 bundle;可在 prepublish 时生成 `version.ts`。收益小,§8 时有意保留。 --- ## 2. Android 层(android/) -### P2 - -**2.1 状态持久化四个写入方无统一互斥,read-modify-write 会丢激活或丢 markSuccess** -`UpdateContext.java:439`(switchVersion)、`:485`(markSuccess)、`:654`(getBundleUrl 的 resolve-launch 提交)、`:712`(commitNativeCheckResult)。所有状态操作都是 `getStateSnapshot()` → JNI 纯函数 → `applyState(editor)` → `commit()`;只有 `commitNativeCheckResult` 和 `resetToPackagedBundle` 拿 `commitLock`。`switchVersion/markSuccess/clearRollbackMark` 在 `pushy-state-serial` 线程,`restartApp` 在 **UI 线程**(`ReactReloadManager.java:27,32`)跑 switchVersion+getBundleUrl,getConstants 在 JS 线程跑 `consumeFirstLoadMarker/clearRollbackMark`。具体交错:原生轮次在 `commitLock` 下提交 `current=H,firstTime=true`,而 JS `markSuccess`(状态线程,快照里 `current=A`)最后提交 → H 的激活被静默撤销;在 crash-rescue 路径上这就是救砖被丢。 -修法:让 `commitLock` 成为所有 snapshot→commit 序列的唯一互斥(含 `switchVersion/markSuccess/clearFirstTime/clearRollbackMark/rollBack`、`getBundleUrl` 内提交、`consumeFirstLoadMarker`),或全部经 `StateSerialRunner`、编排器把提交也 submit 进去。 - -**2.2 `reloadUpdate/restartApp` 在 UI 线程做整包 SHA-256 + 两次同步 commit** -`UpdateModuleImpl.java:152` → `UiThreadRunner` → `ReactReloadManager.java:27` `switchVersion` → `UpdateContext.java:425` `InstallRecord.verifyForActivation` → `sha256Hex(bundleFile)`;然后 `:32` `getBundleUrl()` 再 `commit()`(`:654`)。这是 JS `switchVersion` 的路径,低端机上多 MB Hermes 包 = 数十到数百 ms 主线程卡顿,且它就是 2.1 里的 UI 线程写入方。 -修法:`restartApp` 拆成 (a) 状态串行线程做 switchVersion + getBundleUrl,(b) 只把 `recreateReactContext/reload` post 到 UI 线程。 - -**2.3 旧架构模块丢弃持久化失败,违反 TS Promise 契约** -`android/src/oldarch/.../UpdateModule.java:69 setNeedUpdate`、`:74 markSuccess`、`:89 setUuid`、`:109 setLocalHashInfo` 没有 `Promise` 参数(newarch 有);`src/NativePushy.ts` 全部声明为 `Promise`,`src/client.ts:1065` `await PushyModule.setNeedUpdate(...)`。旧架构下 await 立即 resolve,`commit()` 失败只被 `StateSerialRunner` `Log.e`,JS 报告 `switchVersionLater` 成功——6e9ae76 的"持久化失败拒绝 promise"在旧架构 app 上不成立;`switchVersionLater()` 后紧接 `restartApp()` 还会与仍在队列里的 switch 竞争。 -修法:oldarch 加带 `Promise` 的重载(旧架构支持尾参 Promise),删掉 `UpdateModuleImpl` 里无 promise 的重载(`:180,204,312,365`)。 - -**2.4 JNI 构造的结果类没有硬 keep 规则** -`android/proguard.pro:4` `-keepnames class cn.reactnative.modules.update.** { *; }` 等于 `-keep,allowshrinking`。`ArchivePatchPlanResult`、`CopyGroupResult` 从不在 Java 里 `new`(只有 `StateCoreResult` 是),只经 `FindClass + GetMethodID("") + NewObject` 构造(`cpp/patch_core/update_core_android.cpp:274-280,299-304`),字段按名查找。R8 收缩掉无引用的无参构造或字段后,`GetMethodID/GetFieldID` 留下 pending `NoSuchMethodError`,所有 diff/pdiff 安装在混淆 release 包里失败。三个示例 app 都 `enableProguardInReleaseBuilds=false`,仓库自己测不到。同时消费者规则里的 `-keepnames class com.facebook.react.** { *; }` 与 `-keepclassmembers class com.facebook.react.ReactActivity { *; }` 过宽,影响所有宿主 app 的 R8 效果。 -修法:显式 `-keep class ...StateCoreResult { (); ; }`(三个结果类同样)+ `-keepclasseswithmembernames class cn.reactnative.modules.update.** { native ; }`;删掉对 `com.facebook.react.**` 的整体 keepnames;用一次 `minifyEnabled true` 的 release 构建验证。 - -**2.5 清理 keep 集合漏掉正在运行的版本目录(`launchVersion`)** -`UpdateContext.java:778-784` `cleanUp()` 只传 `currentVersion/lastVersion`;C++ `CleanupOldEntries`(`patch_core.cpp:612-627`)删除不在这两个名字里、mtime 超过 3 天的所有非点开头条目。`SwitchVersion`(`state_core.cpp:28-38`)会 `last=current`,两次切换不重启(JS `switchVersionLater(B)` 后原生轮次激活 C)就把运行中的 A 挤出 keep 集合;之后任何时候可调用的 `markSuccess()/clearRollbackMark()` 会删掉 A 的目录,而按需资源(图片/字体)还在从 A 读——正是 `resetToPackagedBundle` 用 `launchVersion` 防的那种故障。 -修法:`cleanupOldEntries` 增加第三个 keep 名(或传 `String[]`),始终包含 `launchVersion`。 - -**2.6 APK 安装路径无完整性校验** -`UpdateModuleImpl.java:70-78` 把 `hash` 传给 `downloadFile` 然后无条件安装 `params.targetFile`;`DownloadTask.java:797` `TASK_TYPE_PLAIN_DOWNLOAD` 什么都不校验;JS 传的 `hash` 是进度键 `'downloadingApk'`(`src/client.ts:1568-1571`)。唯一保护是 `rejectProtocolDowngrade`,对服务端直接下发 `http://` APK(`usesCleartextTraffic=true` 或 targetSdk<28)无效。安装未验证 APK = 代码执行。 -修法:要求服务端提供 sha256(或至少拒绝非 https APK),安装前 `sha256Hex(apk)` 校验,`session.commit` 后删除 APK。 +- **2.1** 状态持久化写入方无统一互斥 — 已修 092f544a(`UpdateContext.commitLock` 为所有 snapshot→commit 序列的唯一互斥) +- **2.2** `restartApp` 在 UI 线程做整包 SHA-256 + 同步 commit — 已修 092f544a(`prepareRestart` 走 `StateSerialRunner`,只有 reload 上 UI 线程) +- **2.3** 旧架构模块无 Promise、持久化失败静默 — 已修 092f544a +- **2.4** JNI 结果类无硬 keep、消费者规则过宽 — 已修 092f544a;10.56.0 发版前用 minify release 构建 + dexdump 核对,并修正 `reload` 返回类型匹配(583801ab) +- **2.5** cleanup keep 集合漏掉 `launchVersion` — 已修 092f544a(`CleanupKeepNames.select`,有 JVM 单测) +- **2.7** 检查响应体无大小上限 — 已修 092f544a(`SyncCoordinator.readBoundedBody`,1 MiB) +- **2.8** 冷启动关键路径冗余 IPC/commit — 已修 092f544a +- **2.9** 旧架构反射字段名无回退、失败时 recreate 加载旧 bundle — 已修 092f544a(`getCompatibleField`,失败 reject `RESTART_FAILED`) +- **2.10** 重下运行中 hash 会抹掉活目录 — 已修 092f544a(`ensureNotReinstallingRunningVersion`) +- **2.11** `options.getString` 缺 key 崩溃 — 已修 092f544a(`readRequiredString` → `INVALID_OPTIONS`) +- **2.12** 编排下载阶段超时不取消在途传输 — 已修 092f544a(`DownloadTaskParams.cancel` 取消 OkHttp Call,共享绝对 deadline) +- **P4**(无名 executor、`volatile`、`printStackTrace`、目录 fsync 等)— 已修 092f544a;`UpdatePackage` 仍继承 `TurboReactPackage`(替代 API 需 RN 0.74,peer 下限 0.59,已注明) -### P3 +### 仍开放 -- **2.7** `NativeCheckOrchestrator.java:473` `response.body().string()` 无大小上限(checkUpdate 与远程 queryUrls 列表都走这里),劫持的端点可 OOM 后台线程 → 崩溃 → crash handler 又去 hold 进程。`:475` 吞掉所有异常且无日志。修法:`body.source()` 限 1MiB,超出视为端点失败;加一行异常类名日志。 -- **2.8** 冷启动关键路径冗余:`UpdateContext.java:81-108` 构造函数(主线程,`getJSBundleFile/getReactHost` 调用)多次 `getPackageVersion()`(Binder IPC)、`computeBundleHash` 两次;`getConstants`(TurboModule 下 JS 线程同步)做两次独立 `commit()`(`consumeFirstLoadMarker:23`、`clearRollbackMark:29`)+ 一次文件读。缓存 `PackageInfo`,两次提交合成一个 editor。 -- **2.9** `ReactReloadManager.java:51,55` 旧架构反射用字面 `mBundleLoader/mJSBundleFile` 无回退;RN 把 `ReactInstanceManager` 转 Kotlin 后两处都失败,外层 catch 走 `currentActivity.recreate()`(`:83`)加载**旧** bundle 却 resolve 成功。改用 `getCompatibleField(...,"bundleLoader")`,注入失败应 reject `RESTART_FAILED`。 -- **2.10** `DownloadTask.java:586-590` `promoteStaging` 删除 `unzipDirectory` 再 rename;`hasCompletedPatchDirectory()` 只认 `.pushy-complete`,10.5x 之前安装的当前运行 hash 无 marker,重下同 hash 会在进程中抹掉活目录。`params.hash.equals(launchVersion)` 时拒绝/跳过。 -- **2.11** `UpdateModuleImpl.java:34,96,143,170` 的 `options.getString(...)` 不在 try/catch 内(`downloadAndInstallApk/downloadPatchFromPpk` 却包了),缺 key 时在 native-modules 线程抛异常直接崩 app。统一 `readRequiredString(options,key,promise)` reject `INVALID_OPTIONS`。 -- **2.12** `NativeCheckOrchestrator.performAttempts:604-655` 阶段超时后 `break` 到下一 attempt 并在同一单线程 executor 上再排一个任务,前一个可能仍在传输;full 阶段又是新的 600s 窗口。超时应 `cancel()` 在途 OkHttp Call,或编排器用独立 executor。 -- **2.13** 重复与死代码:两份 `UpdateModule.java` 各 ~120 行几乎相同(只差基类与 promise 签名);`UpdateContext.clearFirstTime(:506)`、`isFirstTime()(:467)`、`cn...ReactNativeHostHandler` 接口未用;minSdk 21 下的 `LOLLIPOP` 守卫(`ApkInstaller.java:58`、`BundledResourceCopier.java:365`)与不可达的 `UNSUPPORTED_PLATFORM` 分支;`build.gradle:150` `java/expo/modules/pushy` 路径不存在;`:128-132` `compileSdk 31 / targetSdk 27` 回退值过旧;`repositories { maven "$rootDir/../node_modules/react-native/android" }` 对 RN≥0.71 是死路径;`lintOptions/buildToolsVersion` 已废弃;`versionName "1.81.4"` 陈旧;`resolveInstalledExpoMajorVersion` 每次 Gradle 配置都 spawn `node`。 +**2.6(P2)APK 安装路径无内容校验**(部分修复) +已做:`downloadAndInstallApk` 拒绝非 https URL(`INVALID_OPTIONS`),`ApkInstaller.install` 在 session 拿到字节或失败后删除 APK。 +剩余:安装前没有 sha256 校验(`ApkInstaller.java` 的 `TODO(CODE_AUDIT 2.6)`)。JS 传给 `downloadAndInstallApk` 的 `hash` 仍是进度键 `'downloadingApk'`,不是摘要;需要服务端下发 APK sha256 + JS/原生透传后才能校验。在此之前 https-only 是唯一的完整性边界。 -### P4 +**2.13(P3)重复与死代码**(部分修复) +已删:`clearFirstTime/isFirstTime`、`ReactNativeHostHandler`、`LOLLIPOP` 守卫、`build.gradle` 的陈旧路径/`repositories`/`versionName`,SDK 回退值更新到 34,`node` 只作最后手段。 +剩余:两份 `UpdateModule.java`(oldarch/newarch,均已是薄转发层,只差基类与注解);`buildToolsVersion` 仍设置。 -`UpdateContext.java:83` 无名 executor;`:32` `reactInstanceManager` 在 `sLock` 下写但无同步读(加 `volatile`);`:123` `e.printStackTrace()`;`UpdatePackage` 用已废弃的 `TurboReactPackage` 与 7 参 `ReactModuleInfo`;`InstallRecord.write` fsync 了文件但 rename 前没 fsync 目录(API 26+ 可 `FileChannel.open(dir).force(true)`)。 - -### 已核实正常 -JNI 契约(签名与 `verify-android-so.js` 符号表一致,字符串/数组全部释放,异常经 `ThrowRuntimeException` 上抛,`update_flow_jni.cpp` 正确避开非 BMP 的 `NewStringUTF`);`PackageInstallerStatusReceiver` manifest 声明 + `exported=false`;`CrashRescue` 链式调用前任 handler、每进程一次、主线程 3.5s 上限、全部 `catch Throwable`;`SafeZipFile` 的穿越/保留名/限额检查;流全部 try-with-resources;Range/If-Range/416/Content-Range 续传逻辑正确;`ErrorCodes.java ↔ error_codes.h ↔ src/error.ts` 同步;16KB 对齐由脚本强制。 - -### 单测建议(纯 JUnit,零 Android 依赖) -`UpdateContext.isSafePathComponent`、`DownloadTask.parseContentRange`、`SafeZipFile.isReservedEntryName/inspect()/unzipToPath`(构造内存 zip:穿越、炸弹比、条目数、保留名)、`InstallRecord.build/read/isComplete/verifyForActivation`、`ArchiveLimits.ensureFreeSpace`、`NativeCheckOrchestrator.normalizeEndpointBase` 与 `runCheckRequest` 的次数上限(注入 HTTP 函数)、`BundledResourceCopier.normalizeResPath/extractResourceName/parseDensityQualifier`。 +**单测建议**(部分) +已有 `android/src/test` 6 个 JVM 测试类(CleanupKeepNames、ConfigEquivalence、HttpUtils、InstallRecord、SafeZipFile、UpdateFileUtils;CI 不跑,本地 `gradle testReleaseUnitTest`)。未覆盖:`ArchiveLimits.ensureFreeSpace`、`runCheckRequest` 次数上限、`BundledResourceCopier` 的路径/密度解析。 --- ## 3. iOS 层(ios/) -NATIVE_CHECK_FOLLOWUPS 中两项已在代码里修复、可关闭:"iOS 合流下载的两个语义代价"(`PushyReportDownloadProgress` 进度广播 `RCTPushy.mm:378-391`,异类型请求 deferred `:351-361`)与"启动状态解析抛异常时救援检测永不调度"(`@finally` `:881-886`)。 - -### P2 - -**3.1 缺少隐私清单(PrivacyInfo.xcprivacy),且 SDK 新增了磁盘空间这一 required-reason API** -`RCTPushy.mm:186-195`(`NSFileSystemFreeSize`)、`:773-781`(`fileModificationDate`)、`:88-90`(`systemUptime`)、`:446-448`(`NSUserDefaults`);`podspec:98` 只随包携带 `pushy_build_time.txt`,`ios/` 下无任何 `*.xcprivacy`。 -6e9ae76 加入的 `PushyFreeDiskSpaceForPath` 读 `NSFileSystemFreeSize`,属于 `NSPrivacyAccessedAPICategoryDiskSpace`。RN 模板 app 的清单(见 `Example/testHotUpdate/ios/.../PrivacyInfo.xcprivacy`)只声明了 FileTimestamp C617.1、SystemBootTime 35F9.1、UserDefaults CA92.1,所以每个升级过 10.53 的集成方都会静默获得一个未声明的 API 使用,上传时 ITMS-91053(2024-05 起是审核阻断)。其余三类目前靠宿主模板"恰好"覆盖。 -修法:`s.resource_bundles = { 'react-native-update_privacy' => 'ios/PrivacyInfo.xcprivacy' }`,声明 DiskSpace `E174.1`、FileTimestamp `C617.1`、SystemBootTime `35F9.1`、UserDefaults `CA92.1`,`NSPrivacyTracking=false`;CHANGELOG 说明。 - -**3.2 `packageVersion` / `buildTime` 以无前缀 key 存在 `standardUserDefaults` 顶层,撞 key 时每次启动都抹掉热更新** -`RCTPushy.mm:38-39`(`paramPackageVersion=@"packageVersion"`、`paramBuildTime=@"buildTime"`),`:555-556` `stringForKey:` 顶层读取(已核实),`:571-572` 写入,`:817-827` 消费;`state_core.cpp:12-24`。其他 key 都是 `REACTNATIVECN_PUSHY_*`,唯独这两个二进制身份 key 用了宿主 app 与其他 SDK 常用的通用名。宿主若把 `buildTime` 存成 NSNumber,`stringForKey:` 返回 nil → `""` → `SyncBinaryVersion` 判定 `changed` → 清空 `current/last_version` → 已装更新被丢弃并触发 `clearInvalidFiles`;Pushy 再写回字符串、宿主再覆盖,每次启动循环。Android 用私有 SharedPreferences 文件,仅 iOS 有此问题。 -修法:改名 `REACTNATIVECN_PUSHY_PACKAGEVERSION/_BUILDTIME`,`PushyStateFromDefaults` 里一次性迁移(读新 key、回退旧 key、首次写入后删旧 key);长期迁到 `initWithSuiteName:@"cn.reactnative.pushy"`。 +- **3.1** 缺 PrivacyInfo.xcprivacy — 已修 77b5488c(`ios/PrivacyInfo.xcprivacy` 经 `s.resource_bundles` 发布) +- **3.2** `packageVersion/buildTime` 无前缀 defaults key — 已修 77b5488c(`REACTNATIVECN_PUSHY_*` + 旧 key 回退迁移;未改用 suite) +- **3.3** 归档 symlink 条目被物化 — 已修 77b5488c(unzip guard 拒绝 `S_IFLNK` 与 `.pushy-` 名,`preserveAttributes:NO`) +- **3.4** 畸形 `__diff.json` 抛 ObjC 异常 — 已修 77b5488c(`PushyPatchManifestFromJson` 全量 `isKindOfClass:` 校验,返回原因;下沉 C++ 见 3.14) +- **3.5** 归档大小/剩余空间检查在落盘之后 — 已修 77b5488c(`didReceiveResponse` 前置检查 + `didReceiveData` 流式上限) +- **3.6** `use_frameworks!` 下 `buildTime` 资源找不到 — 已修 77b5488c(mainBundle → `bundleForClass:` → Info.plist mtime 回退) +- **3.7** 重载路径预消费首载状态、失败仍 resolve — 已修 77b5488c(去掉 `RCTReloadCommandSetBundleURL`,无可用 bridge 时 reject `RESTART_FAILED`) +- **3.8** 救援窗口里哈希内嵌 bundle — 已修 77b5488c(救援期只用缓存值,与 JS `getBundleHash` 单飞共享) +- **3.9** iOS 从不发出 `MARK_SUCCESS_FAILED/RESET_FAILED/RESTART_FAILED` — 已修 77b5488c +- **3.11** SSZipArchive 无版本约束 — 已修 77b5488c(`'~> 2.4'`) +- **3.12** `isUsingBundleUrl` 未导出 — 已修 77b5488c +- **P4**(锁断言、锁外哈希、`PushyHttpRequest` 重定向)— 已修 77b5488c -**3.3(安全)归档可含 symlink 条目,SSZipArchive 无条件按任意目标物化** -`RCTPushy.mm:1714-1723`(`unzipFileAtPath … preserveAttributes:YES … delegate:guard`),`PushyUnzipGuard :232-259`;SSZipArchive 2.4.3 的 symlink 分支不受 `preserveAttributes` 门控。`_sanitizedPath` 只中和条目名里的 `../`;一个 `assets -> ../../../Documents` 的 symlink 条目再跟一个 `assets/x`,就能写到沙箱内任意位置(Preferences plist、Documents、另一版本目录);`index.bundlejs` 做成 symlink 会让安装记录 digest 与 bundle 加载指向版本目录之外。Android `SafeZipFile`(java.util.zip)不支持 symlink,仅 iOS 有此缺口。 -修法:`zipArchiveShouldUnzipFileAtIndex:` 里拒绝 `((external_fa >> 16) & S_IFMT) == S_IFLNK` 的条目(顺带拒绝 `.pushy-` 前缀名),记录违规使其成为硬 `PATCH_FAILED`;`preserveAttributes:NO`。 +### 仍开放 -**3.4 畸形 `__diff.json` 在 `_fileQueue` 上抛 ObjC 异常直接杀 app,而非 `PATCH_FAILED`** -`RCTPushy.mm:518-552`(`PushyPatchManifestFromJson`,从 `:1449` 的 GCD block 调用)。`copies` 未做类型检查:`for (NSString *to in copies)` + `copies[to]` 遇 NSArray/NSString 值 → unrecognized selector;`from.length` 遇 NSNumber 同样;`deletes` 的值未检查类型就 `[value UTF8String]`。只有 `copiesCrc` 与 `hbcTransform` 有守卫。dispatch block 里未捕获异常是致命的(还会顺带触发 crash-rescue)。Android(`DownloadTask.java:450-475`)用 `optJSONObject/getString`,`JSONException` 在 `:804` 被接住变成补丁失败。 -修法:把 manifest 解析搬进 C++(用 `flowjson` 解析成 `PatchManifest` 并返回 `Status`),同时消掉 Java/ObjC 两份解析器(见 3.13);短期在 `copies`、每个 `from`、`deletes` 及每个 key 上加 `isKindOfClass:`。 +**3.10(P3)构建时脚本写入 pod 源码目录**(部分修复) +已做:去掉 `USER_HEADER_SEARCH_PATHS` 里的绝对路径;script phase 声明了 output files。 +剩余:脚本仍在 pod 源码目录可写时写 `ios/pushy_build_time.txt`(`:path` pod 每次构建弄脏 checkout,git 源 pod 改写 CocoaPods 缓存),而不是 `$(DERIVED_FILE_DIR)`;脚本内嵌 `podspec_dir` 作回退。 -**3.5(对等)iOS 的归档大小上限与剩余空间检查在整个 body 落盘之后才执行** -`RCTPushyDownloader.mm:301-410`(`didReceiveResponse` 无 Content-Length 上限、无磁盘检查),`:412-430`(`didReceiveData` 无界追加);上限在 `RCTPushy.mm:1697-1703` 事后应用。Android `DownloadTask.java:349-353` 流式前比对 Content-Length 与 `MAX_ARCHIVE_BYTES` 并 `ensureFreeSpace`,`:385-388` 有流式字节兜底。`archive_limits.h` 注释说的"Content-Length up front, streamed bytes as backstop"iOS 两样都没实现,恶意服务端可在 `timeoutIntervalForResource`(10 分钟)内把卷写满,续传 partial 还留在盘上。 -修法:`didReceiveResponse` 里 `expectedTotal > kMaxArchiveBytes` 即拒绝并 `PushyEnsureFreeSpace(savePath, expectedTotal - baseOffset)`;`didReceiveData` 里 `baseOffset + received > kMaxArchiveBytes` 即失败并 cancel。 +**3.13(P3,结构)`RCTPushy.mm` 拆分**(部分) +已做:重复实现已合并(`.resume` 后缀、文件大小、`runCheckRequest` 单一 `queryEndpoints`);拆出 `RCTPushyOrchestrator.mm` 与共享的 `RCTPushyInternal.h`。 +剩余:`RCTPushy.mm` 仍约 2150 行(目标约 900);`PushyInstallRecord` / `PushyArchiveGuard` / `PushyDownloadRegistry` / `PushyState` / `PushyCrashRescue` 未拆。 -**3.6(很可能)`use_frameworks!` 动态链接下 `buildTime` 资源在 framework bundle 里,`[NSBundle mainBundle]` 找不到** -`RCTPushy.mm:1822-1836`;`podspec:98-99`。CocoaPods 对动态 framework 把 `s.resource` 放进 framework bundle。此时 `pathForResource:` 为 nil → `buildTime` 空 → `SyncBinaryVersion` 以 `""` 运行(同 `CFBundleShortVersionString` 的原生重建不再被检测,旧热更新在 App Store 更新后存活),检查请求的 `buildTime` 也为空。89c638e 证明动态 framework 构建在野。本环境无法跑 CocoaPods,请用 `:linkage => :dynamic` 验证;修法很便宜。 -修法:`[NSBundle mainBundle]` 找不到时回退 `[NSBundle bundleForClass:[RCTPushy class]]`,两者都失败时打 warning。 - -### P3 - -- **3.7** 重载路径:`RCTPushy.mm:1263-1273` 先 `RCTReloadCommandSetBundleURL([[self class] bundleURL])` 跑 `ResolveLaunchState`(消费 `first_time`、置 `keyFirstLoadMarked`、`ignoreRollback=true`)再 `RCTTriggerReloadCommandListeners`。RN 自己会重新解析 URL(`RCTHost.didReceiveReloadCommand` 调 `_bundleURLProvider()`,`RCTBridge.setUp` 重问 `sourceURLForBridge:`),这个设置没有读者。若无监听器触发(bridgeless 下的 `[self.bridge reload]` 回退、无 RCTHost/RCTBridge 的宿主、未来 RN 变更),被消费的首载状态让**下次冷启动**把新版本回滚——正是 89c638e 的症状,仍可经 `#else` 分支到达。`restartApp` 无论如何都 resolve `@true`;Android 会 reject `RESTART_FAILED`。修法:去掉 `RCTReloadCommandSetBundleURL(...)`,`#else` 分支 `#error` 或在 `self.bridge == nil` 时 reject `kRestartFailed`。 -- **3.8** 主线程预算档的 crash rescue 可能把 3.5s 花在哈希内嵌 bundle 上:`:2154-2155` 轮次总是调 `PushyBundleHashSync()`,二进制新装后首启缓存未命中时在救援窗口里对多 MB 文件做 SHA-256;且 +5s 的 utility 队列与 JS `getBundleHash`(`_fileQueue`,`:1140`)可能双重哈希。救援期间只用缓存值(miss 发 `""`),非救援期算一次共享。 -- **3.9(对等)** iOS 从不发出 `MARK_SUCCESS_FAILED / RESET_FAILED / RESTART_FAILED`:`markSuccess(:1146-1166)`、`resetToPackagedBundle(:1168-1223)` 吞掉所有失败(`CleanupOldEntries` 失败只 `RCTLogWarn` 且在 `resolve(@true)` 之后)。按码聚合的遥测在 iOS 上对这些操作是盲的。用 `@try` 包住并以对应码 reject;reset 的清理同步在 `_fileQueue` 上跑完再 resolve。 -- **3.10** 构建时脚本写入 pod 源码目录:`podspec:98-99` `date +%s > "#{podspec_dir}/ios/pushy_build_time.txt"` 在 `before_compile` 执行;`:path` pod(示例、monorepo)每次构建弄脏 checkout,git 源 pod 改写 CocoaPods 缓存;配合 Xcode build cache 时 `buildTime` 取决于哪次运行最后碰了文件,不可复现。`:95` `USER_HEADER_SEARCH_PATHS` 嵌入绝对路径 `#{podspec_dir}/ios`,xcconfig 输出机器相关。已提交的 `ios/pushy_build_time.txt` 是陈旧占位(`1680488830`)。修法:写到 `$(DERIVED_FILE_DIR)` 并声明 `:output_files`,或干脆运行时从 `CFBundleVersion`/`Info.plist` mtime 派生 `buildTime` 删掉脚本;去掉绝对搜索路径。 -- **3.11** `podspec:103` `s.dependency 'SSZipArchive'` 无版本约束;`_sanitizedPath` 与 guard 依赖的 delegate 方法只在 ≥2.2 存在,未来 3.x 可静默改契约。改 `'~> 2.4'`。 -- **3.12(P4)** `isUsingBundleUrl` Spec 声明但 iOS 未导出(Android/鸿蒙在 `getBundleUrl` 被调用后置 true);JS 无人读,无害。要么 Spec 标可选,要么 iOS 在 `+bundleURL` 顶部置一个静态标记(两行)。 -- **3.13(结构)** 重复:续传 sidecar 后缀 `@".resume"` 硬编码在 `RCTPushy.mm:1707,1729`,重复 `RCTPushyResumeSidecarPath`(`RCTPushyDownloader.mm:13-15`);`PushyFileSizeAtPath(:178-182)` == `RCTPushyFileSize(Downloader:38-43)`;`runCheckRequest` 同一段 15 行"归一→去重→预算→POST→校验"出现两次(`:2338-2358`、`:2380-2400`);安装记录校验在 `PushyHasCompletedVersionAtPath(:119-135)` 与 `PushyVerifyInstallForActivation(:139-152)` 各写一遍(Java/ArkTS 又各一份);`setNeedUpdate/reloadUpdate` 共享主体(`:1092-1117`)。死/误导注释:`:277 //TASK_TYPE_PLAIN_DOWNLOAD=4?`;`:794` `zipExtension default:` 不可达;`:1694-1696` 说 SSZipArchive 拿不到中央目录,其实 `zipArchiveWillUnzipArchiveAtPath:zipInfo:` 给了 `number_entry`(`:225` 已用);`Downloader:5-8` 仍在解释已被替换的 NSURLSessionDownloadTask 设计。拆分建议:`PushyKeys.h`(`:37-90,437-516`)、`PushyInstallRecord.mm`(`:94-175`)、`PushyArchiveGuard.mm`(`:178-262` + `unzipFileAtPath`)、`PushyDownloadRegistry.mm`(`:292-412`)、`PushyState.mm`(`:414-609`)、`PushyCrashRescue.mm`(`:665-763`)、`RCTPushyOrchestrator.mm`(`:1848-2479`),`RCTPushy.mm` 留模块表面 + 管线约 900 行。 -- **3.14(可测试性)** 应下沉到 `cpp/` 的纯决策(已有 `state_core/CleanupOldEntries` 的 harness):`__diff.json` → `PatchManifest` 解析含类型校验(替代 `PushyPatchManifestFromJson` 与 `DownloadTask.appendManifestEntries`);安装记录解析/校验 `install_record::Validate(json, hash)` 三端共用;归档限额累加器 `archive_limits::EntryAccumulator::Accept(size, compressed, isSymlink)`;下载注册表 join/defer 规则(`:337-349`)与 `Content-Range` 解析(`Downloader:47-69`,Java 又一份);crash-rescue 门控(`:706-716`)与检查端点尝试策略(`:2332-2403`);`PushyIsSafePathComponent(:483-497)` 放到 `IsSafeRelativePath` 旁边。另建 `ios/Tests` XCTest target 由 `xcodebuild test` 跑:`RCTPushyDownloader` 对本地 `NSURLProtocol` stub(416/206/redirect/encoded)与 `unzipFileAtPath` 对夹具 zip(symlink、保留名、比值炸弹)。 - -### P4(线程,未发现缺陷) -`PushyWithStateLock(:419-428)` 是不可重入的 `os_unfair_lock`,当前无嵌套但无守卫,DEBUG 下加 `os_unfair_lock_assert_not_owner`;`switchVersion` 在锁内哈希整包(`:1620`),包变大时移到锁外再在锁内复验记录;`PushyHttpRequest(:1853-1895)` 让共享 session 跟随 https→http 重定向后才拒绝响应,POST 体(appKey、uuid、版本)已明文发出一次——ATS 默认拦截,仅 `NSAllowsArbitraryLoads` 的 app 受影响,可像 downloader 一样用 delegate session。冷启动成本正常(`+bundleURL` 只做 defaults 读、一次 `stat`、一次小文件读);无观察者/计时器泄漏;downloader 每条完成路径都 invalidate session;不持久化绝对路径(容器迁移安全)。 +**3.14(P3,可测试性)纯决策下沉 C++ + XCTest**(未做) +应下沉到 `cpp/` 的纯决策:`__diff.json` → `PatchManifest` 解析含类型校验(替代 `PushyPatchManifestFromJson` 与 Java `DownloadTask` 的解析);安装记录校验 `install_record::Validate(json, hash)` 三端共用(目前 `install_record.h` 只有常量);归档限额累加器;下载注册表 join/defer 规则与 `Content-Range` 解析(Java/ObjC 各一份);crash-rescue 门控与检查端点尝试策略;`PushyIsSafePathComponent` 放到 `IsSafeRelativePath` 旁边。另建 `ios/Tests` XCTest target:`RCTPushyDownloader` 对 `NSURLProtocol` stub(416/206/redirect/encoded)、`unzipFileAtPath` 对夹具 zip(symlink、保留名、比值炸弹)。 --- ## 4. 共享 C++ 内核(cpp/) -### P1 - -**4.1 `IsSafeRelativePath` 不拒绝嵌入 NUL → 鸿蒙上 staging 沙箱逃逸** -`cpp/patch_core/patch_core.cpp:638-652`。只按 `/` 切段并拒绝空段、`.`、`..`,不检查 NUL 或其他控制字节。manifest `to` 为 `"..\0x"` 时 `!= ".."`,通过;下游全部经 `.c_str()`(`CopyFile/JoinPath → fopen/stat/link`)在 NUL 处截断,目标变成 `..`。编译 PoC 验证:`IsSafeRelativePath("..\0x")` 返回 1,`ApplyPatchFromFileSource`(根 `root/v2.staging`)把拷贝目标解析到 `root/`,即存放其他已安装版本目录的父目录。可达性按字符串桥而异:**鸿蒙可达**(`napi_get_value_string_utf8` 返回含原始 `\0` 的 UTF-8,`pushy.cpp:79-86` `GetString` 通过 `resize(written)` 保留它);iOS 侥幸安全(`[v UTF8String]` 在 NUL 处停止,目标变成 `..` 被拒);Android 安全(JNI modified UTF-8 把 `\0` 编成 `C0 80`)。manifest 是来自下载归档的攻击者可控输入。 -修法:在内核层拒绝任一段里 `< 0x20` 的字节(含 NUL),三端在边界上一并受保护。 - -### P2 +- **4.1(P1)** `IsSafeRelativePath` 不拒绝 NUL/控制字节(鸿蒙 staging 逃逸)— 已修 90f8d58e +- **4.2(P2)** 递归删目录/合并目录无深度上限 — 已修 90f8d58e(`kMaxDirectoryDepth`) +- **4.3(P2)** `patch_core` 测试不在 CI — 已修 1e46ee06(`test.yml` 跑 `SANITIZE=1 ./scripts/test-patch-core.sh`) +- **4.5(P3)** `flowjson::Parse` 无输入/节点上限 — 已修 90f8d58e(1 MiB / 65536 节点) +- **4.6(P3)** 数值解析/输出受 `LC_NUMERIC` 影响 — 已修 90f8d58e(手写十进制解析) +- **4.7(P3)** Murmur3 对非 ASCII key 与 TS 不一致 — 已修 90f8d58e(向量含非 ASCII) +- **4.8(P3)** 孤立代理项转义输出非法 UTF-8 — 已修 90f8d58e(归一到 U+FFFD) +- **4.9(P3)** HBC 变换路径整包读入内存两次 — 已修 90f8d58e(mmap 临时文件原地变换) +- **4.10(P3)** LZMA 字典大小无上限 — 已修 90f8d58e(`hpatch.c` `kMaxLzmaDictSize`) +- **4.11(P3)** payload 写入后 rename 前不 fsync — 已修 90f8d58e +- **4.13(P3)** 不可信输入解析器无 fuzz — 已修 90f8d58e(`fuzz_flow_json.cpp`、`fuzz_hbc_transform_wire.cpp`,CI `FUZZ=1`) +- **4.14(P3)** `ftell` 在 32 位 ABI 上截断 — 已修 90f8d58e(`fstat`) +- **P4** 测试脚本无 `-Werror` — 已修(`scripts/lib/cpp-test-common.sh` `CORE_WARNINGS`;vendored C 源仍只 `-Wall -Wextra`) -**4.2 `RemovePathRecursively` / `MergeDirectoryRecursively` 递归无深度上限** -`patch_core.cpp:235-274,453-512`。ASan 下 PoC 已把 `RemovePathRecursively` 驱动到栈溢出 abort。独立于 4.1,恶意归档可放几千层嵌套目录(远在 20000 条目上限内),第一次 `CleanupOldEntries`/merge/removal 就栈溢出崩 app。修法:显式深度上限(对齐 flow parser 的 `kMaxDepth`)或用显式工作栈迭代。 +### 仍开放 -**4.3 `patch_core` 测试套件在 CI 完全不跑** -`.github/workflows/test.yml`:`cpp-test` 只构建 HDiffPatch 自带 `unit_test` 并跑 `SANITIZE=1 ./scripts/test-update-flow-core.sh`,从不调用 `./scripts/test-patch-core.sh`。`patch_core.cpp / archive_patch_core.cpp / hbc_transform*.cpp / digest.cpp / state_core.cpp` 及其 29 项测试**零 CI 覆盖**,而这正是消费不可信补丁/归档字节的代码。本地 `SANITIZE=1 ./scripts/test-patch-core.sh` 已通过,直接加一步即可。 +**4.4(P2)STL 分配抛出的 C++ 异常穿越 JNI/NAPI/ObjC 边界**(部分修复) +Android 已兜住:`Application.mk` 改 `-fexceptions`,每个 JNI 入口经 `jni_util::GuardJni` / catch-all 转成 Java 异常或 null。 +剩余:鸿蒙 `harmony/pushy/src/main/cpp/pushy.cpp` 的 NAPI 回调无 C++ `try/catch`,`bad_alloc/length_error` 会 unwind 进 NAPI;iOS `.mm` 只有 `@try/@catch(NSException*)`,抓不到 C++ 异常。4.5 的输入上限压低了可达性,但入口仍应 `catch (...)` 转成干净错误。另:`Application.mk` 仍带 `-fno-unwind-tables -fno-asynchronous-unwind-tables`,宜确认与 `-fexceptions` 同时使用时 unwind 正常。 -**4.4 Android 以 `-fno-exceptions` 编译,但 STL 分配可能抛 → `std::terminate` 穿越 JNI/NAPI/ObjC 边界** -`android/jni/Application.mk:43`。`cpp/` 内无任何 `try/catch`,flow parser 与 `std::string/vector` 按输入大小自由分配。Android 上 `bad_alloc/length_error` 在 `-fno-exceptions` 下直接 terminate(硬崩溃,无 Java 异常、无 promise 拒绝);鸿蒙 CMake 没设 `-fno-exceptions`,C++ 异常会 unwind 进无处理的 NAPI 回调;iOS 的 `@try/@catch(NSException*)` 抓不到 C++ 异常。结合 4.5 可达。修法:每个 JNI/NAPI/ObjC 入口包 `try/catch(...)` 转成干净错误(这些 TU 去掉 `-fno-exceptions`),或保证分配有界。 +**4.12(P3)对等性:`buildCheckFingerprint` 无 C++ 移植**(部分修复) +`IsMirrorRetryableCode` 已移植并进向量,`paths:null` 已有向量。`buildCheckFingerprint`(`src/updateFlowCore.ts`)仍只有 TS 实现,各原生编排器各自重实现了等价的去重逻辑——对等性框架抓不到这类漂移。 -### P3 - -- **4.5** `flowjson::Parse`(`flow_json.cpp:621-624`)无总输入长度/节点数上限(HBC wire parser 有 64KB 上限)。`sizeof(Value)=96`,实测 1MB `[0,0,...]` 膨胀到 ~100MB RSS;Android 编排器 `response.body().string()` 无限长直接喂给它(见 2.7)。加 max-input-bytes 守卫。 -- **4.6** `flow_json.cpp:605 strtod` / `:244 "%.17g"` 受 `LC_NUMERIC` 影响:宿主进程 `setlocale` 到逗号小数区域后 `strtod("1.5")` 停在 `.` → 整个合法响应被拒;`%.17g` 会输出 `1,5`。用 `strtod_l/snprintf_l` 固定 C locale 或手写十进制。 -- **4.7** Murmur3 在 C++ 里哈希 UTF-8 字节(`update_flow_core.cpp:106-112`),TS 参考实现哈希 `charCodeAt & 0xff`(`updateFlowCore.ts:29-32`);非 ASCII key 下 `IsInRollout` 分桶不一致。今天 key 是 SDK 生成的 ASCII uuid,属潜在项,但金标向量只测 ASCII。按契约限定 ASCII 并断言,或让 C++ 读解码后码点低字节。 -- **4.8** 孤立代理项 `\u` 转义"成功"解析并输出非法 UTF-8(`flow_json.cpp:481-495`),与 `JSON.stringify` 行为不一致,向量未覆盖;且把非法 UTF-8 推进 `napi_create_string_utf8`。跟踪"曾是代理项"状态,拒绝或归一到 U+FFFD。 -- **4.9** HBC 变换补丁路径把整个 bundle 读进内存两次(`patch_core.cpp:554-599`:`ReadFileBytes(origin)` → 变换 → 写临时 → hpatch → `ReadFileBytes(temp_patched)` → 变换 → 写),非变换路径是端到端流式。10–50MB 包在低内存设备上是真实开销;考虑 mmap 临时文件原地变换。 -- **4.10** LZMA 解码器无字典大小上限:`decompress_plugin_demo.h` `_lzma_open/_lzma2_open` → `LzmaDec_Allocate(props)` 按补丁流里攻击者可控的 `props` 分配;`hpatch.c` 只限旧文件内存加载(8MB),字典可声明到 4GB。对 `dicSize` 加合理上限(如旧包大小的几倍)。 -- **4.11** bundle/资产写入在两阶段 rename 前不 fsync(`patch_core.cpp:304-319 WriteFileBytes`、`CopyFile`)。安装记录已由平台层 fsync,但 payload 数据块没有;掉电后可能出现"看起来完整"但 `index.bundlejs` 截断的目录。有 `bundleSha256` 激活复验兜底(变成拒绝激活+回滚),仍建议记录写入前 fsync payload。 -- **4.12** 对等性:`updateFlowCore.ts` 的 `buildCheckFingerprint(235-249)` 与 `isMirrorRetryableCode(258-260)` 没有 C++ 移植也不在向量里,各原生编排器各自重实现了等价的重试/去重逻辑——这是对等性框架抓不到的漂移。另 `paths: null` 时 TS `const {paths=[]}` 只默认 `undefined`,`null.map` 抛错;C++ `DecideDownload(update_flow_core.cpp:335-337)` 遍历空 elements 返回 `noArtifact`。补 `paths:null` 与非字符串端点条目的向量。 -- **4.13** 两个不可信输入解析器无 fuzz:`flowjson::Parse(const std::string&, bool*)` 与 `pushy::hbc::ParseHbcTransformMeta` 签名干净无副作用,libFuzzer harness 各 ~10 行,可挂到现有 ASan/UBSan 配置。 -- **4.14** `patch_core.cpp:285 const long size = ftell(file)` 在 armeabi-v7a/x86 上 32 位;用 `fseeko/ftello` 或 `fstat`。 - -### P4 -- 鸿蒙 NAPI 结果对象对空串字段直接省略(`pushy.cpp:322-331`),Android 显式设 `null`(`update_core_android.cpp:170-177`);形状不一致。 -- 测试脚本用 `-Wall -Wextra` 但无 `-Werror`;`cpp/` 内核在 CI 里没有以 warnings 编译(见 4.3)。 -- 已核实正常:`TransformHbcInPlace` 逐字节追踪无 OOB;HBC wire parser 严格有界(64KB、深度 8、字段范围检查);C++17 三端一致;`-fvisibility=hidden` + `--exclude-libs,ALL`;NDK r28 固定,16KB LOAD 段对齐由脚本强制;NAPI 异步管线在每条失败路径都 settle promise。 +**P4 鸿蒙 NAPI 结果对象对空串字段直接省略** +`pushy.cpp` `SetStringProperty` 跳过空串,Android(`update_core_android.cpp` `SetStringField`)显式设 `null`;两端结果对象形状不一致。 --- ## 5. 鸿蒙层(harmony/) -### 对 6e9ae76 声明的核实 -| 声明 | 结论 | -|---|---| -| "Harmony single flush" | 提交路径成立(`commitNativeCheckResult` 用 `beginFlushBatch/endFlushBatch` 包住,`UpdateContext.ts:206-244,521-548`)。但见 5.4:`flushSync` 缺失时退化为异步 best-effort;batch 内 `switchVersion` 抛错时内存里已 `setKv` 的 hash 信息仍会被 `finally` 落盘,不是"要么全有要么全无"。 | -| 归档限额 | 部分。归档大小上限在前置和流式兜底里都有;条目数/条目大小/总大小在 `zlib.decompressFile` **之后**才量(见 5.9);`MAX_COMPRESSION_RATIO / RATIO_CHECK_MIN_BYTES`(`ArchiveLimits.ts:10-11`)导出但从未使用。 | -| NAPI 严格响应校验 | 成立(`pushy.cpp:1082-1096` → `IsValidCheckResult`,`NativeCheckOrchestrator.ts:420,458` 逐端点使用)。 | -| FOLLOWUPS 2026-08-13 功能性断裂 | 已修(`PushyTurboModule.cpp:106-111` 注册了全部 10.50+ 方法含 `markJsCheckCompleted`)。仍开放:鸿蒙 e2e 无 native-check 场景;`Logger.ts:52` 仍硬编码 `isDebug=false`。 | - -### P1 - -**5.1 只改 `cpp/` 或 `android/jni/` 时 publish 复用陈旧 HAR** -`.github/workflows/publish.yml:48-54,173-186`。`check_changes` 用 `git diff --name-only $PREV_TAG HEAD | grep -q '^harmony/'` 判定,否则 `publish_without_harmony` 从 npm 上一版取 `pushy.har`。但 HAR 里的 `librnupdate.so` 由 `cpp/patch_core/*`、`cpp/update_flow_core/*`、`android/jni/hpatch.c`、HDiffPatch、lzma 编译(`CMakeLists.txt:31-46`;`build-harmony-har.js:186-250` 正是 staging 这些)。一次只修 state_core / update_flow_core / hpatch 的发版,Android/iOS 拿到修复而鸿蒙带着旧决策逻辑和旧 patcher 出去——正是为 Android 加 `.so` 重建所防的那类静默分叉。 -修法:`grep -qE '^(harmony|cpp|android/jni)/|^scripts/build-harmony-har\.js$'`;submodule 指针变化在 `android/jni/` 下同样以路径形式出现。 - -**5.2 三个 async 方法在 C++ 方法表注册为 SYNC,拒绝到不了 JS** -`PushyTurboModule.cpp:51-53` 用 `PUSHY_SYNC_METHOD` 注册 `setUuid/setNeedUpdate/markSuccess`,ArkTS 实现却是 `async`(`PushyTurboModule.ts:191,246,259`)。同步调用把 ArkTS 返回值(一个 Promise,无自有属性的对象)转成 jsi 值;async 体内的抛错(如 `switchVersion` → "Bundle version X is incomplete." / "bundle digest mismatch.",`UpdateContext.ts:668-678` 的 SR-2 检查)变成 ArkTS 侧 unhandled rejection 而非 JS 异常。JS(`src/client.ts:1063-1072`)于是把 `setNeedUpdate` 当成功,报告切换成功并告诉用户重启后生效——实际什么都没切。运行时验证:`PushyModule.setNeedUpdate({hash:'does-not-exist'})` 应 reject;若 resolve 即确认。 -修法:改为 `PUSHY_ASYNC_METHOD`(JS spec 就是 Promise);加一个不依赖 SDK 的 Node 测试,解析 `NativePushy.ts`、`PushyTurboModule.ts`、`PushyTurboModule.cpp`,断言每个 Spec 方法都在 C++ 表里且 sync/async 种类匹配——该表已经咬过一次(10.51 三个版本冷启动检测全灭)。 - -### P2 - -- **5.3** `DownloadTask.ts:788-796` `readTimeout: 60000`。OpenHarmony netstack 把 `readTimeout` 传给 libcurl 的 `CURLOPT_TIMEOUT_MS`(整次传输总时长)而非空闲超时;代码自己按空闲语义设计(另有 60s 不活动看门狗 `:657-681` 与 10 分钟整轮 deadline `:113,566-568`)。若映射如此,超过 60s 的传输全部失败,续传掩盖了它(每次重试多拿 60s 的字节),但 40–50MB 全量包在慢网下 3 次重试仍会失败。修法:`readTimeout = max(1, deadlineUptimeMs - now)`,靠已有看门狗管卡死;两种语义下都正确。 -- **5.4** 持久化在 `flushSync` 不可用时是 best-effort 异步(`UpdateContext.ts:223-244`),HAR 兼容 `compatibleSdkVersion 5.0.0(12)` 而 `flushSync` 是 API 14 才有;`reloadUpdate`(`PushyTurboModule.ts:223-234`)在 `switchVersion` 后立即 `restartApp` 杀进程,切换可能丢失、重启到旧 bundle。这也是 SR-5 在鸿蒙不成立的原因(Android 是 `persistEditorOrThrow`)。修法:有 promise 的操作在 `flushSync` 缺失时 `await preferences.flush()` 并让失败拒绝 promise;`commitNativeCheckResult` 里把 switch 校验放到第一个 `putSync` 之前。 -- **5.5** 整包/整 bundle SHA-256 在 UI 线程同步执行:`Sha256HexFile` 是普通同步 NAPI(`pushy.cpp:902-916`),从 ArkTS 主线程对下载归档(上限 512MB)调用于 `DownloadTask.ts:976,999,1067`、对最终 bundle `:175`、且每次激活 `verifyInstallForActivation`(`InstallRecord.ts:125` ← `UpdateContext.ts:673`)再来一次。30–50MB 全量包 = 数百 ms 冻结。`applyPatchFromFileSource/cleanupOldEntries` 已因同样原因搬到 `napi_create_async_work`(`pushy.cpp:635-643`)。修法:加 `sha256HexFileAsync`,或在 `transferArchive` 写入队列里边写边算 digest 免去第二遍。 -- **5.6** 鸿蒙原生几乎没有稳定错误码:仅 `DownloadTask.ts:328,989,1057` 设 `PATCH_FAILED`,其余抛裸 `Error`(空 hash `PushyTurboModule.ts:55-60` 应为 `INVALID_OPTIONS`、`downloadAndInstallApk:329` 应为 `UNSUPPORTED_PLATFORM`、`reloadUpdate/setNeedUpdate/resetToPackagedBundle` 重包并丢上下文 `:232,255,287`、`getLocalHashInfo` 应为 `INVALID_HASH_INFO`)。且 RNOH 异步桥以纯字符串 reject,`PATCH_FAILED` 标记很可能也丢,JS `toUpdateError(e,'DOWNLOAD_FAILED')` 把每次 pdiff/diff 应用失败在 patch-health 遥测里记成网络失败。修法:加 `ErrorCodes.ts` 镜像并在每个 throw 点附码;若 RNOH 丢 `code`,消息前缀 `[PATCH_FAILED] …` 并在 JS 鸿蒙分支解析。 -- **5.7** https→http 降级在鸿蒙未拒绝(SR-1 对等缺口):`NativeCheckOrchestrator.ts:347-390`、`DownloadTask.ts:788-796` 用 `@ohos.net.http`,它内部跟随重定向且既无开关也不暴露最终 URL。commit message 把 SR-1 限定在 Android/iOS 不算虚报,但设计文档"no https->http downgrade anywhere"对鸿蒙不成立。修法:至少文档要求消费者 `network_config.json` 设 `cleartextTrafficPermitted:false`;配置端点为 https 时拒绝决策里的 `http:` URL;可接受 HMS-only 的话 RCP(`autoRedirect=false` + effective URL)能完全关闭。 -- **5.8** 必需 CI 门里没有任何鸿蒙检查:`check-harmony-types.js:52-66` 无 DevEco SDK 时 exit 0(托管 runner 上永远如此),即便跑也只含 `.ts` 不含 `.ets`(`:86`);`harmony/pushy/src/test` 的 hypium 测试没有任何 workflow 跑;`e2e_harmony.yml` 仅手动且被 `native-gate.yml` 排除;HAR 只在发布时于固定容器里编译。`.ets` 类型错误、坏的 NAPI 导出、10.51 那类方法表断裂都要到发版才看见。修法:`test.yml` 加 `harmony-build` job,用同一容器 digest 跑 `check-harmony-types.js`(`HARMONY_TYPECHECK_REQUIRED=1` 时失败而非跳过)和 `build:harmony-har -- --build-mode release`(`assembleHar` 里的 ArkTS 编译器才是 `.ets` 的真正类型检查);把纯逻辑测试搬到 Node(5.15)让每个 PR 都跑。 - -### P3 - -- **5.9** 归档限额在解压之后才生效(`DownloadTask.ts:337-349`:`ensureFreeSpace(size*2)` → `zlib.decompressFile` → `measureExtractedDirectory`):20MB 归档 100:1 载荷通过 40MB+64MB 空间检查后先解出 2GB 再被拒。Android 先看中央目录(`SafeZipFile.inspect`)含压缩比。路径穿越依赖平台 zip 读取器拒绝 `..`/绝对名(代码里无断言;manifest `copies/deletes` 路径在 C++ `ValidateManifestImpl` 里验证,所以 `copyFromResource` 的拼接是安全的)。修法:解压前 `zlib.getOriginalSize(archiveFile)`(API 12)超过 `MAX_TOTAL_UNCOMPRESSED_BYTES` 或比值超 `MAX_COMPRESSION_RATIO` 即拒绝。 -- **5.10** `cleanUp()`(`UpdateContext.ts:784-798`,`markSuccess/clearFirstTime/clearRollbackMark/syncStateWithBinaryVersion` 调用)绕过串行任务链直接在 libuv worker 跑 `cleanupOldEntries`,而下载可能正在追加 >3 天的续传 partial 或填充 staging(`CleanupOldEntries` 按 mtime)。Android 与下载同一单线程 executor。修法:`enqueueSerialTask(() => NativePatchCore.cleanupOldEntries(...))`。 -- **5.11** `forceBootRescue` 标记缺失:`NativeCheckOrchestrator.ts:278-293` 只拷 `name/description/metaInfo`;Android(`NativeCheckOrchestrator.java:380-383`)和 iOS(`RCTPushy.mm:2228`)在 `config.forceBoot` 时写 `forceBootRescue:true`,`src/client.ts:1013-1014` 据此在 `markSuccess` 上报回执。鸿蒙的救砖永远不被记为救砖。 -- **5.12** 无可观测性:`Logger.ts:52` 出厂 `isDebug=false`,编排器里程碑除 "skipped" 外全是 `logger.debug`(`NativeCheckOrchestrator.ts:227,250,313-317,547`),Android 是 `Log.i/w`;`UpdateContext.DEBUG(:39)` 硬编码 false 未接 `BuildProfile.DEBUG`。轮次生命周期日志提到 `info/warn`;`DEBUG` 从 `BuildProfile` 派生。 -- **5.13** HAR 版本标识陈旧:`harmony/pushy/oh-package.json5:10` `version: '10.35.1'`,`package.json` 是 10.55.1;`check-release-version.js` 不看它。消费者 `oh-package-lock.json5` 和诊断无法区分装的是哪个 HAR。让 `build-harmony-har.js` 在 `assembleHar` 前从 `package.json` 重写版本,并扩展 `check-release-version.js` 断言相等。 -- **5.14** `hvigor-plugin.ts:7-31` 在 `apply()`(每次 hvigor 调用含 IDE sync)里跑,硬编码 `entry/` 和 `process.cwd()`,用 `Date.now()` 改写消费者源码树里的 `rawfile/meta.json`(git 噪音;模块名不是 entry 时写错位置);debug 构建也拿真实时间戳,每次 debug 构建都像新二进制(Android debug 输出 `0`)。改挂到构建任务、从 hvigor 节点上下文取模块路径、debug 输出 `0`。 -- **5.15** 测试:已覆盖 `parseManifestToArrays/isSafePathComponent/validateHashInfo/getErrorMessage/DownloadTaskParams`(`EventHub` 的 on/off 测试断言为空,因为 `emit` 不看监听器)。未覆盖的纯逻辑:`parseContentRangeTotal`(专为测试导出)、`buildInstallRecord/isInstallComplete/verifyInstallForActivation`、`readString/readBoolean`、`normalizeEndpointBase`、`buildResponseCacheJson`、flush-batch 深度语义、以及最有价值的:`ArchiveLimits.ts/InstallRecord.ts` 与它们声称"手工镜像"的 C++ 头文件之间的漂移。修法:JS 套件里加一个 Node 测试解析 `archive_limits.h/install_record.h/error_codes.h` 与 ArkTS 镜像并断言常量相等。 - -### P4 -- `EventHub.ts:19-34` `listeners` map 从未被 `emit` 使用;`rnInstance: any` 只在构造新 TurboModule 时替换,已销毁的 RNInstance 仍被引用。 -- 死代码/重复:`persistState({clearExisting})`(`UpdateContext.ts:290-292`)无调用且未 await `preferences.clear()`;`downloadFile()`(`:571-583`)无 JS 入口;`VERSION_COMPLETE_FILE_NAME`(`DownloadTask.ts:28`)重复 `INSTALL_RECORD_FILE_NAME`;`removeDirectory(:252-277)` 重实现递归 `fileIo.rmdir`;`transferArchive` ~420 行 15 个闭包捕获的可变局部;`doPatchFromApp*/doPatchFromPpk*` 共享同样的下载→staging→digest→解压→plan 脚手架;`console.*` 与 `logger` 混用。潜在 unhandled rejection:`writeError` 在 `dataEnd` 已触发后才设置(`:863-866`),`dataEndPromise` 链(`:686-712`)无订阅者地 reject。 -- `pushy.cpp` 小项:`Sha256HexFile` 参数非字符串时抛两次(`:910-914`);`FlowIsValidCheckResponse` 在异常 pending 时返回 boolean(`:1090-1095`);`CMakeLists.txt:75-78` `--gc-sections` 没配 `-ffunction-sections -fdata-sections`(无效),无 `-fvisibility=hidden`。 -- `rejectReservedEntries`(`DownloadTask.ts:315-330`)只查顶层精确 `.pushy-complete`,Android 拒绝任意深度 `.pushy-` 前缀。 -- `module.json5` 未声明 `requestPermissions`,`ohos.permission.INTERNET` 需消费者声明但仓库无文档。 -- `.ts` 源大量使用 ArkTS 禁止的写法(解构 `DownloadTask.ts:63,1146`、`Logger.ts:31`;Set 展开 `:1125-1129`;`any`;交叉类型 `UpdateContext.ts:31-33`;动态属性赋值 `error.code=`)——`tsc --strict` 不会告警,迁移到 `.ets`/严格 ArkTS 时全部暴露。在 5.8 的容器里跑 DevEco `codelinter` 能拿到真实清单。 - -已核实正常:回滚/first-time 记账与 Android 镜像(含 `ignoreRollback` 与循环守卫);`resetGeneration` 对在途轮次的围栏;`taskChain` 串行化与开跑时去重;`transferArchive/httpRequest/runWithinDeadline` 的计时器全部清理;服务端控制的路径分量在触盘前已验证;激活时的安装记录 digest 校验存在;不做 crash hold 由"RNOH 未捕获 JS 错误不杀进程"的模拟器实证支撑。 +- **6e9ae76 声明核实**:single flush(激活校验已前移到第一个 `putSync` 之前)、归档限额(解压前检查原始大小与压缩比)、NAPI 严格响应校验、FOLLOWUPS 2026-08-13 方法表断裂与 `Logger isDebug` — 均已修 6a7f4163;鸿蒙 native-check e2e 仍开放,见 `docs/NATIVE_CHECK_FOLLOWUPS.md` +- **5.1(P1)** 只改 `cpp/`/`android/jni/` 时 publish 复用陈旧 HAR — 已修 1e46ee06(`publish.yml` 变更检测含 `cpp|android/jni|build-harmony-har.js|.gitmodules`) +- **5.2(P1)** 三个 async 方法注册为 SYNC — 已修 6a7f4163(`PUSHY_ASYNC_METHOD` + `scripts/check-native-spec-parity.js`) +- **5.3(P2)** `readTimeout` 语义是整次传输 — 已修 6a7f4163(绑定剩余 deadline) +- **5.4(P2)** `flushSync` 缺失时持久化 best-effort — 已修 6a7f4163(await `preferences.flush()`,失败拒绝) +- **5.5(P2)** 整包 SHA-256 在 UI 线程 — 已修 6a7f4163(只剩 `Sha256HexFileAsync`) +- **5.6(P2)** 鸿蒙几乎无稳定错误码 — 已修 6a7f4163(`ErrorCodes.ts` + `[CODE] msg` 前缀,JS `readErrorCode` 解析) +- **5.9(P3)** 归档限额在解压之后才生效 — 已修 6a7f4163(`zlib.getOriginalSize` 前置) +- **5.10(P3)** `cleanUp()` 绕过串行任务链 — 已修 6a7f4163 +- **5.11(P3)** `forceBootRescue` 标记缺失 — 已修 6a7f4163 +- **5.12(P3)** 无可观测性 — 已修 6a7f4163(里程碑 info/warn;DEBUG 取宿主 `isDebugModeEnabled`,HAR 内无法引用 `BuildProfile`) +- **5.13(P3)** HAR 版本标识陈旧 — 已修 6a7f4163(`build-harmony-har.js` 同步版本,`check-release-version.js` 断言) +- **5.14(P3)** `hvigor-plugin.ts` 每次调用改写消费者源码 — 已修 6a7f4163(只在 assemble 时由注册任务写,debug 输出 `0`) +- **P4**(EventHub、死代码、`removeDirectory`、`doPatchFrom*` 重复、`console.*`、`dataEnd` 未处理拒绝、`pushy.cpp` 小项、CMake 段/可见性、`.pushy-` 任意深度、INTERNET 权限文档)— 已修 6a7f4163 + +### 仍开放 + +**5.7(P2)https→http 降级**(部分修复) +已做:配置端点全为 https 时 `SyncCoordinator.ts` 丢弃非 https 远程端点与 `http:` 产物 URL;`harmony/pushy/src/README.md` 要求消费者 `network_config.json` 设 `cleartextTrafficPermitted:false`。 +剩余:`@ohos.net.http` 内部跟随重定向且不暴露最终 URL,https→http 的重定向降级仍可能发生;可接受 HMS-only 的话 RCP(`autoRedirect=false` + effective URL)能完全关闭。 + +**5.8(P2)鸿蒙 CI 门**(部分修复) +已做:`harmony-build.yml` 在固定容器里做类型检查(`HARMONY_TYPECHECK_REQUIRED=1` 时失败)与 release HAR 编译,并进 `native-gate`。 +剩余:`harmony/pushy/src/test` 的 hypium 测试没有任何 workflow 跑;`e2e_harmony.yml` 仅手动,不在门内(无托管鸿蒙 runner)。 + +**5.15(P3)测试**(部分修复) +已覆盖:ContentRange、InstallRecord、ErrorCodes、ArchiveLimits、NativeConfig、EventHub;C++ 头文件 ↔ ArkTS/Java/JS 镜像的常量对等由 `scripts/check-constant-parity.js`(`bun lint`)断言。 +未覆盖:`readString/readBoolean`、`normalizeEndpointBase`、`buildResponseCacheJson`、flush-batch 深度语义。 + +**§5 P4 余项** +- `transferArchive`(`DownloadTask.ts`)仍约 470 行、大量闭包捕获的可变局部,未拆。 +- ArkTS 禁止写法已大部分清除,仍有 `Record`(`DownloadTask.ts`);DevEco `codelinter` 未接入 CI。 --- ## 6. CI / 发布 / 打包 / 仓库卫生 -- **6.1(P2)测试覆盖缺口汇总**:`patch_core` 测试不在 CI(4.3);鸿蒙不编译不类型检查(5.8);`native-gate` 的 e2e 只在路径过滤命中时才要求,`e2e-harmony` 永远不在门内。 -- **6.2(P1,同 5.1)** publish 的 HAR 变更检测只看 `harmony/`。 -- **6.3(P3)根 package.json 里的死依赖与死脚本**:`firebase-tools`、`jest`、`ts-jest`、`@types/jest`、`detox`、`pod-install` 在根目录无任何使用(detox/pod-install 由 `Example/e2etest` 自己的 lock 提供;测试跑的是 `bun test`);`tests:emulator:prepare / tests:emulator:start-ci / tests:packager:jet-ci / tests:ios:pod:install` 脚本和整个 `.github/workflows/scripts/`(firebase emulator、functions、rules)是模板残留,仓库里没有任何 workflow 引用;根 `e2e/starter.test.js` 测的是不存在的 welcome/hello 元素。清掉后 `bun install` 少装 ~30MB,lockfile 与 dependabot 面都小。 -- **6.4(P3)仓库里的大二进制**:`Example/harmony_use_pushy/debug.png`(761KB)、`demo.png`(345KB)在 git 历史里;`android/lib/*/librnupdate.so` 每次重建都是 4×~290KB 的新 blob(历史里已有 3 代)。发布已改为 CI 重建(publish.yml),可以考虑不再提交 `.so`(本地开发走 `bun build:so`,或 git LFS)。 -- **6.5(P3)README 与公开 API**:`testChannel / healthCheck / autoMarkSuccessDelayMs / resetToPackagedBundle / getUpdateMetadata / attachToSentry / maxRetries / beforeReload / onPackageExpired / overridePackageVersion / dismissErrorAfter / useUpdateProgress / onError` 在两份 README 里均无提及(只有外站文档链接)。至少在 README 里放一张 options 索引表指向文档页。 -- **6.6(P4)设计文档**:`NATIVE_CHECKUPDATE_DESIGN.md`(36KB)、`NATIVE_CHECK_FOLLOWUPS.md`(31KB)、`BUNDLEHASH_MIGRATION.md`(25KB)都在仓库根;FOLLOWUPS 里大量已关闭项与原始描述并存。建议移到 `docs/`,FOLLOWUPS 只保留开放项表。(2026-10-12:四份设计文档已移到 `docs/`;FOLLOWUPS 精简未做。) -- **6.7(P4)方法表对等性**:本次核对 16 个 Spec 方法在 oldarch/newarch/iOS/鸿蒙 C++/鸿蒙 ArkTS 五处全部注册(好)。但没有自动化守护,鸿蒙已经断过一次;见 5.2 的脚本建议。另 iOS 未导出 Spec 声明的 `isUsingBundleUrl` 常量(Android/鸿蒙有;JS 侧无人使用)——要么 iOS 补上,要么从 Spec 删掉。 -- **6.8(P3)安全模型说明**:三端均无 bundle 签名校验(grep 无 signature/ed25519/publicKey),完整性完全依赖 TLS + 服务端下发的 hash;端点列表又可由 gitee/jsdelivr 上的 `endpoints.json` 远程注入(`getRemoteEndpoints` 只要求是字符串,不要求 https)。这是既定信任模型,但两处低成本加固值得做:远程端点列表只接受 `https://`;长期看可加可选的离线签名(app 内置公钥、服务端签 manifest),对自托管/http 部署尤其有价值。 - ---- - -## 8. 处理状态(2026-09-02,同分支后续提交) - -全部条目已按层落地,提交:`2d21709`(CI/发布/脚本/README/C++ keep 列表)、`604ef60`(JS)、`4efa5f9`(iOS)、`36ddfc2`(Android)、`b56ecd2`(鸿蒙)、`f40d28d`(C++ 内核与 `.so` 重建)。 - -| 层 | 已完成 | 明确未做 / 需人工跟进 | -|---|---|---| -| JS | §1 全部(含 P4)、6.3、6.8、FOLLOWUPS 7/8/9;单测 222 → 273 | `require('../package.json')` 改生成 `version.ts`(收益小,保留) | -| Android | 2.1–2.13 与 P4 全部;新增 25 项纯 JVM JUnit | 无法在本环境跑 Gradle/R8:`build.gradle` 与 `proguard.pro` 需在一次 `minifyEnabled true` 的 release 构建里确认 diff/pdiff 仍可应用 | -| iOS | 3.1–3.12 与 P4 全部;`PrivacyInfo.xcprivacy` 随 pod 发布 | 3.13 文件拆分、3.14 逻辑下沉 C++ 未做(无 Xcode 无法验证大重构);两处改动需真机/模拟器回归:defaults key 迁移、`use_frameworks!` 下 `buildTime` 回退 | -| C++ | 4.1–4.14 与 P4 全部;`.so` 已用 NDK 28.2.13676358 重建 | `buildCheckFingerprint` 未移植到 C++(原生各端未复用它) | -| 鸿蒙 | 5.2–5.15 与 P4 全部;`oh-package.json5` 版本对齐 | 5.7 的 RCP 重定向控制(HMS-only)未做;HAR 内无法引用 hvigor 生成的 `BuildProfile`(首次 CI 编译已证实),DEBUG 只取宿主 `isDebugModeEnabled`;`transferArchive` 大函数未拆 | -| CI/发布 | 4.3、5.1、5.8、6.1、6.2、6.5、6.7 全部;新增 `check-native-spec-parity.js`、`harmony-build.yml`、`FUZZ=1` 步骤 | 6.4(不再提交 `.so` / 迁 LFS)与 6.6(设计文档搬家)未做,属流程决策;`harmony-build` 首次运行时若容器未暴露 `DEVECO_SDK_HOME`,type-check 步骤会失败并提示 | - -发版前提醒:鸿蒙 HAR 由 publish 在容器内重建;Android `.so` 已随本分支提交。iOS 的 `PrivacyInfo.xcprivacy` 与 defaults key 迁移、Android 的 old-arch Promise 签名属于行为变化,发版说明需提及。 - -## 7. 建议的执行顺序 - -**第一批(小改动、高收益,可一个 PR)** -1. `IsSafeRelativePath` 拒绝控制字节(4.1)+ 递归深度上限(4.2)+ `flowjson::Parse` 输入上限(4.5)。 -2. `publish.yml` 变更检测扩到 `cpp/ android/jni/ scripts/build-harmony-har.js`(5.1)。 -3. 鸿蒙三个方法改 ASYNC 注册 + spec/方法表一致性脚本(5.2)。 -4. `test.yml` 加 `SANITIZE=1 ./scripts/test-patch-core.sh`(4.3)。 -5. `core.ts` web 常量(1.1);`readNativeCheckCache` 加 schema 门(1.5);`setOptions` 应用 locale(1.3);`getRemoteEndpoints` 要求 `ok`(1.4)。 -6. iOS:随包发 `PrivacyInfo.xcprivacy`(3.1);`PushyUnzipGuard` 拒绝 symlink(3.3);`__diff.json` 解析加类型检查(3.4);下载前置大小/空间上限(3.5);SSZipArchive 固定 `~> 2.4`(3.11)。 +- **6.1(P2)** 测试覆盖缺口 — 已修 1e46ee06(patch_core + fuzz 进 `test.yml`、`harmony-build` 进门);`e2e_harmony` 仍不在门内,见 5.8 +- **6.2(P1)** publish 的 HAR 变更检测只看 `harmony/` — 已修,同 5.1 +- **6.3(P3)** 根 package.json 死依赖/死脚本、firebase 残留、根 `e2e/` — 已修 a60809b3 +- **6.5(P3)** README 无 options 索引 — 已修 1e46ee06(两份 README 的 Options reference) +- **6.6(P4)** 设计文档在仓库根、FOLLOWUPS 已关闭项与原文并存 — 已修(2026-10-12 移到 `docs/`;FOLLOWUPS 只留开放项) +- **6.7(P4)** 方法表对等性无自动化守护 — 已修 1e46ee06(`scripts/check-native-spec-parity.js`,`bun lint`);iOS 已导出 `isUsingBundleUrl` +- **6.8(P3)** 远程端点列表不要求 https — 已修 a60809b3;可选的离线签名(内置公钥签 manifest)仍是长期建议 -**第二批(并发与契约)** -7. Android:所有 snapshot→commit 走 `commitLock`,`restartApp` 的 hash/切换搬离 UI 线程(2.1、2.2);oldarch 补 Promise 签名(2.3);JNI 结果类硬 keep + 收窄消费者 proguard(2.4);cleanup keep 集合加 `launchVersion`(2.5)。 -8. iOS:无前缀 defaults key 改名并迁移(3.2);`buildTime` 资源 `bundleForClass:` 回退(3.6);重载路径去掉预消费状态、失败 reject(3.7)。 -9. provider 主 effect 拆分(1.2)+ 用真实 client 的契约测试(1.12)。 -10. 鸿蒙:`readTimeout` 绑到剩余 deadline(5.3)、`flush` 真正 await(5.4)、异步 sha256(5.5)、错误码(5.6)。 +### 仍开放 -**第三批(CI 与卫生)** -11. `harmony-build` PR job(5.8);libFuzzer harness(4.13);Android 纯 JUnit(§2 末);manifest/安装记录/限额累加器下沉 C++(3.14);`RCTPushy.mm` 拆分(3.13)。 -12. 删死依赖/死脚本/firebase 残留(6.3);`react-native-url-polyfill` 替换(1.10);README options 索引(6.5);APK 完整性校验(2.6);构建时脚本改写方式(3.10)。 +**6.4(P3)仓库里的大二进制**(未做,流程决策) +`android/lib/*/librnpushy.so` 仍提交,每次重建都是 4 个新 blob;发布已由 `publish.yml` 在 CI 重建,可考虑不再提交 `.so`(本地开发走 `bun build:so`,或 git LFS)。`Example/harmony_use_pushy/debug.png`、`demo.png` 仍在仓库里。 From 2b789678267e657172173bde6eb12c7323d02c47 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 15:11:51 +0800 Subject: [PATCH 054/104] build(android): drop two proguard rules nothing needs any more ReactReloadManager no longer matches the Expo host delegate by class name, so its -keepnames rule goes; the field names it probes stay covered by the ReactHostDelegate member rule. UpdatePackage is always constructed directly by the host, so it may be renamed. Verified with an R8-minified release build of Example/e2etest (RN 0.85): dexdump shows the JNI result classes with ()V and every field, all native methods under their names, UpdateModule/NativePushySpec with every spec method, DefaultReactHostDelegate.jsBundleLoader; the app started on an emulator, read the module constants, reset and checked. Co-Authored-By: Claude Opus 5.5 --- android/proguard.pro | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/android/proguard.pro b/android/proguard.pro index 33955d08..6022e473 100644 --- a/android/proguard.pro +++ b/android/proguard.pro @@ -13,9 +13,12 @@ native ; } -# Instantiated by React Native / Expo / the system by name. +# Instantiated or called by name: the module's methods by the bridge / +# TurboModule JNI (not every React Native version ships a consumer rule +# keeping NativeModule members), the receiver by the system, Expo's package +# by Expo. +# UpdatePackage needs no rule: hosts construct it directly. -keep class cn.reactnative.modules.update.UpdateModule { *; } --keep class cn.reactnative.modules.update.UpdatePackage { *; } -keep class cn.reactnative.modules.update.PackageInstallerStatusReceiver { *; } -keep class expo.modules.pushy.** { *; } @@ -72,5 +75,3 @@ -keep class **.BuildConfig { public static boolean IS_NEW_ARCHITECTURE_ENABLED; } - --keepnames class expo.modules.ExpoReactHostFactory$ExpoReactHostDelegate { *; } From d4c78a631db90564d6ecdae411c1d8b01ce553c4 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 15:14:58 +0800 Subject: [PATCH 055/104] refactor(ios): Foundation-only common header for shared internals - RCTPushyCommon.h (private, Foundation-only, so RCTPushyConfiguration.mm and RCTPushyDownloader.mm can use it without React): PUSHY_HIDDEN, one error domain + code key constant instead of three "cn.reactnative.pushy" literals, and the shared helpers below. - RCTPushyRevealText now wraps pushy::text::Reveal (cpp/patch_core/obscured_text.h) instead of duplicating the decoder; it and RCTPushyQueryPath move from the public RCTPushyConfiguration.h to the private header (pod-internal, hidden). - Configuration errors use error_codes.h kInvalidOptions. - RCTPushyIsHttpsDowngrade: one redirect rule for the check session and the downloader. RCTPushyDiscardArchive: archive + resume sidecar removal; the downloader's file-handle close is one private method. Co-Authored-By: Claude Opus 5.5 --- ios/RCTPushy/RCTPushy.mm | 22 ++++---- ios/RCTPushy/RCTPushyCommon.h | 37 +++++++++++++ ios/RCTPushy/RCTPushyConfiguration.h | 8 --- ios/RCTPushy/RCTPushyConfiguration.mm | 50 +++++------------- ios/RCTPushy/RCTPushyDownloader.mm | 52 ++++++++++--------- ios/RCTPushy/RCTPushyInternal.h | 5 +- ios/RCTPushy/RCTPushyOrchestrator.mm | 12 ++--- react-native-update.podspec | 8 +-- .../ios-purge-restore/purge_restore_test.mm | 5 +- 9 files changed, 100 insertions(+), 99 deletions(-) create mode 100644 ios/RCTPushy/RCTPushyCommon.h diff --git a/ios/RCTPushy/RCTPushy.mm b/ios/RCTPushy/RCTPushy.mm index 1e41ab0c..b687fc89 100644 --- a/ios/RCTPushy/RCTPushy.mm +++ b/ios/RCTPushy/RCTPushy.mm @@ -93,7 +93,6 @@ // on the next launch means the previous process died mid-round (a crash // rescue was truncated): that launch resumes immediately instead of waiting. PUSHY_HIDDEN NSString *const keyNativeCheckIncomplete = @"REACTNATIVECN_PUSHY_NATIVE_CHECK_INCOMPLETE_KEY"; -static NSString *const PushyErrorDomain = @"cn.reactnative.pushy"; // file def static NSString * const BUNDLE_FILE_NAME = @"index.bundlejs"; @@ -124,10 +123,9 @@ static NSString * const VERSION_COMPLETE_FILE_NAME = @VERSION_COMPLETE_FILE_NAME_LITERAL; // error def — messages are human-readable; the stable cross-platform codes -// live in cpp/patch_core/error_codes.h and travel in PushyErrorCodeKey. +// live in cpp/patch_core/error_codes.h and travel in RCTPushyErrorCodeKey. static NSString * const ERROR_OPTIONS = @"options error"; static NSString * const ERROR_FILE_OPERATION = @"file operation error"; -static NSString * const PushyErrorCodeKey = @"PushyErrorCode"; static NSString *PushyCode(const char *code) { return [NSString stringWithUTF8String:code]; @@ -370,11 +368,11 @@ - (void)zipArchiveDidUnzipFileAtIndex:(NSInteger)fileIndex @end static NSError *PushyDownloadDeadlineExpiredError(void) { - return [NSError errorWithDomain:PushyErrorDomain + return [NSError errorWithDomain:RCTPushyErrorDomain code:-1 userInfo:@{ NSLocalizedDescriptionKey: @"download deadline expired before start", - PushyErrorCodeKey: PushyCode(pushy::error_codes::kDownloadFailed), + RCTPushyErrorCodeKey: PushyCode(pushy::error_codes::kDownloadFailed), }]; } @@ -547,11 +545,11 @@ PUSHY_HIDDEN void PushyWithStateLock(void (NS_NOESCAPE ^block)(void)) { } static NSError *PushyNSErrorFromStatus(const pushy::delta::Status &status) { - return [NSError errorWithDomain:PushyErrorDomain + return [NSError errorWithDomain:RCTPushyErrorDomain code:-1 userInfo:@{ NSLocalizedDescriptionKey: [NSString stringWithUTF8String:status.message.c_str()], - PushyErrorCodeKey: PushyCode(pushy::error_codes::kDeltaFailed), + RCTPushyErrorCodeKey: PushyCode(pushy::error_codes::kDeltaFailed), }]; } @@ -611,7 +609,7 @@ PUSHY_HIDDEN BOOL PushyIsSafePathComponent(NSString *value) { static void PushyRejectError(RCTPromiseRejectBlock reject, NSError *error) { // Prefer the stable cross-platform code (error_codes.h); fall back to the // numeric NSError code for system errors that were not classified. - NSString *code = error.userInfo[PushyErrorCodeKey]; + NSString *code = error.userInfo[RCTPushyErrorCodeKey]; if (code == nil) { code = [NSString stringWithFormat:@"%ld", (long)error.code]; } @@ -619,11 +617,11 @@ static void PushyRejectError(RCTPromiseRejectBlock reject, NSError *error) { } static NSError *PushyErrorWithCode(const char *code, NSString *message) { - return [NSError errorWithDomain:PushyErrorDomain + return [NSError errorWithDomain:RCTPushyErrorDomain code:-1 userInfo:@{ NSLocalizedDescriptionKey: message ?: @"unknown error", - PushyErrorCodeKey: PushyCode(code), + RCTPushyErrorCodeKey: PushyCode(code), }]; } @@ -1450,7 +1448,7 @@ - (void)fetchPackage:(PushyType)type { [self performFetch:type options:options callback:^(NSError *error) { if (error != nil) { - if (error.userInfo[PushyErrorCodeKey] == nil) { + if (error.userInfo[RCTPushyErrorCodeKey] == nil) { // Unclassified (system/network) errors from the download // pipeline; keep the original message. error = PushyErrorWithCode(pushy::error_codes::kDownloadFailed, @@ -2023,7 +2021,7 @@ - (void)unzipFileAtPath:(NSString *)path unzipError = PushyErrorWithCode(pushy::error_codes::kDeltaFailed, guard.violation); } else if (!succeeded && unzipError == nil) { unzipError = PushyErrorWithCode(pushy::error_codes::kDeltaFailed, @"unzip failed"); - } else if (unzipError != nil && unzipError.userInfo[PushyErrorCodeKey] == nil) { + } else if (unzipError != nil && unzipError.userInfo[RCTPushyErrorCodeKey] == nil) { // SSZipArchive's own NSError (corrupt zip, bad magic, ...) has // no stable code; without one, fetchPackage's fallback would // classify it as DOWNLOAD_FAILED even though the download diff --git a/ios/RCTPushy/RCTPushyCommon.h b/ios/RCTPushy/RCTPushyCommon.h new file mode 100644 index 00000000..ea702175 --- /dev/null +++ b/ios/RCTPushy/RCTPushyCommon.h @@ -0,0 +1,37 @@ +// Internal to the RCTPushy pod and Foundation-only: what every translation +// unit shares, including RCTPushyConfiguration.mm and RCTPushyDownloader.mm, +// which compile without React Native (the purge-restore test host builds +// RCTPushyConfiguration.mm on its own). Not a public header. +#pragma once + +#import + +// Shared across the pod's translation units, never exported from a dynamic +// framework. +#ifndef PUSHY_HIDDEN +#define PUSHY_HIDDEN __attribute__((visibility("hidden"))) +#endif + +// Every NSError this pod creates: the domain, and the userInfo key carrying +// the stable cross-platform code (cpp/patch_core/error_codes.h). +static NSString *const RCTPushyErrorDomain = @"cn.reactnative.pushy"; +static NSString *const RCTPushyErrorCodeKey = @"PushyErrorCode"; + +// Text produced by scripts/encode-native-text.ts, decoded with +// pushy::text::Reveal (cpp/patch_core/obscured_text.h). Keeps service +// addresses, paths and protocol strings out of static string scans of the +// binary; not a secret. +PUSHY_HIDDEN NSString *RCTPushyRevealText(const char *hex); + +// Request path appended to an endpoint base. +PUSHY_HIDDEN NSString *RCTPushyQueryPath(void); + +// Whether following a redirect from `from` to `to` would leave https for +// plaintext http. Every https request (check and artifact) refuses that +// downgrade before the redirected request is sent. +PUSHY_HIDDEN BOOL RCTPushyIsHttpsDowngrade(NSURL *from, NSURL *to); + +// Removes a downloaded archive together with its resume sidecar +// (RCTPushyResumeSidecarPath): nothing may survive to vouch for bytes that +// are gone. +PUSHY_HIDDEN void RCTPushyDiscardArchive(NSString *path); diff --git a/ios/RCTPushy/RCTPushyConfiguration.h b/ios/RCTPushy/RCTPushyConfiguration.h index c7d00150..8231b68c 100644 --- a/ios/RCTPushy/RCTPushyConfiguration.h +++ b/ios/RCTPushy/RCTPushyConfiguration.h @@ -3,11 +3,3 @@ // Validates and snapshots host options without changing any update state. FOUNDATION_EXPORT NSString * _Nullable RCTPushyNormalizeConfiguration( NSDictionary * _Nonnull options, NSError * _Nullable * _Nullable error); - -// Decodes text produced by scripts/encode-native-text.ts (byte i XORed with -// (0x5A + 0x1D * i) & 0xFF), keeping service addresses and paths out of static -// string scans of the binary. Not a secret. -FOUNDATION_EXPORT NSString * _Nonnull RCTPushyRevealText(const char * _Nonnull hex); - -// Request path appended to an endpoint base. -FOUNDATION_EXPORT NSString * _Nonnull RCTPushyQueryPath(void); diff --git a/ios/RCTPushy/RCTPushyConfiguration.mm b/ios/RCTPushy/RCTPushyConfiguration.mm index 793bbbca..1b1c38e9 100644 --- a/ios/RCTPushy/RCTPushyConfiguration.mm +++ b/ios/RCTPushy/RCTPushyConfiguration.mm @@ -1,36 +1,18 @@ #import "RCTPushyConfiguration.h" - -// Read through a volatile so the optimizer cannot fold the decode of a constant -// argument back into a plain string in the binary. -static volatile unsigned char PushyTextKeyBase = 0x5A; +#import "RCTPushyCommon.h" +#include "../../cpp/patch_core/error_codes.h" +#include "../../cpp/patch_core/obscured_text.h" NSString *RCTPushyRevealText(const char *hex) { - size_t length = strlen(hex) / 2; - unsigned char *bytes = (unsigned char *)malloc(length > 0 ? length : 1); - if (bytes == NULL) { - return @""; - } - unsigned char base = PushyTextKeyBase; - for (size_t i = 0; i < length; i++) { - char pair[3] = {hex[i * 2], hex[i * 2 + 1], 0}; - unsigned char byte = (unsigned char)strtoul(pair, NULL, 16); - bytes[i] = byte ^ (unsigned char)(base + 0x1D * i); - } - // One decode into a byte buffer (the texts are ASCII, see - // scripts/encode-native-text.ts) instead of a format call per character. - NSString *text = [[NSString alloc] initWithBytes:bytes - length:length - encoding:NSUTF8StringEncoding]; - free(bytes); - return text ?: @""; + // The texts are ASCII (scripts/encode-native-text.ts). + std::string text = pushy::text::Reveal(hex); + return [[NSString alloc] initWithBytes:text.data() + length:text.size() + encoding:NSUTF8StringEncoding] ?: @""; } NSString *RCTPushyQueryPath(void) { - static NSString *path; - static dispatch_once_t once; - dispatch_once(&once, ^{ - path = RCTPushyRevealText("7514fcd4ad805d55263e08fc99"); - }); + static NSString *path = RCTPushyRevealText("7514fcd4ad805d55263e08fc99"); return path; } @@ -56,13 +38,9 @@ static void PushyConfigInvalid(NSString *message) { name, base ? @"a non-empty" : @"an"]); } NSMutableArray *result = [NSMutableArray array]; - static NSRegularExpression *authority; - static dispatch_once_t once; - dispatch_once(&once, ^{ - authority = [NSRegularExpression regularExpressionWithPattern: - @"^https?://(\\[[0-9a-fA-F:]+\\]|[a-zA-Z0-9.-]+)(:[0-9]+)?([/?#]|$)" - options:0 error:nil]; - }); + static NSRegularExpression *authority = [NSRegularExpression regularExpressionWithPattern: + @"^https?://(\\[[0-9a-fA-F:]+\\]|[a-zA-Z0-9.-]+)(:[0-9]+)?([/?#]|$)" + options:0 error:nil]; for (id item in value) { NSString *url = [PushyConfigString(item, name, NO) stringByTrimmingCharactersInSet: NSCharacterSet.whitespaceAndNewlineCharacterSet]; @@ -133,9 +111,9 @@ static void PushyConfigInvalid(NSString *message) { return data == nil ? nil : [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; } @catch (NSException *exception) { if (error != NULL) { - *error = [NSError errorWithDomain:@"cn.reactnative.pushy" code:1 userInfo:@{ + *error = [NSError errorWithDomain:RCTPushyErrorDomain code:1 userInfo:@{ NSLocalizedDescriptionKey: exception.reason ?: @"Invalid native configuration", - @"PushyErrorCode": @"INVALID_OPTIONS" + RCTPushyErrorCodeKey: @(pushy::error_codes::kInvalidOptions) }]; } return nil; diff --git a/ios/RCTPushy/RCTPushyDownloader.mm b/ios/RCTPushy/RCTPushyDownloader.mm index ceb6bc03..e142303d 100644 --- a/ios/RCTPushy/RCTPushyDownloader.mm +++ b/ios/RCTPushy/RCTPushyDownloader.mm @@ -1,8 +1,7 @@ #import "RCTPushyDownloader.h" +#import "RCTPushyCommon.h" #include "../../cpp/patch_core/archive_limits.h" -static NSString *const RCTPushyDownloaderErrorDomain = @"cn.reactnative.pushy"; - // Cross-launch resumable download (NATIVE_CHECKUPDATE_DESIGN §11.4): a data // task streams the archive straight into savePath (a download task's // temporary file is discarded on failure, which made every partial byte @@ -36,6 +35,16 @@ static void RCTPushyDeleteResumeSidecar(NSString *savePath) { error:nil]; } +void RCTPushyDiscardArchive(NSString *path) { + [[NSFileManager defaultManager] removeItemAtPath:path error:nil]; + RCTPushyDeleteResumeSidecar(path); +} + +BOOL RCTPushyIsHttpsDowngrade(NSURL *from, NSURL *to) { + return [[from.scheme lowercaseString] isEqualToString:@"https"] + && [[to.scheme lowercaseString] isEqualToString:@"http"]; +} + long long RCTPushyFileSize(NSString *path) { NSDictionary *attributes = [[NSFileManager defaultManager] attributesOfItemAtPath:path error:nil]; @@ -174,7 +183,7 @@ - (void)startTransfer { NSURL *url = [NSURL URLWithString:self.urlString]; if (url == nil) { - [self completeWithError:[NSError errorWithDomain:RCTPushyDownloaderErrorDomain + [self completeWithError:[NSError errorWithDomain:RCTPushyErrorDomain code:-1 userInfo:@{ NSLocalizedDescriptionKey: @"invalid download url", @@ -263,13 +272,8 @@ - (void)writeResumeMetaWithResponse:(NSHTTPURLResponse *)response total:(long lo } } -- (void)completeWithError:(NSError *)error +- (void)closeFileHandle { - if (self.finished) { - return; - } - self.finished = YES; - if (self.fileHandle != nil) { @try { [self.fileHandle closeFile]; @@ -277,9 +281,18 @@ - (void)completeWithError:(NSError *)error } self.fileHandle = nil; } +} + +- (void)completeWithError:(NSError *)error +{ + if (self.finished) { + return; + } + self.finished = YES; + + [self closeFileHandle]; if (self.discardPartial) { - [[NSFileManager defaultManager] removeItemAtPath:self.savePath error:nil]; - RCTPushyDeleteResumeSidecar(self.savePath); + RCTPushyDiscardArchive(self.savePath); } void (^completionHandler)(NSString *, NSError *) = self.completionHandler; @@ -297,7 +310,7 @@ - (void)completeWithError:(NSError *)error - (void)failWithDescription:(NSString *)description code:(NSInteger)code { - self.fileError = [NSError errorWithDomain:RCTPushyDownloaderErrorDomain + self.fileError = [NSError errorWithDomain:RCTPushyErrorDomain code:code userInfo:@{NSLocalizedDescriptionKey: description}]; } @@ -317,15 +330,8 @@ - (void)restartFromZero self.encodedBody = NO; self.lastReportedPercentage = 0; self.lastReportedBytes = 0; - if (self.fileHandle != nil) { - @try { - [self.fileHandle closeFile]; - } @catch (NSException *exception) { - } - self.fileHandle = nil; - } - [[NSFileManager defaultManager] removeItemAtPath:self.savePath error:nil]; - RCTPushyDeleteResumeSidecar(self.savePath); + [self closeFileHandle]; + RCTPushyDiscardArchive(self.savePath); [self.session finishTasksAndInvalidate]; self.session = nil; [self startTransfer]; @@ -346,9 +352,7 @@ - (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task completionHandler(nil); return; } - NSString *originalScheme = [[NSURL URLWithString:self.urlString].scheme lowercaseString]; - NSString *nextScheme = [request.URL.scheme lowercaseString]; - if ([originalScheme isEqualToString:@"https"] && [nextScheme isEqualToString:@"http"]) { + if (RCTPushyIsHttpsDowngrade([NSURL URLWithString:self.urlString], request.URL)) { [self failWithDescription:[NSString stringWithFormat: @"https download redirected to plaintext http: %@", request.URL.absoluteString] code:-1]; diff --git a/ios/RCTPushy/RCTPushyInternal.h b/ios/RCTPushy/RCTPushyInternal.h index 28c02a42..813f9f6c 100644 --- a/ios/RCTPushy/RCTPushyInternal.h +++ b/ios/RCTPushy/RCTPushyInternal.h @@ -4,6 +4,7 @@ #pragma once #import "RCTPushy.h" +#import "RCTPushyCommon.h" #import #import @@ -19,10 +20,6 @@ #define _RCTLog(lvl, ...) _RCTLogNativeInternal(lvl, __FILE_NAME__, __LINE__, __VA_ARGS__) #endif -// Shared across the pod's translation units, never exported from a dynamic -// framework. -#define PUSHY_HIDDEN __attribute__((visibility("hidden"))) - typedef NS_ENUM(NSInteger, PushyType) { PushyTypeFullDownload = 1, PushyTypePatchFromPackage = 2, diff --git a/ios/RCTPushy/RCTPushyOrchestrator.mm b/ios/RCTPushy/RCTPushyOrchestrator.mm index 7ebcdee4..3ad23ed7 100644 --- a/ios/RCTPushy/RCTPushyOrchestrator.mm +++ b/ios/RCTPushy/RCTPushyOrchestrator.mm @@ -1,5 +1,4 @@ #import "RCTPushyInternal.h" -#import "RCTPushyConfiguration.h" #include "../../cpp/patch_core/error_codes.h" #include "../../cpp/patch_core/hbc_transform_wire.h" #include "../../cpp/update_flow_core/flow_json.h" @@ -216,7 +215,8 @@ static void PushyInstallCrashHoldHandler(void) { // it the POST body (appKey, uuid, versions) — leaves the device. The shared // session would follow first and only the response could be rejected; ATS // blocks plaintext by default, this covers apps that opted out with -// NSAllowsArbitraryLoads. Same rule as RCTPushyDownloader. Returning nil +// NSAllowsArbitraryLoads. Same rule as RCTPushyDownloader +// (RCTPushyIsHttpsDowngrade). Returning nil // delivers the 3xx itself as the final response, which the status check in // PushyHttpRequest then treats as a failed endpoint. @interface PushyRequestRedirectGuard : NSObject @@ -229,13 +229,7 @@ - (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task newRequest:(NSURLRequest *)request completionHandler:(void (^)(NSURLRequest *))completionHandler { - NSString *originalScheme = [task.originalRequest.URL.scheme lowercaseString]; - NSString *nextScheme = [request.URL.scheme lowercaseString]; - if ([originalScheme isEqualToString:@"https"] && [nextScheme isEqualToString:@"http"]) { - completionHandler(nil); - return; - } - completionHandler(request); + completionHandler(RCTPushyIsHttpsDowngrade(task.originalRequest.URL, request.URL) ? nil : request); } @end diff --git a/react-native-update.podspec b/react-native-update.podspec index 225b61b9..9a3d1292 100644 --- a/react-native-update.podspec +++ b/react-native-update.podspec @@ -215,10 +215,10 @@ Pod::Spec.new do |s| ss.public_header_files = ['ios/RCTPushy/RCTPushy.h', 'ios/RCTPushy/RCTPushyConfiguration.h', 'ios/RCTPushy/RCTPushyDownloader.h'] - # RCTPushyInternal.h is shared between RCTPushy.mm and - # RCTPushyOrchestrator.mm only; it pulls in C++ headers, so it must stay - # out of the module umbrella. - ss.private_header_files = ['ios/RCTPushy/RCTPushyInternal.h'] + # Shared by the pod's own sources only (RCTPushyInternal.h pulls in C++ + # headers), so they must stay out of the module umbrella. + ss.private_header_files = ['ios/RCTPushy/RCTPushyCommon.h', + 'ios/RCTPushy/RCTPushyInternal.h'] end # Conditionally add Expo subspec and check ExpoModulesCore version diff --git a/scripts/tests/ios-purge-restore/purge_restore_test.mm b/scripts/tests/ios-purge-restore/purge_restore_test.mm index b23e14b8..5f21918f 100644 --- a/scripts/tests/ios-purge-restore/purge_restore_test.mm +++ b/scripts/tests/ios-purge-restore/purge_restore_test.mm @@ -16,7 +16,7 @@ #include "cpp/patch_core/error_codes.h" #include "cpp/patch_core/digest.h" #include "cpp/patch_core/install_record.h" -#include "ios/RCTPushy/RCTPushyConfiguration.h" +#include "ios/RCTPushy/RCTPushyCommon.h" #undef TARGET_OS_TV #define TARGET_OS_TV 1 @@ -31,7 +31,8 @@ static void TestLog(NSString *, ...) {} #define RCTLogWarn TestLog #define RCTLogError TestLog -// Pod-internal linkage (RCTPushyInternal.h); one translation unit here. +// Pod-internal linkage (RCTPushyCommon.h); one translation unit here. +#undef PUSHY_HIDDEN #define PUSHY_HIDDEN // RCTPushy.mm's own #define, not extracted; InstallRecordActivation checks it From b5f572a9da276ebe6e908938c87c625edbe8b538 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 15:15:36 +0800 Subject: [PATCH 056/104] fix(ios): align the native round's download and config rules with JS/Harmony - Mirror retry: a failed attempt whose code is not mirror-retryable (flowcore::IsMirrorRetryableCode, i.e. PATCH_FAILED) moves on to the next strategy instead of downloading the same unappliable artifact from every mirror, like client.ts does. - https-only (Harmony SyncCoordinator isHttpsOnly, SR-1): when every configured endpoint is https, endpoints from a fetched remote list must be https and plaintext http artifact URLs are skipped. - The runnable-config gate trims appKey like Android and Harmony (the trimmed key is the one used in the request URL). Co-Authored-By: Claude Opus 5.5 --- ios/RCTPushy/RCTPushyOrchestrator.mm | 72 +++++++++++++++++++++++----- 1 file changed, 61 insertions(+), 11 deletions(-) diff --git a/ios/RCTPushy/RCTPushyOrchestrator.mm b/ios/RCTPushy/RCTPushyOrchestrator.mm index 3ad23ed7..453b47b2 100644 --- a/ios/RCTPushy/RCTPushyOrchestrator.mm +++ b/ios/RCTPushy/RCTPushyOrchestrator.mm @@ -310,6 +310,31 @@ - (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task return base; } +// Same rule as Harmony (SyncCoordinator.ts isHttpsOnly, SR-1): when every +// configured endpoint is https, endpoints from a fetched remote list must be +// https and artifact URLs from the decision may not be plaintext http, so the +// network cannot downgrade the round. Redirects are guarded separately +// (RCTPushyIsHttpsDowngrade). +static BOOL PushyIsHttpsUrl(NSString *url) { + return [[url lowercaseString] hasPrefix:@"https://"]; +} + +static BOOL PushyIsCleartextUrl(NSString *url) { + return [[url lowercaseString] hasPrefix:@"http://"]; +} + +static BOOL PushyIsHttpsOnly(const flowjson::Value &endpoints) { + if (endpoints.elements().empty()) { + return NO; + } + for (const auto &endpoint : endpoints.elements()) { + if (!endpoint.IsString() || !PushyIsHttpsUrl(PushyFromStdString(endpoint.AsString()))) { + return NO; + } + } + return YES; +} + static BOOL PushyIsValidResponse(NSString *responseText) { if (responseText == nil) { return NO; @@ -415,7 +440,8 @@ static BOOL PushyConfigsEquivalent(NSString *a, NSString *b) { // The persisted config a round may run with. Every gate (crash-hold handler // install, round start, prepareBundle, the round itself) reads it through // PushyLoadRunnableConfig, so they agree on what "configured" and "disabled" -// mean. disabled follows JS truthiness, like the flow core. +// mean. disabled follows JS truthiness, like the flow core; appKey is trimmed +// like Android and Harmony do. struct PushyRunnableConfig { NSString *json; flowjson::Value value; @@ -434,7 +460,9 @@ static BOOL PushyLoadRunnableConfig(PushyRunnableConfig *config, NSString **reas bool ok = false; flowjson::Value value = flowjson::Parse(PushyToStdString(json), &ok); NSString *appKey = ok && value.IsObject() - ? PushyFromStdString(value.Get("appKey").AsString()) : nil; + ? [PushyFromStdString(value.Get("appKey").AsString()) + stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] + : nil; if (!ok || !value.IsObject()) { failure = @"invalid_config"; } else if (value.Get("disabled").Truthy()) { @@ -888,9 +916,11 @@ + (void)runConfiguredRound:(const flowjson::Value &)config return; } + BOOL httpsOnly = PushyIsHttpsOnly(config.Get("endpoints")); NSString *responseText = [self runQueryRequest:config appKey:appKey body:body + httpsOnly:httpsOnly deadline:deadlineUptime]; if (responseText == nil) { RCTLogInfo(@"RCTPushy -- native sync: no endpoint reachable, giving up until next launch"); @@ -930,6 +960,7 @@ + (void)runConfiguredRound:(const flowjson::Value &)config downloaded = [self performAttempts:decision.Get("attempts") hash:hash originHash:currentVersion + httpsOnly:httpsOnly deadline:deadlineUptime]; } if (!downloaded) { @@ -1089,10 +1120,6 @@ + (BOOL)commitRoundWithGeneration:(uint64_t)generation 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. // Per-request timeout, capped to the crash-rescue budget when one is active. // <= 0 means the budget is gone and the round must stop issuing requests. static NSTimeInterval PushyQueryRequestTimeout(NSTimeInterval deadlineUptime) { @@ -1102,9 +1129,14 @@ static NSTimeInterval PushyQueryRequestTimeout(NSTimeInterval deadlineUptime) { return MIN(10, deadlineUptime - PushyMonotonicNow()); } +// 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 *)runQueryRequest:(const flowjson::Value &)config appKey:(NSString *)appKey body:(NSString *)body + httpsOnly:(BOOL)httpsOnly deadline:(NSTimeInterval)deadlineUptime { NSMutableSet *tried = [NSMutableSet set]; const NSUInteger maxHttpAttempts = 8; @@ -1120,15 +1152,20 @@ + (NSString *)runQueryRequest:(const flowjson::Value &)config return *timeout > 0; }; // Tries each not-yet-tried endpoint base in order and returns the first - // valid response; *stop is set when the budget ran out. - NSString *(^queryEndpoints)(const flowjson::Value &, BOOL *) = - ^NSString *(const flowjson::Value &endpoints, BOOL *stop) { + // valid response; *stop is set when the budget ran out. A remote list + // (fetched from queryUrls) may not downgrade an https-only config. + NSString *(^queryEndpoints)(const flowjson::Value &, BOOL, BOOL *) = + ^NSString *(const flowjson::Value &endpoints, BOOL remote, BOOL *stop) { for (const auto &endpoint : endpoints.elements()) { NSString *base = PushyNormalizeEndpointBase( PushyFromStdString(endpoint.AsString())); if (base.length == 0 || [tried containsObject:base]) { continue; } + if (remote && httpsOnly && !PushyIsHttpsUrl(base)) { + RCTLogWarn(@"RCTPushy -- native sync: ignoring non-https remote endpoint %@", base); + continue; + } NSTimeInterval timeout = 0; if (!takeAttempt(&timeout)) { *stop = YES; @@ -1148,7 +1185,7 @@ + (NSString *)runQueryRequest:(const flowjson::Value &)config BOOL stop = NO; double sample = arc4random() / 4294967296.0; NSString *response = queryEndpoints( - flowcore::OrderEndpointCandidates(config.Get("endpoints"), sample), &stop); + flowcore::OrderEndpointCandidates(config.Get("endpoints"), sample), NO, &stop); if (response != nil || stop) { return response; } @@ -1171,14 +1208,19 @@ + (NSString *)runQueryRequest:(const flowjson::Value &)config continue; } // One successfully fetched remote list is enough. - return queryEndpoints(remote, &stop); + return queryEndpoints(remote, YES, &stop); } return nil; } +// Strategies in order (diff, pdiff, full), each over its mirrors. A failure +// that is not mirror-retryable (PATCH_FAILED: the bytes arrived but could not +// be applied, and every mirror serves the same bytes) moves on to the next +// strategy instead of downloading the same artifact again, like the JS side. + (BOOL)performAttempts:(const flowjson::Value &)attempts hash:(NSString *)hash originHash:(NSString *)originHash + httpsOnly:(BOOL)httpsOnly deadline:(NSTimeInterval)holdDeadline { RCTPushy *engine = [self engine]; // Crash-rescue budget caps every phase; 0 keeps the normal 10min windows. @@ -1209,6 +1251,10 @@ + (BOOL)performAttempts:(const flowjson::Value &)attempts if (url == nil) { continue; } + if (httpsOnly && PushyIsCleartextUrl(url)) { + RCTLogWarn(@"RCTPushy -- native sync: ignoring cleartext %s url %@", type.c_str(), url); + continue; + } NSTimeInterval remaining = deadline - PushyMonotonicNow(); if (remaining <= 0) { if (isFullAttempt) { @@ -1244,6 +1290,10 @@ + (BOOL)performAttempts:(const flowjson::Value &)attempts } RCTLogInfo(@"RCTPushy -- native sync: %s attempt failed: %@", type.c_str(), resultError.localizedDescription); + if (!flowcore::IsMirrorRetryableCode( + PushyToStdString(resultError.userInfo[RCTPushyErrorCodeKey]))) { + break; + } } } return NO; From ed9d28bd2befd9738a2907568d9ef3f8f02de021 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 15:23:10 +0800 Subject: [PATCH 057/104] test(harmony): load Harmony sources through the shared evalHarmonyModule Co-Authored-By: Claude Opus 5.5 --- src/__tests__/harmonyArchiveLimits.test.ts | 16 ++----- src/__tests__/harmonyDownloadTask.test.ts | 15 ++----- .../harmonyInstallRecordLogic.test.ts | 16 ++----- src/__tests__/harmonySyncAttempts.test.ts | 44 ++++++++----------- src/__tests__/harmonySyncQuery.test.ts | 19 ++------ src/__tests__/harmonyValidation.test.ts | 19 ++------ 6 files changed, 38 insertions(+), 91 deletions(-) diff --git a/src/__tests__/harmonyArchiveLimits.test.ts b/src/__tests__/harmonyArchiveLimits.test.ts index 6df95ffc..9862639a 100644 --- a/src/__tests__/harmonyArchiveLimits.test.ts +++ b/src/__tests__/harmonyArchiveLimits.test.ts @@ -1,13 +1,11 @@ import { describe, expect, test } from 'bun:test'; -import { readFileSync } from 'node:fs'; -import { URL } from 'node:url'; -import { runInNewContext } from 'node:vm'; import { createPushyError, ERROR_DELTA_FAILED, ERROR_FILE_OPERATION_FAILED, type PushyError, } from '../../harmony/pushy/src/main/ets/ErrorCodes'; +import { evalHarmonyModule } from './nativeSources'; // The pure limit logic of Harmony ArchiveLimits.ts (ArkTS mirror of // cpp/patch_core/archive_limits.h), evaluated from source with its file @@ -34,15 +32,9 @@ interface ArchiveLimits { ): FreeSpaceProbePlan; } -const source = readFileSync( - new URL('../../harmony/pushy/src/main/ets/ArchiveLimits.ts', import.meta.url), - 'utf8' -) - .replace(/^import[\s\S]*?;\r?\n/gm, '') - .replace(/^export /gm, ''); -const limits = runInNewContext( - `${new Bun.Transpiler({ loader: 'ts' }).transformSync(source)} -({ UNKNOWN_LENGTH_FREE_SPACE_PROBE_BYTES, MAX_TOTAL_UNCOMPRESSED_BYTES, +const limits = evalHarmonyModule( + 'ArchiveLimits.ts', + `({ UNKNOWN_LENGTH_FREE_SPACE_PROBE_BYTES, MAX_TOTAL_UNCOMPRESSED_BYTES, MAX_COMPRESSION_RATIO, RATIO_CHECK_MIN_BYTES, ORIGINAL_SIZE_UNAVAILABLE, checkOriginalSize, checkUncompressedSize, planUnknownLengthFreeSpaceProbe });`, { diff --git a/src/__tests__/harmonyDownloadTask.test.ts b/src/__tests__/harmonyDownloadTask.test.ts index 119be2cc..8a6d51b8 100644 --- a/src/__tests__/harmonyDownloadTask.test.ts +++ b/src/__tests__/harmonyDownloadTask.test.ts @@ -1,9 +1,7 @@ import { describe, expect, test } from 'bun:test'; -import { readFileSync } from 'node:fs'; -import { URL } from 'node:url'; -import { runInNewContext } from 'node:vm'; import { DownloadTaskParams } from '../../harmony/pushy/src/main/ets/DownloadTaskParams'; import { BUNDLE_DELTA_ENTRY } from '../../harmony/pushy/src/main/ets/Texts'; +import { evalHarmonyModule } from './nativeSources'; // The pure parsers of Harmony DownloadTask.ts, evaluated from source with its // platform imports stripped (the download/patch class itself needs a device). @@ -17,14 +15,9 @@ interface PatchManifestArrays { hbcTransformMeta: string; } -const source = readFileSync( - new URL('../../harmony/pushy/src/main/ets/DownloadTask.ts', import.meta.url), - 'utf8' -) - .replace(/^import[\s\S]*?;\r?\n/gm, '') - .replace(/^export /gm, ''); -const { parseContentRangeTotal, parseManifestToArrays } = runInNewContext( - `${new Bun.Transpiler({ loader: 'ts' }).transformSync(source)}\n({ parseContentRangeTotal, parseManifestToArrays });`, +const { parseContentRangeTotal, parseManifestToArrays } = evalHarmonyModule( + 'DownloadTask.ts', + '({ parseContentRangeTotal, parseManifestToArrays });', { HARMONY_BUNDLE_FILE_NAME, BUNDLE_DELTA_ENTRY } ) as { parseContentRangeTotal(header: string, expectedStart: number): number; diff --git a/src/__tests__/harmonyInstallRecordLogic.test.ts b/src/__tests__/harmonyInstallRecordLogic.test.ts index 61d8d2a8..59744844 100644 --- a/src/__tests__/harmonyInstallRecordLogic.test.ts +++ b/src/__tests__/harmonyInstallRecordLogic.test.ts @@ -1,13 +1,11 @@ import { describe, expect, test } from 'bun:test'; -import { readFileSync } from 'node:fs'; -import { URL } from 'node:url'; -import { runInNewContext } from 'node:vm'; import { createPushyError, ERROR_DELTA_FAILED, ERROR_SWITCH_VERSION_FAILED, type PushyError, } from '../../harmony/pushy/src/main/ets/ErrorCodes'; +import { evalHarmonyModule } from './nativeSources'; // The pure record logic of Harmony InstallRecord.ts (ArkTS mirror of // cpp/patch_core/install_record.h): parsing, version matching and the @@ -35,15 +33,9 @@ interface InstallRecordLogic { isReservedEntryName(name: string): boolean; } -const source = readFileSync( - new URL('../../harmony/pushy/src/main/ets/InstallRecord.ts', import.meta.url), - 'utf8' -) - .replace(/^import[\s\S]*?;\r?\n/gm, '') - .replace(/^export /gm, ''); -const record = runInNewContext( - `${new Bun.Transpiler({ loader: 'ts' }).transformSync(source)} -({ INSTALL_RECORD_SCHEMA, INSTALL_RECORD_FILE_NAME, STAGING_SUFFIX, +const record = evalHarmonyModule( + 'InstallRecord.ts', + `({ INSTALL_RECORD_SCHEMA, INSTALL_RECORD_FILE_NAME, STAGING_SUFFIX, buildInstallRecord, parseInstallRecord, isRecordForVersion, expectedBundleSha256ForActivation, stagingDirectoryFor, isReservedEntryName });`, diff --git a/src/__tests__/harmonySyncAttempts.test.ts b/src/__tests__/harmonySyncAttempts.test.ts index dff2e400..b0e9383b 100644 --- a/src/__tests__/harmonySyncAttempts.test.ts +++ b/src/__tests__/harmonySyncAttempts.test.ts @@ -1,28 +1,16 @@ import { describe, expect, test } from 'bun:test'; -import { readFileSync } from 'node:fs'; -import { URL } from 'node:url'; -import { runInNewContext } from 'node:vm'; import { createPushyError, ERROR_DELTA_FAILED, ERROR_DOWNLOAD_FAILED, PushyError, } from '../../harmony/pushy/src/main/ets/ErrorCodes'; +import { evalHarmonyModule } from './nativeSources'; // The download strategy loop of the Harmony cold-start orchestrator // (SyncCoordinator.performAttempts), evaluated from source with its platform // imports stripped. A patch that downloaded but failed to apply is not // retried on the other mirrors (update_flow_core IsMirrorRetryableCode). -const source = readFileSync( - new URL( - '../../harmony/pushy/src/main/ets/SyncCoordinator.ts', - import.meta.url - ), - 'utf8' -) - .replace(/^import[\s\S]*?;\r?\n/gm, '') - .replace(/^export /gm, ''); -const javascript = new Bun.Transpiler({ loader: 'ts' }).transformSync(source); interface Attempt { type: string; @@ -47,19 +35,23 @@ function harness(failures: Map) { downloadPatchFromPackage: (url: string) => fail(url), downloadFullUpdate: (url: string) => fail(url), }; - const performAttempts = runInNewContext(`${javascript}\nperformAttempts;`, { - PushyError, - ERROR_DELTA_FAILED, - ERROR_DOWNLOAD_FAILED, - createPushyError, - getErrorMessage: (error: unknown) => String(error), - logger: { info() {}, warn() {}, error() {} }, - monotonicNowMs: () => performance.now(), - setTimeout, - clearTimeout, - BundlePreparationRound: class {}, - bundlePreparationResult: () => ({}), - }) as ( + const performAttempts = evalHarmonyModule( + 'SyncCoordinator.ts', + 'performAttempts;', + { + PushyError, + ERROR_DELTA_FAILED, + ERROR_DOWNLOAD_FAILED, + createPushyError, + getErrorMessage: (error: unknown) => String(error), + logger: { info() {}, warn() {}, error() {} }, + monotonicNowMs: () => performance.now(), + setTimeout, + clearTimeout, + BundlePreparationRound: class {}, + bundlePreparationResult: () => ({}), + } + ) as ( ctx: typeof context, attempts: Attempt[], hash: string, diff --git a/src/__tests__/harmonySyncQuery.test.ts b/src/__tests__/harmonySyncQuery.test.ts index d7df365f..02f87044 100644 --- a/src/__tests__/harmonySyncQuery.test.ts +++ b/src/__tests__/harmonySyncQuery.test.ts @@ -1,21 +1,9 @@ import { describe, expect, test } from 'bun:test'; -import { readFileSync } from 'node:fs'; -import { URL } from 'node:url'; -import { runInNewContext } from 'node:vm'; +import { evalHarmonyModule } from './nativeSources'; // The endpoint fallback of the Harmony cold-start orchestrator // (SyncCoordinator.runQueryRequest), evaluated from source with its platform // imports stripped and HTTP replaced by a scripted fake. -const source = readFileSync( - new URL( - '../../harmony/pushy/src/main/ets/SyncCoordinator.ts', - import.meta.url - ), - 'utf8' -) - .replace(/^import[\s\S]*?;\r?\n/gm, '') - .replace(/^export /gm, ''); -const javascript = new Bun.Transpiler({ loader: 'ts' }).transformSync(source); const VALID = '{"ok":true}'; @@ -30,8 +18,9 @@ function run( httpsOnly = false ) { const requests: string[] = []; - const runQueryRequest = runInNewContext( - `${javascript}\nhttpRequest = fakeHttp;\nrunQueryRequest;`, + const runQueryRequest = evalHarmonyModule( + 'SyncCoordinator.ts', + 'httpRequest = fakeHttp;\nrunQueryRequest;', { QUERY_PATH: '/q/', logger: { info() {}, warn() {}, error() {} }, diff --git a/src/__tests__/harmonyValidation.test.ts b/src/__tests__/harmonyValidation.test.ts index aeec5fd9..8541af6a 100644 --- a/src/__tests__/harmonyValidation.test.ts +++ b/src/__tests__/harmonyValidation.test.ts @@ -1,7 +1,4 @@ import { describe, expect, test } from 'bun:test'; -import { readFileSync } from 'node:fs'; -import { URL } from 'node:url'; -import { runInNewContext } from 'node:vm'; import { createPushyError, ERROR_INVALID_HASH_INFO, @@ -12,24 +9,16 @@ import { assertSafePathComponent, isSafePathComponent, } from '../../harmony/pushy/src/main/ets/PathUtils'; +import { evalHarmonyModule } from './nativeSources'; // Harmony input validation (path components, hash info JSON) and the // download-progress event hub. // validateHashInfo lives in PushyTurboModule.ts, which extends RNOH's // UITurboModule: evaluate it with its imports stripped and a stub base class. -const turboModuleSource = readFileSync( - new URL( - '../../harmony/pushy/src/main/ets/PushyTurboModule.ts', - import.meta.url - ), - 'utf8' -) - .replace(/^import[\s\S]*?;\r?\n/gm, '') - .replace(/^export\s*\{[^}]*\}(?:\s*from\s*[^;]*)?;\r?\n/gm, '') - .replace(/^export /gm, ''); -const validateHashInfo = runInNewContext( - `${new Bun.Transpiler({ loader: 'ts' }).transformSync(turboModuleSource)}\nvalidateHashInfo;`, +const validateHashInfo = evalHarmonyModule( + 'PushyTurboModule.ts', + 'validateHashInfo;', { UITurboModule: class {}, ERROR_INVALID_HASH_INFO, From dd6618f5f9f1e7854001a53975b871df0fdc9a99 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 15:23:27 +0800 Subject: [PATCH 058/104] test(text-encoding): drop stale PushyTurboModule.cpp allowlist entries The method table is now an X-macro and the names are stringified with #name, so they no longer appear as string literals. Co-Authored-By: Claude Opus 5.5 --- src/__tests__/nativeTextEncoding.test.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/__tests__/nativeTextEncoding.test.ts b/src/__tests__/nativeTextEncoding.test.ts index 0ca946ed..c162ac10 100644 --- a/src/__tests__/nativeTextEncoding.test.ts +++ b/src/__tests__/nativeTextEncoding.test.ts @@ -87,12 +87,6 @@ describe('native text encoding', () => { 'android/src/main/java/cn/reactnative/modules/update/BundlePreparationResult.java: noUpdate', // Public ArkTS option type (compile-time only). 'harmony/pushy/src/main/ets/PushyConfiguration.ts: setNeedUpdate', - // JS bridge method names registered with RNOH. - 'harmony/pushy/src/main/cpp/PushyTurboModule.cpp: reloadUpdate', - 'harmony/pushy/src/main/cpp/PushyTurboModule.cpp: setNeedUpdate', - 'harmony/pushy/src/main/cpp/PushyTurboModule.cpp: downloadPatchFromPpk', - 'harmony/pushy/src/main/cpp/PushyTurboModule.cpp: downloadPatchFromPackage', - 'harmony/pushy/src/main/cpp/PushyTurboModule.cpp: downloadFullUpdate', ]; const wording = /update|patch|rescue|reload|hotfix/i; const literal = From 3adcffb47d6371a2c3e04f57e3b103fdc6db283a Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 15:23:32 +0800 Subject: [PATCH 059/104] test(harmony): drop the stale hypium suite mention from harmonyInstallRecord Co-Authored-By: Claude Opus 5.5 --- src/__tests__/harmonyInstallRecord.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/__tests__/harmonyInstallRecord.test.ts b/src/__tests__/harmonyInstallRecord.test.ts index 90de68f7..f7f3cfb1 100644 --- a/src/__tests__/harmonyInstallRecord.test.ts +++ b/src/__tests__/harmonyInstallRecord.test.ts @@ -4,8 +4,8 @@ import { evalHarmonyModule } from './nativeSources'; // Harmony InstallRecord.ts (ArkTS mirror of cpp/patch_core/install_record.h) // imports @ohos modules, so it is evaluated with its imports stripped and its // collaborators (file system, native digest, error factory) supplied as -// in-memory stubs. The on-device hypium suite (InstallRecord.test.ets) only -// runs in harmony CI; this keeps the record contract under `bun run test`. +// in-memory stubs. This covers the disk-backed activation path; the pure +// record logic is in harmonyInstallRecordLogic.test.ts. class PushyError extends Error { code: string; From d5791c256539507d1d86e8a0a309243781cc92a3 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 15:23:53 +0800 Subject: [PATCH 060/104] docs(harmony): point comments at scripts/check-constant-parity.js, drop hypium mentions Co-Authored-By: Claude Opus 5.5 --- CODE_AUDIT.md | 6 +++--- harmony/pushy/src/main/ets/ArchiveLimits.ts | 2 +- harmony/pushy/src/main/ets/ErrorCodes.ts | 2 +- harmony/pushy/src/main/ets/EventHub.ts | 2 +- harmony/pushy/src/main/ets/InstallRecord.ts | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/CODE_AUDIT.md b/CODE_AUDIT.md index f11f551a..de67145f 100644 --- a/CODE_AUDIT.md +++ b/CODE_AUDIT.md @@ -20,7 +20,7 @@ > | 4.12 | 部分:`buildCheckFingerprint` 无 C++ 移植 | > | §4 P4 | 开放:鸿蒙 NAPI 结果对象省略空串字段(Android 设 null) | > | 5.7 | 部分:RCP 重定向控制(HMS-only)未做 | -> | 5.8 | 部分:hypium 测试无 CI;`e2e_harmony` 不在门内 | +> | 5.8 | 部分:`e2e_harmony` 不在门内 | > | 5.15 / §5 P4 | 部分:若干纯函数未覆盖;`transferArchive` 未拆;`codelinter` 未接入 | > | 6.4 | 未做(流程决策):`.so` 仍提交在 `android/lib/` | > @@ -166,8 +166,8 @@ Android 已兜住:`Application.mk` 改 `-fexceptions`,每个 JNI 入口经 ` 剩余:`@ohos.net.http` 内部跟随重定向且不暴露最终 URL,https→http 的重定向降级仍可能发生;可接受 HMS-only 的话 RCP(`autoRedirect=false` + effective URL)能完全关闭。 **5.8(P2)鸿蒙 CI 门**(部分修复) -已做:`harmony-build.yml` 在固定容器里做类型检查(`HARMONY_TYPECHECK_REQUIRED=1` 时失败)与 release HAR 编译,并进 `native-gate`。 -剩余:`harmony/pushy/src/test` 的 hypium 测试没有任何 workflow 跑;`e2e_harmony.yml` 仅手动,不在门内(无托管鸿蒙 runner)。 +已做:`harmony-build.yml` 在固定容器里做类型检查(`HARMONY_TYPECHECK_REQUIRED=1` 时失败)与 release HAR 编译,并进 `native-gate`;hypium 套件已删除,鸿蒙纯逻辑测试改为 `src/__tests__/harmony*.test.ts` 在 bun 下跑(`test.yml`)。 +剩余:`e2e_harmony.yml` 仅手动,不在门内(无托管鸿蒙 runner)。 **5.15(P3)测试**(部分修复) 已覆盖:ContentRange、InstallRecord、ErrorCodes、ArchiveLimits、NativeConfig、EventHub;C++ 头文件 ↔ ArkTS/Java/JS 镜像的常量对等由 `scripts/check-constant-parity.js`(`bun lint`)断言。 diff --git a/harmony/pushy/src/main/ets/ArchiveLimits.ts b/harmony/pushy/src/main/ets/ArchiveLimits.ts index 44ca0674..e03495c3 100644 --- a/harmony/pushy/src/main/ets/ArchiveLimits.ts +++ b/harmony/pushy/src/main/ets/ArchiveLimits.ts @@ -8,7 +8,7 @@ import { import { isReservedEntryName } from './InstallRecord'; // ArkTS mirror of cpp/patch_core/archive_limits.h — keep in sync by hand -// (harmony/pushy/src/test/check-constant-parity.js asserts the values match). +// (scripts/check-constant-parity.js asserts the values match). // 损坏或恶意更新包的伤害上限:最多耗费有限的磁盘/内存/时间,绝不撑爆磁盘。 export const MAX_ARCHIVE_BYTES = 512 * 1024 * 1024; export const MAX_TOTAL_UNCOMPRESSED_BYTES = 2048 * 1024 * 1024; diff --git a/harmony/pushy/src/main/ets/ErrorCodes.ts b/harmony/pushy/src/main/ets/ErrorCodes.ts index c65c8790..611ca654 100644 --- a/harmony/pushy/src/main/ets/ErrorCodes.ts +++ b/harmony/pushy/src/main/ets/ErrorCodes.ts @@ -1,5 +1,5 @@ // ArkTS mirror of cpp/patch_core/error_codes.h — keep in sync by hand -// (harmony/pushy/src/test/check-constant-parity.js asserts the values match). +// (scripts/check-constant-parity.js asserts the values match). // 与 Android/iOS 共用的稳定错误码:JS 层(src/error.ts)按 code 聚合遥测, // 消息文案各平台可不同,只有码是契约。 // diff --git a/harmony/pushy/src/main/ets/EventHub.ts b/harmony/pushy/src/main/ets/EventHub.ts index 4e705709..e1144953 100644 --- a/harmony/pushy/src/main/ets/EventHub.ts +++ b/harmony/pushy/src/main/ets/EventHub.ts @@ -1,5 +1,5 @@ // The subset of RNOH's RNInstance the SDK needs: device events for download -// progress. Typed structurally so the hypium tests can pass a mock. +// progress. Typed structurally so tests can pass a mock. export interface DeviceEventEmitter { emitDeviceEvent(eventName: string, payload: Object): void; } diff --git a/harmony/pushy/src/main/ets/InstallRecord.ts b/harmony/pushy/src/main/ets/InstallRecord.ts index 64803034..791afcf0 100644 --- a/harmony/pushy/src/main/ets/InstallRecord.ts +++ b/harmony/pushy/src/main/ets/InstallRecord.ts @@ -9,7 +9,7 @@ import { } from './ErrorCodes'; // ArkTS mirror of cpp/patch_core/install_record.h — keep in sync by hand -// (harmony/pushy/src/test/check-constant-parity.js asserts the values match). +// (scripts/check-constant-parity.js asserts the values match). // 两阶段安装的完成记录:作为安装最后一步写进 staging 目录,随后 staging // 原子 rename 成版本目录;激活前重新校验 bundle 摘要。空文件 = 旧版 SDK // 写的历史标记,仍视为完整安装(无摘要可验)。 From d0a4409efe0af1bb9ab55852f29034dd5859da51 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 15:24:02 +0800 Subject: [PATCH 061/104] chore(package): drop the files exclusion for the removed harmony/pushy/src/test Co-Authored-By: Claude Opus 5.5 --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index 7498826e..22d900c1 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,6 @@ "harmony/pushy.har", "harmony/hvigor-plugin.ts", "harmony/pushy/src", - "!harmony/pushy/src/test", "react-native-update.podspec", "react-native.config.js", "expo-module.config.json", From 97a4e8b7ee4de829f495921b924165fff2459b02 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 15:24:15 +0800 Subject: [PATCH 062/104] chore(e2e): remove the one-off iOS crash-rescue experiment scaffolding Co-Authored-By: Claude Opus 5.5 --- Example/e2etest/e2e/entry.brick.ts | 29 ----- Example/e2etest/e2e/entry.fix.ts | 16 --- Example/e2etest/scripts/ios-rescue-server.ts | 106 ------------------- 3 files changed, 151 deletions(-) delete mode 100644 Example/e2etest/e2e/entry.brick.ts delete mode 100644 Example/e2etest/e2e/entry.fix.ts delete mode 100644 Example/e2etest/scripts/ios-rescue-server.ts diff --git a/Example/e2etest/e2e/entry.brick.ts b/Example/e2etest/e2e/entry.brick.ts deleted file mode 100644 index a361bd9f..00000000 --- a/Example/e2etest/e2e/entry.brick.ts +++ /dev/null @@ -1,29 +0,0 @@ -// §11 crash-rescue experiment (not part of the regular e2e suites): the first -// healthy launch arms a flag well after markSuccess; every later launch reads -// it and dies a few hundred ms in. -import { PushyModule } from 'react-native-update'; -import { LOCAL_UPDATE_LABELS } from './localUpdateConfig.ts'; - -const bundleLabelGlobal = globalThis as typeof globalThis & { - __RNU_E2E_BUNDLE_LABEL?: string; -}; - -bundleLabelGlobal.__RNU_E2E_BUNDLE_LABEL = `${LOCAL_UPDATE_LABELS.base}_BRICK`; - -PushyModule.getLocalHashInfo('brickflag').then((v: string) => { - if (v && v.includes('armed') && !v.includes('disarmed')) { - setTimeout(() => { - throw new Error('BRICK: crash on launch'); - }, 0); - } else { - setTimeout(() => { - PushyModule.setLocalHashInfo( - 'brickflag', - JSON.stringify({ state: 'armed' }) - ); - console.warn('brick armed for next launch'); - }, 8000); - } -}); - -require('../index'); diff --git a/Example/e2etest/e2e/entry.fix.ts b/Example/e2etest/e2e/entry.fix.ts deleted file mode 100644 index c9e43259..00000000 --- a/Example/e2etest/e2e/entry.fix.ts +++ /dev/null @@ -1,16 +0,0 @@ -// §11 crash-rescue experiment fix bundle: disarm the brick flag immediately. -import { PushyModule } from 'react-native-update'; -import { LOCAL_UPDATE_LABELS } from './localUpdateConfig.ts'; - -const bundleLabelGlobal = globalThis as typeof globalThis & { - __RNU_E2E_BUNDLE_LABEL?: string; -}; - -bundleLabelGlobal.__RNU_E2E_BUNDLE_LABEL = `${LOCAL_UPDATE_LABELS.base}_FIXED`; - -PushyModule.setLocalHashInfo( - 'brickflag', - JSON.stringify({ state: 'disarmed' }) -); - -require('../index'); diff --git a/Example/e2etest/scripts/ios-rescue-server.ts b/Example/e2etest/scripts/ios-rescue-server.ts deleted file mode 100644 index f421f657..00000000 --- a/Example/e2etest/scripts/ios-rescue-server.ts +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env bun -// One-off server for the §11 iOS crash-rescue experiment (not part of the -// regular e2e suites). Update chain: no hash → brick; brick → fix. The /state -// endpoint reports what was served, so the driver can assert the sequence. - -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -declare const Bun: any; - -const moduleDir = path.dirname(fileURLToPath(import.meta.url)); -const artifactsDir = path.resolve(moduleDir, '../.e2e-artifacts/ios-rescue'); -// The app hardcodes this port (localUpdateConfig LOCAL_UPDATE_PORT). -const port = 31337; - -const BRICK_HASH = 'e2e-rescue-brick'; -const FIX_HASH = 'e2e-rescue-fix'; - -const served: string[] = []; - -function json(body: unknown, status = 200) { - return new Response(JSON.stringify(body), { - status, - headers: { - 'Content-Type': 'application/json; charset=utf-8', - 'Cache-Control': 'no-store', - }, - }); -} - -const server = Bun.serve({ - port, - hostname: '0.0.0.0', - async fetch(request: Request) { - const url = new URL(request.url); - - if (url.pathname === '/state') { - return json({ served }); - } - - if (url.pathname.startsWith('/checkUpdate/')) { - const payload = (await request.json().catch(() => ({}))) as { - hash?: unknown; - }; - const currentHash = typeof payload.hash === 'string' ? payload.hash : ''; - const assetBasePath = `${url.origin}/artifacts`; - let response: Record; - if (!currentHash) { - response = { - update: true, - name: 'rescue-brick', - hash: BRICK_HASH, - description: 'brick: crashes on launch after arming', - paths: [assetBasePath], - full: 'brick.ppk', - // Deliver the brick via forceBoot (the app runs checkStrategy:null / - // afterDownload:none, so nothing else would activate it). The FIX - // response deliberately has no forceBoot: its activation must come - // from the crash-rescue window itself. - config: { forceBoot: true }, - }; - } else if (currentHash === BRICK_HASH) { - response = { - update: true, - name: 'rescue-fix', - hash: FIX_HASH, - description: 'fix: disarms the brick flag', - paths: [assetBasePath], - full: 'fix.ppk', - }; - } else { - response = { upToDate: true }; - } - served.push( - `${currentHash || '(base)'} -> ${JSON.stringify(response.hash ?? 'upToDate')}` - ); - console.log( - `[checkUpdate] hash=${currentHash || '(base)'} ->`, - response.hash ?? 'upToDate' - ); - return json(response); - } - - if (url.pathname.startsWith('/artifacts/')) { - const name = path.basename(url.pathname); - const filePath = path.join(artifactsDir, name); - if (!fs.existsSync(filePath)) { - return new Response('not found', { status: 404 }); - } - console.log(`[artifact] ${name}`); - const file = Bun.file(filePath); - return new Response(request.method === 'HEAD' ? null : file, { - headers: { - 'Content-Type': 'application/octet-stream', - 'Content-Length': String(file.size), - 'Cache-Control': 'no-store', - }, - }); - } - - return new Response('not found', { status: 404 }); - }, -}); - -console.log(`ios rescue server listening on ${server.hostname}:${server.port}`); From fb234a86a79a8f9a06ef46c96abf22c8237afb70 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 15:16:23 +0800 Subject: [PATCH 063/104] refactor(ios): split RCTPushy.mm by responsibility, RCTPushyInstaller RCTPushy.mm (~2160 lines) is split into - RCTPushyState.mm: defaults keys, state lock, State <-> defaults, version switch under the lock, string/error/JSON helpers, embedded bundle hash; - RCTPushyInstallRecord.mm: version/staging dirs, completion record (read, presence, activation verification, the staging promotion write); - RCTPushyInstaller.mm: an RCTPushyInstaller class (class methods) owning the process-wide file queue, the archive guard, the in-flight download registry, fetch/unzip/patch, switchVersion and cleanup. Progress is a callback, so the orchestrator no longer borrows a bridgeless RCTPushy instance (+engine is gone); - RCTPushy+Launch.mm: launch bundle resolution, download dir, binary identity (packageVersion/buildTime); - RCTPushy.mm keeps the RN exports, constants and the public host API (bundleURL/configure/prepareBundle stay in the primary @implementation: methods declared in RCTPushy.h must be implemented there). Cross-file statics become PUSHY_HIDDEN externs in RCTPushyInternal.h; keyNativeConfig/keyNativeCheckIncomplete move into the orchestrator. Also: - dispatch_once -> function-local statics (thread-safe C++ statics in ObjC++); PushyRevealOnce is gone. - PushyNSErrorFromStatus / PushyDownloadDeadlineExpiredError -> PushyErrorWithCode; PushyHostResult defaults hash/activated. - Helpers: PushyJsonDictionary/PushyJsonString, PushyRejectException, PushyVersionDir/PushyStagingDir, PushyWaitSeconds for the timed semaphore waits, one response-only commit block in the round. - The archive digest travels through a __block variable on the serial file queue instead of a global table under @synchronized. - Unused +rollback removed; the completion record name comes from install_record.h (kFileName). - The [bridge reload] fallback for React Native without RCTReloadCommand (< 0.62) is now an #error: incomplete integration fails the build. - SSZipArchive is imported through its public header SSZipArchive/SSZipArchive.h, which resolves with and without use_frameworks!. - Unused private declarations dropped from RCTPushyInternal.h. scripts/tests/ios-purge-restore: SOURCES lists the new files (plus the header holding PushyFileStamp); switchVersion is extracted as an RCTPushyInstaller class method into installer.inc, PushyWaitSeconds ahead of restorePurgedLaunch so the launch-wait seams still apply. Co-Authored-By: Claude Opus 5.5 --- ios/RCTPushy/RCTPushy+Launch.mm | 185 ++ ios/RCTPushy/RCTPushy.mm | 1728 +---------------- ios/RCTPushy/RCTPushyInstallRecord.mm | 166 ++ ios/RCTPushy/RCTPushyInstaller.mm | 827 ++++++++ ios/RCTPushy/RCTPushyInternal.h | 189 +- ios/RCTPushy/RCTPushyOrchestrator.mm | 232 +-- ios/RCTPushy/RCTPushyState.mm | 314 +++ scripts/tests/ios-purge-restore/README.md | 14 +- scripts/tests/ios-purge-restore/extract.py | 34 +- .../ios-purge-restore/purge_restore_test.mm | 33 +- .../tests/ios-purge-restore/test_extract.py | 18 +- 11 files changed, 1821 insertions(+), 1919 deletions(-) create mode 100644 ios/RCTPushy/RCTPushy+Launch.mm create mode 100644 ios/RCTPushy/RCTPushyInstallRecord.mm create mode 100644 ios/RCTPushy/RCTPushyInstaller.mm create mode 100644 ios/RCTPushy/RCTPushyState.mm diff --git a/ios/RCTPushy/RCTPushy+Launch.mm b/ios/RCTPushy/RCTPushy+Launch.mm new file mode 100644 index 00000000..99c7299f --- /dev/null +++ b/ios/RCTPushy/RCTPushy+Launch.mm @@ -0,0 +1,185 @@ +// Launch bundle resolution and the identity of the running binary: which +// bundle this launch loads, where downloaded versions live, and the +// packageVersion/buildTime pair that detects a rebuilt binary. +#import "RCTPushyInternal.h" + +// 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 +// under a silent (no-restart) reset would break every image the running app +// has not loaded yet. Guarded by the state lock. +PUSHY_HIDDEN NSString *pushyLaunchVersion = nil; + +#if !DEBUG +static NSString *PushyReadBuildTime(void) { + // The podspec's s.resource lands in the app bundle for a static + // library but inside this pod's own framework bundle under + // use_frameworks!; look in both. + NSString *stampPath = + [[NSBundle mainBundle] pathForResource:@"pushy_build_time" ofType:@"txt"] + ?: [[NSBundle bundleForClass:[RCTPushy class]] pathForResource:@"pushy_build_time" + ofType:@"txt"]; + NSString *stamp = stampPath == nil ? nil + : [[NSString stringWithContentsOfFile:stampPath encoding:NSUTF8StringEncoding error:nil] + stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if (stamp.length == 0) { + // Never empty: with "" SyncBinaryVersion would miss a rebuilt + // binary that kept its CFBundleShortVersionString, and a stale + // hot update would outlive an App Store update. Every build + // rewrites Info.plist, so its mtime (integer seconds) stands in. + RCTLogWarn(@"RCTPushy -- pushy_build_time.txt %@; using the Info.plist mtime as buildTime", + stampPath == nil ? @"not found in the main or framework bundle" : @"is empty"); + NSString *infoPlist = + [[NSBundle mainBundle].bundlePath stringByAppendingPathComponent:@"Info.plist"]; + NSDate *modified = [[NSFileManager defaultManager] attributesOfItemAtPath:infoPlist + error:nil].fileModificationDate; + stamp = [NSString stringWithFormat:@"%lld", (long long)[modified timeIntervalSince1970]]; + } + return stamp; +} +#endif + +@implementation RCTPushy (Launch) + +// Resolves the bundle to launch from the persisted state; nil means the +// packaged bundle. Consumes the launch's one-shot state (first_time), so a +// second call is only valid after something re-armed it (a switchVersion). +// purgedVersion is set on tvOS when the installed version's files are gone. ++ (NSURL *)resolveLaunchBundleURL:(NSString **)launchRolledBackVersionOut + purgedVersion:(NSString **)purgedVersionOut +{ + __block NSURL *resolvedURL = nil; + __block NSString *launchRolledBackVersion = nil; + __block NSString *purgedVersion = nil; + @try { + PushyWithStateLock(^{ + NSUserDefaults *defaults = PushyDefaults(); + + NSString *curPackageVersion = [RCTPushy packageVersion]; + NSString *curBuildTime = [RCTPushy buildTime]; + + pushy::state::State state = PushyStateFromDefaults(defaults); + pushy::state::BinaryVersionSyncResult sync = pushy::state::SyncBinaryVersion( + state, + PushyToStdString(curPackageVersion), + PushyToStdString(curBuildTime) + ); + if (sync.changed) { + [defaults setObject:@(YES) forKey:KeyPackageUpdatedMarked()]; + state = sync.state; + PushyApplyStateToDefaults(defaults, state); + } + + if (!state.current_version.empty()) { + std::string const versionBeforeLaunch = state.current_version; + pushy::state::LaunchDecision decision = pushy::state::ResolveLaunchState( + state, + ignoreRollback.load(), + true + ); + state = decision.state; + + if (decision.did_rollback) { + // The crash-protection rollback: the new version never called + // markSuccess. Keep this visible in release logs. + RCTLogWarn(@"RCTPushy -- version %@ was not marked as successful, rolled back to %@", + PushyFromStdString(versionBeforeLaunch), + PushyFromStdString(state.current_version)); + } + if (decision.did_rollback || decision.consumed_first_time) { + PushyApplyStateToDefaults(defaults, state); + } + if (decision.consumed_first_time) { + // bundleURL may be called many times, ignore rollbacks before process restarted again. + ignoreRollback = true; + [defaults setObject:@(YES) forKey:keyFirstLoadMarked]; + } + + NSString *loadVersion = PushyFromStdString(decision.load_version); + // 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). + NSMutableSet *visitedVersions = [NSMutableSet set]; + while (loadVersion.length && ![visitedVersions containsObject:loadVersion]) { + [visitedVersions addObject:loadVersion]; + NSString *bundlePath = [PushyVersionDir(loadVersion) stringByAppendingPathComponent:BUNDLE_FILE_NAME]; + if ([[NSFileManager defaultManager] fileExistsAtPath:bundlePath isDirectory:NULL]) { + pushyLaunchVersion = loadVersion; + resolvedURL = [NSURL fileURLWithPath:bundlePath]; + break; + } else { +#if TARGET_OS_TV + // Caches is purgeable on tvOS: a missing bundle is the OS + // reclaiming space, not a bad release. Everything under + // rctpushy went with it, so walking back to last_version + // is pointless, and a rollback mark would make this + // launch's check refuse to reinstall the version. + RCTLogWarn(@"RCTPushy -- bundle version %@ was purged by the system", loadVersion); + purgedVersion = loadVersion; + state = pushy::state::ForgetPurgedVersions(state); + PushyApplyStateToDefaults(defaults, state); + if (decision.consumed_first_time) { + // The first load being consumed never happens: the + // packaged bundle must not report isFirstTime. + ignoreRollback = false; + [defaults removeObjectForKey:keyFirstLoadMarked]; + } + break; +#else + RCTLogError(@"RCTPushy -- bundle version %@ not found, rolling back", loadVersion); + state = pushy::state::Rollback(state); + PushyApplyStateToDefaults(defaults, state); + loadVersion = PushyFromStdString(state.current_version); +#endif + } + } + } + // 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); + }); + } @finally { + *launchRolledBackVersionOut = launchRolledBackVersion; + *purgedVersionOut = purgedVersion; + } + return resolvedURL; +} + ++ (NSString *)downloadDir +{ +#if TARGET_OS_TV + // tvOS apps cannot write Application Support; Caches is the only + // writable, non-temporary location, and the system may purge it while + // the app is not running (see the purged-version path in +bundleURL). + NSSearchPathDirectory base = NSCachesDirectory; +#else + NSSearchPathDirectory base = NSApplicationSupportDirectory; +#endif + NSString *directory = [NSSearchPathForDirectoriesInDomains(base, NSUserDomainMask, YES) firstObject]; + return [directory stringByAppendingPathComponent:@"rctpushy"]; +} + ++ (NSURL *)binaryBundleURL +{ + return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"]; +} + ++ (NSString *)packageVersion +{ + static NSString *version = + [[[NSBundle mainBundle] infoDictionary] objectForKey:@"CFBundleShortVersionString"]; + return version; +} + ++ (NSString *)buildTime +{ +#if DEBUG + return @"0"; +#else + static NSString *buildTime = PushyReadBuildTime(); + return buildTime; +#endif +} + +@end diff --git a/ios/RCTPushy/RCTPushy.mm b/ios/RCTPushy/RCTPushy.mm index b687fc89..cbd41dce 100644 --- a/ios/RCTPushy/RCTPushy.mm +++ b/ios/RCTPushy/RCTPushy.mm @@ -1,840 +1,50 @@ #import "RCTPushyInternal.h" #import "RCTPushyConfiguration.h" -#import "RCTPushyDownloader.h" -#import "ZipArchive.h" -#include "../../cpp/patch_core/archive_limits.h" -#include "../../cpp/patch_core/archive_patch_core.h" -#include "../../cpp/patch_core/digest.h" -#include "../../cpp/patch_core/hbc_transform_wire.h" -#include "../../cpp/patch_core/install_record.h" #include "../../cpp/patch_core/error_codes.h" +#include "../../cpp/patch_core/hbc_transform_wire.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 // use_frameworks! 构建里 React 头文件只有 框架形式可见,引号形式的 -// __has_include 会探测失败;漏掉这里会静默编入 [self.bridge reload] 兜底,而 -// bridgeless 下 bridge 是 nil/代理,reload 变成无声 no-op(切版本后不重启)。 +// __has_include 会探测失败;漏掉框架形式会让重启变成无声 no-op(切版本后不 +// 重启),所以两种形式都探测不到时直接编译失败。 #if __has_include("RCTReloadCommand.h") #import "RCTReloadCommand.h" -#define PUSHY_HAS_RELOAD_COMMAND 1 #elif __has_include() #import -#define PUSHY_HAS_RELOAD_COMMAND 1 +#else +#error React Native 0.62 or newer is required: RCTReloadCommand.h was not found #endif #ifdef RCT_NEW_ARCH_ENABLED #import "RCTPushySpec.h" #endif -#import -#import -#import -#import - -#include -#include - -// Immutable host-facing snapshot; activated always means NEXT launch. -PUSHY_HIDDEN NSDictionary *PushyHostResult(NSString *status, NSString *reason, - NSString *hash, BOOL activated) { - return @{@"status": status, @"reason": reason ?: @"", - @"hash": hash ?: @"", @"activated": @(activated)}; -} - -static NSString *const keyPushyInfo = @"REACTNATIVECN_PUSHY_INFO_KEY"; -// Binary identity (SyncBinaryVersion input). Prefixed like every other key: -// the unprefixed names collided with generic host-app/SDK defaults, and a -// foreign value there made every launch look like a rebuilt binary and wipe -// the installed update. -static NSString *const paramPackageVersion = @"REACTNATIVECN_PUSHY_PACKAGEVERSION"; -static NSString *const paramBuildTime = @"REACTNATIVECN_PUSHY_BUILDTIME"; -// The unprefixed names earlier SDKs wrote. Read only as a fallback until the -// first state write migrates them (PushyStateFromDefaults / -// PushyApplyStateToDefaults). -static NSString *const legacyParamPackageVersion = @"packageVersion"; -static NSString *const legacyParamBuildTime = @"buildTime"; -static NSString *const paramLastVersion = @"lastVersion"; -static NSString *const paramCurrentVersion = @"currentVersion"; -static NSString *const paramIsFirstTime = @"isFirstTime"; -static NSString *const paramIsFirstLoadOk = @"isFirstLoadOK"; -PUSHY_HIDDEN NSString *const keyUuid = @"REACTNATIVECN_PUSHY_UUID"; -static NSString *const keyHashInfo = @"REACTNATIVECN_PUSHY_HASH_"; -static NSString *const keyFirstLoadMarked = @"REACTNATIVECN_PUSHY_FIRSTLOADMARKED_KEY"; -static NSString *const keyRolledBackMarked = @"REACTNATIVECN_PUSHY_ROLLEDBACKMARKED_KEY"; -// Encoded constants (RCTPushyRevealText) are decoded on first use, not by -// static initializers at image load. -PUSHY_HIDDEN NSString *PushyRevealOnce(NSString *__strong *slot, dispatch_once_t *once, - const char *hex) { - dispatch_once(once, ^{ - *slot = RCTPushyRevealText(hex); - }); - return *slot; -} - -static NSString *KeyPackageUpdatedMarked(void) { - static NSString *value; - static dispatch_once_t once; - return PushyRevealOnce(&value, &once, "0832d5f29aa549710b0939daf88ca058790f3dded7e888b451640d2ec3f69099bb43711523cafa8ea7bb4372132a"); -} -// bundleHash cache: "|" where cacheKey identifies the -// 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. -PUSHY_HIDDEN 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. -PUSHY_HIDDEN NSString *const keyNativeCheckCache = @"REACTNATIVECN_PUSHY_NATIVE_CHECK_RESP_KEY"; -// Set when a native check round starts, cleared when it ends (§11.4). Residue -// on the next launch means the previous process died mid-round (a crash -// rescue was truncated): that launch resumes immediately instead of waiting. -PUSHY_HIDDEN NSString *const keyNativeCheckIncomplete = @"REACTNATIVECN_PUSHY_NATIVE_CHECK_INCOMPLETE_KEY"; - -// file def -static NSString * const BUNDLE_FILE_NAME = @"index.bundlejs"; -static NSString * const SOURCE_PATCH_NAME = @"__diff.json"; -static NSString *BUNDLE_PATCH_NAME(void) { - static NSString *value; - static dispatch_once_t once; - return PushyRevealOnce(&value, &once, "3319f0d4b6c56a502c3b10fcdca0de7d4b3307e9"); -} -// Protocol strings and version-info flags read by JS, stored encoded -// (RCTPushyRevealText) so they do not appear as plain binary strings. -PUSHY_HIDDEN NSString *PushyOptionUrlKey(void) { - static NSString *value; - static dispatch_once_t once; - return PushyRevealOnce(&value, &once, "2f07f0d0ba8e5d572e"); -} -static NSString *PushyIpaDeltaSuffix(void) { - static NSString *value; - static dispatch_once_t once; - return PushyRevealOnce(&value, &once, "741ee4d0e09b69512137"); -} -static NSString *PushyPpkDeltaSuffix(void) { - static NSString *value; - static dispatch_once_t once; - return PushyRevealOnce(&value, &once, "7407e4dae09b69512137"); -} -#define VERSION_COMPLETE_FILE_NAME_LITERAL ".pushy-complete" -static NSString * const VERSION_COMPLETE_FILE_NAME = @VERSION_COMPLETE_FILE_NAME_LITERAL; - -// error def — messages are human-readable; the stable cross-platform codes -// live in cpp/patch_core/error_codes.h and travel in RCTPushyErrorCodeKey. -static NSString * const ERROR_OPTIONS = @"options error"; -static NSString * const ERROR_FILE_OPERATION = @"file operation error"; - -static NSString *PushyCode(const char *code) { - return [NSString stringWithUTF8String:code]; -} - // event def static NSString * const EVENT_PROGRESS_DOWNLOAD = @"RCTPushyDownloadProgress"; static NSString * const PARAM_PROGRESS_HASH = @"hash"; static NSString * const PARAM_PROGRESS_RECEIVED = @"received"; static NSString * const PARAM_PROGRESS_TOTAL = @"total"; -PUSHY_HIDDEN NSTimeInterval PushyMonotonicNow(void) { - return [NSProcessInfo processInfo].systemUptime; -} - -static NSString *PushyStagingDirForVersionDir(NSString *versionDir) { - return [versionDir stringByAppendingString:@(pushy::install_record::kStagingSuffix)]; -} - -// Parsed completion record (cpp/patch_core/install_record.h): nil when the -// file is absent or malformed; an empty dictionary for the legacy empty -// marker written by SDK < 10.53. -static NSDictionary *PushyReadInstallRecord(NSString *versionDir) { - NSString *path = [versionDir stringByAppendingPathComponent:VERSION_COMPLETE_FILE_NAME]; - NSData *data = [NSData dataWithContentsOfFile:path]; - if (data == nil) { - return nil; - } - if (data.length == 0) { - return @{}; - } - if (data.length > 64 * 1024) { - return nil; - } - id object = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; - return [object isKindOfClass:[NSDictionary class]] ? (NSDictionary *)object : nil; -} - -// Whether a non-empty record names this version under the current schema. -static BOOL PushyInstallRecordMatches(NSDictionary *record, NSString *hash) { - return [record[@"schema"] isKindOfClass:[NSNumber class]] - && [record[@"schema"] intValue] == pushy::install_record::kSchema - && [record[@"versionHash"] isKindOfClass:[NSString class]] - && [record[@"versionHash"] isEqualToString:hash]; -} - -// Whether the directory carries a completion record file at all (the legacy -// empty marker included). Only directories WITHOUT one are grandfathered by -// current/last state in switchVersion; a present record is always verified. -static BOOL PushyHasInstallRecordFile(NSString *versionDir) { - return [[NSFileManager defaultManager] fileExistsAtPath: - [versionDir stringByAppendingPathComponent:VERSION_COMPLETE_FILE_NAME]]; -} - -// Presence check (launch / dedup paths, no digest work): bundle present and -// the record exists and, unless legacy-empty, names this version. -PUSHY_HIDDEN BOOL PushyHasCompletedVersionAtPath(NSString *versionDir, NSString *hash) { - NSString *bundlePath = [versionDir stringByAppendingPathComponent:BUNDLE_FILE_NAME]; - if (![[NSFileManager defaultManager] fileExistsAtPath:bundlePath]) { - return NO; - } - NSDictionary *record = PushyReadInstallRecord(versionDir); - if (record == nil) { - return NO; - } - return record.count == 0 || PushyInstallRecordMatches(record, hash); -} - -// Identity of a file's bytes on disk without reading them: a rewrite or an -// atomic replacement (new inode) changes it. -struct PushyFileStamp { - bool exists = false; - dev_t device = 0; - ino_t inode = 0; - off_t size = 0; - struct timespec modified = {0, 0}; - - bool operator==(const PushyFileStamp &other) const { - return exists == other.exists && device == other.device && inode == other.inode - && size == other.size && modified.tv_sec == other.modified.tv_sec - && modified.tv_nsec == other.modified.tv_nsec; - } - bool operator!=(const PushyFileStamp &other) const { return !(*this == other); } -}; - -static PushyFileStamp PushyStampFile(NSString *path) { - PushyFileStamp stamp; - struct stat info; - if (path != nil && stat(path.fileSystemRepresentation, &info) == 0) { - stamp.exists = true; - stamp.device = info.st_dev; - stamp.inode = info.st_ino; - stamp.size = info.st_size; - stamp.modified = info.st_mtimespec; - } - return stamp; -} - -// Activation check: re-hashes the bundle against the record's digest, which -// schema 1 makes mandatory (only the legacy empty marker has none). -// Returns nil when the directory may be activated, the reason otherwise. -// Slow (a full read of the bundle): never call it under the state lock. On -// success *recordOut and *stampOut describe exactly what was verified, so the -// caller can confirm under the lock that nothing changed since. -static NSString *PushyVerifyInstallForActivation(NSString *versionDir, NSString *hash, - NSDictionary **recordOut, - PushyFileStamp *stampOut) { - NSString *bundlePath = [versionDir stringByAppendingPathComponent:BUNDLE_FILE_NAME]; - PushyFileStamp before = PushyStampFile(bundlePath); - NSDictionary *record = PushyReadInstallRecord(versionDir); - if (record == nil) { - return [NSString stringWithFormat:@"Bundle version %@ has no valid completion record.", hash]; - } - *recordOut = record; - *stampOut = before; - if (record.count == 0) { - return nil; - } - if (!PushyInstallRecordMatches(record, hash)) { - return [NSString stringWithFormat:@"Bundle version %@ completion record mismatch.", hash]; - } - NSString *expected = record[@"bundleSha256"]; - if (![expected isKindOfClass:[NSString class]] || expected.length == 0) { - return [NSString stringWithFormat:@"Bundle version %@ completion record mismatch.", hash]; - } - std::string actual = pushy::digest::Sha256File(PushyToStdString(bundlePath)); - if (PushyStampFile(bundlePath) != before) { - return [NSString stringWithFormat:@"Bundle version %@ changed while being verified.", hash]; - } - if ([[expected lowercaseString] isEqualToString:[NSString stringWithUTF8String:actual.c_str()]]) { - return nil; - } - return [NSString stringWithFormat:@"Bundle version %@ bundle digest mismatch.", hash]; -} - -// SHA-256 of each in-flight download's archive, keyed by hash; consumed by -// the completion record write. Guarded by @synchronized on the table. -static NSMutableDictionary *PushyArtifactDigests(void) { - static NSMutableDictionary *table; - static dispatch_once_t once; - dispatch_once(&once, ^{ - table = [NSMutableDictionary dictionary]; - }); - return table; -} - -static NSError *PushyErrorWithCode(const char *code, NSString *message); - -// nil when the volume holding `path` can take `bytesToWrite` plus the safety -// margin (or the free space is unknown); a PATCH_FAILED error otherwise. The -// probe is the downloader's (RCTPushyFreeSpaceShortfall), which applies the -// same rule to the announced Content-Length before the first byte lands. -static NSError *PushyEnsureFreeSpace(NSString *path, long long bytesToWrite) { - NSString *shortfall = RCTPushyFreeSpaceShortfall(path, bytesToWrite); - if (shortfall == nil) { - return nil; - } - return PushyErrorWithCode(pushy::error_codes::kDeltaFailed, shortfall); -} - -// SSZipArchive delegate enforcing cpp/patch_core/archive_limits.h while the -// archive is walked (entry count, per-entry size, total size, compression -// ratio) plus two entry-shape rules Android's java.util.zip gets for free: -// - no symlinks: SSZipArchive materialises them with any target regardless -// of preserveAttributes, so `assets -> ../../Documents` followed by -// `assets/x` would write outside the version directory; -// - no ".pushy-" reserved names at any depth: the completion marker is -// written by this SDK only, after the whole install succeeded. An archive -// shipping its own would be trusted as a finished install even if the -// patch step that follows fails. -// A NO from zipArchiveShouldUnzipFileAtIndex: cancels the whole unzip (the -// 2.x contract the podspec pins), and the completion handler turns the -// recorded violation into a hard PATCH_FAILED; the caller then discards the -// staging directory. -@interface PushyUnzipGuard : NSObject -@property (nonatomic, copy) NSString *violation; -@property (nonatomic, assign) long long totalUncompressed; -@end - -@implementation PushyUnzipGuard - -- (void)zipArchiveWillUnzipArchiveAtPath:(NSString *)path zipInfo:(unz_global_info)zipInfo { - if ((long long)zipInfo.number_entry > pushy::archive_limits::kMaxEntries) { - self.violation = [NSString stringWithFormat:@"archive has too many entries (%lu)", - (unsigned long)zipInfo.number_entry]; - } -} - -- (BOOL)zipArchiveShouldUnzipFileAtIndex:(NSInteger)fileIndex - totalFiles:(NSInteger)totalFiles - archivePath:(NSString *)archivePath - fileInfo:(unz_file_info)fileInfo { - if (self.violation != nil) { - return NO; - } - // Unix mode bits travel in the high 16 bits of the external attributes - // (the same field SSZipArchive keys its own symlink branch on). - if (((fileInfo.external_fa >> 16) & S_IFMT) == S_IFLNK) { - self.violation = [NSString stringWithFormat:@"archive entry #%ld is a symlink", - (long)fileIndex]; - return NO; - } - long long size = (long long)fileInfo.uncompressed_size; - long long compressed = (long long)fileInfo.compressed_size; - if (size > pushy::archive_limits::kMaxEntryBytes) { - self.violation = [NSString stringWithFormat:@"archive entry #%ld too large (%lld bytes)", - (long)fileIndex, size]; - return NO; - } - self.totalUncompressed += size; - if (self.totalUncompressed > pushy::archive_limits::kMaxTotalUncompressedBytes) { - self.violation = [NSString stringWithFormat:@"archive expands beyond %lld bytes", - pushy::archive_limits::kMaxTotalUncompressedBytes]; - return NO; - } - if (compressed > 0 && size > pushy::archive_limits::kRatioCheckMinBytes - && size / compressed > pushy::archive_limits::kMaxCompressionRatio) { - self.violation = [NSString stringWithFormat:@"archive entry #%ld compression ratio too high", - (long)fileIndex]; - return NO; - } - return YES; -} - -// The entry name is only exposed once the entry is on disk (shouldUnzip -// runs before the name is read). A reserved name is flagged here; the next -// shouldUnzip refuses (cancelling the unzip) or, for the last entry, the -// completion handler picks the violation up. Either way the caller drops -// the whole staging directory, so the written file never survives. -- (void)zipArchiveDidUnzipFileAtIndex:(NSInteger)fileIndex - totalFiles:(NSInteger)totalFiles - archivePath:(NSString *)archivePath - unzippedFilePath:(NSString *)unzippedFilePath { - NSString *name = unzippedFilePath.lastPathComponent; - if (self.violation == nil && [name hasPrefix:@".pushy-"]) { - self.violation = [NSString stringWithFormat:@"archive contains reserved entry %@", name]; - } -} - -@end - -static NSError *PushyDownloadDeadlineExpiredError(void) { - return [NSError errorWithDomain:RCTPushyErrorDomain - code:-1 - userInfo:@{ - NSLocalizedDescriptionKey: @"download deadline expired before start", - RCTPushyErrorCodeKey: PushyCode(pushy::error_codes::kDownloadFailed), - }]; -} - - -static std::atomic ignoreRollback{false}; // Whether the host asked +bundleURL for the bundle to load (the // isUsingBundleUrl constant, mirroring Android/Harmony). NO means the app // runs a bundle this SDK never resolved, so updates cannot take effect. static std::atomic pushyIsUsingBundleUrl{false}; -// Bumped by resetToPackagedBundle only (config replacement has its own -// pushyNativeConfigGeneration). 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. -PUSHY_HIDDEN 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 -// under a silent (no-restart) reset would break every image the running app -// 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 -// read→transform→write sequence must be atomic to avoid e.g. markSuccess being -// overwritten by a concurrent bundleURL and the version being rolled back. -static os_unfair_lock pushyStateLock = OS_UNFAIR_LOCK_INIT; - -PUSHY_HIDDEN void PushyWithStateLock(void (NS_NOESCAPE ^block)(void)) { -#if DEBUG - // Not re-entrant: a nested acquisition deadlocks in release, so catch it - // where it is written. - os_unfair_lock_assert_not_owner(&pushyStateLock); -#endif - os_unfair_lock_lock(&pushyStateLock); - @try { - block(); - } @finally { - os_unfair_lock_unlock(&pushyStateLock); - } -} -PUSHY_HIDDEN std::string PushyToStdString(NSString *value) { - if (value == nil) { - return std::string(); - } - return std::string([value UTF8String]); -} - -static NSError *PushyNSErrorFromStatus(const pushy::delta::Status &status) { - return [NSError errorWithDomain:RCTPushyErrorDomain - code:-1 - userInfo:@{ - NSLocalizedDescriptionKey: [NSString stringWithUTF8String:status.message.c_str()], - RCTPushyErrorCodeKey: PushyCode(pushy::error_codes::kDeltaFailed), - }]; -} - -PUSHY_HIDDEN NSUserDefaults *PushyDefaults(void) { - return [NSUserDefaults standardUserDefaults]; -} - -PUSHY_HIDDEN NSString *PushyFromStdString(const std::string &value) { - if (value.empty()) { - return nil; - } - return [NSString stringWithUTF8String:value.c_str()]; -} - -static void PushySetNullableString(NSUserDefaults *defaults, NSString *key, NSString *value) { - if (value != nil) { - [defaults setObject:value forKey:key]; - } else { - [defaults removeObjectForKey:key]; - } -} - -PUSHY_HIDDEN NSString *PushyHashInfoKey(NSString *hash) { - return [keyHashInfo stringByAppendingString:hash ?: @""]; -} - -static NSString *PushyOptionString(NSDictionary *options, NSString *key) { - return [RCTConvert NSString:options[key]]; -} - -static BOOL PushyStringIsBlank(NSString *value) { - if (value == nil || [value isKindOfClass:[NSNull class]]) { - return YES; - } - return [[value stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] length] == 0; -} - -// Server-provided identifiers (hash/originHash) are used as child names under -// the download root; anything that could resolve outside of it (path -// separators, "..", ".") must be rejected before touching the filesystem. -PUSHY_HIDDEN BOOL PushyIsSafePathComponent(NSString *value) { - if (PushyStringIsBlank(value)) { - return NO; - } - if ([value isEqualToString:@"."] || [value isEqualToString:@".."]) { - return NO; - } - if ([value containsString:@"/"] || [value containsString:@"\\"]) { - return NO; - } - if ([value rangeOfString:@"\0"].location != NSNotFound) { - return NO; - } - return YES; -} - -static void PushyRejectError(RCTPromiseRejectBlock reject, NSError *error) { - // Prefer the stable cross-platform code (error_codes.h); fall back to the - // numeric NSError code for system errors that were not classified. - NSString *code = error.userInfo[RCTPushyErrorCodeKey]; - if (code == nil) { - code = [NSString stringWithFormat:@"%ld", (long)error.code]; - } - reject(code, error.localizedDescription, error); -} - -static NSError *PushyErrorWithCode(const char *code, NSString *message) { - return [NSError errorWithDomain:RCTPushyErrorDomain - code:-1 - userInfo:@{ - NSLocalizedDescriptionKey: message ?: @"unknown error", - RCTPushyErrorCodeKey: PushyCode(code), - }]; -} - -static BOOL PushyJsonIsAbsent(id value) { - return value == nil || [value isKindOfClass:[NSNull class]]; -} - -// Builds the patch-core manifest from a parsed __diff.json. Every field is -// type-checked: this runs inside a GCD block on _fileQueue, where the -// unrecognized-selector exception a `copies` array or a numeric `from` would -// raise takes the whole app down (and trips the crash rescue) instead of -// failing the patch. NO with `reason` set for a malformed manifest. -static BOOL PushyPatchManifestFromJson(NSDictionary *json, - pushy::delta::PatchManifest *manifest, - NSString **reason) { - id copies = json[@"copies"]; - if (!PushyJsonIsAbsent(copies)) { - if (![copies isKindOfClass:[NSDictionary class]]) { - *reason = @"delta manifest: copies is not an object"; - return NO; - } - // Content checksum per copy target ("copiesCrc", pdiff manifests - // from CLI >= 2.21.2). The patch core verifies the copy source - // against it and fails the patch on mismatch, so the JS strategy - // chain falls back to the full package instead of copying drifted - // bytes from a rebuilt binary. - NSDictionary *copiesCrc = json[@"copiesCrc"]; - if (![copiesCrc isKindOfClass:[NSDictionary class]]) { - copiesCrc = nil; - } - for (id to in (NSDictionary *)copies) { - id from = ((NSDictionary *)copies)[to]; - if (![to isKindOfClass:[NSString class]] || ![from isKindOfClass:[NSString class]]) { - *reason = @"delta manifest: copies entry is not a string"; - return NO; - } - if ([from length] == 0) { - from = to; - } - pushy::delta::CopyOperation operation; - operation.from = PushyToStdString(from); - operation.to = PushyToStdString(to); - NSNumber *expectedCrc = copiesCrc[to]; - if ([expectedCrc isKindOfClass:[NSNumber class]]) { - operation.has_expected_crc = true; - operation.expected_crc = (uint32_t)[expectedCrc unsignedLongLongValue]; - } - manifest->copies.push_back(operation); - } - } - - // The CLI emits `deletes` as an object keyed by path (Android reads its - // keys); a plain array of paths is accepted as well, like Harmony. - id deletes = json[@"deletes"]; - if (!PushyJsonIsAbsent(deletes)) { - if (![deletes isKindOfClass:[NSDictionary class]] && ![deletes isKindOfClass:[NSArray class]]) { - *reason = @"delta manifest: deletes is not an object or array"; - return NO; - } - for (id path in deletes) { - if (![path isKindOfClass:[NSString class]]) { - *reason = @"delta manifest: deletes entry is not a string"; - return NO; - } - manifest->deletes.push_back(PushyToStdString(path)); - } - } - return YES; -} - -// Binary identity under the prefixed key, or — until the first state write -// migrates it — the value an earlier SDK left under the unprefixed one, so an -// installed update survives the SDK upgrade instead of reading as a rebuilt -// binary. -static NSString *PushyBinaryIdentityValue(NSUserDefaults *defaults, NSString *key, NSString *legacyKey) { - return [defaults stringForKey:key] ?: [defaults stringForKey:legacyKey]; -} - -PUSHY_HIDDEN pushy::state::State PushyStateFromDefaults(NSUserDefaults *defaults) { - pushy::state::State state; - state.package_version = PushyToStdString( - PushyBinaryIdentityValue(defaults, paramPackageVersion, legacyParamPackageVersion)); - state.build_time = PushyToStdString( - PushyBinaryIdentityValue(defaults, paramBuildTime, legacyParamBuildTime)); - NSDictionary *pushyInfo = [defaults dictionaryForKey:keyPushyInfo]; - if (pushyInfo != nil) { - state.current_version = PushyToStdString(pushyInfo[paramCurrentVersion]); - state.last_version = PushyToStdString(pushyInfo[paramLastVersion]); - state.first_time = [pushyInfo[paramIsFirstTime] boolValue]; - id firstLoadOk = pushyInfo[paramIsFirstLoadOk]; - state.first_time_ok = firstLoadOk == nil ? true : [firstLoadOk boolValue]; - } - state.rolled_back_version = PushyToStdString([defaults stringForKey:keyRolledBackMarked]); - return state; -} - -static void PushyApplyStateToDefaults(NSUserDefaults *defaults, const pushy::state::State &state) { - PushySetNullableString(defaults, paramPackageVersion, PushyFromStdString(state.package_version)); - PushySetNullableString(defaults, paramBuildTime, PushyFromStdString(state.build_time)); - // One-time migration: once the prefixed keys hold the identity, the - // legacy ones are retired so a host-app write there is never read again. - [defaults removeObjectForKey:legacyParamPackageVersion]; - [defaults removeObjectForKey:legacyParamBuildTime]; - - BOOL hasPushyInfo = !state.current_version.empty() || !state.last_version.empty() || state.first_time || !state.first_time_ok; - if (hasPushyInfo) { - NSMutableDictionary *newInfo = [[NSMutableDictionary alloc] init]; - if (!state.current_version.empty()) { - newInfo[paramCurrentVersion] = PushyFromStdString(state.current_version); - } - if (!state.last_version.empty()) { - newInfo[paramLastVersion] = PushyFromStdString(state.last_version); - } - newInfo[paramIsFirstTime] = @(state.first_time); - newInfo[paramIsFirstLoadOk] = @(state.first_time_ok); - [defaults setObject:newInfo forKey:keyPushyInfo]; - } else { - [defaults removeObjectForKey:keyPushyInfo]; - } - - PushySetNullableString( - defaults, - keyRolledBackMarked, - 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. -PUSHY_HIDDEN 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; -} - - - -// Single-flight for the digest in PushyBundleHashSync: the delayed cold-start -// round (utility queue) and JS getBundleHash (_fileQueue) can both miss the -// cache right after an install; the second caller waits for the first and -// then reads its cache entry instead of hashing the same multi-MB file twice. -static NSLock *PushyBundleHashLock(void) { - static NSLock *lock; - static dispatch_once_t once; - dispatch_once(&once, ^{ - lock = [NSLock new]; +// Triggers a JS reload. React Native re-resolves the bundle URL itself on +// reload (RCTHost asks its bundleURLProvider, RCTBridge.setUp re-asks +// sourceURLForBridge:), so +bundleURL is deliberately NOT called here: it +// consumes one-shot launch state (first_time, the first-load mark, +// ignoreRollback), and consuming it for a reload that never happens would +// make the next cold start roll the freshly switched version back. +static void PushyTriggerReload(NSString *reason) { + dispatch_async(dispatch_get_main_queue(), ^{ + RCTTriggerReloadCommandListeners(reason); }); - return lock; } -static NSString *PushyCachedBundleHash(NSUserDefaults *defaults, NSString *cachedPrefix) { - NSString *cached = [defaults stringForKey:keyBundleHashCache]; - if ([cached hasPrefix:cachedPrefix]) { - return [cached substringFromIndex:cachedPrefix.length]; - } - return nil; -} - -// Shared by the getBundleHash RCT method and the native cold-start check. -// Returns @"" when unknown. With computeIfMissing the sha256 of the embedded -// bundle is taken on a cache miss (once per install; blocking — call off the -// main thread). Without it only the cache is consulted: a crash rescue on -// the main thread has a 3.5s budget in total, and hashing a large bundle on -// a fresh install would spend all of it before the first request. -PUSHY_HIDDEN NSString *PushyBundleHashSync(BOOL computeIfMissing) { - 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 *cachedPrefix = [cacheKey stringByAppendingString:@"|"]; - NSString *cached = PushyCachedBundleHash(defaults, cachedPrefix); - if (cached != nil) { - return cached; - } - if (!computeIfMissing) { - return @""; - } - - NSLock *lock = PushyBundleHashLock(); - [lock lock]; - @try { - // The previous holder may have filled the cache while we waited. - cached = PushyCachedBundleHash(defaults, cachedPrefix); - if (cached != nil) { - return cached; - } - NSString *hash = PushyFromStdString( - pushy::digest::Sha256File(PushyToStdString(path))) ?: @""; - if (hash.length > 0) { - [defaults setObject:[cachedPrefix stringByAppendingString:hash] - forKey:keyBundleHashCache]; - } - return hash; - } @finally { - [lock unlock]; - } +// A promise rejection for an exception caught around a state transition. +static void PushyRejectException(RCTPromiseRejectBlock reject, const char *code, + NSException *exception, NSString *fallback) { + PushyRejectError(reject, PushyErrorWithCode(code, exception.reason ?: fallback)); } // Host preparation runs off the caller's thread; the completion is delivered @@ -845,18 +55,15 @@ PUSHY_HIDDEN void PushySwitchVersionLocked(NSString *hash) { // every waiter when the round ends. static void PushyRunHostPreparation(NSDictionary *(^work)(void), RCTPushyBundlePreparationCompletion completion) { - static dispatch_queue_t hostQueue; - static dispatch_once_t once; - dispatch_once(&once, ^{ - hostQueue = dispatch_queue_create("cn.reactnative.pushy.host-sync", DISPATCH_QUEUE_CONCURRENT); - }); + static dispatch_queue_t hostQueue = + dispatch_queue_create("cn.reactnative.pushy.host-sync", DISPATCH_QUEUE_CONCURRENT); dispatch_async(hostQueue, ^{ NSDictionary *result; @try { result = work(); } @catch (NSException *exception) { RCTLogWarn(@"RCTPushy -- native host sync failed: %@", exception.reason); - result = PushyHostResult(@"failed", @"internal_error", nil, NO); + result = PushyHostResult(@"failed", @"internal_error"); } if (completion != nil) { NSDictionary *snapshot = [result copy]; @@ -868,7 +75,6 @@ static void PushyRunHostPreparation(NSDictionary *(^work)(void), } @implementation RCTPushy { - dispatch_queue_t _fileQueue; bool hasListeners; } @@ -903,134 +109,14 @@ + (NSURL *)bundleURL } } -// Resolves the bundle to launch from the persisted state; nil means the -// packaged bundle. Consumes the launch's one-shot state (first_time), so a -// second call is only valid after something re-armed it (a switchVersion). -// purgedVersion is set on tvOS when the installed version's files are gone. -+ (NSURL *)resolveLaunchBundleURL:(NSString **)launchRolledBackVersionOut - purgedVersion:(NSString **)purgedVersionOut -{ - __block NSURL *resolvedURL = nil; - __block NSString *launchRolledBackVersion = nil; - __block NSString *purgedVersion = nil; - @try { - PushyWithStateLock(^{ - NSUserDefaults *defaults = PushyDefaults(); - - NSString *curPackageVersion = [RCTPushy packageVersion]; - NSString *curBuildTime = [RCTPushy buildTime]; - - pushy::state::State state = PushyStateFromDefaults(defaults); - pushy::state::BinaryVersionSyncResult sync = pushy::state::SyncBinaryVersion( - state, - PushyToStdString(curPackageVersion), - PushyToStdString(curBuildTime) - ); - if (sync.changed) { - [defaults setObject:@(YES) forKey:KeyPackageUpdatedMarked()]; - state = sync.state; - PushyApplyStateToDefaults(defaults, state); - } - - if (!state.current_version.empty()) { - std::string const versionBeforeLaunch = state.current_version; - pushy::state::LaunchDecision decision = pushy::state::ResolveLaunchState( - state, - ignoreRollback.load(), - true - ); - state = decision.state; - - if (decision.did_rollback) { - // The crash-protection rollback: the new version never called - // markSuccess. Keep this visible in release logs. - RCTLogWarn(@"RCTPushy -- version %@ was not marked as successful, rolled back to %@", - PushyFromStdString(versionBeforeLaunch), - PushyFromStdString(state.current_version)); - } - if (decision.did_rollback || decision.consumed_first_time) { - PushyApplyStateToDefaults(defaults, state); - } - if (decision.consumed_first_time) { - // bundleURL may be called many times, ignore rollbacks before process restarted again. - ignoreRollback = true; - [defaults setObject:@(YES) forKey:keyFirstLoadMarked]; - } - - NSString *loadVersion = PushyFromStdString(decision.load_version); - NSString *downloadDir = [RCTPushy downloadDir]; - // 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). - NSMutableSet *visitedVersions = [NSMutableSet set]; - while (loadVersion.length && ![visitedVersions containsObject:loadVersion]) { - [visitedVersions addObject:loadVersion]; - NSString *bundlePath = [[downloadDir stringByAppendingPathComponent:loadVersion] stringByAppendingPathComponent:BUNDLE_FILE_NAME]; - if ([[NSFileManager defaultManager] fileExistsAtPath:bundlePath isDirectory:NULL]) { - pushyLaunchVersion = loadVersion; - resolvedURL = [NSURL fileURLWithPath:bundlePath]; - break; - } else { -#if TARGET_OS_TV - // Caches is purgeable on tvOS: a missing bundle is the OS - // reclaiming space, not a bad release. Everything under - // rctpushy went with it, so walking back to last_version - // is pointless, and a rollback mark would make this - // launch's check refuse to reinstall the version. - RCTLogWarn(@"RCTPushy -- bundle version %@ was purged by the system", loadVersion); - purgedVersion = loadVersion; - state = pushy::state::ForgetPurgedVersions(state); - PushyApplyStateToDefaults(defaults, state); - if (decision.consumed_first_time) { - // The first load being consumed never happens: the - // packaged bundle must not report isFirstTime. - ignoreRollback = false; - [defaults removeObjectForKey:keyFirstLoadMarked]; - } - break; -#else - RCTLogError(@"RCTPushy -- bundle version %@ not found, rolling back", loadVersion); - state = pushy::state::Rollback(state); - PushyApplyStateToDefaults(defaults, state); - loadVersion = PushyFromStdString(state.current_version); -#endif - } - } - } - // 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); - }); - } @finally { - *launchRolledBackVersionOut = launchRolledBackVersion; - *purgedVersionOut = purgedVersion; - } - return resolvedURL; -} - -+ (NSString *) rollback { - __block NSString *currentVersion = nil; - PushyWithStateLock(^{ - NSUserDefaults *defaults = PushyDefaults(); - pushy::state::State state = pushy::state::Rollback(PushyStateFromDefaults(defaults)); - PushyApplyStateToDefaults(defaults, state); - currentVersion = PushyFromStdString(state.current_version); - }); - return currentVersion; -} - + (void)configure:(NSDictionary *)options completion:(RCTPushyConfigurationCompletion)completion { NSError *validationError = nil; // Snapshot nested mutable caller values before crossing a queue boundary. NSString *config = RCTPushyNormalizeConfiguration(options, &validationError); - static dispatch_queue_t configQueue; - static dispatch_once_t once; - dispatch_once(&once, ^{ - configQueue = dispatch_queue_create("cn.reactnative.pushy.host-config", DISPATCH_QUEUE_SERIAL); - }); + static dispatch_queue_t configQueue = + dispatch_queue_create("cn.reactnative.pushy.host-config", DISPATCH_QUEUE_SERIAL); dispatch_async(configQueue, ^{ NSError *failure = validationError; if (config != nil) { @@ -1093,8 +179,7 @@ - (NSDictionary *)constantsToExport // bundleSha256 from the install record, for crash-report attribution. NSString *bundleSha256 = @""; if (PushyIsSafePathComponent(currentVersion)) { - NSDictionary *record = PushyReadInstallRecord( - [[RCTPushy downloadDir] stringByAppendingPathComponent:currentVersion]); + NSDictionary *record = PushyReadInstallRecord(PushyVersionDir(currentVersion)); if ([record[@"bundleSha256"] isKindOfClass:[NSString class]]) { bundleSha256 = record[@"bundleSha256"]; } @@ -1108,36 +193,18 @@ - (NSDictionary *)constantsToExport if (ret[@"rolledBackVersion"] != nil) { [defaults removeObjectForKey:keyRolledBackMarked]; - [self clearInvalidFiles]; + [RCTPushyInstaller clearInvalidFiles]; } if ([[defaults objectForKey:KeyPackageUpdatedMarked()] boolValue]) { [defaults removeObjectForKey:KeyPackageUpdatedMarked()]; - [self clearInvalidFiles]; + [RCTPushyInstaller clearInvalidFiles]; } }); return ret; } -- (instancetype)init -{ - self = [super init]; - if (self) { - // One process-wide serial queue, not per-instance: a bridge reload can - // briefly keep two RCTPushy instances alive, and destructive file work - // (resetToPackagedBundle's full cleanup) must stay serialized with the - // other instance's unzip/patch jobs. - static dispatch_queue_t sharedFileQueue; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - sharedFileQueue = dispatch_queue_create("cn.reactnative.pushy.file", DISPATCH_QUEUE_SERIAL); - }); - _fileQueue = sharedFileQueue; - } - return self; -} - RCT_EXPORT_METHOD(setUuid:(NSString *)uuid resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { @@ -1162,10 +229,8 @@ - (instancetype)init 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]]) { + if (PushyJsonDictionary(config, &error) == nil) { PushyRejectError(reject, PushyErrorWithCode( pushy::error_codes::kInvalidOptions, error != nil ? error.localizedDescription : ERROR_OPTIONS)); @@ -1175,8 +240,8 @@ - (instancetype)init [RCTPushyOrchestrator persistConfiguration:config]; resolve(@true); } @catch (NSException *exception) { - PushyRejectError(reject, PushyErrorWithCode(pushy::error_codes::kFileOperationFailed, - exception.reason ?: @"Native configuration failed")); + PushyRejectException(reject, pushy::error_codes::kFileOperationFailed, exception, + @"Native configuration failed"); } } @@ -1207,13 +272,9 @@ - (instancetype)init return; } - NSData *data = [value dataUsingEncoding:NSUTF8StringEncoding]; NSError *error = nil; - id object = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error]; - if (object && [object isKindOfClass:[NSDictionary class]]) { - NSUserDefaults *defaults = PushyDefaults(); - [defaults setObject:value forKey:PushyHashInfoKey(hash)]; - + if (PushyJsonDictionary(value, &error) != nil) { + [PushyDefaults() setObject:value forKey:PushyHashInfoKey(hash)]; resolve(@true); } else { PushyRejectError(reject, PushyErrorWithCode( @@ -1267,7 +328,7 @@ - (instancetype)init rejecter:(RCTPromiseRejectBlock)reject) { NSError *error = nil; - if (![self switchVersion:PushyOptionString(options, @"hash") error:&error]) { + if (![RCTPushyInstaller switchVersion:PushyOptionString(options, @"hash") error:&error]) { PushyRejectError(reject, error); return; } @@ -1280,27 +341,19 @@ - (instancetype)init rejecter:(RCTPromiseRejectBlock)reject) { NSError *error = nil; - if (![self switchVersion:PushyOptionString(options, @"hash") error:&error]) { + if (![RCTPushyInstaller switchVersion:PushyOptionString(options, @"hash") error:&error]) { PushyRejectError(reject, error); return; } - NSError *reloadError = [self restartBridgeWithReason:@"pushy restart"]; - if (reloadError != nil) { - PushyRejectError(reject, reloadError); - return; - } + PushyTriggerReload(@"pushy restart"); resolve(@true); } RCT_EXPORT_METHOD(restartApp:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { - NSError *reloadError = [self restartBridgeWithReason:@"pushy restartApp"]; - if (reloadError != nil) { - PushyRejectError(reject, reloadError); - return; - } + PushyTriggerReload(@"pushy restartApp"); resolve(@true); } @@ -1318,7 +371,7 @@ - (instancetype)init // is running. Mirrors the dev behaviour of buildTime. resolve(@""); #else - dispatch_async(_fileQueue, ^{ + dispatch_async(PushyFileQueue(), ^{ resolve(PushyBundleHashSync(YES)); }); #endif @@ -1344,12 +397,12 @@ - (instancetype)init PushyApplyStateToDefaults(defaults, result.state); }); } @catch (NSException *exception) { - PushyRejectError(reject, PushyErrorWithCode(pushy::error_codes::kMarkSuccessFailed, - exception.reason ?: @"markSuccess failed")); + PushyRejectException(reject, pushy::error_codes::kMarkSuccessFailed, exception, + @"markSuccess failed"); return; } - [self clearInvalidFiles]; + [RCTPushyInstaller clearInvalidFiles]; resolve(@true); #endif } @@ -1395,12 +448,11 @@ - (instancetype)init ignoreRollback = false; }); } @catch (NSException *exception) { - PushyRejectError(reject, PushyErrorWithCode(pushy::error_codes::kResetFailed, - exception.reason ?: @"reset failed")); + PushyRejectException(reject, pushy::error_codes::kResetFailed, exception, @"reset failed"); return; } - dispatch_async(_fileQueue, ^{ + dispatch_async(PushyFileQueue(), ^{ // maxAgeDays=0: remove every downloaded entry except the running // version's directory (cleaned up by the next regular cleanup). The // state was just wiped, so that is the only name left to keep. The @@ -1424,7 +476,6 @@ - (instancetype)init } - #pragma mark - private - (NSArray *)supportedEvents { @@ -1446,7 +497,17 @@ - (void)fetchPackage:(PushyType)type resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject { - [self performFetch:type options:options callback:^(NSError *error) { + NSString *hash = PushyOptionString(options, @"hash") ?: @""; + 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), + }]; + } + }; + [RCTPushyInstaller fetch:type options:options progress:progress callback:^(NSError *error) { if (error != nil) { if (error.userInfo[RCTPushyErrorCodeKey] == nil) { // Unclassified (system/network) errors from the download @@ -1461,691 +522,6 @@ - (void)fetchPackage:(PushyType)type }]; } -// Triggers a JS reload; returns nil when one was scheduled, a RESTART_FAILED -// error when this host cannot restart. React Native re-resolves the bundle -// URL itself on reload (RCTHost asks its bundleURLProvider, RCTBridge.setUp -// re-asks sourceURLForBridge:), so +bundleURL is deliberately NOT called -// here: it consumes one-shot launch state (first_time, the first-load mark, -// ignoreRollback), and consuming it for a reload that never happens would -// make the next cold start roll the freshly switched version back. -- (NSError *)restartBridgeWithReason:(NSString *)reason -{ -#if PUSHY_HAS_RELOAD_COMMAND - dispatch_async(dispatch_get_main_queue(), ^{ - RCTTriggerReloadCommandListeners(reason); - }); - return nil; -#else - // No RCTReloadCommand in this React Native: only the legacy bridge can - // reload. Under bridgeless the module's bridge is nil or an RCTBridgeProxy - // whose -reload is a logged no-op (object_getClass avoids messaging the - // proxy) — report that instead of resolving a restart that never happens. - RCTBridge *bridge = self.bridge; - if (bridge == nil || object_getClass(bridge) == NSClassFromString(@"RCTBridgeProxy")) { - return PushyErrorWithCode(pushy::error_codes::kRestartFailed, - @"no bridge available to restart (bridgeless host without the RN restart command)"); - } - dispatch_async(dispatch_get_main_queue(), ^{ - [bridge reload]; - }); - return nil; -#endif -} - -- (void)performFetch:(PushyType)type options:(NSDictionary *)options callback:(void (^)(NSError *error))callback -{ - NSString *updateUrl = PushyOptionString(options, PushyOptionUrlKey()); - NSString *hash = PushyOptionString(options, @"hash"); - - if (PushyStringIsBlank(updateUrl) || !PushyIsSafePathComponent(hash)) { - callback(PushyErrorWithCode(pushy::error_codes::kInvalidOptions, ERROR_OPTIONS)); - return; - } - NSString *originHash = PushyOptionString(options, @"originHash"); - if (type == PushyTypePatchFromPpk && !PushyIsSafePathComponent(originHash)) { - callback(PushyErrorWithCode(pushy::error_codes::kInvalidOptions, ERROR_OPTIONS)); - return; - } - - NSString *dir = [RCTPushy downloadDir]; - BOOL success = [self ensureDirectoryExistsAtPath:dir]; - if (!success) { - callback(PushyErrorWithCode(pushy::error_codes::kFileOperationFailed, ERROR_FILE_OPERATION)); - return; - } - - NSString *unzipDir = [dir stringByAppendingPathComponent:hash]; - if (PushyHasCompletedVersionAtPath(unzipDir, hash)) { - 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 performFetch: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. - void (^completion)(NSError *) = ^(NSError *error) { - // 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; - NSFileManager *fileManager = [NSFileManager defaultManager]; - NSString *staging = PushyStagingDirForVersionDir(unzipDir); - NSString *artifactSha256 = nil; - @synchronized (PushyArtifactDigests()) { - artifactSha256 = PushyArtifactDigests()[hash]; - [PushyArtifactDigests() removeObjectForKey:hash]; - } - if (finalError == nil) { - // Two-phase install (cpp/patch_core/install_record.h): the - // completion record with the final bundle's digest goes into - // the staging directory, which is then renamed over the - // version directory in one atomic step. - NSString *bundlePath = [staging stringByAppendingPathComponent:BUNDLE_FILE_NAME]; - // The digest is mandatory: a version whose bundle could not - // be hashed is never recorded as installed. - std::string bundleSha256; - if (![fileManager fileExistsAtPath:bundlePath]) { - finalError = PushyErrorWithCode(pushy::error_codes::kDeltaFailed, - @"bundle missing after install"); - } else if ((bundleSha256 = pushy::digest::Sha256File(PushyToStdString(bundlePath))).empty()) { - finalError = PushyErrorWithCode(pushy::error_codes::kDeltaFailed, - @"bundle digest failed after install"); - } else { - NSMutableDictionary *record = [NSMutableDictionary dictionary]; - record[@"schema"] = @(pushy::install_record::kSchema); - record[@"versionHash"] = hash; - record[@"bundleSha256"] = [NSString stringWithUTF8String:bundleSha256.c_str()]; - if (artifactSha256.length > 0) { - record[@"artifactSha256"] = artifactSha256; - } - NSError *recordError = nil; - NSData *recordData = [NSJSONSerialization dataWithJSONObject:record options:0 error:&recordError]; - NSString *marker = [staging stringByAppendingPathComponent:VERSION_COMPLETE_FILE_NAME]; - if (recordData == nil || ![recordData writeToFile:marker - options:NSDataWritingAtomic - error:&recordError]) { - finalError = recordError ?: PushyErrorWithCode( - pushy::error_codes::kFileOperationFailed, - @"failed to write completion record"); - } else { - if ([fileManager fileExistsAtPath:unzipDir]) { - [fileManager removeItemAtPath:unzipDir error:nil]; - } - NSError *moveError = nil; - if (![fileManager moveItemAtPath:staging toPath:unzipDir error:&moveError]) { - finalError = moveError ?: PushyErrorWithCode( - pushy::error_codes::kFileOperationFailed, - @"failed to promote staging directory"); - } - } - } - } - if (finalError != nil) { - // Only the staging directory is ours to drop: the final - // version directory is never touched by a failed install. - [fileManager removeItemAtPath:staging error:nil]; - } - PushyFinishDownload(hash, finalError); - }); - }; - - RCTLogInfo(@"RCTPushy -- download file %@", updateUrl); - 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); - return; - } - [self unzipDownloadedPackage:zipFilePath - hash:hash - type:type - originHash:originHash - callback:completion]; - }]; -} - -- (void)unzipDownloadedPackage:(NSString *)zipFilePath - hash:(NSString *)hash - type:(PushyType)type - originHash:(NSString *)originHash - callback:(void (^)(NSError *error))callback -{ - RCTLogInfo(@"RCTPushy -- unzip file %@", zipFilePath); - // Everything lands in .staging; the completion block promotes it. - NSString *unzipFilePath = PushyStagingDirForVersionDir( - [[RCTPushy downloadDir] stringByAppendingPathComponent:hash]); - dispatch_async(_fileQueue, ^{ - // Archive digest for the completion record, taken before the unzip - // consumes the file (same serial queue, so it runs first). - std::string digest = pushy::digest::Sha256File(PushyToStdString(zipFilePath)); - @synchronized (PushyArtifactDigests()) { - PushyArtifactDigests()[hash] = [NSString stringWithUTF8String:digest.c_str()]; - } - }); - [self unzipFileAtPath:zipFilePath - toDestination:unzipFilePath - completionHandler:^(NSError *error) { - dispatch_async(self->_fileQueue, ^{ - if (error != nil) { - callback(error); - return; - } - @try { - [self finishDownloadedPackage:hash type:type originHash:originHash callback:callback]; - } @catch (NSException *exception) { - // An uncaught exception in a GCD block is fatal (and would - // trip the crash rescue); a bad package is a PATCH_FAILED. - callback(PushyErrorWithCode(pushy::error_codes::kDeltaFailed, - exception.reason ?: @"delta failed")); - } - }); - }]; -} - -- (void)finishDownloadedPackage:(NSString *)hash - type:(PushyType)type - originHash:(NSString *)originHash - callback:(void (^)(NSError *error))callback -{ - switch (type) { - case PushyTypePatchFromPackage: - [self applyDeltaForHash:hash - type:type - fromBundle:[[RCTPushy binaryBundleURL] path] - source:[[NSBundle mainBundle] resourcePath] - callback:callback]; - return; - case PushyTypePatchFromPpk: { - NSString *lastVersionDir = [[RCTPushy downloadDir] stringByAppendingPathComponent:originHash]; - [self applyDeltaForHash:hash - type:type - fromBundle:[lastVersionDir stringByAppendingPathComponent:BUNDLE_FILE_NAME] - source:lastVersionDir - callback:callback]; - return; - } - case PushyTypeFullDownload: - callback(nil); - return; - } -} - -- (void)applyDeltaForHash:(NSString *)hash - type:(PushyType)type - fromBundle:(NSString *)bundleOrigin - source:(NSString *)sourceOrigin - callback:(void (^)(NSError *error))callback -{ - // Patch work happens in the staging directory (two-phase install). - NSString *unzipDir = PushyStagingDirForVersionDir( - [[RCTPushy downloadDir] stringByAppendingPathComponent:hash]); - NSString *sourcePatch = [unzipDir stringByAppendingPathComponent:SOURCE_PATCH_NAME]; - NSString *bundlePatch = [unzipDir stringByAppendingPathComponent:BUNDLE_PATCH_NAME()]; - - NSString *destination = [unzipDir stringByAppendingPathComponent:BUNDLE_FILE_NAME]; - long long manifestBytes = RCTPushyFileSize(sourcePatch); - if (manifestBytes > pushy::archive_limits::kMaxManifestBytes) { - callback(PushyErrorWithCode(pushy::error_codes::kDeltaFailed, - [NSString stringWithFormat:@"delta manifest too large: %lld bytes", manifestBytes])); - return; - } - NSData *data = [NSData dataWithContentsOfFile:sourcePatch]; - if (data == nil) { - callback(PushyErrorWithCode(pushy::error_codes::kDeltaFailed, @"missing delta manifest")); - return; - } - - NSError *error = nil; - id jsonObject = [NSJSONSerialization JSONObjectWithData:data options:NSJSONReadingAllowFragments error:&error]; - if (error != nil) { - // Classify as a patch failure like the sibling manifest branches; - // unclassified errors would otherwise be tagged DOWNLOAD_FAILED by the - // fetchPackage fallback even though the download itself succeeded. - callback(PushyErrorWithCode(pushy::error_codes::kDeltaFailed, error.localizedDescription)); - return; - } - if (![jsonObject isKindOfClass:[NSDictionary class]]) { - callback(PushyErrorWithCode(pushy::error_codes::kDeltaFailed, @"invalid delta manifest")); - return; - } - NSDictionary *json = (NSDictionary *)jsonObject; - pushy::delta::PatchManifest manifest; - NSString *manifestReason = nil; - if (!PushyPatchManifestFromJson(json, &manifest, &manifestReason)) { - callback(PushyErrorWithCode(pushy::error_codes::kDeltaFailed, manifestReason)); - return; - } - - std::vector entryNames; - if ([[NSFileManager defaultManager] fileExistsAtPath:sourcePatch isDirectory:NULL]) { - entryNames.push_back(PushyToStdString(SOURCE_PATCH_NAME)); - } - if ([[NSFileManager defaultManager] fileExistsAtPath:bundlePatch isDirectory:NULL]) { - entryNames.push_back(PushyToStdString(BUNDLE_PATCH_NAME())); - } - - pushy::archive_patch::ArchivePatchPlan plan; - pushy::delta::Status planStatus = pushy::archive_patch::BuildArchivePatchPlan( - type == PushyTypePatchFromPackage - ? pushy::archive_patch::ArchivePatchType::kPatchFromPackage - : pushy::archive_patch::ArchivePatchType::kPatchFromPpk, - manifest, - entryNames, - &plan - ); - if (!planStatus.ok) { - callback(PushyNSErrorFromStatus(planStatus)); - return; - } - - pushy::delta::FileSourcePatchOptions options; - pushy::delta::Status optionStatus = pushy::archive_patch::BuildFileSourcePatchOptions( - plan, - PushyToStdString(sourceOrigin), - PushyToStdString(unzipDir), - PushyToStdString(bundleOrigin), - PushyToStdString(bundlePatch), - PushyToStdString(destination), - &options - ); - if (!optionStatus.ok) { - callback(PushyNSErrorFromStatus(optionStatus)); - return; - } - - // __diff.json 的 hbcTransform 元数据(HBC 变换域 patch,hdiffv2 轨道): - // 存在时透传给 patch 内核执行 T(origin) → hpatch → T⁻¹;缺失走现状路径。 - NSDictionary *hbcTransform = json[@"hbcTransform"]; - if ([hbcTransform isKindOfClass:[NSDictionary class]]) { - NSDictionary *meta = hbcTransform[BUNDLE_PATCH_NAME()]; - if ([meta isKindOfClass:[NSDictionary class]]) { - NSError *metaError = nil; - NSData *metaData = [NSJSONSerialization dataWithJSONObject:meta options:0 error:&metaError]; - if (metaData != nil && metaError == nil) { - NSString *metaString = [[NSString alloc] initWithData:metaData encoding:NSUTF8StringEncoding]; - options.bundle_hbc_transform_meta = PushyToStdString(metaString); - } - } - } - - pushy::delta::Status status = pushy::delta::ApplyPatchFromFileSource(options); - if (!status.ok) { - callback(PushyNSErrorFromStatus(status)); - return; - } - - callback(nil); -} - -- (BOOL)switchVersion:(NSString *)hash error:(NSError **)error -{ - if (!PushyIsSafePathComponent(hash)) { - if (error != NULL) { - *error = PushyErrorWithCode(pushy::error_codes::kInvalidOptions, ERROR_OPTIONS); - } - return NO; - } - - // Same rule as Android: only a version this SDK recorded as completely - // installed (bundle + marker) may be activated. Markerless versions - // activated before markers existed are grandfathered through current/last - // state; any other markerless directory may be a crash-left partial - // install or a directory something else put there. A directory that does - // carry a record is verified even when it is current/last. - NSString *versionDir = [[RCTPushy downloadDir] stringByAppendingPathComponent:hash]; - std::string hashStd = PushyToStdString(hash); - BOOL (^isLegacyActivated)(void) = ^BOOL { - if (PushyHasInstallRecordFile(versionDir)) { - return NO; - } - pushy::state::State state = PushyStateFromDefaults(PushyDefaults()); - return state.current_version == hashStd || state.last_version == hashStd; - }; - NSError *(^incompleteError)(void) = ^NSError * { - BOOL hasBundle = [[NSFileManager defaultManager] fileExistsAtPath: - [versionDir stringByAppendingPathComponent:BUNDLE_FILE_NAME]]; - return PushyErrorWithCode( - pushy::error_codes::kSwitchVersionFailed, - [NSString stringWithFormat:@"Bundle version %@ %@", hash, - hasBundle ? @"is incomplete." : @"not found."]); - }; - - // The digest check reads the whole bundle, so it runs OUTSIDE the state - // lock: +bundleURL takes that lock on the main thread at launch. - __block BOOL legacyActivated = NO; - PushyWithStateLock(^{ - legacyActivated = isLegacyActivated(); - }); - NSError *switchError = nil; - NSDictionary *verifiedRecord = nil; - PushyFileStamp verifiedStamp; - BOOL verified = NO; - if (!legacyActivated) { - if (!PushyHasCompletedVersionAtPath(versionDir, hash)) { - switchError = incompleteError(); - } else { - // The record's bundle digest must match the bytes on disk before - // the next launch is pointed at them. - NSString *reason = PushyVerifyInstallForActivation( - versionDir, hash, &verifiedRecord, &verifiedStamp); - if (reason != nil) { - switchError = PushyErrorWithCode(pushy::error_codes::kSwitchVersionFailed, reason); - } else { - verified = YES; - } - } - } - - __block NSError *lockedError = switchError; - if (lockedError == nil) { - NSDictionary *record = verifiedRecord; - PushyFileStamp stamp = verifiedStamp; - PushyWithStateLock(^{ - if (!isLegacyActivated()) { - // Re-check cheaply that the directory is still the one that - // was verified: same record, same bundle file (size, mtime, - // inode). - if (!PushyHasCompletedVersionAtPath(versionDir, hash)) { - lockedError = incompleteError(); - return; - } - NSString *bundlePath = [versionDir stringByAppendingPathComponent:BUNDLE_FILE_NAME]; - if (!verified || ![PushyReadInstallRecord(versionDir) isEqual:record] - || PushyStampFile(bundlePath) != stamp) { - lockedError = PushyErrorWithCode( - pushy::error_codes::kSwitchVersionFailed, - [NSString stringWithFormat:@"Bundle version %@ changed while switching.", hash]); - return; - } - } - PushySwitchVersionLocked(hash); - }); - } - if (lockedError != nil) { - if (error != NULL) { - *error = lockedError; - } - return NO; - } - return YES; -} - -- (BOOL)ensureDirectoryExistsAtPath:(NSString *)path -{ - // No _fileQueue hop here: that queue also runs multi-second unzip/patch - // work, and a dispatch_sync onto it would block the whole module method - // queue for the duration. createDirectoryAtPath is idempotent and - // thread-safe, so checking inline is fine. - NSFileManager *fileManager = [NSFileManager defaultManager]; - BOOL isDirectory = NO; - if ([fileManager fileExistsAtPath:path isDirectory:&isDirectory]) { - if (isDirectory) { - // Directories created by older versions never got the flag. - [RCTPushy excludeFromBackup:path]; - } - return isDirectory; - } - - NSError *error = nil; - BOOL success = [fileManager createDirectoryAtPath:path - withIntermediateDirectories:YES - attributes:nil - error:&error]; - if (!success && error != nil) { - RCTLogWarn(@"Pushy create directory error: %@", error.localizedDescription); - } - if (success) { - [RCTPushy excludeFromBackup:path]; - } - - return success; -} - -// Everything under rctpushy is re-downloadable, and Application Support is -// backed up to iCloud by default — Apple requires such content to be -// excluded from backups. -+ (void)excludeFromBackup:(NSString *)path -{ -#if !TARGET_OS_TV // tvOS stores under Caches, which is never backed up. - NSURL *url = [NSURL fileURLWithPath:path isDirectory:YES]; - NSError *error = nil; - if (![url setResourceValue:@YES - forKey:NSURLIsExcludedFromBackupKey - error:&error]) { - RCTLogWarn(@"Pushy exclude from backup error: %@", error.localizedDescription); - } -#endif -} - -- (void)unzipFileAtPath:(NSString *)path - toDestination:(NSString *)destination - completionHandler:(void (^)(NSError *error))completionHandler -{ - dispatch_async(_fileQueue, ^{ - NSFileManager *fileManager = [NSFileManager defaultManager]; - if ([fileManager fileExistsAtPath:destination]) { - [fileManager removeItemAtPath:destination error:nil]; - } - - // Resource caps before the first byte is extracted - // (cpp/patch_core/archive_limits.h). SSZipArchive only exposes the - // entry count up front (zipArchiveWillUnzipArchiveAtPath:zipInfo:), - // not the uncompressed total, so the disk check uses a 2x-archive - // heuristic; the delegate below enforces the exact caps entry by - // entry. - long long archiveBytes = RCTPushyFileSize(path); - NSError *preflight = nil; - if (archiveBytes > pushy::archive_limits::kMaxArchiveBytes) { - preflight = PushyErrorWithCode(pushy::error_codes::kDeltaFailed, - [NSString stringWithFormat:@"archive too large: %lld bytes", archiveBytes]); - } else { - preflight = PushyEnsureFreeSpace(destination, MAX(0LL, archiveBytes) * 2); - } - if (preflight != nil) { - [fileManager removeItemAtPath:path error:nil]; - [fileManager removeItemAtPath:RCTPushyResumeSidecarPath(path) error:nil]; - if (completionHandler != nil) { - completionHandler(preflight); - } - return; - } - - PushyUnzipGuard *guard = [PushyUnzipGuard new]; - // preserveAttributes:NO — the archive's mode/mtime bits have no use - // here and are one less thing a hostile package controls. - [SSZipArchive unzipFileAtPath:path - toDestination:destination - preserveAttributes:NO - overwrite:YES - nestedZipLevel:0 - password:nil - error:nil - delegate:guard - progressHandler:nil - completionHandler:^(NSString *archivePath, BOOL succeeded, NSError *error) { - [fileManager removeItemAtPath:archivePath error:nil]; - // The resume sidecar dies with its archive (§11.4): whether the - // unzip consumed it or classified it as poisoned, nothing must - // survive to vouch for bytes that are gone. - [fileManager removeItemAtPath:RCTPushyResumeSidecarPath(archivePath) error:nil]; - if (completionHandler == nil) { - return; - } - - NSError *unzipError = error; - if (guard.violation != nil) { - unzipError = PushyErrorWithCode(pushy::error_codes::kDeltaFailed, guard.violation); - } else if (!succeeded && unzipError == nil) { - unzipError = PushyErrorWithCode(pushy::error_codes::kDeltaFailed, @"unzip failed"); - } else if (unzipError != nil && unzipError.userInfo[RCTPushyErrorCodeKey] == nil) { - // SSZipArchive's own NSError (corrupt zip, bad magic, ...) has - // no stable code; without one, fetchPackage's fallback would - // classify it as DOWNLOAD_FAILED even though the download - // succeeded — keep the classification deterministic. - unzipError = PushyErrorWithCode(pushy::error_codes::kDeltaFailed, - unzipError.localizedDescription ?: @"unzip failed"); - } - completionHandler(unzipError); - }]; - }); -} - -- (void)clearInvalidFiles -{ - dispatch_async(_fileQueue, ^{ - // Snapshot the state under the lock, but run the (slow) filesystem - // cleanup outside of it so state operations are not blocked. - __block pushy::state::State state; - __block NSString *launchVersion = nil; - PushyWithStateLock(^{ - state = PushyStateFromDefaults(PushyDefaults()); - launchVersion = pushyLaunchVersion; - }); - NSString *downloadDir = [RCTPushy downloadDir]; - // Keep the persisted current/last versions AND the version this - // process booted from: two switches without a restart would otherwise - // evict the running bundle while its on-demand assets are still - // being served. - pushy::delta::Status status = pushy::delta::CleanupOldEntries( - PushyToStdString(downloadDir), - std::vector{ - state.current_version, - state.last_version, - PushyToStdString(launchVersion), - }, - 3 - ); - if (!status.ok) { - RCTLogWarn(@"Pushy cleanup error: %s", status.message.c_str()); - } - }); -} - -- (NSString *)zipExtension:(PushyType)type -{ - switch (type) { - case PushyTypePatchFromPackage: - return PushyIpaDeltaSuffix(); - case PushyTypePatchFromPpk: - return PushyPpkDeltaSuffix(); - case PushyTypeFullDownload: - break; - } - return @".ppk"; -} - -+ (NSString *)downloadDir -{ -#if TARGET_OS_TV - // tvOS apps cannot write Application Support; Caches is the only - // writable, non-temporary location, and the system may purge it while - // the app is not running (see the purged-version path in +bundleURL). - NSSearchPathDirectory base = NSCachesDirectory; -#else - NSSearchPathDirectory base = NSApplicationSupportDirectory; -#endif - NSString *directory = [NSSearchPathForDirectoriesInDomains(base, NSUserDomainMask, YES) firstObject]; - return [directory stringByAppendingPathComponent:@"rctpushy"]; -} - -+ (NSURL *)binaryBundleURL -{ - return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"]; -} - -+ (NSString *)packageVersion -{ - static NSString *version = nil; - - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - NSDictionary *infoDictionary = [[NSBundle mainBundle] infoDictionary]; - version = [infoDictionary objectForKey:@"CFBundleShortVersionString"]; - }); - return version; -} - -+ (NSString *)buildTime -{ -#if DEBUG - return @"0"; -#else - static NSString *buildTime; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - // The podspec's s.resource lands in the app bundle for a static - // library but inside this pod's own framework bundle under - // use_frameworks!; look in both. - NSString *stampPath = - [[NSBundle mainBundle] pathForResource:@"pushy_build_time" ofType:@"txt"] - ?: [[NSBundle bundleForClass:[RCTPushy class]] pathForResource:@"pushy_build_time" - ofType:@"txt"]; - NSString *stamp = stampPath == nil ? nil - : [[NSString stringWithContentsOfFile:stampPath encoding:NSUTF8StringEncoding error:nil] - stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; - if (stamp.length == 0) { - // Never empty: with "" SyncBinaryVersion would miss a rebuilt - // binary that kept its CFBundleShortVersionString, and a stale - // hot update would outlive an App Store update. Every build - // rewrites Info.plist, so its mtime (integer seconds) stands in. - RCTLogWarn(@"RCTPushy -- pushy_build_time.txt %@; using the Info.plist mtime as buildTime", - stampPath == nil ? @"not found in the main or framework bundle" : @"is empty"); - NSString *infoPlist = - [[NSBundle mainBundle].bundlePath stringByAppendingPathComponent:@"Info.plist"]; - NSDate *modified = [[NSFileManager defaultManager] attributesOfItemAtPath:infoPlist - error:nil].fileModificationDate; - stamp = [NSString stringWithFormat:@"%lld", (long long)[modified timeIntervalSince1970]]; - } - buildTime = stamp; - }); - return buildTime; -#endif -} - #ifdef RCT_NEW_ARCH_ENABLED - (std::shared_ptr)getTurboModule: (const facebook::react::ObjCTurboModule::InitParams &)params diff --git a/ios/RCTPushy/RCTPushyInstallRecord.mm b/ios/RCTPushy/RCTPushyInstallRecord.mm new file mode 100644 index 00000000..dc96ee24 --- /dev/null +++ b/ios/RCTPushy/RCTPushyInstallRecord.mm @@ -0,0 +1,166 @@ +// Version directories and the completion record +// (cpp/patch_core/install_record.h): where an install lands, how it is +// recorded as complete, and how a recorded version is verified before it +// may be activated. +#import "RCTPushyInternal.h" +#include "../../cpp/patch_core/digest.h" +#include "../../cpp/patch_core/error_codes.h" +#include "../../cpp/patch_core/install_record.h" + +PUSHY_HIDDEN NSString *const BUNDLE_FILE_NAME = @"index.bundlejs"; + +static NSString *PushyInstallRecordFileName(void) { + static NSString *name = @(pushy::install_record::kFileName); + return name; +} + +PUSHY_HIDDEN NSString *PushyVersionDir(NSString *hash) { + return [[RCTPushy downloadDir] stringByAppendingPathComponent:hash]; +} + +// Two-phase install: everything lands in .staging first. +PUSHY_HIDDEN NSString *PushyStagingDir(NSString *hash) { + return [PushyVersionDir(hash) stringByAppendingString:@(pushy::install_record::kStagingSuffix)]; +} + +// Parsed completion record: nil when the file is absent or malformed; an +// empty dictionary for the legacy empty marker written by SDK < 10.53. +PUSHY_HIDDEN NSDictionary *PushyReadInstallRecord(NSString *versionDir) { + NSString *path = [versionDir stringByAppendingPathComponent:PushyInstallRecordFileName()]; + NSData *data = [NSData dataWithContentsOfFile:path]; + if (data == nil) { + return nil; + } + if (data.length == 0) { + return @{}; + } + if (data.length > 64 * 1024) { + return nil; + } + return PushyJsonDictionary(data, NULL); +} + +// Whether a non-empty record names this version under the current schema. +static BOOL PushyInstallRecordMatches(NSDictionary *record, NSString *hash) { + return [record[@"schema"] isKindOfClass:[NSNumber class]] + && [record[@"schema"] intValue] == pushy::install_record::kSchema + && [record[@"versionHash"] isKindOfClass:[NSString class]] + && [record[@"versionHash"] isEqualToString:hash]; +} + +// Whether the directory carries a completion record file at all (the legacy +// empty marker included). Only directories WITHOUT one are grandfathered by +// current/last state in switchVersion; a present record is always verified. +PUSHY_HIDDEN BOOL PushyHasInstallRecordFile(NSString *versionDir) { + return [[NSFileManager defaultManager] fileExistsAtPath: + [versionDir stringByAppendingPathComponent:PushyInstallRecordFileName()]]; +} + +// Presence check (launch / dedup paths, no digest work): bundle present and +// the record exists and, unless legacy-empty, names this version. +PUSHY_HIDDEN BOOL PushyHasCompletedVersionAtPath(NSString *versionDir, NSString *hash) { + NSString *bundlePath = [versionDir stringByAppendingPathComponent:BUNDLE_FILE_NAME]; + if (![[NSFileManager defaultManager] fileExistsAtPath:bundlePath]) { + return NO; + } + NSDictionary *record = PushyReadInstallRecord(versionDir); + if (record == nil) { + return NO; + } + return record.count == 0 || PushyInstallRecordMatches(record, hash); +} + +PUSHY_HIDDEN PushyFileStamp PushyStampFile(NSString *path) { + PushyFileStamp stamp; + struct stat info; + if (path != nil && stat(path.fileSystemRepresentation, &info) == 0) { + stamp.exists = true; + stamp.device = info.st_dev; + stamp.inode = info.st_ino; + stamp.size = info.st_size; + stamp.modified = info.st_mtimespec; + } + return stamp; +} + +// Activation check: re-hashes the bundle against the record's digest, which +// schema 1 makes mandatory (only the legacy empty marker has none). +// Returns nil when the directory may be activated, the reason otherwise. +// Slow (a full read of the bundle): never call it under the state lock. On +// success *recordOut and *stampOut describe exactly what was verified, so the +// caller can confirm under the lock that nothing changed since. +PUSHY_HIDDEN NSString *PushyVerifyInstallForActivation(NSString *versionDir, NSString *hash, + NSDictionary **recordOut, + PushyFileStamp *stampOut) { + NSString *bundlePath = [versionDir stringByAppendingPathComponent:BUNDLE_FILE_NAME]; + PushyFileStamp before = PushyStampFile(bundlePath); + NSDictionary *record = PushyReadInstallRecord(versionDir); + if (record == nil) { + return [NSString stringWithFormat:@"Bundle version %@ has no valid completion record.", hash]; + } + *recordOut = record; + *stampOut = before; + if (record.count == 0) { + return nil; + } + if (!PushyInstallRecordMatches(record, hash)) { + return [NSString stringWithFormat:@"Bundle version %@ completion record mismatch.", hash]; + } + NSString *expected = record[@"bundleSha256"]; + if (![expected isKindOfClass:[NSString class]] || expected.length == 0) { + return [NSString stringWithFormat:@"Bundle version %@ completion record mismatch.", hash]; + } + std::string actual = pushy::digest::Sha256File(PushyToStdString(bundlePath)); + if (PushyStampFile(bundlePath) != before) { + return [NSString stringWithFormat:@"Bundle version %@ changed while being verified.", hash]; + } + if ([[expected lowercaseString] isEqualToString:[NSString stringWithUTF8String:actual.c_str()]]) { + return nil; + } + return [NSString stringWithFormat:@"Bundle version %@ bundle digest mismatch.", hash]; +} + +// Second phase of an install: writes the completion record, with the final +// bundle's digest, into .staging and renames the staging directory over +// in one atomic step. nil on success. Runs on the file queue. The +// digest is mandatory: a version whose bundle could not be hashed is never +// recorded as installed. The caller drops the staging directory on failure. +PUSHY_HIDDEN NSError *PushyPromoteStagingDir(NSString *hash, NSString *artifactSha256) { + NSFileManager *fileManager = [NSFileManager defaultManager]; + NSString *staging = PushyStagingDir(hash); + NSString *bundlePath = [staging stringByAppendingPathComponent:BUNDLE_FILE_NAME]; + if (![fileManager fileExistsAtPath:bundlePath]) { + return PushyErrorWithCode(pushy::error_codes::kDeltaFailed, @"bundle missing after install"); + } + std::string bundleSha256 = pushy::digest::Sha256File(PushyToStdString(bundlePath)); + if (bundleSha256.empty()) { + return PushyErrorWithCode(pushy::error_codes::kDeltaFailed, + @"bundle digest failed after install"); + } + NSMutableDictionary *record = [NSMutableDictionary dictionary]; + record[@"schema"] = @(pushy::install_record::kSchema); + record[@"versionHash"] = hash; + record[@"bundleSha256"] = @(bundleSha256.c_str()); + if (artifactSha256.length > 0) { + record[@"artifactSha256"] = artifactSha256; + } + NSError *recordError = nil; + NSData *recordData = [NSJSONSerialization dataWithJSONObject:record options:0 error:&recordError]; + NSString *marker = [staging stringByAppendingPathComponent:PushyInstallRecordFileName()]; + if (recordData == nil || ![recordData writeToFile:marker + options:NSDataWritingAtomic + error:&recordError]) { + return recordError ?: PushyErrorWithCode(pushy::error_codes::kFileOperationFailed, + @"failed to write completion record"); + } + NSString *versionDir = PushyVersionDir(hash); + if ([fileManager fileExistsAtPath:versionDir]) { + [fileManager removeItemAtPath:versionDir error:nil]; + } + NSError *moveError = nil; + if (![fileManager moveItemAtPath:staging toPath:versionDir error:&moveError]) { + return moveError ?: PushyErrorWithCode(pushy::error_codes::kFileOperationFailed, + @"failed to promote staging directory"); + } + return nil; +} diff --git a/ios/RCTPushy/RCTPushyInstaller.mm b/ios/RCTPushy/RCTPushyInstaller.mm new file mode 100644 index 00000000..bd6f12ed --- /dev/null +++ b/ios/RCTPushy/RCTPushyInstaller.mm @@ -0,0 +1,827 @@ +// The install pipeline shared by JS calls and the native round: download +// (RCTPushyDownloader) → unzip (archive guard) → patch (cpp/patch_core) → +// promote (RCTPushyInstallRecord.mm), plus version activation and cleanup. +#import "RCTPushyInternal.h" +#import "RCTPushyDownloader.h" +#import +#include "../../cpp/patch_core/archive_limits.h" +#include "../../cpp/patch_core/archive_patch_core.h" +#include "../../cpp/patch_core/digest.h" +#include "../../cpp/patch_core/error_codes.h" +#include "../../cpp/patch_core/patch_core.h" + +// file def +static NSString *const SOURCE_PATCH_NAME = @"__diff.json"; +static NSString *const ERROR_FILE_OPERATION = @"file operation error"; + +// Protocol strings stored encoded (RCTPushyRevealText) so they do not appear +// as plain binary strings; decoded on first use. +static NSString *BUNDLE_PATCH_NAME(void) { + static NSString *value = RCTPushyRevealText("3319f0d4b6c56a502c3b10fcdca0de7d4b3307e9"); + return value; +} +PUSHY_HIDDEN NSString *PushyOptionUrlKey(void) { + static NSString *value = RCTPushyRevealText("2f07f0d0ba8e5d572e"); + return value; +} +static NSString *PushyIpaDeltaSuffix(void) { + static NSString *value = RCTPushyRevealText("741ee4d0e09b69512137"); + return value; +} +static NSString *PushyPpkDeltaSuffix(void) { + static NSString *value = RCTPushyRevealText("7407e4dae09b69512137"); + return value; +} + +static NSString *PushyArchiveExtension(PushyType type) { + switch (type) { + case PushyTypePatchFromPackage: + return PushyIpaDeltaSuffix(); + case PushyTypePatchFromPpk: + return PushyPpkDeltaSuffix(); + case PushyTypeFullDownload: + break; + } + return @".ppk"; +} + +PUSHY_HIDDEN dispatch_queue_t PushyFileQueue(void) { + // One process-wide serial queue: JS calls (from any bridge instance; a + // reload can briefly keep two alive) and the native round share it, so + // destructive file work (resetToPackagedBundle's full cleanup) stays + // serialized with every unzip/patch job. + static dispatch_queue_t queue = + dispatch_queue_create("cn.reactnative.pushy.file", DISPATCH_QUEUE_SERIAL); + return queue; +} + +// nil when the volume holding `path` can take `bytesToWrite` plus the safety +// margin (or the free space is unknown); a PATCH_FAILED error otherwise. The +// probe is the downloader's (RCTPushyFreeSpaceShortfall), which applies the +// same rule to the announced Content-Length before the first byte lands. +static NSError *PushyEnsureFreeSpace(NSString *path, long long bytesToWrite) { + NSString *shortfall = RCTPushyFreeSpaceShortfall(path, bytesToWrite); + if (shortfall == nil) { + return nil; + } + return PushyErrorWithCode(pushy::error_codes::kDeltaFailed, shortfall); +} + +// SSZipArchive delegate enforcing cpp/patch_core/archive_limits.h while the +// archive is walked (entry count, per-entry size, total size, compression +// ratio) plus two entry-shape rules Android's java.util.zip gets for free: +// - no symlinks: SSZipArchive materialises them with any target regardless +// of preserveAttributes, so `assets -> ../../Documents` followed by +// `assets/x` would write outside the version directory; +// - no ".pushy-" reserved names at any depth: the completion marker is +// written by this SDK only, after the whole install succeeded. An archive +// shipping its own would be trusted as a finished install even if the +// patch step that follows fails. +// A NO from zipArchiveShouldUnzipFileAtIndex: cancels the whole unzip (the +// 2.x contract the podspec pins), and the completion handler turns the +// recorded violation into a hard PATCH_FAILED; the caller then discards the +// staging directory. +@interface PushyUnzipGuard : NSObject +@property (nonatomic, copy) NSString *violation; +@property (nonatomic, assign) long long totalUncompressed; +@end + +@implementation PushyUnzipGuard + +- (void)zipArchiveWillUnzipArchiveAtPath:(NSString *)path zipInfo:(unz_global_info)zipInfo { + if ((long long)zipInfo.number_entry > pushy::archive_limits::kMaxEntries) { + self.violation = [NSString stringWithFormat:@"archive has too many entries (%lu)", + (unsigned long)zipInfo.number_entry]; + } +} + +- (BOOL)zipArchiveShouldUnzipFileAtIndex:(NSInteger)fileIndex + totalFiles:(NSInteger)totalFiles + archivePath:(NSString *)archivePath + fileInfo:(unz_file_info)fileInfo { + if (self.violation != nil) { + return NO; + } + // Unix mode bits travel in the high 16 bits of the external attributes + // (the same field SSZipArchive keys its own symlink branch on). + if (((fileInfo.external_fa >> 16) & S_IFMT) == S_IFLNK) { + self.violation = [NSString stringWithFormat:@"archive entry #%ld is a symlink", + (long)fileIndex]; + return NO; + } + long long size = (long long)fileInfo.uncompressed_size; + long long compressed = (long long)fileInfo.compressed_size; + if (size > pushy::archive_limits::kMaxEntryBytes) { + self.violation = [NSString stringWithFormat:@"archive entry #%ld too large (%lld bytes)", + (long)fileIndex, size]; + return NO; + } + self.totalUncompressed += size; + if (self.totalUncompressed > pushy::archive_limits::kMaxTotalUncompressedBytes) { + self.violation = [NSString stringWithFormat:@"archive expands beyond %lld bytes", + pushy::archive_limits::kMaxTotalUncompressedBytes]; + return NO; + } + if (compressed > 0 && size > pushy::archive_limits::kRatioCheckMinBytes + && size / compressed > pushy::archive_limits::kMaxCompressionRatio) { + self.violation = [NSString stringWithFormat:@"archive entry #%ld compression ratio too high", + (long)fileIndex]; + return NO; + } + return YES; +} + +// The entry name is only exposed once the entry is on disk (shouldUnzip +// runs before the name is read). A reserved name is flagged here; the next +// shouldUnzip refuses (cancelling the unzip) or, for the last entry, the +// completion handler picks the violation up. Either way the caller drops +// the whole staging directory, so the written file never survives. +- (void)zipArchiveDidUnzipFileAtIndex:(NSInteger)fileIndex + totalFiles:(NSInteger)totalFiles + archivePath:(NSString *)archivePath + unzippedFilePath:(NSString *)unzippedFilePath { + NSString *name = unzippedFilePath.lastPathComponent; + if (self.violation == nil && [name hasPrefix:@".pushy-"]) { + self.violation = [NSString stringWithFormat:@"archive contains reserved entry %@", name]; + } +} + +@end + +// JS (any bridge instance) and the native round share one download per target +// hash. Without this process-wide registry two transfers 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 = + [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); + } +} + +static BOOL PushyJsonIsAbsent(id value) { + return value == nil || [value isKindOfClass:[NSNull class]]; +} + +// Builds the patch-core manifest from a parsed __diff.json. Every field is +// type-checked: this runs inside a GCD block on the file queue, where the +// unrecognized-selector exception a `copies` array or a numeric `from` would +// raise takes the whole app down (and trips the crash rescue) instead of +// failing the patch. NO with `reason` set for a malformed manifest. +static BOOL PushyPatchManifestFromJson(NSDictionary *json, + pushy::delta::PatchManifest *manifest, + NSString **reason) { + id copies = json[@"copies"]; + if (!PushyJsonIsAbsent(copies)) { + if (![copies isKindOfClass:[NSDictionary class]]) { + *reason = @"delta manifest: copies is not an object"; + return NO; + } + // Content checksum per copy target ("copiesCrc", pdiff manifests + // from CLI >= 2.21.2). The patch core verifies the copy source + // against it and fails the patch on mismatch, so the JS strategy + // chain falls back to the full package instead of copying drifted + // bytes from a rebuilt binary. + NSDictionary *copiesCrc = json[@"copiesCrc"]; + if (![copiesCrc isKindOfClass:[NSDictionary class]]) { + copiesCrc = nil; + } + for (id to in (NSDictionary *)copies) { + id from = ((NSDictionary *)copies)[to]; + if (![to isKindOfClass:[NSString class]] || ![from isKindOfClass:[NSString class]]) { + *reason = @"delta manifest: copies entry is not a string"; + return NO; + } + if ([from length] == 0) { + from = to; + } + pushy::delta::CopyOperation operation; + operation.from = PushyToStdString(from); + operation.to = PushyToStdString(to); + NSNumber *expectedCrc = copiesCrc[to]; + if ([expectedCrc isKindOfClass:[NSNumber class]]) { + operation.has_expected_crc = true; + operation.expected_crc = (uint32_t)[expectedCrc unsignedLongLongValue]; + } + manifest->copies.push_back(operation); + } + } + + // The CLI emits `deletes` as an object keyed by path (Android reads its + // keys); a plain array of paths is accepted as well, like Harmony. + id deletes = json[@"deletes"]; + if (!PushyJsonIsAbsent(deletes)) { + if (![deletes isKindOfClass:[NSDictionary class]] && ![deletes isKindOfClass:[NSArray class]]) { + *reason = @"delta manifest: deletes is not an object or array"; + return NO; + } + for (id path in deletes) { + if (![path isKindOfClass:[NSString class]]) { + *reason = @"delta manifest: deletes entry is not a string"; + return NO; + } + manifest->deletes.push_back(PushyToStdString(path)); + } + } + return YES; +} + +// Everything under rctpushy is re-downloadable, and Application Support is +// backed up to iCloud by default — Apple requires such content to be +// excluded from backups. +static void PushyExcludeFromBackup(NSString *path) { +#if !TARGET_OS_TV // tvOS stores under Caches, which is never backed up. + NSURL *url = [NSURL fileURLWithPath:path isDirectory:YES]; + NSError *error = nil; + if (![url setResourceValue:@YES + forKey:NSURLIsExcludedFromBackupKey + error:&error]) { + RCTLogWarn(@"Pushy exclude from backup error: %@", error.localizedDescription); + } +#endif +} + +static BOOL PushyEnsureDirectoryExists(NSString *path) { + // No file-queue hop here: that queue also runs multi-second unzip/patch + // work, and a dispatch_sync onto it would block the whole module method + // queue for the duration. createDirectoryAtPath is idempotent and + // thread-safe, so checking inline is fine. + NSFileManager *fileManager = [NSFileManager defaultManager]; + BOOL isDirectory = NO; + if ([fileManager fileExistsAtPath:path isDirectory:&isDirectory]) { + if (isDirectory) { + // Directories created by older versions never got the flag. + PushyExcludeFromBackup(path); + } + return isDirectory; + } + + NSError *error = nil; + BOOL success = [fileManager createDirectoryAtPath:path + withIntermediateDirectories:YES + attributes:nil + error:&error]; + if (!success && error != nil) { + RCTLogWarn(@"Pushy create directory error: %@", error.localizedDescription); + } + if (success) { + PushyExcludeFromBackup(path); + } + + return success; +} + +@implementation RCTPushyInstaller + ++ (void)fetch:(PushyType)type + options:(NSDictionary *)options + progress:(void (^)(long long, long long))progress + callback:(void (^)(NSError *error))callback +{ + NSString *updateUrl = PushyOptionString(options, PushyOptionUrlKey()); + NSString *hash = PushyOptionString(options, @"hash"); + + if (PushyStringIsBlank(updateUrl) || !PushyIsSafePathComponent(hash)) { + callback(PushyErrorWithCode(pushy::error_codes::kInvalidOptions, ERROR_OPTIONS)); + return; + } + NSString *originHash = PushyOptionString(options, @"originHash"); + if (type == PushyTypePatchFromPpk && !PushyIsSafePathComponent(originHash)) { + callback(PushyErrorWithCode(pushy::error_codes::kInvalidOptions, ERROR_OPTIONS)); + return; + } + + NSString *dir = [RCTPushy downloadDir]; + if (!PushyEnsureDirectoryExists(dir)) { + callback(PushyErrorWithCode(pushy::error_codes::kFileOperationFailed, ERROR_FILE_OPERATION)); + return; + } + + if (PushyHasCompletedVersionAtPath(PushyVersionDir(hash), hash)) { + callback(nil); + return; + } + + NSTimeInterval deadlineUptime = PushyMonotonicNow() + 600; + NSNumber *configuredDeadline = options[@"deadlineUptime"]; + if ([configuredDeadline isKindOfClass:[NSNumber class]]) { + deadlineUptime = configuredDeadline.doubleValue; + } + NSError *(^deadlineExpired)(void) = ^NSError * { + return PushyErrorWithCode(pushy::error_codes::kDownloadFailed, + @"download deadline expired before start"); + }; + if (deadlineUptime <= PushyMonotonicNow()) { + callback(deadlineExpired()); + return; + } + + void (^deferredStart)(void) = ^{ + [self fetch:type options:options progress:progress 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: + [hash stringByAppendingString:PushyArchiveExtension(type)]]; + // SHA-256 of the downloaded archive for the completion record. Written and + // read on the serial file queue only: taken before the unzip consumes the + // file, consumed by the completion block after it. + __block NSString *artifactSha256 = nil; + + // Settles every JS/native waiter only after the staging directory was + // promoted (or dropped) on the process-wide file queue. On failure only + // the staging directory is removed, like Android/Harmony: a half-unzipped + // or half-patched dir leaks disk and could later be mistaken for a + // complete version, while the final version directory is never touched + // by a failed install. hash is validated above, so this can never resolve + // to the download root itself. + void (^completion)(NSError *) = ^(NSError *error) { + dispatch_async(PushyFileQueue(), ^{ + NSError *finalError = error ?: PushyPromoteStagingDir(hash, artifactSha256); + if (finalError != nil) { + [[NSFileManager defaultManager] removeItemAtPath:PushyStagingDir(hash) error:nil]; + } + PushyFinishDownload(hash, finalError); + }); + }; + + RCTLogInfo(@"RCTPushy -- download file %@", updateUrl); + NSTimeInterval timeoutSeconds = deadlineUptime - PushyMonotonicNow(); + if (timeoutSeconds <= 0) { + completion(deadlineExpired()); + 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); + return; + } + dispatch_async(PushyFileQueue(), ^{ + artifactSha256 = PushyFromStdString( + pushy::digest::Sha256File(PushyToStdString(zipFilePath))); + }); + [self unzipDownloadedPackage:zipFilePath + hash:hash + type:type + originHash:originHash + callback:completion]; + }]; +} + ++ (void)unzipDownloadedPackage:(NSString *)zipFilePath + hash:(NSString *)hash + type:(PushyType)type + originHash:(NSString *)originHash + callback:(void (^)(NSError *error))callback +{ + RCTLogInfo(@"RCTPushy -- unzip file %@", zipFilePath); + // Everything lands in .staging; the completion block promotes it. + [self unzipFileAtPath:zipFilePath + toDestination:PushyStagingDir(hash) + completionHandler:^(NSError *error) { + dispatch_async(PushyFileQueue(), ^{ + if (error != nil) { + callback(error); + return; + } + @try { + [self finishDownloadedPackage:hash type:type originHash:originHash callback:callback]; + } @catch (NSException *exception) { + // An uncaught exception in a GCD block is fatal (and would + // trip the crash rescue); a bad package is a PATCH_FAILED. + callback(PushyErrorWithCode(pushy::error_codes::kDeltaFailed, + exception.reason ?: @"delta failed")); + } + }); + }]; +} + ++ (void)finishDownloadedPackage:(NSString *)hash + type:(PushyType)type + originHash:(NSString *)originHash + callback:(void (^)(NSError *error))callback +{ + switch (type) { + case PushyTypePatchFromPackage: + callback([self applyDeltaForHash:hash + type:type + fromBundle:[[RCTPushy binaryBundleURL] path] + source:[[NSBundle mainBundle] resourcePath]]); + return; + case PushyTypePatchFromPpk: { + NSString *lastVersionDir = PushyVersionDir(originHash); + callback([self applyDeltaForHash:hash + type:type + fromBundle:[lastVersionDir stringByAppendingPathComponent:BUNDLE_FILE_NAME] + source:lastVersionDir]); + return; + } + case PushyTypeFullDownload: + callback(nil); + return; + } +} + +// Applies the unpacked patch in the staging directory (two-phase install); +// nil on success, a PATCH_FAILED error otherwise. ++ (NSError *)applyDeltaForHash:(NSString *)hash + type:(PushyType)type + fromBundle:(NSString *)bundleOrigin + source:(NSString *)sourceOrigin +{ + NSString *unzipDir = PushyStagingDir(hash); + NSString *sourcePatch = [unzipDir stringByAppendingPathComponent:SOURCE_PATCH_NAME]; + NSString *bundlePatch = [unzipDir stringByAppendingPathComponent:BUNDLE_PATCH_NAME()]; + + NSString *destination = [unzipDir stringByAppendingPathComponent:BUNDLE_FILE_NAME]; + long long manifestBytes = RCTPushyFileSize(sourcePatch); + if (manifestBytes > pushy::archive_limits::kMaxManifestBytes) { + return PushyErrorWithCode(pushy::error_codes::kDeltaFailed, + [NSString stringWithFormat:@"delta manifest too large: %lld bytes", manifestBytes]); + } + NSData *data = [NSData dataWithContentsOfFile:sourcePatch]; + if (data == nil) { + return PushyErrorWithCode(pushy::error_codes::kDeltaFailed, @"missing delta manifest"); + } + + // A parse failure is a patch failure like the sibling manifest branches; + // unclassified errors would otherwise be tagged DOWNLOAD_FAILED by the + // fetchPackage fallback even though the download itself succeeded. + NSError *error = nil; + NSDictionary *json = PushyJsonDictionary(data, &error); + if (json == nil) { + return PushyErrorWithCode(pushy::error_codes::kDeltaFailed, + error != nil ? error.localizedDescription : @"invalid delta manifest"); + } + pushy::delta::PatchManifest manifest; + NSString *manifestReason = nil; + if (!PushyPatchManifestFromJson(json, &manifest, &manifestReason)) { + return PushyErrorWithCode(pushy::error_codes::kDeltaFailed, manifestReason); + } + + std::vector entryNames; + if ([[NSFileManager defaultManager] fileExistsAtPath:sourcePatch isDirectory:NULL]) { + entryNames.push_back(PushyToStdString(SOURCE_PATCH_NAME)); + } + if ([[NSFileManager defaultManager] fileExistsAtPath:bundlePatch isDirectory:NULL]) { + entryNames.push_back(PushyToStdString(BUNDLE_PATCH_NAME())); + } + + NSError *(^statusError)(const pushy::delta::Status &) = ^NSError *(const pushy::delta::Status &status) { + return PushyErrorWithCode(pushy::error_codes::kDeltaFailed, @(status.message.c_str())); + }; + pushy::archive_patch::ArchivePatchPlan plan; + pushy::delta::Status planStatus = pushy::archive_patch::BuildArchivePatchPlan( + type == PushyTypePatchFromPackage + ? pushy::archive_patch::ArchivePatchType::kPatchFromPackage + : pushy::archive_patch::ArchivePatchType::kPatchFromPpk, + manifest, + entryNames, + &plan + ); + if (!planStatus.ok) { + return statusError(planStatus); + } + + pushy::delta::FileSourcePatchOptions options; + pushy::delta::Status optionStatus = pushy::archive_patch::BuildFileSourcePatchOptions( + plan, + PushyToStdString(sourceOrigin), + PushyToStdString(unzipDir), + PushyToStdString(bundleOrigin), + PushyToStdString(bundlePatch), + PushyToStdString(destination), + &options + ); + if (!optionStatus.ok) { + return statusError(optionStatus); + } + + // __diff.json 的 hbcTransform 元数据(HBC 变换域 patch,hdiffv2 轨道): + // 存在时透传给 patch 内核执行 T(origin) → hpatch → T⁻¹;缺失走现状路径。 + NSDictionary *hbcTransform = json[@"hbcTransform"]; + if ([hbcTransform isKindOfClass:[NSDictionary class]]) { + NSDictionary *meta = hbcTransform[BUNDLE_PATCH_NAME()]; + if ([meta isKindOfClass:[NSDictionary class]]) { + NSString *metaString = PushyJsonString(meta); + if (metaString != nil) { + options.bundle_hbc_transform_meta = PushyToStdString(metaString); + } + } + } + + pushy::delta::Status status = pushy::delta::ApplyPatchFromFileSource(options); + if (!status.ok) { + return statusError(status); + } + return nil; +} + ++ (void)unzipFileAtPath:(NSString *)path + toDestination:(NSString *)destination + completionHandler:(void (^)(NSError *error))completionHandler +{ + dispatch_async(PushyFileQueue(), ^{ + NSFileManager *fileManager = [NSFileManager defaultManager]; + if ([fileManager fileExistsAtPath:destination]) { + [fileManager removeItemAtPath:destination error:nil]; + } + + // Resource caps before the first byte is extracted + // (cpp/patch_core/archive_limits.h). SSZipArchive only exposes the + // entry count up front (zipArchiveWillUnzipArchiveAtPath:zipInfo:), + // not the uncompressed total, so the disk check uses a 2x-archive + // heuristic; the delegate below enforces the exact caps entry by + // entry. + long long archiveBytes = RCTPushyFileSize(path); + NSError *preflight = nil; + if (archiveBytes > pushy::archive_limits::kMaxArchiveBytes) { + preflight = PushyErrorWithCode(pushy::error_codes::kDeltaFailed, + [NSString stringWithFormat:@"archive too large: %lld bytes", archiveBytes]); + } else { + preflight = PushyEnsureFreeSpace(destination, MAX(0LL, archiveBytes) * 2); + } + if (preflight != nil) { + RCTPushyDiscardArchive(path); + completionHandler(preflight); + return; + } + + PushyUnzipGuard *guard = [PushyUnzipGuard new]; + // preserveAttributes:NO — the archive's mode/mtime bits have no use + // here and are one less thing a hostile package controls. + [SSZipArchive unzipFileAtPath:path + toDestination:destination + preserveAttributes:NO + overwrite:YES + nestedZipLevel:0 + password:nil + error:nil + delegate:guard + progressHandler:nil + completionHandler:^(NSString *archivePath, BOOL succeeded, NSError *error) { + // The resume sidecar dies with its archive (§11.4): whether the + // unzip consumed it or classified it as poisoned, nothing must + // survive to vouch for bytes that are gone. + RCTPushyDiscardArchive(archivePath); + + NSError *unzipError = error; + if (guard.violation != nil) { + unzipError = PushyErrorWithCode(pushy::error_codes::kDeltaFailed, guard.violation); + } else if (!succeeded && unzipError == nil) { + unzipError = PushyErrorWithCode(pushy::error_codes::kDeltaFailed, @"unzip failed"); + } else if (unzipError != nil && unzipError.userInfo[RCTPushyErrorCodeKey] == nil) { + // SSZipArchive's own NSError (corrupt zip, bad magic, ...) has + // no stable code; without one, fetchPackage's fallback would + // classify it as DOWNLOAD_FAILED even though the download + // succeeded — keep the classification deterministic. + unzipError = PushyErrorWithCode(pushy::error_codes::kDeltaFailed, + unzipError.localizedDescription ?: @"unzip failed"); + } + completionHandler(unzipError); + }]; + }); +} + ++ (BOOL)switchVersion:(NSString *)hash error:(NSError **)error +{ + if (!PushyIsSafePathComponent(hash)) { + if (error != NULL) { + *error = PushyErrorWithCode(pushy::error_codes::kInvalidOptions, ERROR_OPTIONS); + } + return NO; + } + + // Same rule as Android: only a version this SDK recorded as completely + // installed (bundle + marker) may be activated. Markerless versions + // activated before markers existed are grandfathered through current/last + // state; any other markerless directory may be a crash-left partial + // install or a directory something else put there. A directory that does + // carry a record is verified even when it is current/last. + NSString *versionDir = PushyVersionDir(hash); + std::string hashStd = PushyToStdString(hash); + BOOL (^isLegacyActivated)(void) = ^BOOL { + if (PushyHasInstallRecordFile(versionDir)) { + return NO; + } + pushy::state::State state = PushyStateFromDefaults(PushyDefaults()); + return state.current_version == hashStd || state.last_version == hashStd; + }; + NSError *(^incompleteError)(void) = ^NSError * { + BOOL hasBundle = [[NSFileManager defaultManager] fileExistsAtPath: + [versionDir stringByAppendingPathComponent:BUNDLE_FILE_NAME]]; + return PushyErrorWithCode( + pushy::error_codes::kSwitchVersionFailed, + [NSString stringWithFormat:@"Bundle version %@ %@", hash, + hasBundle ? @"is incomplete." : @"not found."]); + }; + + // The digest check reads the whole bundle, so it runs OUTSIDE the state + // lock: +bundleURL takes that lock on the main thread at launch. + __block BOOL legacyActivated = NO; + PushyWithStateLock(^{ + legacyActivated = isLegacyActivated(); + }); + NSError *switchError = nil; + NSDictionary *verifiedRecord = nil; + PushyFileStamp verifiedStamp; + BOOL verified = NO; + if (!legacyActivated) { + if (!PushyHasCompletedVersionAtPath(versionDir, hash)) { + switchError = incompleteError(); + } else { + // The record's bundle digest must match the bytes on disk before + // the next launch is pointed at them. + NSString *reason = PushyVerifyInstallForActivation( + versionDir, hash, &verifiedRecord, &verifiedStamp); + if (reason != nil) { + switchError = PushyErrorWithCode(pushy::error_codes::kSwitchVersionFailed, reason); + } else { + verified = YES; + } + } + } + + __block NSError *lockedError = switchError; + if (lockedError == nil) { + NSDictionary *record = verifiedRecord; + PushyFileStamp stamp = verifiedStamp; + PushyWithStateLock(^{ + if (!isLegacyActivated()) { + // Re-check cheaply that the directory is still the one that + // was verified: same record, same bundle file (size, mtime, + // inode). + if (!PushyHasCompletedVersionAtPath(versionDir, hash)) { + lockedError = incompleteError(); + return; + } + NSString *bundlePath = [versionDir stringByAppendingPathComponent:BUNDLE_FILE_NAME]; + if (!verified || ![PushyReadInstallRecord(versionDir) isEqual:record] + || PushyStampFile(bundlePath) != stamp) { + lockedError = PushyErrorWithCode( + pushy::error_codes::kSwitchVersionFailed, + [NSString stringWithFormat:@"Bundle version %@ changed while switching.", hash]); + return; + } + } + PushySwitchVersionLocked(hash); + }); + } + if (lockedError != nil) { + if (error != NULL) { + *error = lockedError; + } + return NO; + } + return YES; +} + ++ (void)clearInvalidFiles +{ + dispatch_async(PushyFileQueue(), ^{ + // Snapshot the state under the lock, but run the (slow) filesystem + // cleanup outside of it so state operations are not blocked. + __block pushy::state::State state; + __block NSString *launchVersion = nil; + PushyWithStateLock(^{ + state = PushyStateFromDefaults(PushyDefaults()); + launchVersion = pushyLaunchVersion; + }); + // Keep the persisted current/last versions AND the version this + // process booted from: two switches without a restart would otherwise + // evict the running bundle while its on-demand assets are still + // being served. + pushy::delta::Status status = pushy::delta::CleanupOldEntries( + PushyToStdString([RCTPushy downloadDir]), + std::vector{ + state.current_version, + state.last_version, + PushyToStdString(launchVersion), + }, + 3 + ); + if (!status.ok) { + RCTLogWarn(@"Pushy cleanup error: %s", status.message.c_str()); + } + }); +} + +@end diff --git a/ios/RCTPushy/RCTPushyInternal.h b/ios/RCTPushy/RCTPushyInternal.h index 813f9f6c..9585045b 100644 --- a/ios/RCTPushy/RCTPushyInternal.h +++ b/ios/RCTPushy/RCTPushyInternal.h @@ -1,6 +1,16 @@ -// Internal to the RCTPushy pod: what RCTPushy.mm (module, install pipeline, -// launch resolution) and RCTPushyOrchestrator.mm (native round) share. Not a -// public header. +// Internal to the RCTPushy pod: what the module's translation units share. +// Not a public header; it pulls in C++ headers, so it must stay out of the +// module umbrella. +// +// RCTPushy.mm RN exports, constants, host API entry points +// RCTPushy+Launch.mm launch bundle resolution, paths, binary identity +// RCTPushyState.mm persisted keys, state lock, State <-> defaults, +// string/error/JSON helpers, bundle hash +// RCTPushyInstallRecord.mm version directories, completion record, +// activation verification +// RCTPushyInstaller.mm download/unzip/patch pipeline, switchVersion, +// cleanup (owns the process-wide file queue) +// RCTPushyOrchestrator.mm the native round #pragma once #import "RCTPushy.h" @@ -10,6 +20,7 @@ #include #include +#include #include "../../cpp/patch_core/state_core.h" @@ -26,48 +37,125 @@ typedef NS_ENUM(NSInteger, PushyType) { PushyTypePatchFromPpk = 3, }; -@interface RCTPushy () -- (void)fetchPackage:(PushyType)type - options:(NSDictionary *)options - resolver:(RCTPromiseResolveBlock)resolve - rejecter:(RCTPromiseRejectBlock)reject; -- (void)performFetch:(PushyType)type - options:(NSDictionary *)options - callback:(void (^)(NSError *error))callback; -- (NSError *)restartBridgeWithReason:(NSString *)reason; -- (void)unzipDownloadedPackage:(NSString *)zipFilePath - hash:(NSString *)hash - type:(PushyType)type - originHash:(NSString *)originHash - callback:(void (^)(NSError *error))callback; -- (void)finishDownloadedPackage:(NSString *)hash - type:(PushyType)type - originHash:(NSString *)originHash - callback:(void (^)(NSError *error))callback; -- (void)applyDeltaForHash:(NSString *)hash - type:(PushyType)type - fromBundle:(NSString *)bundleOrigin - source:(NSString *)sourceOrigin - callback:(void (^)(NSError *error))callback; -- (BOOL)switchVersion:(NSString *)hash error:(NSError **)error; -- (BOOL)ensureDirectoryExistsAtPath:(NSString *)path; -+ (void)excludeFromBackup:(NSString *)path; -- (void)unzipFileAtPath:(NSString *)path - toDestination:(NSString *)destination - completionHandler:(void (^)(NSError *error))completionHandler; +#pragma mark - RCTPushyState.mm + +extern PUSHY_HIDDEN NSString *const keyPushyInfo; +extern PUSHY_HIDDEN NSString *const paramCurrentVersion; +extern PUSHY_HIDDEN NSString *const keyUuid; +extern PUSHY_HIDDEN NSString *const keyHashInfo; +extern PUSHY_HIDDEN NSString *const keyFirstLoadMarked; +extern PUSHY_HIDDEN NSString *const keyRolledBackMarked; +extern PUSHY_HIDDEN NSString *const keyNativeCheckCache; +extern PUSHY_HIDDEN NSString *const ERROR_OPTIONS; +// Consumed by the first +bundleURL of a process; re-armed by a version switch. +extern PUSHY_HIDDEN std::atomic ignoreRollback; +// Bumped by resetToPackagedBundle only; see RCTPushyState.mm. +extern PUSHY_HIDDEN std::atomic pushyResetGeneration; + +PUSHY_HIDDEN NSString *KeyPackageUpdatedMarked(void); +PUSHY_HIDDEN NSString *PushyHashInfoKey(NSString *hash); +// Immutable host-facing snapshot; activated always means NEXT launch. +PUSHY_HIDDEN NSDictionary *PushyHostResult(NSString *status, NSString *reason, + NSString *hash = nil, BOOL activated = NO); +PUSHY_HIDDEN NSTimeInterval PushyMonotonicNow(void); +PUSHY_HIDDEN NSUserDefaults *PushyDefaults(void); +PUSHY_HIDDEN void PushyWithStateLock(void (NS_NOESCAPE ^block)(void)); +PUSHY_HIDDEN std::string PushyToStdString(NSString *value); +PUSHY_HIDDEN NSString *PushyFromStdString(const std::string &value); +PUSHY_HIDDEN NSString *PushyOptionString(NSDictionary *options, NSString *key); +PUSHY_HIDDEN BOOL PushyStringIsBlank(NSString *value); +PUSHY_HIDDEN BOOL PushyIsSafePathComponent(NSString *value); +PUSHY_HIDDEN NSError *PushyErrorWithCode(const char *code, NSString *message); +PUSHY_HIDDEN void PushyRejectError(RCTPromiseRejectBlock reject, NSError *error); +// The parsed JSON object in `json` (NSString or NSData); nil when it does not +// parse or is not an object. *error carries the parse error, if any. +PUSHY_HIDDEN NSDictionary *PushyJsonDictionary(id json, NSError **error); +// `object` serialized as a JSON string; nil when it cannot be. +PUSHY_HIDDEN NSString *PushyJsonString(id object); +PUSHY_HIDDEN pushy::state::State PushyStateFromDefaults(NSUserDefaults *defaults); +PUSHY_HIDDEN void PushyApplyStateToDefaults(NSUserDefaults *defaults, + const pushy::state::State &state); +PUSHY_HIDDEN void PushySwitchVersionLocked(NSString *hash); +PUSHY_HIDDEN NSString *PushyBundleHashSync(BOOL computeIfMissing); + +#pragma mark - RCTPushyInstallRecord.mm + +extern PUSHY_HIDDEN NSString *const BUNDLE_FILE_NAME; + +// Identity of a file's bytes on disk without reading them: a rewrite or an +// atomic replacement (new inode) changes it. +struct PushyFileStamp { + bool exists = false; + dev_t device = 0; + ino_t inode = 0; + off_t size = 0; + struct timespec modified = {0, 0}; + + bool operator==(const PushyFileStamp &other) const { + return exists == other.exists && device == other.device && inode == other.inode + && size == other.size && modified.tv_sec == other.modified.tv_sec + && modified.tv_nsec == other.modified.tv_nsec; + } + bool operator!=(const PushyFileStamp &other) const { return !(*this == other); } +}; + +PUSHY_HIDDEN NSString *PushyVersionDir(NSString *hash); +PUSHY_HIDDEN NSString *PushyStagingDir(NSString *hash); +PUSHY_HIDDEN NSDictionary *PushyReadInstallRecord(NSString *versionDir); +PUSHY_HIDDEN BOOL PushyHasInstallRecordFile(NSString *versionDir); +PUSHY_HIDDEN BOOL PushyHasCompletedVersionAtPath(NSString *versionDir, NSString *hash); +PUSHY_HIDDEN PushyFileStamp PushyStampFile(NSString *path); +PUSHY_HIDDEN NSString *PushyVerifyInstallForActivation(NSString *versionDir, NSString *hash, + NSDictionary **recordOut, + PushyFileStamp *stampOut); +PUSHY_HIDDEN NSError *PushyPromoteStagingDir(NSString *hash, NSString *artifactSha256); + +#pragma mark - RCTPushyInstaller.mm + +// Protocol key of the artifact URL in download options. +PUSHY_HIDDEN NSString *PushyOptionUrlKey(void); +// Process-wide serial queue for every destructive file operation (unzip, +// patch, promote, cleanup), shared by JS calls and the native round. +PUSHY_HIDDEN dispatch_queue_t PushyFileQueue(void); + +// The download → unzip → patch → install pipeline and version activation. No +// bridge needed: JS calls and the native round share it. +@interface RCTPushyInstaller : NSObject +// options: url (PushyOptionUrlKey), hash, originHash (diff only), optional +// deadlineUptime. progress may be nil. callback runs on the file queue, or +// synchronously when nothing has to be downloaded (invalid options, version +// already installed, deadline already passed). ++ (void)fetch:(PushyType)type + options:(NSDictionary *)options + progress:(void (^)(long long received, long long total))progress + callback:(void (^)(NSError *error))callback; ++ (BOOL)switchVersion:(NSString *)hash error:(NSError **)error; ++ (void)clearInvalidFiles; +@end + +#pragma mark - RCTPushy+Launch.mm + +// The version whose bundle this process actually loaded; guarded by the state +// lock. See RCTPushy+Launch.mm. +extern PUSHY_HIDDEN NSString *pushyLaunchVersion; + +@interface RCTPushy (Launch) ++ (NSURL *)resolveLaunchBundleURL:(NSString **)launchRolledBackVersionOut + purgedVersion:(NSString **)purgedVersionOut; + (NSString *)downloadDir; + (NSURL *)binaryBundleURL; + (NSString *)packageVersion; + (NSString *)buildTime; @end +#pragma mark - RCTPushyOrchestrator.mm + // 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)persistConfiguration:(NSString *)config; -+ (BOOL)hasRunnableConfig; + (NSDictionary *)prepareBundle; + (NSDictionary *)prepareBundleWithin:(NSTimeInterval)timeout; + (void)scheduleFromColdStart:(NSString *)launchRolledBackVersion; @@ -76,39 +164,4 @@ typedef NS_ENUM(NSInteger, PushyType) { rolledBack:(NSString *)launchRolledBackVersion; #endif + (void)recordJsRound:(NSString *)config; -+ (void)startRoundWithDeadline:(NSTimeInterval)deadlineUptime; -+ (BOOL)claimRound; -+ (void)runClaimedRoundWithDeadline:(NSTimeInterval)deadlineUptime; -+ (void)runOnce:(NSString *)launchRolledBackVersion deadline:(NSTimeInterval)deadlineUptime; -+ (void)runHoldRoundWithDeadline:(NSTimeInterval)deadlineUptime; -+ (BOOL)commitRoundWithGeneration:(uint64_t)generation - configGeneration:(uint64_t)configGeneration - hashInfo:(NSDictionary *)hashInfoEntry - activate:(NSString *)hashToActivate - responseText:(NSString *)responseText - request:(NSString *)requestBody - config:(NSString *)configJson - responseAt:(long long)responseAtSeconds - activated:(BOOL *)activatedOut; @end - -extern PUSHY_HIDDEN NSString *const keyUuid; -extern PUSHY_HIDDEN NSString *const keyNativeConfig; -extern PUSHY_HIDDEN NSString *const keyNativeCheckCache; -extern PUSHY_HIDDEN NSString *const keyNativeCheckIncomplete; -extern PUSHY_HIDDEN std::atomic pushyResetGeneration; -PUSHY_HIDDEN NSDictionary *PushyHostResult(NSString *status, NSString *reason, NSString *hash, BOOL activated); -PUSHY_HIDDEN NSTimeInterval PushyMonotonicNow(void); -PUSHY_HIDDEN std::string PushyToStdString(NSString *value); -PUSHY_HIDDEN NSString *PushyFromStdString(const std::string &value); -PUSHY_HIDDEN NSUserDefaults *PushyDefaults(void); -PUSHY_HIDDEN void PushyWithStateLock(void (NS_NOESCAPE ^block)(void)); -PUSHY_HIDDEN NSString *PushyHashInfoKey(NSString *hash); -PUSHY_HIDDEN BOOL PushyIsSafePathComponent(NSString *value); -PUSHY_HIDDEN BOOL PushyHasCompletedVersionAtPath(NSString *versionDir, NSString *hash); -PUSHY_HIDDEN void PushySwitchVersionLocked(NSString *hash); -PUSHY_HIDDEN NSString *PushyBundleHashSync(BOOL computeIfMissing); -PUSHY_HIDDEN NSString *PushyOptionUrlKey(void); -PUSHY_HIDDEN NSString *PushyRevealOnce(NSString *__strong *slot, dispatch_once_t *once, const char *hex); - -PUSHY_HIDDEN pushy::state::State PushyStateFromDefaults(NSUserDefaults *defaults); diff --git a/ios/RCTPushy/RCTPushyOrchestrator.mm b/ios/RCTPushy/RCTPushyOrchestrator.mm index 453b47b2..88ac7969 100644 --- a/ios/RCTPushy/RCTPushyOrchestrator.mm +++ b/ios/RCTPushy/RCTPushyOrchestrator.mm @@ -4,29 +4,39 @@ #include "../../cpp/update_flow_core/flow_json.h" #include "../../cpp/update_flow_core/update_flow_core.h" -#include #if TARGET_OS_TV #import #include #include #endif +// Raw JSON persisted by JS (syncNativeConfig) or the host (configure) for the +// native round; parsed on read (PushyLoadRunnableConfig). Absent = no round. +static NSString *const keyNativeConfig = @"REACTNATIVECN_PUSHY_NATIVE_CONFIG_KEY"; +// Set when a native check round starts, cleared when it ends (§11.4). Residue +// on the next launch means the previous process died mid-round (a crash +// rescue was truncated): that launch resumes immediately instead of waiting. +static NSString *const keyNativeCheckIncomplete = @"REACTNATIVECN_PUSHY_NATIVE_CHECK_INCOMPLETE_KEY"; + // Version-info flags and the no-op status read by JS, stored encoded // (RCTPushyRevealText) so they do not appear as plain binary strings. static NSString *PushyInfoCrashHold(void) { - static NSString *value; - static dispatch_once_t once; - return PushyRevealOnce(&value, &once, "3905f5c2a6b96d56212a19"); + static NSString *value = RCTPushyRevealText("3905f5c2a6b96d56212a19"); + return value; } static NSString *PushyInfoForceBoot(void) { - static NSString *value; - static dispatch_once_t once; - return PushyRevealOnce(&value, &once, "3c18e6d2aba9674a360d19ead5a695"); + static NSString *value = RCTPushyRevealText("3c18e6d2aba9674a360d19ead5a695"); + return value; } static NSString *PushyStatusNone(void) { - static NSString *value; - static dispatch_once_t once; - return PushyRevealOnce(&value, &once, "3418c1c1aa8a7c40"); + static NSString *value = RCTPushyRevealText("3418c1c1aa8a7c40"); + return value; +} + +// YES when `semaphore` was signalled within `seconds`. +static BOOL PushyWaitSeconds(dispatch_semaphore_t semaphore, NSTimeInterval seconds) { + return dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, + (int64_t)(seconds * NSEC_PER_SEC))) == 0; } // cInfo.os label prefix; must match the JS side (core.ts) so the server @@ -129,7 +139,7 @@ static BOOL PushyHasNetworkPath(void) { } }); nw_path_monitor_start(monitor); - dispatch_semaphore_wait(answered, dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC / 2)); + PushyWaitSeconds(answered, 0.5); nw_path_monitor_cancel(monitor); __block nw_path_status_t result; dispatch_sync(queue, ^{ @@ -142,6 +152,10 @@ static BOOL PushyHasNetworkPath(void) { // past the longest round (8 query requests, then two 600s download phases). static const NSTimeInterval kPushyHostPrepareDefaultTimeout = 1800; +@interface RCTPushyOrchestrator () ++ (void)runHoldRoundWithDeadline:(NSTimeInterval)deadlineUptime; +@end + static void PushyMaybeHoldProcess(void) { // Once per process; a second crashing thread passes straight through // instead of waiting behind the first (§11.3: prefer under-rescuing over @@ -178,8 +192,7 @@ static void PushyMaybeHoldProcess(void) { } dispatch_semaphore_signal(done); }); - if (dispatch_semaphore_wait(done, dispatch_time(DISPATCH_TIME_NOW, - (int64_t)(budget * NSEC_PER_SEC))) != 0) { + if (!PushyWaitSeconds(done, budget)) { NSLog(@"RCTPushy -- crash hold: budget exhausted, letting go"); } } @@ -202,11 +215,12 @@ static void PushyCrashHoldExceptionHandler(NSException *exception) { } static void PushyInstallCrashHoldHandler(void) { - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ + static const bool installed = [] { pushyPreviousExceptionHandler = NSGetUncaughtExceptionHandler(); NSSetUncaughtExceptionHandler(&PushyCrashHoldExceptionHandler); - }); + return true; + }(); + (void)installed; } #pragma mark - native cold-start check orchestration @@ -238,14 +252,10 @@ - (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task // sees the 3xx before it is followed; completion handlers still receive the // final response. Never invalidated: it lives as long as the process. static NSURLSession *PushyRequestSession(void) { - static NSURLSession *session; - static dispatch_once_t once; - dispatch_once(&once, ^{ - session = [NSURLSession - sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration] - delegate:[PushyRequestRedirectGuard new] - delegateQueue:nil]; - }); + static NSURLSession *session = [NSURLSession + sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration] + delegate:[PushyRequestRedirectGuard new] + delegateQueue:nil]; return session; } @@ -294,9 +304,7 @@ - (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task dispatch_semaphore_signal(sem); }]; [task resume]; - if (dispatch_semaphore_wait( - sem, dispatch_time(DISPATCH_TIME_NOW, - (int64_t)((timeout + 5) * NSEC_PER_SEC))) != 0) { + if (!PushyWaitSeconds(sem, timeout + 5)) { [task cancel]; return nil; } @@ -409,13 +417,10 @@ static id PushyCanonicalUrlList(id value) { } static NSDictionary *PushyCanonicalConfig(NSString *json) { - NSData *data = [json dataUsingEncoding:NSUTF8StringEncoding]; - id object = data == nil ? nil - : [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; - if (![object isKindOfClass:NSDictionary.class]) { + NSMutableDictionary *config = [PushyJsonDictionary(json, NULL) mutableCopy]; + if (config == nil) { return nil; } - NSMutableDictionary *config = [object mutableCopy]; NSDictionary *defaults = @{@"disabled": @NO, @"queryUrls": @[], @"rnu": @"", @"rn": @"", @"afterDownload": @"none"}; for (NSString *key in defaults) { @@ -484,7 +489,7 @@ static BOOL PushyLoadRunnableConfig(PushyRunnableConfig *config, NSString **reas // skip, a malformed one a failure. static NSDictionary *PushyUnrunnableConfigResult(NSString *reason) { BOOL skip = [reason isEqualToString:@"not_configured"] || [reason isEqualToString:@"disabled"]; - return PushyHostResult(skip ? @"skipped" : @"failed", reason, nil, NO); + return PushyHostResult(skip ? @"skipped" : @"failed", reason); } @implementation RCTPushyOrchestrator @@ -523,8 +528,7 @@ + (BOOL)hasRunnableConfig { // Process-wide round state, set up by whichever launch path needs a round // first: the delayed cold-start check or the tvOS purged-version restore. + (void)prepareProcess:(NSString *)launchRolledBackVersion { - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ + static const bool prepared = [launchRolledBackVersion] { pushyRoundDone = dispatch_semaphore_create(0); pushyHostRoundGroup = dispatch_group_create(); dispatch_group_enter(pushyHostRoundGroup); @@ -534,10 +538,12 @@ + (void)prepareProcess:(NSString *)launchRolledBackVersion { // The crash-hold rescue shares the orchestrator's rollout gate: no // runnable config, no handler (§11.3). persistConfiguration installs // it once a runnable config arrives later in the process. - if ([self hasRunnableConfig]) { + if ([RCTPushyOrchestrator hasRunnableConfig]) { PushyInstallCrashHoldHandler(); } - }); + return true; + }(); + (void)prepared; } + (void)scheduleFromColdStart:(NSString *)launchRolledBackVersion { @@ -546,23 +552,26 @@ + (void)scheduleFromColdStart:(NSString *)launchRolledBackVersion { // cold-start critical path (§7 R5) — its result targets the NEXT launch. // Unless the previous process died mid-round (residual incomplete // marker), in which case every launch second counts (§11.4). - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - [self prepareProcess:launchRolledBackVersion]; + // No `self` inside the once-lambdas: a block created in a lambda that + // captured `self` by reference keeps that (dangling) reference. + static const bool scheduled = [launchRolledBackVersion] { + [RCTPushyOrchestrator prepareProcess:launchRolledBackVersion]; NSUserDefaults *defaults = PushyDefaults(); int64_t delaySeconds = [defaults objectForKey:keyNativeCheckIncomplete] != nil ? 0 : 5; dispatch_after(dispatch_time(DISPATCH_TIME_NOW, delaySeconds * NSEC_PER_SEC), dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{ - if ([self hasJsRound]) { + if ([RCTPushyOrchestrator hasJsRound]) { // Not consuming the round: a later crash rescue may still // need it. NSLog(@"RCTPushy -- native sync skipped: JS already queried in this process"); return; } - [self startRoundWithDeadline:0]; + [RCTPushyOrchestrator startRoundWithDeadline:0]; }); - }); + return true; + }(); + (void)scheduled; #endif } @@ -608,8 +617,7 @@ + (BOOL)restorePurgedLaunch:(NSString *)purgedVersion [self startRoundWithDeadline:deadline]; dispatch_semaphore_signal(done); }); - BOOL timedOut = dispatch_semaphore_wait(done, dispatch_time(DISPATCH_TIME_NOW, - (int64_t)(budget * NSEC_PER_SEC))) != 0; + BOOL timedOut = !PushyWaitSeconds(done, budget); // The round's deadline does not bound a slow response (the request // timeout is an idle timeout) nor a version already on disk, so the close // is what stops a late activation — not the deadline. @@ -636,10 +644,10 @@ + (NSDictionary *)prepareBundle { // launch); the host gets failed/timeout instead of a result. + (NSDictionary *)prepareBundleWithin:(NSTimeInterval)timeout { #if DEBUG - return PushyHostResult(@"skipped", @"debug", nil, NO); + return PushyHostResult(@"skipped", @"debug"); #else if (!pushySyncReady.load()) { - return PushyHostResult(@"skipped", @"not_initialized", nil, NO); + return PushyHostResult(@"skipped", @"not_initialized"); } PushyRunnableConfig runnable; NSString *reason = nil; @@ -657,24 +665,24 @@ + (NSDictionary *)prepareBundleWithin:(NSTimeInterval)timeout { // A group is broadcast-style. Sharing the rescue semaphore would let one // waiter consume the only signal and leave the other waiting forever. if (!pushyRoundStarted.load()) { - return PushyHostResult(@"skipped", @"config_changed", nil, NO); + return PushyHostResult(@"skipped", @"config_changed"); } if (!(timeout > 0)) { timeout = 0; // also NaN } if (dispatch_group_wait(pushyHostRoundGroup, dispatch_time(DISPATCH_TIME_NOW, (int64_t)(MIN(timeout, (NSTimeInterval)(INT64_MAX / NSEC_PER_SEC)) * NSEC_PER_SEC))) != 0) { - return PushyHostResult(@"failed", @"timeout", nil, NO); + return PushyHostResult(@"failed", @"timeout"); } if (pushyHostRoundConfigGeneration != pushyNativeConfigGeneration.load() || !PushyConfigsEquivalent(configJson, pushyHostRoundConfig) || !PushyConfigsEquivalent(configJson, [PushyDefaults() stringForKey:keyNativeConfig])) { - return PushyHostResult(@"cancelled", @"config_changed", nil, NO); + return PushyHostResult(@"cancelled", @"config_changed"); } if (pushyHostRoundGeneration != pushyResetGeneration.load()) { - return PushyHostResult(@"cancelled", @"reset", nil, NO); + return PushyHostResult(@"cancelled", @"reset"); } - return pushyHostRoundResult ?: PushyHostResult(@"failed", @"internal_error", nil, NO); + return pushyHostRoundResult ?: PushyHostResult(@"failed", @"internal_error"); #endif } @@ -725,7 +733,7 @@ + (void)runClaimedRoundWithDeadline:(NSTimeInterval)deadlineUptime { } @catch (NSException *exception) { // The rescue path must never take the app down with it. RCTLogWarn(@"RCTPushy -- native sync crashed: %@", exception.reason); - pushyHostRoundResult = PushyHostResult(@"failed", @"internal_error", nil, NO); + pushyHostRoundResult = PushyHostResult(@"failed", @"internal_error"); } @finally { pushyRoundCompleted.store(true); dispatch_semaphore_signal(pushyRoundDone); @@ -744,8 +752,7 @@ + (void)runHoldRoundWithDeadline:(NSTimeInterval)deadlineUptime { if (pushyRoundStarted.load() && !pushyRoundCompleted.load()) { NSTimeInterval remaining = deadlineUptime - PushyMonotonicNow(); if (remaining > 0) { - dispatch_semaphore_wait(pushyRoundDone, dispatch_time(DISPATCH_TIME_NOW, - (int64_t)(remaining * NSEC_PER_SEC))); + PushyWaitSeconds(pushyRoundDone, remaining); } } [self activatePendingVersion]; @@ -770,17 +777,14 @@ + (void)activatePendingVersion { // validate the version directory; never point the next launch at bytes // that are gone (e.g. wiped by a reset whose generation bump we would // catch below anyway, or by cleanup). - NSString *versionDir = [[RCTPushy downloadDir] stringByAppendingPathComponent:hash]; - if (!PushyHasCompletedVersionAtPath(versionDir, hash)) { + if (!PushyHasCompletedVersionAtPath(PushyVersionDir(hash), hash)) { NSLog(@"RCTPushy -- crash hold: version %@ no longer on disk, dropping activation", hash); return; } NSDictionary *hashInfoEntry = nil; - NSString *existingInfo = [PushyDefaults() stringForKey:PushyHashInfoKey(hash)]; - NSData *existingData = [existingInfo dataUsingEncoding:NSUTF8StringEncoding]; - id parsed = existingData == nil ? nil : - [NSJSONSerialization JSONObjectWithData:existingData options:0 error:nil]; - if ([parsed isKindOfClass:[NSDictionary class]]) { + NSDictionary *parsed = + PushyJsonDictionary([PushyDefaults() stringForKey:PushyHashInfoKey(hash)], NULL); + if (parsed != nil) { NSMutableDictionary *merged = [parsed mutableCopy]; merged[PushyInfoCrashHold()] = @YES; hashInfoEntry = @{@"hash": hash, @"info": merged}; @@ -804,18 +808,6 @@ + (void)activatePendingVersion { } } -// 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 deadline:(NSTimeInterval)deadlineUptime { // Generations first, config second: a config replaced in between is // caught by the commit's generation check. @@ -823,7 +815,7 @@ + (void)runOnce:(NSString *)launchRolledBackVersion deadline:(NSTimeInterval)dea const uint64_t configGeneration = pushyNativeConfigGeneration.load(); pushyHostRoundGeneration = resetGeneration; pushyHostRoundConfigGeneration = configGeneration; - pushyHostRoundResult = PushyHostResult(@"failed", @"check_failed", nil, NO); + pushyHostRoundResult = PushyHostResult(@"failed", @"check_failed"); NSUserDefaults *defaults = PushyDefaults(); PushyRunnableConfig config; NSString *reason = nil; @@ -912,7 +904,7 @@ + (void)runConfiguredRound:(const flowjson::Value &)config NSString *body = [NSString stringWithUTF8String:bodyJson.c_str()]; if (body == nil) { RCTLogWarn(@"RCTPushy -- native sync: request body is not valid UTF-8"); - pushyHostRoundResult = PushyHostResult(@"failed", @"invalid_request", nil, NO); + pushyHostRoundResult = PushyHostResult(@"failed", @"invalid_request"); return; } @@ -927,35 +919,40 @@ + (void)runConfiguredRound:(const flowjson::Value &)config return; } long long responseAtSeconds = (long long)[[NSDate date] timeIntervalSince1970]; + NSDictionary *cancelledByReset = PushyHostResult(@"cancelled", @"reset"); + // Commits only the response cache (nothing to install or activate) and + // yields `result`, unless a reset or config change since the round + // started won. + NSDictionary *(^commitResponseOnly)(NSDictionary *) = ^NSDictionary *(NSDictionary *result) { + BOOL committed = [self commitRoundWithGeneration:resetGeneration + configGeneration:configGeneration + hashInfo:nil + activate:nil + responseText:responseText + request:body + config:configJson + responseAt:responseAtSeconds + activated:NULL]; + return committed ? result : cancelledByReset; + }; flowjson::Value decision = flowcore::HandleResponse( PushyToStdString(responseText), identity, false, config.Get("afterDownload").AsString()); if (decision.Get("action").AsString() != "download") { - BOOL committed = [self commitRoundWithGeneration:resetGeneration - configGeneration:configGeneration - hashInfo:nil - activate:nil - responseText:responseText - request:body - config:configJson - responseAt:responseAtSeconds - activated:NULL]; - pushyHostRoundResult = committed - ? PushyHostResult(PushyStatusNone(), PushyFromStdString(decision.Get("reason").AsString()), nil, NO) - : PushyHostResult(@"cancelled", @"reset", nil, NO); + pushyHostRoundResult = commitResponseOnly(PushyHostResult( + PushyStatusNone(), PushyFromStdString(decision.Get("reason").AsString()))); RCTLogInfo(@"RCTPushy -- native sync: nothing to do (%s)", decision.Get("reason").AsString().c_str()); return; } NSString *hash = PushyFromStdString(decision.Get("hash").AsString()); if (!PushyIsSafePathComponent(hash)) { - pushyHostRoundResult = PushyHostResult(@"failed", @"invalid_response", nil, NO); + pushyHostRoundResult = PushyHostResult(@"failed", @"invalid_response"); return; } - NSString *versionDir = [[RCTPushy downloadDir] stringByAppendingPathComponent:hash]; - BOOL downloaded = PushyHasCompletedVersionAtPath(versionDir, hash); + BOOL downloaded = PushyHasCompletedVersionAtPath(PushyVersionDir(hash), hash); if (!downloaded) { downloaded = [self performAttempts:decision.Get("attempts") hash:hash @@ -964,18 +961,7 @@ + (void)runConfiguredRound:(const flowjson::Value &)config deadline:deadlineUptime]; } if (!downloaded) { - BOOL committed = [self commitRoundWithGeneration:resetGeneration - configGeneration:configGeneration - hashInfo:nil - activate:nil - responseText:responseText - request:body - config:configJson - responseAt:responseAtSeconds - activated:NULL]; - pushyHostRoundResult = committed - ? PushyHostResult(@"failed", @"download_failed", nil, NO) - : PushyHostResult(@"cancelled", @"reset", nil, NO); + pushyHostRoundResult = commitResponseOnly(PushyHostResult(@"failed", @"download_failed")); return; } @@ -1037,7 +1023,7 @@ + (void)runConfiguredRound:(const flowjson::Value &)config } pushyHostRoundResult = committed ? PushyHostResult(@"downloaded", @"", hash, activate) - : PushyHostResult(@"cancelled", @"reset", nil, NO); + : cancelledByReset; } // Everything a round persists — version info, the activation, the response @@ -1056,16 +1042,12 @@ + (BOOL)commitRoundWithGeneration:(uint64_t)generation activated:(BOOL *)activatedOut { // responseText is nil for the crash handler's activation-only commit // (activatePendingVersion): no round ran, so there is no cache to write. - NSData *cacheData = nil; - if (responseText != nil) { - NSDictionary *cacheEntry = @{ - @"ts": @(responseAtSeconds), - @"body": responseText, - @"request": requestBody, - @"config": configJson, - }; - cacheData = [NSJSONSerialization dataWithJSONObject:cacheEntry options:0 error:nil]; - } + NSString *cache = responseText == nil ? nil : PushyJsonString(@{ + @"ts": @(responseAtSeconds), + @"body": responseText, + @"request": requestBody, + @"config": configJson, + }); __block BOOL committed = NO; __block BOOL activated = NO; PushyWithStateLock(^{ @@ -1095,22 +1077,16 @@ + (BOOL)commitRoundWithGeneration:(uint64_t)generation } } NSUserDefaults *defaults = PushyDefaults(); - if (hashInfoEntry != nil) { - NSData *infoData = [NSJSONSerialization dataWithJSONObject:info - options:0 - error:nil]; - if (infoData != nil) { - [defaults setObject:[[NSString alloc] initWithData:infoData encoding:NSUTF8StringEncoding] - forKey:PushyHashInfoKey(hashInfoEntry[@"hash"])]; - } + NSString *infoJson = hashInfoEntry == nil ? nil : PushyJsonString(info); + if (infoJson != nil) { + [defaults setObject:infoJson forKey:PushyHashInfoKey(hashInfoEntry[@"hash"])]; } if (activation != nil) { PushySwitchVersionLocked(activation); activated = YES; } - if (cacheData != nil) { - [defaults setObject:[[NSString alloc] initWithData:cacheData encoding:NSUTF8StringEncoding] - forKey:keyNativeCheckCache]; + if (cache != nil) { + [defaults setObject:cache forKey:keyNativeCheckCache]; } committed = YES; }); @@ -1222,7 +1198,6 @@ + (BOOL)performAttempts:(const flowjson::Value &)attempts originHash:(NSString *)originHash httpsOnly:(BOOL)httpsOnly deadline:(NSTimeInterval)holdDeadline { - RCTPushy *engine = [self engine]; // Crash-rescue budget caps every phase; 0 keeps the normal 10min windows. NSTimeInterval incrementalDeadline = PushyMonotonicNow() + 600; if (holdDeadline > 0) { @@ -1273,12 +1248,11 @@ + (BOOL)performAttempts:(const flowjson::Value &)attempts } dispatch_semaphore_t sem = dispatch_semaphore_create(0); __block NSError *resultError = nil; - [engine performFetch:pushyType options:options callback:^(NSError *error) { + [RCTPushyInstaller fetch:pushyType options:options progress:nil 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) { + if (!PushyWaitSeconds(sem, remaining)) { RCTLogWarn(@"RCTPushy -- native sync: %s attempt timed out", type.c_str()); if (isFullAttempt) { return NO; diff --git a/ios/RCTPushy/RCTPushyState.mm b/ios/RCTPushy/RCTPushyState.mm new file mode 100644 index 00000000..e01c70ae --- /dev/null +++ b/ios/RCTPushy/RCTPushyState.mm @@ -0,0 +1,314 @@ +// Persisted update state: the defaults keys, the state lock, the State <-> +// defaults mapping, the string/error/JSON helpers every other file uses, and +// the embedded bundle's hash. +#import "RCTPushyInternal.h" +#include "../../cpp/patch_core/digest.h" + +#import +#import + +PUSHY_HIDDEN NSString *const keyPushyInfo = @"REACTNATIVECN_PUSHY_INFO_KEY"; +// Binary identity (SyncBinaryVersion input). Prefixed like every other key: +// the unprefixed names collided with generic host-app/SDK defaults, and a +// foreign value there made every launch look like a rebuilt binary and wipe +// the installed update. +static NSString *const paramPackageVersion = @"REACTNATIVECN_PUSHY_PACKAGEVERSION"; +static NSString *const paramBuildTime = @"REACTNATIVECN_PUSHY_BUILDTIME"; +// The unprefixed names earlier SDKs wrote. Read only as a fallback until the +// first state write migrates them (PushyStateFromDefaults / +// PushyApplyStateToDefaults). +static NSString *const legacyParamPackageVersion = @"packageVersion"; +static NSString *const legacyParamBuildTime = @"buildTime"; +static NSString *const paramLastVersion = @"lastVersion"; +PUSHY_HIDDEN NSString *const paramCurrentVersion = @"currentVersion"; +static NSString *const paramIsFirstTime = @"isFirstTime"; +static NSString *const paramIsFirstLoadOk = @"isFirstLoadOK"; +PUSHY_HIDDEN NSString *const keyUuid = @"REACTNATIVECN_PUSHY_UUID"; +PUSHY_HIDDEN NSString *const keyHashInfo = @"REACTNATIVECN_PUSHY_HASH_"; +PUSHY_HIDDEN NSString *const keyFirstLoadMarked = @"REACTNATIVECN_PUSHY_FIRSTLOADMARKED_KEY"; +PUSHY_HIDDEN NSString *const keyRolledBackMarked = @"REACTNATIVECN_PUSHY_ROLLEDBACKMARKED_KEY"; +// Encoded constants (RCTPushyRevealText) are decoded on first use, not by +// static initializers at image load. +PUSHY_HIDDEN NSString *KeyPackageUpdatedMarked(void) { + static NSString *value = RCTPushyRevealText("0832d5f29aa549710b0939daf88ca058790f3dded7e888b451640d2ec3f69099bb43711523cafa8ea7bb4372132a"); + return value; +} +// bundleHash cache: "|" where cacheKey identifies the +// 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 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. +PUSHY_HIDDEN NSString *const keyNativeCheckCache = @"REACTNATIVECN_PUSHY_NATIVE_CHECK_RESP_KEY"; + +// error def — messages are human-readable; the stable cross-platform codes +// live in cpp/patch_core/error_codes.h and travel in RCTPushyErrorCodeKey. +PUSHY_HIDDEN NSString *const ERROR_OPTIONS = @"options error"; + +PUSHY_HIDDEN std::atomic ignoreRollback{false}; +// Bumped by resetToPackagedBundle only (config replacement has its own +// pushyNativeConfigGeneration). 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. +PUSHY_HIDDEN std::atomic pushyResetGeneration{0}; + +PUSHY_HIDDEN NSDictionary *PushyHostResult(NSString *status, NSString *reason, + NSString *hash, BOOL activated) { + return @{@"status": status, @"reason": reason ?: @"", + @"hash": hash ?: @"", @"activated": @(activated)}; +} + +PUSHY_HIDDEN NSTimeInterval PushyMonotonicNow(void) { + return [NSProcessInfo processInfo].systemUptime; +} + +// 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, the file queue), so the +// read→transform→write sequence must be atomic to avoid e.g. markSuccess being +// overwritten by a concurrent bundleURL and the version being rolled back. +static os_unfair_lock pushyStateLock = OS_UNFAIR_LOCK_INIT; + +PUSHY_HIDDEN void PushyWithStateLock(void (NS_NOESCAPE ^block)(void)) { +#if DEBUG + // Not re-entrant: a nested acquisition deadlocks in release, so catch it + // where it is written. + os_unfair_lock_assert_not_owner(&pushyStateLock); +#endif + os_unfair_lock_lock(&pushyStateLock); + @try { + block(); + } @finally { + os_unfair_lock_unlock(&pushyStateLock); + } +} + +PUSHY_HIDDEN std::string PushyToStdString(NSString *value) { + if (value == nil) { + return std::string(); + } + return std::string([value UTF8String]); +} + +PUSHY_HIDDEN NSUserDefaults *PushyDefaults(void) { + return [NSUserDefaults standardUserDefaults]; +} + +PUSHY_HIDDEN NSString *PushyFromStdString(const std::string &value) { + if (value.empty()) { + return nil; + } + return [NSString stringWithUTF8String:value.c_str()]; +} + +static void PushySetNullableString(NSUserDefaults *defaults, NSString *key, NSString *value) { + if (value != nil) { + [defaults setObject:value forKey:key]; + } else { + [defaults removeObjectForKey:key]; + } +} + +PUSHY_HIDDEN NSString *PushyHashInfoKey(NSString *hash) { + return [keyHashInfo stringByAppendingString:hash ?: @""]; +} + +PUSHY_HIDDEN NSString *PushyOptionString(NSDictionary *options, NSString *key) { + return [RCTConvert NSString:options[key]]; +} + +PUSHY_HIDDEN BOOL PushyStringIsBlank(NSString *value) { + if (value == nil || [value isKindOfClass:[NSNull class]]) { + return YES; + } + return [[value stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] length] == 0; +} + +// Server-provided identifiers (hash/originHash) are used as child names under +// the download root; anything that could resolve outside of it (path +// separators, "..", ".") must be rejected before touching the filesystem. +PUSHY_HIDDEN BOOL PushyIsSafePathComponent(NSString *value) { + if (PushyStringIsBlank(value)) { + return NO; + } + if ([value isEqualToString:@"."] || [value isEqualToString:@".."]) { + return NO; + } + if ([value containsString:@"/"] || [value containsString:@"\\"]) { + return NO; + } + if ([value rangeOfString:@"\0"].location != NSNotFound) { + return NO; + } + return YES; +} + +PUSHY_HIDDEN NSError *PushyErrorWithCode(const char *code, NSString *message) { + return [NSError errorWithDomain:RCTPushyErrorDomain + code:-1 + userInfo:@{ + NSLocalizedDescriptionKey: message ?: @"unknown error", + RCTPushyErrorCodeKey: @(code), + }]; +} + +PUSHY_HIDDEN void PushyRejectError(RCTPromiseRejectBlock reject, NSError *error) { + // Prefer the stable cross-platform code (error_codes.h); fall back to the + // numeric NSError code for system errors that were not classified. + NSString *code = error.userInfo[RCTPushyErrorCodeKey]; + if (code == nil) { + code = [NSString stringWithFormat:@"%ld", (long)error.code]; + } + reject(code, error.localizedDescription, error); +} + +PUSHY_HIDDEN NSDictionary *PushyJsonDictionary(id json, NSError **error) { + NSData *data = [json isKindOfClass:[NSString class]] + ? [(NSString *)json dataUsingEncoding:NSUTF8StringEncoding] + : ([json isKindOfClass:[NSData class]] ? (NSData *)json : nil); + id object = data == nil ? nil + : [NSJSONSerialization JSONObjectWithData:data options:0 error:error]; + return [object isKindOfClass:[NSDictionary class]] ? (NSDictionary *)object : nil; +} + +PUSHY_HIDDEN NSString *PushyJsonString(id object) { + NSData *data = object == nil ? nil + : [NSJSONSerialization dataWithJSONObject:object options:0 error:nil]; + return data == nil ? nil : [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; +} + +// Binary identity under the prefixed key, or — until the first state write +// migrates it — the value an earlier SDK left under the unprefixed one, so an +// installed update survives the SDK upgrade instead of reading as a rebuilt +// binary. +static NSString *PushyBinaryIdentityValue(NSUserDefaults *defaults, NSString *key, NSString *legacyKey) { + return [defaults stringForKey:key] ?: [defaults stringForKey:legacyKey]; +} + +PUSHY_HIDDEN pushy::state::State PushyStateFromDefaults(NSUserDefaults *defaults) { + pushy::state::State state; + state.package_version = PushyToStdString( + PushyBinaryIdentityValue(defaults, paramPackageVersion, legacyParamPackageVersion)); + state.build_time = PushyToStdString( + PushyBinaryIdentityValue(defaults, paramBuildTime, legacyParamBuildTime)); + NSDictionary *pushyInfo = [defaults dictionaryForKey:keyPushyInfo]; + if (pushyInfo != nil) { + state.current_version = PushyToStdString(pushyInfo[paramCurrentVersion]); + state.last_version = PushyToStdString(pushyInfo[paramLastVersion]); + state.first_time = [pushyInfo[paramIsFirstTime] boolValue]; + id firstLoadOk = pushyInfo[paramIsFirstLoadOk]; + state.first_time_ok = firstLoadOk == nil ? true : [firstLoadOk boolValue]; + } + state.rolled_back_version = PushyToStdString([defaults stringForKey:keyRolledBackMarked]); + return state; +} + +PUSHY_HIDDEN void PushyApplyStateToDefaults(NSUserDefaults *defaults, const pushy::state::State &state) { + PushySetNullableString(defaults, paramPackageVersion, PushyFromStdString(state.package_version)); + PushySetNullableString(defaults, paramBuildTime, PushyFromStdString(state.build_time)); + // One-time migration: once the prefixed keys hold the identity, the + // legacy ones are retired so a host-app write there is never read again. + [defaults removeObjectForKey:legacyParamPackageVersion]; + [defaults removeObjectForKey:legacyParamBuildTime]; + + BOOL hasPushyInfo = !state.current_version.empty() || !state.last_version.empty() || state.first_time || !state.first_time_ok; + if (hasPushyInfo) { + NSMutableDictionary *newInfo = [[NSMutableDictionary alloc] init]; + if (!state.current_version.empty()) { + newInfo[paramCurrentVersion] = PushyFromStdString(state.current_version); + } + if (!state.last_version.empty()) { + newInfo[paramLastVersion] = PushyFromStdString(state.last_version); + } + newInfo[paramIsFirstTime] = @(state.first_time); + newInfo[paramIsFirstLoadOk] = @(state.first_time_ok); + [defaults setObject:newInfo forKey:keyPushyInfo]; + } else { + [defaults removeObjectForKey:keyPushyInfo]; + } + + PushySetNullableString( + defaults, + keyRolledBackMarked, + 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. +PUSHY_HIDDEN 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; +} + +static NSString *PushyCachedBundleHash(NSUserDefaults *defaults, NSString *cachedPrefix) { + NSString *cached = [defaults stringForKey:keyBundleHashCache]; + if ([cached hasPrefix:cachedPrefix]) { + return [cached substringFromIndex:cachedPrefix.length]; + } + return nil; +} + +// Shared by the getBundleHash RCT method and the native cold-start check. +// Returns @"" when unknown. With computeIfMissing the sha256 of the embedded +// bundle is taken on a cache miss (once per install; blocking — call off the +// main thread). Without it only the cache is consulted: a crash rescue on +// the main thread has a 3.5s budget in total, and hashing a large bundle on +// a fresh install would spend all of it before the first request. +PUSHY_HIDDEN NSString *PushyBundleHashSync(BOOL computeIfMissing) { + 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 *cachedPrefix = [cacheKey stringByAppendingString:@"|"]; + NSString *cached = PushyCachedBundleHash(defaults, cachedPrefix); + if (cached != nil) { + return cached; + } + if (!computeIfMissing) { + return @""; + } + + // Single-flight for the digest: the delayed cold-start round (utility + // queue) and JS getBundleHash (file queue) can both miss the cache right + // after an install; the second caller waits for the first and then reads + // its cache entry instead of hashing the same multi-MB file twice. + static NSLock *lock = [NSLock new]; + [lock lock]; + @try { + // The previous holder may have filled the cache while we waited. + cached = PushyCachedBundleHash(defaults, cachedPrefix); + if (cached != nil) { + return cached; + } + NSString *hash = PushyFromStdString( + pushy::digest::Sha256File(PushyToStdString(path))) ?: @""; + if (hash.length > 0) { + [defaults setObject:[cachedPrefix stringByAppendingString:hash] + forKey:keyBundleHashCache]; + } + return hash; + } @finally { + [lock unlock]; + } +} diff --git a/scripts/tests/ios-purge-restore/README.md b/scripts/tests/ios-purge-restore/README.md index d44f7738..58d30499 100644 --- a/scripts/tests/ios-purge-restore/README.md +++ b/scripts/tests/ios-purge-restore/README.md @@ -1,8 +1,8 @@ # Deterministic native purge-restore ordering tests Follow-up to #646. These tests exercise the tvOS Release branch of the real -`RCTPushy.mm` / `RCTPushyOrchestrator.mm` startup/commit/reset code in a macOS -Foundation-only executable. +`ios/RCTPushy` startup/commit/reset/switch code in a macOS Foundation-only +executable. There are no production code changes, React Native mocks to install, CocoaPods, simulator builds, network requests, or sleeps. @@ -26,13 +26,15 @@ python3 -m unittest discover -s scripts/tests/ios-purge-restore -p test_extract. ## Production code, not a second state machine -`extract.py` reads the checkout's `ios/RCTPushy/RCTPushy.mm` and -`RCTPushyOrchestrator.mm` on every build (each definition must exist exactly +`extract.py` reads the checkout's `ios/RCTPushy` sources listed in its +`SOURCES` (the module's `.mm` files plus `RCTPushyInternal.h`, which holds the +`PushyFileStamp` struct) on every build (each definition must exist exactly once across them; pod-internal `PUSHY_HIDDEN` definitions count like `static` -ones). It copies the exact definitions (with `#line` locations) of `bundleURL`, +ones, the header's `extern` declarations never match). It copies the exact definitions (with `#line` locations) of `bundleURL`, `resolveLaunchBundleURL`, `restorePurgedLaunch`, `commitRoundWithGeneration`, `resetToPackagedBundle`, their state/defaults helpers, the install record -activation check (`PushyVerifyInstallForActivation`) with `switchVersion:error:`, +activation check (`PushyVerifyInstallForActivation`) with +`+[RCTPushyInstaller switchVersion:error:]`, and the relevant globals into build-only `.inc` files. These are compiled unchanged with the actual `cpp/patch_core/state_core.cpp`. diff --git a/scripts/tests/ios-purge-restore/extract.py b/scripts/tests/ios-purge-restore/extract.py index eb210270..eb178551 100644 --- a/scripts/tests/ios-purge-restore/extract.py +++ b/scripts/tests/ios-purge-restore/extract.py @@ -2,7 +2,7 @@ """Compile production Objective-C++ methods in a Foundation-only test host. No generated source is checked in. Bodies, signatures and source locations come -from RCTPushy.mm on every run. Extraction fails on missing/ambiguous definitions; +from the ios/RCTPushy sources (SOURCES) on every run. Extraction fails on missing/ambiguous definitions; strings/comments cannot affect brace matching. Only the test host supplies I/O. """ @@ -47,7 +47,9 @@ def definition(source: str, pattern: str, label: str) -> tuple[int, str]: # File-local (static) or shared within the pod (PUSHY_HIDDEN, see -# RCTPushyInternal.h); the test host compiles everything in one unit. +# RCTPushyCommon.h); the test host compiles everything in one unit. The +# header's `extern PUSHY_HIDDEN` declarations never match: they start with +# `extern`. LINKAGE = r'(?:static|PUSHY_HIDDEN)' @@ -59,10 +61,6 @@ def method(source: str, name: str) -> tuple[int, str]: return definition(source, rf'^\+\s*\([^\n)]*\)\s*{re.escape(name)}\b', name) -def instance_method(source: str, name: str) -> tuple[int, str]: - return definition(source, rf'^-\s*\([^\n)]*\)\s*{re.escape(name)}\b', name) - - def exported_method(source: str, name: str) -> tuple[int, str]: return definition(source, rf'^RCT_EXPORT_METHOD\(\s*{re.escape(name)}\s*:', name) @@ -87,14 +85,15 @@ def declaration(source: str, name: str) -> tuple[int, str]: 'legacyParamPackageVersion', 'legacyParamBuildTime', 'paramLastVersion', 'paramCurrentVersion', 'paramIsFirstTime', 'paramIsFirstLoadOk', 'keyUuid', 'keyHashInfo', 'keyFirstLoadMarked', 'keyRolledBackMarked', - 'keyNativeCheckCache', 'BUNDLE_FILE_NAME', 'VERSION_COMPLETE_FILE_NAME', 'ERROR_OPTIONS', + 'keyNativeCheckCache', 'BUNDLE_FILE_NAME', 'ERROR_OPTIONS', 'pushyStateLock', 'ignoreRollback', 'pushyIsUsingBundleUrl', 'pushyResetGeneration', 'pushyNativeConfigGeneration', 'pushyLaunchVersion', 'pushyCrashHoldActive', 'pushyPurgeRestoreActive', 'pushyPurgeRestoreWindowOpen', 'pushyHostRoundResult', 'kPushyPurgeRestoreBudget', 'kPushyPurgeRestoreMinimumBudget', ] HELPERS = [ - 'PushyRevealOnce', 'KeyPackageUpdatedMarked', 'PushyWithStateLock', 'PushyToStdString', 'PushyFromStdString', + 'KeyPackageUpdatedMarked', 'PushyWithStateLock', 'PushyToStdString', 'PushyFromStdString', + 'PushyJsonDictionary', 'PushyJsonString', 'PushyRejectException', 'PushyFileQueue', 'PushySetNullableString', 'PushyHashInfoKey', 'PushyBinaryIdentityValue', 'PushyStateFromDefaults', 'PushyApplyStateToDefaults', 'PushySwitchVersionLocked', # Canonical config equality (cross-platform contract), tested directly. @@ -102,7 +101,7 @@ def declaration(source: str, name: str) -> tuple[int, str]: 'PushyCanonicalUrlList', 'PushyCanonicalConfig', 'PushyConfigsEquivalent', # Install record activation check (cpp/patch_core/install_record.h) and # what switchVersion:error: needs around it. - 'PushyReadInstallRecord', 'PushyInstallRecordMatches', 'PushyHasInstallRecordFile', + 'PushyInstallRecordFileName', 'PushyVersionDir', 'PushyReadInstallRecord', 'PushyInstallRecordMatches', 'PushyHasInstallRecordFile', 'PushyHasCompletedVersionAtPath', 'PushyStampFile', 'PushyVerifyInstallForActivation', 'PushyStringIsBlank', 'PushyIsSafePathComponent', ] @@ -133,9 +132,10 @@ def mutate(text: str, mutation: str) -> str: return mutated -# The sources the module is split across (RCTPushy.mm: module and launch -# resolution; RCTPushyOrchestrator.mm: the native round). -SOURCES = ('RCTPushy.mm', 'RCTPushyOrchestrator.mm') +# The sources the module is split across (see RCTPushyInternal.h, which +# also holds the PushyFileStamp struct). +SOURCES = ('RCTPushyInternal.h', 'RCTPushy.mm', 'RCTPushy+Launch.mm', 'RCTPushyState.mm', + 'RCTPushyInstallRecord.mm', 'RCTPushyInstaller.mm', 'RCTPushyOrchestrator.mm') def locate(sources: list[tuple[Path, str]], extractor, name: str) -> tuple[Path, int, str]: @@ -160,7 +160,7 @@ def generate(source_dir: Path, output: Path, mutation: str | None = None) -> Non restore = locate(sources, method, 'restorePurgedLaunch') commit = locate(sources, method, 'commitRoundWithGeneration') helpers = [locate(sources, function, name) for name in HELPERS] - switch_version = locate(sources, instance_method, 'switchVersion') + switch_version = locate(sources, method, 'switchVersion') if mutation == 'grandfather-recorded': switch_version = (switch_version[0], switch_version[1], mutate(switch_version[2], mutation)) @@ -177,9 +177,11 @@ def generate(source_dir: Path, output: Path, mutation: str | None = None) -> Non 'helpers.inc': [locate(sources, struct, name) for name in STRUCTS] + helpers, 'pushy.inc': [locate(sources, method, 'bundleURL'), locate(sources, method, 'resolveLaunchBundleURL'), - locate(sources, exported_method, 'resetToPackagedBundle'), - switch_version], - 'orchestrator.inc': [restore, commit], + locate(sources, exported_method, 'resetToPackagedBundle')], + 'installer.inc': [switch_version], + # The launch wait helper precedes restorePurgedLaunch so the host's + # dispatch seams apply to it as well. + 'orchestrator.inc': [locate(sources, function, 'PushyWaitSeconds'), restore, commit], } output.mkdir(parents=True, exist_ok=True) for name, snippets in sections.items(): diff --git a/scripts/tests/ios-purge-restore/purge_restore_test.mm b/scripts/tests/ios-purge-restore/purge_restore_test.mm index 5f21918f..4e72d6f2 100644 --- a/scripts/tests/ios-purge-restore/purge_restore_test.mm +++ b/scripts/tests/ios-purge-restore/purge_restore_test.mm @@ -1,6 +1,7 @@ // Foundation-only host for the UNMODIFIED production methods extracted at build // time. The state lock, defaults transitions, launch resolution, restore window, -// commit and reset bodies all come from RCTPushy.mm; see README.md for the seams. +// commit, reset and switchVersion bodies all come from ios/RCTPushy; see +// README.md for the seams. #import #import #import @@ -34,10 +35,6 @@ static void TestLog(NSString *, ...) {} // Pod-internal linkage (RCTPushyCommon.h); one translation unit here. #undef PUSHY_HIDDEN #define PUSHY_HIDDEN - -// RCTPushy.mm's own #define, not extracted; InstallRecordActivation checks it -// against install_record.h. -#define VERSION_COMPLETE_FILE_NAME_LITERAL ".pushy-complete" #include "globals.inc" static const char *testName = "setup"; @@ -166,9 +163,7 @@ Status CleanupOldEntries(const std::string&, const std::vector&, } }} -@interface RCTPushy : NSObject { - dispatch_queue_t _fileQueue; -} +@interface RCTPushy : NSObject + (NSURL *)bundleURL; + (NSURL *)resolveLaunchBundleURL:(NSString **)rolledBack purgedVersion:(NSString **)purged; + (NSURL *)binaryBundleURL; @@ -176,7 +171,10 @@ + (NSString *)packageVersion; + (NSString *)buildTime; + (NSString *)downloadDir; - (void)resetToPackagedBundle:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject; -- (BOOL)switchVersion:(NSString *)hash error:(NSError **)error; +@end + +@interface RCTPushyInstaller : NSObject ++ (BOOL)switchVersion:(NSString *)hash error:(NSError **)error; @end @interface RCTPushyOrchestrator : NSObject @@ -274,11 +272,6 @@ static dispatch_time_t TestLaunchDeadline(dispatch_time_t when, int64_t delta) { } @implementation RCTPushy -- (instancetype)init { - self = [super init]; - if (self) { _fileQueue = dispatch_queue_create("pushy.test.files", DISPATCH_QUEUE_SERIAL); } - return self; -} + (NSURL *)binaryBundleURL { return [NSURL fileURLWithPath:[root stringByAppendingPathComponent:@"packaged.bundle"]]; } + (NSString *)packageVersion { return @"1.0"; } + (NSString *)buildTime { return @"123"; } @@ -286,6 +279,10 @@ + (NSString *)downloadDir { return [root stringByAppendingPathComponent:@"update #include "pushy.inc" @end +@implementation RCTPushyInstaller +#include "installer.inc" +@end + @implementation RCTPushyOrchestrator + (void)prepareProcess:(NSString *)rolledBack { (void)rolledBack; } + (BOOL)hasRunnableConfig { return YES; } @@ -557,8 +554,6 @@ static void ConfigEquivalence(void) { } static void InstallRecordActivation(void) { - Expect([VERSION_COMPLETE_FILE_NAME isEqualToString:@(pushy::install_record::kFileName)], - "record file name matches install_record.h"); NSString *hash = @"0123456789abcdef"; NSString *dir = [NSTemporaryDirectory() stringByAppendingPathComponent: [NSString stringWithFormat:@"pushy-install-record-%@", NSUUID.UUID.UUIDString]]; @@ -567,7 +562,7 @@ static void InstallRecordActivation(void) { NSData *bundle = [@"var a = 1;" dataUsingEncoding:NSUTF8StringEncoding]; Expect([bundle writeToFile:[dir stringByAppendingPathComponent:BUNDLE_FILE_NAME] atomically:YES], "write bundle"); - NSString *marker = [dir stringByAppendingPathComponent:VERSION_COMPLETE_FILE_NAME]; + NSString *marker = [dir stringByAppendingPathComponent:PushyInstallRecordFileName()]; auto verify = ^NSString *(id record) { NSData *data = [record isKindOfClass:NSData.class] ? record : [NSJSONSerialization dataWithJSONObject:record options:0 error:nil]; @@ -626,7 +621,7 @@ static void SwitchVersionRecords(void) { NSData *bundle = [@"C" dataUsingEncoding:NSUTF8StringEncoding]; Expect([bundle writeToFile:[dir stringByAppendingPathComponent:BUNDLE_FILE_NAME] atomically:YES], "write C bundle"); - NSString *marker = [dir stringByAppendingPathComponent:VERSION_COMPLETE_FILE_NAME]; + NSString *marker = [dir stringByAppendingPathComponent:PushyInstallRecordFileName()]; NSNumber *schema = @(pushy::install_record::kSchema); auto setRecord = ^(NSDictionary *record) { [NSFileManager.defaultManager removeItemAtPath:marker error:nil]; @@ -646,7 +641,7 @@ static void SwitchVersionRecords(void) { }; auto rejection = ^NSString *(void) { NSError *error = nil; - if ([engine switchVersion:hash error:&error]) { + if ([RCTPushyInstaller switchVersion:hash error:&error]) { return nil; } Expect([error.domain isEqualToString:@(pushy::error_codes::kSwitchVersionFailed)], diff --git a/scripts/tests/ios-purge-restore/test_extract.py b/scripts/tests/ios-purge-restore/test_extract.py index 38ff7f18..f4f562d3 100644 --- a/scripts/tests/ios-purge-restore/test_extract.py +++ b/scripts/tests/ios-purge-restore/test_extract.py @@ -1,7 +1,7 @@ import unittest -from extract import (declaration, exported_method, function, instance_method, mask_literals, - method, mutate, struct) +from extract import (declaration, exported_method, function, mask_literals, method, mutate, + struct) class ExtractionTests(unittest.TestCase): @@ -24,9 +24,17 @@ def test_objc_declaration_and_definition(self): source = '+ (BOOL)restore:(id)value;\n+ (BOOL)restore:(id)value { return YES; }' self.assertEqual(method(source, 'restore'), (2, source.splitlines()[1])) - def test_objc_instance_method_is_not_a_class_method(self): - source = '+ (BOOL)switchVersion:(id)h { return NO; }\n- (BOOL)switchVersion:(id)h { return YES; }' - self.assertEqual(instance_method(source, 'switchVersion'), (2, source.splitlines()[1])) + def test_objc_class_method_is_not_an_instance_method(self): + source = '- (BOOL)switchVersion:(id)h { return NO; }\n+ (BOOL)switchVersion:(id)h { return YES; }' + self.assertEqual(method(source, 'switchVersion'), (2, source.splitlines()[1])) + + def test_header_extern_is_not_a_definition(self): + source = ('extern PUSHY_HIDDEN NSString *const key;\n' + 'PUSHY_HIDDEN NSString *Run(void);\n' + 'PUSHY_HIDDEN NSString *const key = @"k";\n' + 'PUSHY_HIDDEN NSString *Run(void) {\n return key;\n}') + self.assertEqual(declaration(source, 'key')[0], 3) + self.assertEqual(function(source, 'Run')[0], 4) def test_objc_no_argument_method(self): self.assertEqual(method('+ (id)bundleURL\n{ return nil; }', 'bundleURL')[0], 1) From 457d65e5fd59b71e66587747c83c0c1e8a7c0ce4 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 15:24:42 +0800 Subject: [PATCH 064/104] chore(podspec): drop dead fallbacks and search paths - Remove the RN 0.68-0.70 new-arch fallback used when install_modules_dependencies is missing (and the new_arch_enabled variable it needed); React Native >= 0.71 wires codegen and the new architecture itself. - USER_HEADER_SEARCH_PATHS: drop Headers/Public/React-Codegen/RCTPushySpec (the Pods directory is ReactCodegen, so it never existed) and Headers/Public/SSZipArchive (the sources import SSZipArchive's public header framework-style now). - One node call (printing JSON) answers expo's version and expo-modules-core's location and version, instead of three. - Drop the pre-SDK-50 `s.platform = :ios` regex fallback for ExpoModulesCore.podspec (SDK < 50 is rejected earlier anyway). - Drop `s.dependency 'React'`, a duplicate of React-Core. Verified with pod install + Release simulator builds of Example/e2etest, both static (default) and use_frameworks! :linkage => :dynamic. Co-Authored-By: Claude Opus 5.5 --- react-native-update.podspec | 111 +++++++++++++----------------------- 1 file changed, 40 insertions(+), 71 deletions(-) diff --git a/react-native-update.podspec b/react-native-update.podspec index 9a3d1292..1ed94743 100644 --- a/react-native-update.podspec +++ b/react-native-update.podspec @@ -1,8 +1,6 @@ require 'json' require 'rubygems' # Required for version comparison - -new_arch_enabled = ENV['RCT_NEW_ARCH_ENABLED'] == '1' package = JSON.parse(File.read(File.join(__dir__, 'package.json'))) podspec_dir = File.dirname(__FILE__) @@ -20,37 +18,40 @@ Pod::Spec.new do |s| # Silently ignore errors during check end - # expo-modules-core is a dependency of expo, not of the app: resolve it from - # expo's own location. A bare require from this podspec's directory misses - # it when npm nests it under expo/node_modules (seen with react-native-tvos), - # which left EXPO_SUPPORTS_BUNDLEURL unset; the createBridge fallback that - # selects no longer exists in ExpoModulesCore (SDK 57), so the build failed. - expo_modules_core_package_json_js = - "require.resolve('expo-modules-core/package.json', " \ - "{ paths: [require('path').dirname(require.resolve('expo/package.json'))] })" - - # Determine final validity by checking Podfile presence AND Expo version - valid_expo_project = false # Default + # One node call answers every Expo question: expo's version, and where + # expo-modules-core lives and its version. expo-modules-core is a dependency + # of expo, not of the app: resolve it from expo's own location. A bare + # require from this podspec's directory misses it when npm nests it under + # expo/node_modules (seen with react-native-tvos), which left + # EXPO_SUPPORTS_BUNDLEURL unset; the createBridge fallback that selects no + # longer exists in ExpoModulesCore (SDK 57), so the build failed. + expo_info = {} if is_expo_in_podfile - # Only check expo version if use_expo_modules! is present - is_version_sufficient = false + expo_info_js = <<~JS + const path = require('path'); + const info = {}; + try { + const expoJson = require.resolve('expo/package.json'); + info.expoVersion = require(expoJson).version; + const coreJson = require.resolve('expo-modules-core/package.json', + { paths: [path.dirname(expoJson)] }); + info.coreDir = path.dirname(coreJson); + info.coreVersion = require(coreJson).version; + } catch (e) {} + console.log(JSON.stringify(info)); + JS begin - expo_version_str = `node --print \"require('expo/package.json').version\"`.strip - if expo_version_str && !expo_version_str.empty? - match = expo_version_str.match(/^\d+/) - if match - major_version = match[0].to_i - is_version_sufficient = major_version >= 50 - end - end - rescue - # Node command failed, version remains insufficient + output = IO.popen(['node', '-e', expo_info_js], &:read) + expo_info = JSON.parse(output) if $?.success? + rescue StandardError + # node missing or unparseable output: not a supported Expo project. end - - # Final check - valid_expo_project = is_version_sufficient end + # use_expo_modules! in the Podfile and Expo SDK >= 50. + valid_expo_project = + is_expo_in_podfile && expo_info['expoVersion'].to_s[/^\d+/].to_i >= 50 + # Set platform based on whether it's a valid Expo project and if we can parse its target # React Native's own minimum (defined by react_native_pods.rb, which the # Podfile loads before evaluating podspecs). The pod platform version also @@ -74,12 +75,8 @@ Pod::Spec.new do |s| # --- Try to find and parse ExpoModulesCore.podspec only if it's an Expo project --- parsed_expo_ios_target = nil parsed_expo_tvos_target = nil - expo_modules_core_podspec_path = begin - package_json_path = `node -p "#{expo_modules_core_package_json_js}"`.strip - File.join(File.dirname(package_json_path), 'ExpoModulesCore.podspec') if $?.success? && package_json_path && !package_json_path.empty? - rescue - nil - end + expo_modules_core_podspec_path = + expo_info['coreDir'] && File.join(expo_info['coreDir'], 'ExpoModulesCore.podspec') if expo_modules_core_podspec_path && File.exist?(expo_modules_core_podspec_path) begin @@ -89,11 +86,6 @@ Pod::Spec.new do |s| parsed_expo_ios_target = match[1] tvos_match = content.match(/s\.platforms\s*=\s*\{[\s\S]*?:tvos\s*=>\s*'([^\']+)'/) parsed_expo_tvos_target = tvos_match[1] if tvos_match - else - match = content.match(/s\.platform\s*=\s*:ios,\s*'([^\']+)'/) # Fallback to s.platform = :ios, 'version' - if match && match[1] - parsed_expo_ios_target = match[1] - end end rescue => e # Pod::UI.warn "Failed to read or parse ExpoModulesCore.podspec content: #{e.message}" @@ -108,7 +100,7 @@ Pod::Spec.new do |s| end # tvOS (react-native-tvos) shares the iOS sources; the only platform - # differences live behind TARGET_OS_TV in RCTPushy.mm (download directory, + # differences live behind TARGET_OS_TV in ios/RCTPushy (download directory, # purged-cache recovery). s.platforms = { :ios => final_ios_deployment_target, :tvos => final_tvos_deployment_target } @@ -135,9 +127,9 @@ Pod::Spec.new do |s| # DEFINES_MODULE writes the umbrella under Headers/Public/react_native_update/ # (underscores). CocoaPods copies public headers to react-native-update/ # (hyphens). Include the latter so the umbrella can #import "ImportReact.h". - s.pod_target_xcconfig = { - 'USER_HEADER_SEARCH_PATHS' => "\"$(PODS_ROOT)/Headers/Public/react-native-update\" \"$(PODS_ROOT)/Headers/Public/SSZipArchive\" \"$(PODS_ROOT)/Headers/Public/React-Codegen/RCTPushySpec\"", - "DEFINES_MODULE" => "YES" + s.pod_target_xcconfig = { + 'USER_HEADER_SEARCH_PATHS' => "\"$(PODS_ROOT)/Headers/Public/react-native-update\"", + "DEFINES_MODULE" => "YES" } # buildTime for binary-rebuild detection (SyncBinaryVersion) and the check # request. The stamp is refreshed on every build and ships as a resource. @@ -168,7 +160,6 @@ Pod::Spec.new do |s| # static and dynamic integrations both carry it. s.resource_bundles = { 'PushyPrivacy' => ['ios/PrivacyInfo.xcprivacy'] } - s.dependency 'React' s.dependency "React-Core" # The unzip guard relies on the 2.x delegate contract # (zipArchiveShouldUnzipFileAtIndex:... cancels, _sanitizedPath); a future @@ -225,16 +216,8 @@ Pod::Spec.new do |s| if valid_expo_project supports_bundle_url_final = false # Default - # 1. Try executing node to get the version string - expo_modules_core_version_str = begin - `node --print "require(#{expo_modules_core_package_json_js}).version"` # Execute, keep raw output - rescue - # Node command failed (e.g., node not found, package not found). Return empty string. - '' - end - - # 2. Process the obtained version string (if not empty) - if expo_modules_core_version_str && !expo_modules_core_version_str.empty? + expo_modules_core_version_str = expo_info['coreVersion'].to_s + if !expo_modules_core_version_str.empty? begin # Compare versions using Gem::Version (handles trailing newline) installed_version = Gem::Version.new(expo_modules_core_version_str) @@ -253,23 +236,9 @@ Pod::Spec.new do |s| end end + # React Native >= 0.71 (react_native_pods.rb) wires codegen, the new + # architecture and its dependencies. if defined?(install_modules_dependencies()) != nil - install_modules_dependencies(s); - else - if new_arch_enabled - folly_compiler_flags = '-DFOLLY_NO_CONFIG -DFOLLY_MOBILE=1 -DFOLLY_USE_LIBCPP=1 -Wno-comma -Wno-shorten-64-to-32' - - s.compiler_flags = folly_compiler_flags + " -DRCT_NEW_ARCH_ENABLED=1" - - s.pod_target_xcconfig = { - "HEADER_SEARCH_PATHS" => "\"$(PODS_ROOT)/boost\"", - "CLANG_CXX_LANGUAGE_STANDARD" => "c++17" - }.merge(s.pod_target_xcconfig) - s.dependency "React-Codegen" - s.dependency "RCT-Folly" - s.dependency "RCTRequired" - s.dependency "RCTTypeSafety" - s.dependency "ReactCommon/turbomodule/core" - end + install_modules_dependencies(s) end end From 33761240671ee26b16ebb4affbc515c0e25752a6 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 15:25:03 +0800 Subject: [PATCH 065/104] fix(client): release applyingUpdate before emitting a beforeReload hook error The JS cleanup moved the USER_HOOK_ERROR emit into runBeforeReload, so onError listeners ran while applyingUpdate was still held and a listener that retried switchVersion synchronously was ignored. Emit from the callers again, after the release. Co-Authored-By: Claude Opus 5.5 --- src/__tests__/client.test.ts | 32 +++++++++++++++++++++++++ src/client.ts | 46 ++++++++++++++---------------------- 2 files changed, 50 insertions(+), 28 deletions(-) diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index e21ae0bb..8e232e23 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -742,6 +742,38 @@ describe('error pipeline (onError + stable codes, EH-1/EH-2/EH-3)', () => { expect(sharedState.applyingUpdate).toBe(false); }); + test('an onError listener retrying switchVersion after a beforeReload throw is not ignored', async () => { + const reloadUpdate = mock(() => Promise.resolve()); + setupClientMocks({ reloadUpdate }); + const { Pushy, sharedState } = await importFreshClient(); + sharedState.downloadedHash = 'next-hash'; + let hookCalls = 0; + const client = new Pushy({ + appKey: 'demo-app', + beforeReload: () => { + hookCalls += 1; + if (hookCalls === 1) { + throw new Error('hook exploded'); + } + return true; + }, + }); + let retry: Promise | undefined; + client.onError(() => { + // Synchronous retry: applyingUpdate must already be released. + retry ??= client.switchVersion('next-hash'); + }); + + await expect(client.switchVersion('next-hash')).rejects.toThrow( + 'hook exploded' + ); + + expect(retry).toBeDefined(); + expect(await retry).toBe(true); + expect(hookCalls).toBe(2); + expect(reloadUpdate).toHaveBeenCalledTimes(1); + }); + test('concurrent checkUpdate reusing a failing in-flight check keeps its contracts (JS2-1)', async () => { setupClientMocks(); (globalThis as any).fetch = mock(async () => { diff --git a/src/client.ts b/src/client.ts index 6faa2663..f378af4b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -860,22 +860,7 @@ export class Pushy { if (!beforeReload) { return true; } - let shouldReload: boolean | undefined; - try { - shouldReload = await beforeReload(context); - } catch (e) { - // A throw from the user's beforeReload hook is business-code failure, - // not an update-pipeline one: give it a distinct code so telemetry - // excludes it from the server-side patch-health stats. - throw this.fail( - e, - 'USER_HOOK_ERROR', - context.type === 'switchVersion' - ? 'errorSwitchVersion' - : 'errorRestart', - context.hash ? { data: { newVersion: context.hash } } : {} - ); - } + const shouldReload = await beforeReload(context); if (shouldReload === false) { log('beforeReload returned false, skipping reload'); return false; @@ -1095,19 +1080,20 @@ export class Pushy { } log(`switchVersion: ${hash}`); const applyingGeneration = beginApplyingUpdate(); - let shouldReload = false; try { - shouldReload = await this.runBeforeReload({ - type: 'switchVersion', - hash, - }); - } finally { - if (!shouldReload) { + if (!(await this.runBeforeReload({ type: 'switchVersion', hash }))) { finishApplyingUpdate(applyingGeneration); + return false; } - } - if (!shouldReload) { - return false; + } catch (e) { + // Release before emitting: an onError listener may retry right away. + finishApplyingUpdate(applyingGeneration); + // A throw from the user's beforeReload hook is business-code failure, + // not an update-pipeline one: give it a distinct code so telemetry + // excludes it from the server-side patch-health stats. + throw this.fail(e, 'USER_HOOK_ERROR', 'errorSwitchVersion', { + data: { newVersion: hash }, + }); } // resetToPackagedBundle may have completed while beforeReload awaited. if (!ownsApplyingUpdate(applyingGeneration)) { @@ -1679,8 +1665,12 @@ export class Pushy { } }; restartApp = async () => { - if (!(await this.runBeforeReload({ type: 'restartApp' }))) { - return; + try { + if (!(await this.runBeforeReload({ type: 'restartApp' }))) { + return; + } + } catch (e) { + throw this.fail(e, 'USER_HOOK_ERROR', 'errorRestart'); } try { return await PushyModule.restartApp(); From cae4f9db07b08adb19b4cc36ee3f6998e6b3a895 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 15:25:48 +0800 Subject: [PATCH 066/104] fix(core): let a synchronous getBundleHash throw fail the module load again Routing the prefetch through nativeMethod turned a synchronous native throw (a broken integration) into a logged rejection. Call it directly behind the same feature detect so it propagates as before; async failures still only leave the hash unknown. Co-Authored-By: Claude Opus 5.5 --- src/__tests__/core.test.ts | 47 ++++++++++++++++++++++++++++++++++++++ src/core.ts | 20 ++++++++++------ 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/src/__tests__/core.test.ts b/src/__tests__/core.test.ts index 580a2972..38778b1c 100644 --- a/src/__tests__/core.test.ts +++ b/src/__tests__/core.test.ts @@ -147,6 +147,53 @@ describe('client os label', () => { }); }); +describe('bundle hash prefetch', () => { + const mockGetBundleHash = (getBundleHash: () => unknown) => { + mock.module('react-native', () => ({ + ...baseReactNative(), + Platform: { OS: 'ios', Version: '17.5' }, + DeviceEventEmitter: { + addListener: mock(() => ({ remove: mock(() => {}) })), + }, + NativeModules: { + Pushy: { + currentVersionInfo: '{}', + downloadRootDir: '/tmp', + packageVersion: '1.0.0', + currentVersion: '', + isFirstTime: false, + rolledBackVersion: '', + buildTime: '1', + uuid: 'existing-uuid', + setLocalHashInfo: mock(() => {}), + getLocalHashInfo: mock(() => Promise.resolve('{}')), + setUuid: mock(() => {}), + getBundleHash, + }, + }, + NativeEventEmitter: class { + addListener = mock(() => ({ remove: mock(() => {}) })); + }, + })); + }; + + test('a synchronous native throw fails the module load (broken integration)', async () => { + mockGetBundleHash(() => { + throw new Error('bridge broken'); + }); + await expect(importFreshCore('bundle-hash-sync-throw')).rejects.toThrow( + 'bridge broken' + ); + }); + + test('an async native failure only leaves the hash unknown', async () => { + mockGetBundleHash(() => Promise.reject(new Error('not computed'))); + const core = await importFreshCore('bundle-hash-async-reject'); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(core.getBundleHash()).toBe(''); + }); +}); + describe('web platform', () => { restoreDevAfterEach(); diff --git a/src/core.ts b/src/core.ts index 8f697307..33f07e37 100644 --- a/src/core.ts +++ b/src/core.ts @@ -148,13 +148,19 @@ let uuid = PushyConstants.uuid; // between its dedup window and the fetch, and a hash is never worth delaying // a check for. let bundleHash = ''; -nativeMethod('getBundleHash')?.() - .then((hash: unknown) => { - bundleHash = String(hash || ''); - }) - .catch((e: any) => { - log('getBundleHash failed:', e?.message || e); - }); +// Feature-detected like nativeMethod (older natives lack it, web has no native +// module), but called directly rather than through it: a synchronous throw +// means a broken native integration and must propagate at module load, not be +// turned into a logged rejection. Only an async failure is tolerated. +if (!isWebPlatform && typeof PushyModule.getBundleHash === 'function') { + Promise.resolve(PushyModule.getBundleHash()) + .then((hash: unknown) => { + bundleHash = String(hash || ''); + }) + .catch((e: any) => { + log('getBundleHash failed:', e?.message || e); + }); +} /** '' while unknown (not yet computed, debug build, no embedded bundle, older native). */ export const getBundleHash = (): string => bundleHash; From 1707311bce9d6fe06ba002fb7adb3d60435177c1 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 15:27:13 +0800 Subject: [PATCH 067/104] refactor(harmony): build patch manifests with the shared delta::BuildPatchManifest The three NAPI entry points checked the copy list lengths and assembled the PatchManifest by hand; use the patch_core builder the Android JNI glue already uses. The mismatch error now reads 'copy_froms and copy_tos length mismatch' (nothing matches on the old wording). Co-Authored-By: Claude Opus 5.5 --- harmony/pushy/src/main/cpp/pushy.cpp | 56 ++++++++++++++++------------ 1 file changed, 33 insertions(+), 23 deletions(-) diff --git a/harmony/pushy/src/main/cpp/pushy.cpp b/harmony/pushy/src/main/cpp/pushy.cpp index 36593a81..58c40953 100644 --- a/harmony/pushy/src/main/cpp/pushy.cpp +++ b/harmony/pushy/src/main/cpp/pushy.cpp @@ -410,17 +410,22 @@ napi_value NewCopyGroupArray( return result; } -pushy::delta::PatchManifest BuildManifest( - const std::vector& copy_froms, - const std::vector& copy_tos, - const std::vector& deletes) { - pushy::delta::PatchManifest manifest; - for (size_t index = 0; index < copy_froms.size(); ++index) { - manifest.copies.push_back( - pushy::delta::CopyOperation{copy_froms[index], copy_tos[index]}); +// The parallel copy lists (+ deletes) as a PatchManifest via the shared +// delta::BuildPatchManifest. On failure (length mismatch) a JS error is +// pending and false is returned. +bool ReadPatchManifest( + napi_env env, + std::vector copy_froms, + std::vector copy_tos, + std::vector deletes, + pushy::delta::PatchManifest* out) { + const pushy::delta::Status status = pushy::delta::BuildPatchManifest( + std::move(copy_froms), std::move(copy_tos), std::move(deletes), out); + if (!status.ok) { + ThrowError(env, status.message); + return false; } - manifest.deletes = deletes; - return manifest; + return true; } napi_value SyncStateWithBinaryVersion(napi_env env, napi_callback_info info) { @@ -546,8 +551,13 @@ napi_value BuildArchivePatchPlan(napi_env env, napi_callback_info info) { !GetStringArray(env, args[4], &deletes)) { return nullptr; } - if (copy_froms.size() != copy_tos.size()) { - ThrowError(env, "copyFroms and copyTos length mismatch"); + pushy::delta::PatchManifest manifest; + if (!ReadPatchManifest( + env, + std::move(copy_froms), + std::move(copy_tos), + std::move(deletes), + &manifest)) { return nullptr; } @@ -564,8 +574,6 @@ napi_value BuildArchivePatchPlan(napi_env env, napi_callback_info info) { } } - const pushy::delta::PatchManifest manifest = - BuildManifest(copy_froms, copy_tos, deletes); pushy::archive_patch::ArchivePatchType archive_type; if (!pushy::archive_patch::TryParseArchivePatchType(patch_type, &archive_type)) { ThrowError(env, "Unknown archive delta type"); @@ -600,13 +608,12 @@ napi_value BuildCopyGroups(napi_env env, napi_callback_info info) { !GetStringArray(env, args[1], ©_tos)) { return nullptr; } - if (copy_froms.size() != copy_tos.size()) { - ThrowError(env, "copyFroms and copyTos length mismatch"); + pushy::delta::PatchManifest manifest; + if (!ReadPatchManifest( + env, std::move(copy_froms), std::move(copy_tos), {}, &manifest)) { return nullptr; } - const pushy::delta::PatchManifest manifest = - BuildManifest(copy_froms, copy_tos, std::vector()); std::vector groups; const pushy::delta::Status status = pushy::archive_patch::BuildCopyGroups(manifest, &groups); @@ -851,13 +858,16 @@ napi_value ApplyPatchFromFileSource(napi_env env, napi_callback_info info) { !GetOptionalBoolProperty(env, args[0], "enableMerge", true, &enable_merge)) { return nullptr; } - if (copy_froms.size() != copy_tos.size()) { - ThrowError(env, "copyFroms and copyTos length mismatch"); - return nullptr; - } ApplyPatchWork work; - work.options.manifest = BuildManifest(copy_froms, copy_tos, deletes); + if (!ReadPatchManifest( + env, + std::move(copy_froms), + std::move(copy_tos), + std::move(deletes), + &work.options.manifest)) { + return nullptr; + } work.options.source_root = source_root; work.options.target_root = target_root; work.options.origin_bundle_path = origin_bundle_path; From a122c326328ea4481d7248a9bd7e8e44f674084e Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 15:32:15 +0800 Subject: [PATCH 068/104] chore(parity): check the merged Android UpdateModule and the oldarch NativePushySpec Co-Authored-By: Claude Opus 5.5 --- scripts/check-native-spec-parity.js | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/scripts/check-native-spec-parity.js b/scripts/check-native-spec-parity.js index 4a343002..b91e1a69 100644 --- a/scripts/check-native-spec-parity.js +++ b/scripts/check-native-spec-parity.js @@ -32,11 +32,15 @@ if (methods.length < 10) { // Bridge plumbing that RN itself calls; not part of the Pushy surface. const plumbing = new Set(['addListener', 'removeListeners']); -const oldarch = read( - 'android/src/oldarch/cn/reactnative/modules/update/UpdateModule.java' +// One UpdateModule serves both Android architectures. The old bridge finds +// its methods through @ReactMethod on UpdateModule itself; under the old +// architecture it extends the hand-written NativePushySpec stand-in, which +// must declare every method the codegen spec does. +const androidModule = read( + 'android/src/main/java/cn/reactnative/modules/update/UpdateModule.java' ); -const newarch = read( - 'android/src/newarch/cn/reactnative/modules/update/UpdateModule.java' +const oldarchSpec = read( + 'android/src/oldarch/cn/reactnative/modules/update/NativePushySpec.java' ); const ios = read('ios/RCTPushy/RCTPushy.mm'); const harmonyCpp = read('harmony/pushy/src/main/cpp/PushyTurboModule.cpp'); @@ -67,12 +71,15 @@ for (const { name, returns } of methods) { if (plumbing.has(name)) { continue; } - const javaRe = new RegExp(`public\\s+\\S+\\s+${name}\\s*\\(`); - if (!has(oldarch, javaRe)) { - problems.push(`android/oldarch: ${name} not exported`); + const javaRe = new RegExp( + `@ReactMethod\\s+@Override\\s+public\\s+\\S+\\s+${name}\\s*\\(` + ); + if (!has(androidModule, javaRe)) { + problems.push(`android: ${name} not exported with @ReactMethod`); } - if (!has(newarch, javaRe)) { - problems.push(`android/newarch: ${name} not exported`); + const specRe = new RegExp(`public\\s+abstract\\s+\\S+\\s+${name}\\s*\\(`); + if (!has(oldarchSpec, specRe)) { + problems.push(`android/oldarch: ${name} missing from NativePushySpec`); } if ( !has(ios, new RegExp(`RCT_(EXPORT|REMAP)_METHOD\\(\\s*${name}\\b`)) && From e30cedfa980ef0a6d35e765917cdff335f580655 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 15:32:44 +0800 Subject: [PATCH 069/104] fix(ios): include hpatch.h by relative path so use_frameworks! builds resolve it Also point stale header/core.ts comments at the split iOS files. Co-Authored-By: Claude Opus 5.5 --- cpp/patch_core/archive_limits.h | 2 +- cpp/patch_core/error_codes.h | 2 +- cpp/patch_core/install_record.h | 2 +- cpp/patch_core/patch_core.cpp | 5 ++++- src/core.ts | 2 +- 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/cpp/patch_core/archive_limits.h b/cpp/patch_core/archive_limits.h index 34d116e4..e3b4b8e0 100644 --- a/cpp/patch_core/archive_limits.h +++ b/cpp/patch_core/archive_limits.h @@ -11,7 +11,7 @@ // (scripts/check-constant-parity.js, part of `bun lint`, asserts they do): // - android/.../ArchiveLimits.java // - harmony/pushy/src/main/ets/ArchiveLimits.ts -// iOS (RCTPushy.mm) includes this header directly. +// iOS (RCTPushyInstaller.mm, RCTPushyDownloader.mm) includes this header directly. // Keep the core internal to whichever binary links it (see podspec Core). #pragma GCC visibility push(hidden) diff --git a/cpp/patch_core/error_codes.h b/cpp/patch_core/error_codes.h index d3ac0da6..33bd8013 100644 --- a/cpp/patch_core/error_codes.h +++ b/cpp/patch_core/error_codes.h @@ -11,7 +11,7 @@ // - src/error.ts (UpdateErrorCode, JS layer) // - android/.../ErrorCodes.java (Java constants) // - harmony/pushy/src/main/ets/ErrorCodes.ts -// iOS (RCTPushy.mm) includes this header directly. +// iOS (ios/RCTPushy/*.mm) includes this header directly. // // Human-readable messages are NOT part of this contract: they may differ per // platform and locale. Only the codes are stable. diff --git a/cpp/patch_core/install_record.h b/cpp/patch_core/install_record.h index 95a87fe2..59b368b4 100644 --- a/cpp/patch_core/install_record.h +++ b/cpp/patch_core/install_record.h @@ -26,7 +26,7 @@ // (scripts/check-constant-parity.js, part of `bun lint`, asserts they do): // - android/.../InstallRecord.java // - harmony/pushy/src/main/ets/InstallRecord.ts -// iOS (RCTPushy.mm) includes this header directly. +// iOS (RCTPushyInstallRecord.mm) includes this header directly. // Keep the core internal to whichever binary links it (see podspec Core). #pragma GCC visibility push(hidden) diff --git a/cpp/patch_core/patch_core.cpp b/cpp/patch_core/patch_core.cpp index 21e14517..0a562b90 100644 --- a/cpp/patch_core/patch_core.cpp +++ b/cpp/patch_core/patch_core.cpp @@ -22,7 +22,10 @@ #include "hbc_transform_wire.h" extern "C" { -#include "hpatch.h" +// Relative to this file: under use_frameworks! a bare "hpatch.h" resolves +// to the flattened framework PrivateHeaders copy, whose own HDiffPatch/ +// includes then fail. +#include "../../android/jni/hpatch.h" } namespace pushy { diff --git a/src/core.ts b/src/core.ts index 33f07e37..2540ab4f 100644 --- a/src/core.ts +++ b/src/core.ts @@ -199,7 +199,7 @@ export const cInfo = { rnu: require('../package.json').version, rn: RNVersion, // tvOS reports Platform.OS 'ios'; label it apart so the server can bucket - // tvOS devices separately. Must match PushyOsName in RCTPushy.mm. + // tvOS devices separately. Must match PushyOsName in RCTPushyOrchestrator.mm. os: `${Platform.OS === 'ios' && Platform.isTV ? 'tvos' : Platform.OS} ${Platform.Version}`, uuid, }; From 5a5b8e1d49fbab8832e26b05e11c67951bacc549 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 15:37:42 +0800 Subject: [PATCH 070/104] refactor(android): move the JNI glue out of the shared cores into cpp/android_jni cpp/patch_core and cpp/update_flow_core now hold only platform-neutral code; the Android-only JNI translation units and headers live in cpp/android_jni and include the cores by file-relative path. Co-Authored-By: Claude Opus 5.5 --- android/jni/Android.mk | 8 ++++---- android/proguard.pro | 4 ++-- .../java/cn/reactnative/modules/update/NativeCore.java | 2 +- cpp/{patch_core => android_jni}/jni_log.h | 8 ++++---- cpp/{patch_core => android_jni}/jni_natives.h | 6 +++--- cpp/{patch_core => android_jni}/jni_registration.cpp | 2 +- cpp/{patch_core => android_jni}/jni_util.h | 4 ++-- cpp/{patch_core => android_jni}/patch_core_android.cpp | 2 +- .../update_core_android.cpp | 10 +++++----- .../update_flow_jni.cpp | 6 +++--- cpp/patch_core/state_ops.h | 6 +++--- package.json | 1 + scripts/verify-android-so.js | 4 ++-- src/__tests__/jniRegistration.test.ts | 6 +++--- 14 files changed, 35 insertions(+), 34 deletions(-) rename cpp/{patch_core => android_jni}/jni_log.h (83%) rename cpp/{patch_core => android_jni}/jni_natives.h (94%) rename cpp/{patch_core => android_jni}/jni_registration.cpp (99%) rename cpp/{patch_core => android_jni}/jni_util.h (98%) rename cpp/{patch_core => android_jni}/patch_core_android.cpp (98%) rename cpp/{patch_core => android_jni}/update_core_android.cpp (98%) rename cpp/{update_flow_core => android_jni}/update_flow_jni.cpp (95%) diff --git a/android/jni/Android.mk b/android/jni/Android.mk index 30f2360f..349c107b 100644 --- a/android/jni/Android.mk +++ b/android/jni/Android.mk @@ -24,14 +24,14 @@ LOCAL_SRC_FILES := \ ../../cpp/patch_core/digest.cpp \ ../../cpp/patch_core/hbc_transform.cpp \ ../../cpp/patch_core/hbc_transform_wire.cpp \ - ../../cpp/patch_core/jni_registration.cpp \ + ../../cpp/android_jni/jni_registration.cpp \ ../../cpp/patch_core/patch_core.cpp \ - ../../cpp/patch_core/patch_core_android.cpp \ + ../../cpp/android_jni/patch_core_android.cpp \ ../../cpp/patch_core/state_core.cpp \ - ../../cpp/patch_core/update_core_android.cpp \ + ../../cpp/android_jni/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 \ + ../../cpp/android_jni/update_flow_jni.cpp \ $(Hdp_Files) include $(BUILD_SHARED_LIBRARY) diff --git a/android/proguard.pro b/android/proguard.pro index 6022e473..b7db737e 100644 --- a/android/proguard.pro +++ b/android/proguard.pro @@ -1,4 +1,4 @@ -# JNI-constructed result classes: cpp/patch_core/update_core_android.cpp +# JNI-constructed result classes: cpp/android_jni/update_core_android.cpp # resolves them by FindClass + GetMethodID("") + GetFieldID, so the # no-arg constructor and every field must survive shrinking with their names # (a -keepnames rule alone lets R8 strip the unreferenced constructor/fields). @@ -7,7 +7,7 @@ -keep class cn.reactnative.modules.update.CopyGroupResult { (); ; } # Native methods are bound from JNI_OnLoad by class/method name (the encoded -# table in cpp/patch_core/jni_registration.cpp), not by exported symbols, so +# table in cpp/android_jni/jni_registration.cpp), not by exported symbols, so # their classes and names must still survive shrinking unchanged. -keepclasseswithmembernames class cn.reactnative.modules.update.** { native ; diff --git a/android/src/main/java/cn/reactnative/modules/update/NativeCore.java b/android/src/main/java/cn/reactnative/modules/update/NativeCore.java index fdbb76ec..5bb779fd 100644 --- a/android/src/main/java/cn/reactnative/modules/update/NativeCore.java +++ b/android/src/main/java/cn/reactnative/modules/update/NativeCore.java @@ -2,7 +2,7 @@ /** * Loads librnpushy.so. Its JNI_OnLoad registers the natives of every class - * (cpp/patch_core/jni_registration.cpp), so the library only has to be + * (cpp/android_jni/jni_registration.cpp), so the library only has to be * loaded before the first native call: UpdateContext's static initializer * does that, and DownloadTask, FlowBridge and supportedDiffVersion are only * ever reached through an UpdateContext instance. diff --git a/cpp/patch_core/jni_log.h b/cpp/android_jni/jni_log.h similarity index 83% rename from cpp/patch_core/jni_log.h rename to cpp/android_jni/jni_log.h index d667f698..f65a3e0f 100644 --- a/cpp/patch_core/jni_log.h +++ b/cpp/android_jni/jni_log.h @@ -1,9 +1,9 @@ -#ifndef PUSHY_PATCH_CORE_JNI_LOG_H_ -#define PUSHY_PATCH_CORE_JNI_LOG_H_ +#ifndef PUSHY_ANDROID_JNI_LOG_H_ +#define PUSHY_ANDROID_JNI_LOG_H_ #include -#include "obscured_text.h" +#include "../patch_core/obscured_text.h" // Keep the core internal to whichever binary links it (see podspec Core). #pragma GCC visibility push(hidden) @@ -28,4 +28,4 @@ inline const char* Tag() { #define PUSHY_JNI_LOGE(...) \ __android_log_print(ANDROID_LOG_ERROR, ::pushy::jni_log::Tag(), __VA_ARGS__) -#endif // PUSHY_PATCH_CORE_JNI_LOG_H_ +#endif // PUSHY_ANDROID_JNI_LOG_H_ diff --git a/cpp/patch_core/jni_natives.h b/cpp/android_jni/jni_natives.h similarity index 94% rename from cpp/patch_core/jni_natives.h rename to cpp/android_jni/jni_natives.h index cc21aace..7ecb9443 100644 --- a/cpp/patch_core/jni_natives.h +++ b/cpp/android_jni/jni_natives.h @@ -1,5 +1,5 @@ -#ifndef PUSHY_PATCH_CORE_JNI_NATIVES_H_ -#define PUSHY_PATCH_CORE_JNI_NATIVES_H_ +#ifndef PUSHY_ANDROID_JNI_NATIVES_H_ +#define PUSHY_ANDROID_JNI_NATIVES_H_ #include @@ -30,4 +30,4 @@ jstring FlowHandleResponse(JNIEnv* env, jclass, jstring responseText, jstring id } // namespace jni_natives } // namespace pushy -#endif // PUSHY_PATCH_CORE_JNI_NATIVES_H_ +#endif // PUSHY_ANDROID_JNI_NATIVES_H_ diff --git a/cpp/patch_core/jni_registration.cpp b/cpp/android_jni/jni_registration.cpp similarity index 99% rename from cpp/patch_core/jni_registration.cpp rename to cpp/android_jni/jni_registration.cpp index 13bca7c4..fd4be930 100644 --- a/cpp/patch_core/jni_registration.cpp +++ b/cpp/android_jni/jni_registration.cpp @@ -11,7 +11,7 @@ #include "jni_log.h" #include "jni_natives.h" -#include "obscured_text.h" +#include "../patch_core/obscured_text.h" namespace { diff --git a/cpp/patch_core/jni_util.h b/cpp/android_jni/jni_util.h similarity index 98% rename from cpp/patch_core/jni_util.h rename to cpp/android_jni/jni_util.h index 83ad7d29..03135cb2 100644 --- a/cpp/patch_core/jni_util.h +++ b/cpp/android_jni/jni_util.h @@ -9,11 +9,11 @@ #include #include -#include "patch_core.h" +#include "../patch_core/patch_core.h" // Small JNI helpers shared by every Android glue translation unit // (patch_core_android.cpp, update_core_android.cpp and -// ../update_flow_core/update_flow_jni.cpp). Header-only with inline linkage so +// update_flow_jni.cpp). Header-only with inline linkage so // each can include it without a separate compilation unit. namespace pushy { diff --git a/cpp/patch_core/patch_core_android.cpp b/cpp/android_jni/patch_core_android.cpp similarity index 98% rename from cpp/patch_core/patch_core_android.cpp rename to cpp/android_jni/patch_core_android.cpp index 051240e4..cb3cbdcc 100644 --- a/cpp/patch_core/patch_core_android.cpp +++ b/cpp/android_jni/patch_core_android.cpp @@ -6,7 +6,7 @@ #include #include "jni_util.h" -#include "patch_core.h" +#include "../patch_core/patch_core.h" namespace { diff --git a/cpp/patch_core/update_core_android.cpp b/cpp/android_jni/update_core_android.cpp similarity index 98% rename from cpp/patch_core/update_core_android.cpp rename to cpp/android_jni/update_core_android.cpp index 462b3732..aadda754 100644 --- a/cpp/patch_core/update_core_android.cpp +++ b/cpp/android_jni/update_core_android.cpp @@ -2,16 +2,16 @@ #include "jni_log.h" #include "jni_natives.h" -#include "obscured_text.h" +#include "../patch_core/obscured_text.h" #include #include -#include "archive_patch_core.h" -#include "hbc_transform_wire.h" +#include "../patch_core/archive_patch_core.h" +#include "../patch_core/hbc_transform_wire.h" #include "jni_util.h" -#include "state_core.h" -#include "state_ops.h" +#include "../patch_core/state_core.h" +#include "../patch_core/state_ops.h" namespace { diff --git a/cpp/update_flow_core/update_flow_jni.cpp b/cpp/android_jni/update_flow_jni.cpp similarity index 95% rename from cpp/update_flow_core/update_flow_jni.cpp rename to cpp/android_jni/update_flow_jni.cpp index 55a8e7d9..baeb1834 100644 --- a/cpp/update_flow_core/update_flow_jni.cpp +++ b/cpp/android_jni/update_flow_jni.cpp @@ -10,9 +10,9 @@ #include -#include "../patch_core/jni_util.h" -#include "flow_json.h" -#include "update_flow_core.h" +#include "jni_util.h" +#include "../update_flow_core/flow_json.h" +#include "../update_flow_core/update_flow_core.h" using pushy::jni_util::ToJString; diff --git a/cpp/patch_core/state_ops.h b/cpp/patch_core/state_ops.h index 761293bd..735afadf 100644 --- a/cpp/patch_core/state_ops.h +++ b/cpp/patch_core/state_ops.h @@ -1,9 +1,9 @@ #pragma once // Single source of truth for the state-machine operation codes shared across -// the platform glue layers (Android JNI in update_core_android.cpp and -// HarmonyOS NAPI in pushy.cpp). The integer values MUST stay in sync with the -// callers on each platform: +// the platform glue layers (Android JNI in +// cpp/android_jni/update_core_android.cpp and HarmonyOS NAPI in pushy.cpp). +// The integer values MUST stay in sync with the callers on each platform: // - Android: UpdateContext.java (STATE_OP_* constants) // - HarmonyOS: UpdateContext.ts (StateOperation usage) // Do not renumber existing entries; only append new ones. diff --git a/package.json b/package.json index 22d900c1..e5eaab2a 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "ios", "cpp/patch_core", "cpp/update_flow_core", + "cpp/android_jni", "!cpp/**/tests", "harmony/pushy.har", "harmony/hvigor-plugin.ts", diff --git a/scripts/verify-android-so.js b/scripts/verify-android-so.js index b894a6c4..0fd82189 100644 --- a/scripts/verify-android-so.js +++ b/scripts/verify-android-so.js @@ -3,7 +3,7 @@ * Verify the prebuilt Android native libraries shipped in android/lib/ are * present for every ABI and export the JNI symbols the Java layer binds. This * guards against publishing an npm package whose committed librnpushy.so is - * stale/missing after a cpp/patch_core change (which would crash consumers at + * stale/missing after a cpp/ core change (which would crash consumers at * runtime with UnsatisfiedLinkError while CI stays green). * * Reads the ELF .dynsym table directly so it needs no external tools — the @@ -28,7 +28,7 @@ const ABI_MACHINE = { x86_64: 0x3e, // EM_X86_64 }; -// Native methods are registered from JNI_OnLoad (cpp/patch_core/ +// Native methods are registered from JNI_OnLoad (cpp/android_jni/ // jni_registration.cpp), so JNI_OnLoad must be the library's only JNI export. // A leftover Java_* export means a stale .so built before that change. const REQUIRED_SYMBOLS = ['JNI_OnLoad']; diff --git a/src/__tests__/jniRegistration.test.ts b/src/__tests__/jniRegistration.test.ts index 7835eaab..f9a463b6 100644 --- a/src/__tests__/jniRegistration.test.ts +++ b/src/__tests__/jniRegistration.test.ts @@ -4,7 +4,7 @@ import { URL } from 'node:url'; import { decodeNativeText } from '../../scripts/encode-native-text'; // JNI_OnLoad binds Android native methods from an encoded name/signature table -// (cpp/patch_core/jni_registration.cpp). A drifted entry would silently leave +// (cpp/android_jni/jni_registration.cpp). A drifted entry would silently leave // a method unbound until its first call, so the table must match every Java // `native` declaration exactly. const root = new URL('../../', import.meta.url); @@ -50,7 +50,7 @@ const javaNatives = () => const registered = () => { const source = readFileSync( - new URL('cpp/patch_core/jni_registration.cpp', root), + new URL('cpp/android_jni/jni_registration.cpp', root), 'utf8' ); const entries: string[] = []; @@ -78,7 +78,7 @@ describe('JNI registration table', () => { test('stores no plain class or method names', () => { const source = readFileSync( - new URL('cpp/patch_core/jni_registration.cpp', root), + new URL('cpp/android_jni/jni_registration.cpp', root), 'utf8' ).replace(/\/\/.*$/gm, ''); expect(source).not.toContain('cn/reactnative'); From 2225eba10a54762e0c784a237b4f9765b6291f84 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 15:38:43 +0800 Subject: [PATCH 071/104] refactor(android): fold patch_core_android.cpp into update_core_android.cpp The two remaining delta natives (applyDeltaFromSource, cleanupOldEntries) join the other patch/state natives, so cpp/android_jni has one core glue unit plus the flow glue and the registration table. Co-Authored-By: Claude Opus 5.5 --- android/jni/Android.mk | 1 - cpp/android_jni/jni_util.h | 5 +- cpp/android_jni/patch_core_android.cpp | 79 ------------------------- cpp/android_jni/update_core_android.cpp | 61 +++++++++++++++++++ 4 files changed, 63 insertions(+), 83 deletions(-) delete mode 100644 cpp/android_jni/patch_core_android.cpp diff --git a/android/jni/Android.mk b/android/jni/Android.mk index 349c107b..8d5f9d29 100644 --- a/android/jni/Android.mk +++ b/android/jni/Android.mk @@ -26,7 +26,6 @@ LOCAL_SRC_FILES := \ ../../cpp/patch_core/hbc_transform_wire.cpp \ ../../cpp/android_jni/jni_registration.cpp \ ../../cpp/patch_core/patch_core.cpp \ - ../../cpp/android_jni/patch_core_android.cpp \ ../../cpp/patch_core/state_core.cpp \ ../../cpp/android_jni/update_core_android.cpp \ ../../cpp/update_flow_core/flow_json.cpp \ diff --git a/cpp/android_jni/jni_util.h b/cpp/android_jni/jni_util.h index 03135cb2..233d1d00 100644 --- a/cpp/android_jni/jni_util.h +++ b/cpp/android_jni/jni_util.h @@ -12,9 +12,8 @@ #include "../patch_core/patch_core.h" // Small JNI helpers shared by every Android glue translation unit -// (patch_core_android.cpp, update_core_android.cpp and -// update_flow_jni.cpp). Header-only with inline linkage so -// each can include it without a separate compilation unit. +// (update_core_android.cpp and update_flow_jni.cpp). Header-only with inline +// linkage so each can include it without a separate compilation unit. namespace pushy { namespace jni_util { diff --git a/cpp/android_jni/patch_core_android.cpp b/cpp/android_jni/patch_core_android.cpp deleted file mode 100644 index cb3cbdcc..00000000 --- a/cpp/android_jni/patch_core_android.cpp +++ /dev/null @@ -1,79 +0,0 @@ -#include - -#include "jni_natives.h" - -#include -#include - -#include "jni_util.h" -#include "../patch_core/patch_core.h" - -namespace { - -using pushy::jni_util::GuardJni; -using pushy::jni_util::JArrayToVector; -using pushy::jni_util::JStringToString; -using pushy::jni_util::ReadPatchManifest; -using pushy::jni_util::ThrowRuntimeException; - -} // namespace - -void pushy::jni_natives::ApplyDeltaFromSource( - JNIEnv* env, - jclass, - jstring source_root, - jstring target_root, - jstring origin_bundle_path, - jstring bundle_patch_path, - jstring bundle_output_path, - jstring merge_source_subdir, - jboolean enable_merge, - jobjectArray copy_froms, - jobjectArray copy_tos, - jobjectArray deletes, - jstring hbc_transform_meta) { - GuardJni(env, "Unexpected native exception while applying delta", [&] { - pushy::delta::FileSourcePatchOptions options; - if (!ReadPatchManifest(env, copy_froms, copy_tos, deletes, &options.manifest)) { - return; - } - options.source_root = JStringToString(env, source_root); - options.target_root = JStringToString(env, target_root); - options.origin_bundle_path = JStringToString(env, origin_bundle_path); - options.bundle_patch_path = JStringToString(env, bundle_patch_path); - options.bundle_output_path = JStringToString(env, bundle_output_path); - options.merge_source_subdir = JStringToString(env, merge_source_subdir); - options.enable_merge = enable_merge == JNI_TRUE; - options.bundle_hbc_transform_meta = JStringToString(env, hbc_transform_meta); - - const pushy::delta::Status status = - pushy::delta::ApplyPatchFromFileSource(options); - if (!status.ok) { - ThrowRuntimeException(env, status.message); - } - }); -} - -void pushy::jni_natives::CleanupOldEntries( - JNIEnv* env, - jclass, - jstring root_dir, - jobjectArray keep_names, - jint max_age_days, - jstring trash_dir) { - GuardJni(env, "Unexpected native exception in cleanupOldEntries", [&] { - const std::string root = JStringToString(env, root_dir); - const std::vector keep = JArrayToVector(env, keep_names); - const std::string trash = JStringToString(env, trash_dir); - // A trash directory turns the delete into renames (cheap enough to run - // under the caller's version-files lock); the caller purges it later. - const pushy::delta::Status status = trash.empty() - ? pushy::delta::CleanupOldEntries( - root, keep, static_cast(max_age_days)) - : pushy::delta::MoveOldEntriesToTrash( - root, keep, static_cast(max_age_days), trash); - if (!status.ok) { - ThrowRuntimeException(env, status.message); - } - }); -} diff --git a/cpp/android_jni/update_core_android.cpp b/cpp/android_jni/update_core_android.cpp index aadda754..f637cb0e 100644 --- a/cpp/android_jni/update_core_android.cpp +++ b/cpp/android_jni/update_core_android.cpp @@ -9,6 +9,7 @@ #include "../patch_core/archive_patch_core.h" #include "../patch_core/hbc_transform_wire.h" +#include "../patch_core/patch_core.h" #include "jni_util.h" #include "../patch_core/state_core.h" #include "../patch_core/state_ops.h" @@ -475,3 +476,63 @@ jobjectArray pushy::jni_natives::BuildCopyGroups( return result; }); } + +void pushy::jni_natives::ApplyDeltaFromSource( + JNIEnv* env, + jclass, + jstring source_root, + jstring target_root, + jstring origin_bundle_path, + jstring bundle_patch_path, + jstring bundle_output_path, + jstring merge_source_subdir, + jboolean enable_merge, + jobjectArray copy_froms, + jobjectArray copy_tos, + jobjectArray deletes, + jstring hbc_transform_meta) { + GuardJni(env, "Unexpected native exception while applying delta", [&] { + pushy::delta::FileSourcePatchOptions options; + if (!ReadPatchManifest(env, copy_froms, copy_tos, deletes, &options.manifest)) { + return; + } + options.source_root = JStringToString(env, source_root); + options.target_root = JStringToString(env, target_root); + options.origin_bundle_path = JStringToString(env, origin_bundle_path); + options.bundle_patch_path = JStringToString(env, bundle_patch_path); + options.bundle_output_path = JStringToString(env, bundle_output_path); + options.merge_source_subdir = JStringToString(env, merge_source_subdir); + options.enable_merge = enable_merge == JNI_TRUE; + options.bundle_hbc_transform_meta = JStringToString(env, hbc_transform_meta); + + const pushy::delta::Status status = + pushy::delta::ApplyPatchFromFileSource(options); + if (!status.ok) { + ThrowRuntimeException(env, status.message); + } + }); +} + +void pushy::jni_natives::CleanupOldEntries( + JNIEnv* env, + jclass, + jstring root_dir, + jobjectArray keep_names, + jint max_age_days, + jstring trash_dir) { + GuardJni(env, "Unexpected native exception in cleanupOldEntries", [&] { + const std::string root = JStringToString(env, root_dir); + const std::vector keep = JArrayToVector(env, keep_names); + const std::string trash = JStringToString(env, trash_dir); + // A trash directory turns the delete into renames (cheap enough to run + // under the caller's version-files lock); the caller purges it later. + const pushy::delta::Status status = trash.empty() + ? pushy::delta::CleanupOldEntries( + root, keep, static_cast(max_age_days)) + : pushy::delta::MoveOldEntriesToTrash( + root, keep, static_cast(max_age_days), trash); + if (!status.ok) { + ThrowRuntimeException(env, status.message); + } + }); +} From e1b039f56adb5271b34ea325472f887e10ade648 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 15:39:57 +0800 Subject: [PATCH 072/104] refactor(cpp): move the hpatch wrapper from android/jni to cpp/hpatch hpatch.{c,h} is compiled by all three platforms, so it moves next to the other shared cores. The HDiffPatch and lzma submodules stay under android/jni; hpatch.{c,h} reach them by file-relative path, which keeps the use_frameworks! builds (no header search path into the pod) resolving the real headers, and patch_core.cpp includes ../hpatch/hpatch.h the same way. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/publish.yml | 2 +- android/jni/Android.mk | 2 +- {android/jni => cpp/hpatch}/hpatch.c | 10 +++++----- {android/jni => cpp/hpatch}/hpatch.h | 5 ++++- cpp/patch_core/patch_core.cpp | 2 +- harmony/pushy/src/main/cpp/CMakeLists.txt | 2 +- package.json | 1 + react-native-update.podspec | 2 +- scripts/build-harmony-har.js | 9 +++++---- scripts/check-packlist.js | 2 +- scripts/test-patch-core.sh | 2 +- 11 files changed, 22 insertions(+), 17 deletions(-) rename {android/jni => cpp/hpatch}/hpatch.c (97%) rename {android/jni => cpp/hpatch}/hpatch.h (79%) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a1ecda61..4cc53a1e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -75,7 +75,7 @@ jobs: fi echo "Previous tag is $PREV_TAG" # The HAR embeds librnpushy.so compiled from cpp/patch_core, - # cpp/update_flow_core, android/jni/hpatch.c and the HDiffPatch/lzma + # cpp/update_flow_core, cpp/hpatch and the HDiffPatch/lzma # submodules (harmony/pushy/src/main/cpp/CMakeLists.txt), so any of # those changing must rebuild it too — reusing the previous HAR # would ship Harmony with an older native core than Android/iOS. diff --git a/android/jni/Android.mk b/android/jni/Android.mk index 8d5f9d29..99eb893c 100644 --- a/android/jni/Android.mk +++ b/android/jni/Android.mk @@ -13,7 +13,7 @@ LOCAL_C_INCLUDES := \ $(LOCAL_PATH)/../../cpp/update_flow_core Hdp_Files := \ - hpatch.c \ + ../../cpp/hpatch/hpatch.c \ HDiffPatch/libHDiffPatch/HPatch/patch.c \ HDiffPatch/file_for_patch.c \ lzma/C/LzmaDec.c \ diff --git a/android/jni/hpatch.c b/cpp/hpatch/hpatch.c similarity index 97% rename from android/jni/hpatch.c rename to cpp/hpatch/hpatch.c index 63ed6798..33a5d7d0 100644 --- a/android/jni/hpatch.c +++ b/cpp/hpatch/hpatch.c @@ -1,17 +1,17 @@ // hpatch.c // Copyright 2021 housisong, All rights reserved #include "hpatch.h" -#include "HDiffPatch/libHDiffPatch/HPatch/patch.h" -#include "HDiffPatch/file_for_patch.h" +#include "../../android/jni/HDiffPatch/libHDiffPatch/HPatch/patch.h" +#include "../../android/jni/HDiffPatch/file_for_patch.h" //#define _CompressPlugin_zlib //#define _CompressPlugin_bz2 #define _CompressPlugin_lzma #define _CompressPlugin_lzma2 #define _IsNeedIncludeDefaultCompressHead 0 -#include "lzma/C/LzmaDec.h" -#include "lzma/C/Lzma2Dec.h" -#include "HDiffPatch/decompress_plugin_demo.h" +#include "../../android/jni/lzma/C/LzmaDec.h" +#include "../../android/jni/lzma/C/Lzma2Dec.h" +#include "../../android/jni/HDiffPatch/decompress_plugin_demo.h" #define kMaxLoadMemOldSize ((1<<20)*8) diff --git a/android/jni/hpatch.h b/cpp/hpatch/hpatch.h similarity index 79% rename from android/jni/hpatch.h rename to cpp/hpatch/hpatch.h index cd9488e6..3b74b44c 100644 --- a/android/jni/hpatch.h +++ b/cpp/hpatch/hpatch.h @@ -5,7 +5,10 @@ #ifndef HDIFFPATCH_PATCH_H #define HDIFFPATCH_PATCH_H # include //for uint8_t -#include "HDiffPatch/libHDiffPatch/HPatch/patch_types.h" //for hpatch_singleCompressedDiffInfo +// The HDiffPatch and lzma submodules stay under android/jni; every build +// (iOS without header search paths into the pod) resolves them relative to +// this file. +#include "../../android/jni/HDiffPatch/libHDiffPatch/HPatch/patch_types.h" //for hpatch_singleCompressedDiffInfo #ifdef __cplusplus extern "C" { #endif diff --git a/cpp/patch_core/patch_core.cpp b/cpp/patch_core/patch_core.cpp index 0a562b90..c81ecd8f 100644 --- a/cpp/patch_core/patch_core.cpp +++ b/cpp/patch_core/patch_core.cpp @@ -25,7 +25,7 @@ extern "C" { // Relative to this file: under use_frameworks! a bare "hpatch.h" resolves // to the flattened framework PrivateHeaders copy, whose own HDiffPatch/ // includes then fail. -#include "../../android/jni/hpatch.h" +#include "../hpatch/hpatch.h" } namespace pushy { diff --git a/harmony/pushy/src/main/cpp/CMakeLists.txt b/harmony/pushy/src/main/cpp/CMakeLists.txt index 471056e5..6fbfad91 100644 --- a/harmony/pushy/src/main/cpp/CMakeLists.txt +++ b/harmony/pushy/src/main/cpp/CMakeLists.txt @@ -30,7 +30,7 @@ set(HDP_SOURCES ${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 + ${REPO_ROOT}/cpp/hpatch/hpatch.c ${HDIFFPATCH_DIR}/libHDiffPatch/HPatch/patch.c ${HDIFFPATCH_DIR}/file_for_patch.c ${LZMA_DIR}/C/LzmaDec.c diff --git a/package.json b/package.json index e5eaab2a..6e32d825 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "cpp/patch_core", "cpp/update_flow_core", "cpp/android_jni", + "cpp/hpatch", "!cpp/**/tests", "harmony/pushy.har", "harmony/hvigor-plugin.ts", diff --git a/react-native-update.podspec b/react-native-update.podspec index 1ed94743..4e9c3796 100644 --- a/react-native-update.podspec +++ b/react-native-update.podspec @@ -189,7 +189,7 @@ Pod::Spec.new do |s| 'cpp/patch_core/hbc_transform_wire.{h,cpp}', 'cpp/patch_core/patch_core.{h,cpp}', 'cpp/patch_core/state_core.{h,cpp}', - 'android/jni/hpatch.{h,c}', + 'cpp/hpatch/hpatch.{h,c}', 'android/jni/HDiffPatch/libHDiffPatch/HPatch/*.{h,c}', 'android/jni/HDiffPatch/file_for_patch.{h,c}', 'android/jni/lzma/C/LzmaDec.{h,c}', diff --git a/scripts/build-harmony-har.js b/scripts/build-harmony-har.js index b2c1e12e..22621092 100644 --- a/scripts/build-harmony-har.js +++ b/scripts/build-harmony-har.js @@ -6,6 +6,7 @@ const { spawnSync } = require('child_process'); const projectRoot = path.resolve(__dirname, '..'); const androidJniDir = path.join(projectRoot, 'android', 'jni'); +const hpatchDir = path.join(projectRoot, 'cpp', 'hpatch'); const patchCoreDir = path.join(projectRoot, 'cpp', 'patch_core'); const updateFlowCoreDir = path.join(projectRoot, 'cpp', 'update_flow_core'); const harmonyModuleDir = path.join(projectRoot, 'harmony', 'pushy'); @@ -22,10 +23,10 @@ const wrapperProjectFiles = [ // The shared native sources harmony/pushy/src/main/cpp/CMakeLists.txt // compiles in place from the repository root (submodules included). const requiredNativeSources = [ - [path.join(androidJniDir, 'hpatch.c'), 'Android native source'], - [path.join(androidJniDir, 'hpatch.h'), 'Android native source'], - [path.join(androidJniDir, 'HDiffPatch'), 'Android native source directory'], - [path.join(androidJniDir, 'lzma', 'C'), 'Android native source directory'], + [path.join(hpatchDir, 'hpatch.c'), 'shared hpatch source'], + [path.join(hpatchDir, 'hpatch.h'), 'shared hpatch source'], + [path.join(androidJniDir, 'HDiffPatch'), 'HDiffPatch submodule'], + [path.join(androidJniDir, 'lzma', 'C'), 'lzma submodule'], [path.join(patchCoreDir, 'patch_core.cpp'), 'shared patch core source'], [ path.join(updateFlowCoreDir, 'update_flow_core.cpp'), diff --git a/scripts/check-packlist.js b/scripts/check-packlist.js index d42e54dd..7d3b10e3 100644 --- a/scripts/check-packlist.js +++ b/scripts/check-packlist.js @@ -55,7 +55,7 @@ const required = [ 'src/NativePushy.ts', 'android/build.gradle', 'android/src/main/java/cn/reactnative/modules/update/UpdateContext.java', - 'android/jni/hpatch.c', + 'cpp/hpatch/hpatch.c', 'ios/RCTPushy/RCTPushy.mm', 'ios/RCTPushy/RCTPushyOrchestrator.mm', 'react-native-update.podspec', diff --git a/scripts/test-patch-core.sh b/scripts/test-patch-core.sh index 7c0066fb..508145a5 100755 --- a/scripts/test-patch-core.sh +++ b/scripts/test-patch-core.sh @@ -26,7 +26,7 @@ COMMON_INCLUDES=" # The vendored HDiffPatch / lzma C sources are compiled with -Wall -Wextra # only (no -Werror). -cc -Wall -Wextra $SANITIZE_FLAGS $COMMON_INCLUDES -c "$ROOT_DIR/android/jni/hpatch.c" -o "$BUILD_DIR/hpatch.o" +cc -Wall -Wextra $SANITIZE_FLAGS $COMMON_INCLUDES -c "$ROOT_DIR/cpp/hpatch/hpatch.c" -o "$BUILD_DIR/hpatch.o" cc -Wall -Wextra $SANITIZE_FLAGS $COMMON_INCLUDES -c "$ROOT_DIR/android/jni/HDiffPatch/libHDiffPatch/HPatch/patch.c" -o "$BUILD_DIR/patch.o" cc -Wall -Wextra $SANITIZE_FLAGS $COMMON_INCLUDES -c "$ROOT_DIR/android/jni/HDiffPatch/file_for_patch.c" -o "$BUILD_DIR/file_for_patch.o" cc -Wall -Wextra $SANITIZE_FLAGS $COMMON_INCLUDES -c "$ROOT_DIR/android/jni/lzma/C/LzmaDec.c" -o "$BUILD_DIR/LzmaDec.o" From 6968b12acbf42a0662dd6a8330aea406ed31a9d0 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 15:42:32 +0800 Subject: [PATCH 073/104] build: pick the shared C++ sources by directory on every platform Android.mk ($(wildcard)), the Harmony CMakeLists (file(GLOB CONFIGURE_DEPENDS)), the podspec Core subspec and the core test scripts now compile cpp/patch_core, cpp/update_flow_core and cpp/hpatch (plus cpp/android_jni on Android) by directory instead of hand-copied file lists, so adding a platform-neutral source no longer needs five edits. The globs are non-recursive, so tests/ stays out everywhere. All includes are file-relative now, so Android.mk drops its include paths and the Harmony target keeps only the ones pushy.cpp needs. Co-Authored-By: Claude Opus 5.5 --- android/jni/Android.mk | 36 ++++++++++------------- harmony/pushy/src/main/cpp/CMakeLists.txt | 33 ++++++++++----------- react-native-update.podspec | 15 ++++------ scripts/test-patch-core.sh | 19 +++++------- scripts/test-update-flow-core.sh | 6 ++-- 5 files changed, 46 insertions(+), 63 deletions(-) diff --git a/android/jni/Android.mk b/android/jni/Android.mk index 99eb893c..b656e9bc 100644 --- a/android/jni/Android.mk +++ b/android/jni/Android.mk @@ -4,33 +4,27 @@ include $(CLEAR_VARS) LOCAL_MODULE := rnpushy LOCAL_LDLIBS := -llog -LOCAL_C_INCLUDES := \ - $(LOCAL_PATH) \ - $(LOCAL_PATH)/HDiffPatch \ - $(LOCAL_PATH)/HDiffPatch/libHDiffPatch/HPatch \ - $(LOCAL_PATH)/lzma/C \ - $(LOCAL_PATH)/../../cpp/patch_core \ - $(LOCAL_PATH)/../../cpp/update_flow_core +# The shared cores (cpp/patch_core, cpp/update_flow_core, cpp/hpatch) and the +# Android JNI glue (cpp/android_jni) are picked up by directory, so a new +# source file there needs no edit here; tests/ subdirectories are not matched. +# Every include is file-relative (as on iOS), so no include path is needed. +CPP_DIR := $(LOCAL_PATH)/../../cpp +Shared_Files := \ + $(sort $(wildcard $(CPP_DIR)/patch_core/*.cpp)) \ + $(sort $(wildcard $(CPP_DIR)/update_flow_core/*.cpp)) \ + $(sort $(wildcard $(CPP_DIR)/android_jni/*.cpp)) \ + $(sort $(wildcard $(CPP_DIR)/hpatch/*.c)) + +# HDiffPatch / lzma submodules: only the decoder sources hpatch.c uses. Hdp_Files := \ - ../../cpp/hpatch/hpatch.c \ - HDiffPatch/libHDiffPatch/HPatch/patch.c \ + HDiffPatch/libHDiffPatch/HPatch/patch.c \ HDiffPatch/file_for_patch.c \ lzma/C/LzmaDec.c \ - lzma/C/Lzma2Dec.c + lzma/C/Lzma2Dec.c LOCAL_SRC_FILES := \ - ../../cpp/patch_core/archive_patch_core.cpp \ - ../../cpp/patch_core/digest.cpp \ - ../../cpp/patch_core/hbc_transform.cpp \ - ../../cpp/patch_core/hbc_transform_wire.cpp \ - ../../cpp/android_jni/jni_registration.cpp \ - ../../cpp/patch_core/patch_core.cpp \ - ../../cpp/patch_core/state_core.cpp \ - ../../cpp/android_jni/update_core_android.cpp \ - ../../cpp/update_flow_core/flow_json.cpp \ - ../../cpp/update_flow_core/update_flow_core.cpp \ - ../../cpp/android_jni/update_flow_jni.cpp \ + $(patsubst $(LOCAL_PATH)/%,%,$(Shared_Files)) \ $(Hdp_Files) include $(BUILD_SHARED_LIBRARY) diff --git a/harmony/pushy/src/main/cpp/CMakeLists.txt b/harmony/pushy/src/main/cpp/CMakeLists.txt index 6fbfad91..9a492418 100644 --- a/harmony/pushy/src/main/cpp/CMakeLists.txt +++ b/harmony/pushy/src/main/cpp/CMakeLists.txt @@ -8,29 +8,30 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_VERBOSE_MAKEFILE on) # The shared native sources are compiled in place from the repository root -# (or the npm package root, which ships android/jni and cpp/). +# (or the npm package root, which ships cpp/ and the android/jni submodules). set(REPO_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/../../../../../) -set(ANDROID_JNI_DIR ${REPO_ROOT}/android/jni) set(PATCH_CORE_DIR ${REPO_ROOT}/cpp/patch_core) set(UPDATE_FLOW_CORE_DIR ${REPO_ROOT}/cpp/update_flow_core) +set(HPATCH_DIR ${REPO_ROOT}/cpp/hpatch) +set(HDIFFPATCH_DIR ${REPO_ROOT}/android/jni/HDiffPatch) +set(LZMA_DIR ${REPO_ROOT}/android/jni/lzma) # Always compile from source. A prebuilt-import branch used to live here, but # it had no symbol verification: a stale libs//librnpushy.so missing a # newly added export (e.g. getSupportedDiffVersion) would silently ship and # crash getConstants at runtime. -set(HDIFFPATCH_DIR ${ANDROID_JNI_DIR}/HDiffPatch) -set(LZMA_DIR ${ANDROID_JNI_DIR}/lzma) +# +# The platform-neutral cores are picked up by directory (non-recursive, so +# tests/ stays out; the Android JNI glue lives in cpp/android_jni), so a new +# core source needs no edit here. CONFIGURE_DEPENDS re-globs on every build. +file(GLOB CORE_SOURCES CONFIGURE_DEPENDS + ${PATCH_CORE_DIR}/*.cpp + ${UPDATE_FLOW_CORE_DIR}/*.cpp + ${HPATCH_DIR}/*.c +) set(HDP_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/pushy.cpp - ${PATCH_CORE_DIR}/archive_patch_core.cpp - ${PATCH_CORE_DIR}/digest.cpp - ${PATCH_CORE_DIR}/hbc_transform.cpp - ${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 - ${REPO_ROOT}/cpp/hpatch/hpatch.c + ${CORE_SOURCES} ${HDIFFPATCH_DIR}/libHDiffPatch/HPatch/patch.c ${HDIFFPATCH_DIR}/file_for_patch.c ${LZMA_DIR}/C/LzmaDec.c @@ -41,14 +42,12 @@ add_library(rnpushy SHARED ${HDP_SOURCES} ) +# pushy.cpp includes the cores by name; the cores themselves (and hpatch's +# HDiffPatch/lzma includes) resolve file-relative. target_include_directories(rnpushy PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} ${PATCH_CORE_DIR} ${UPDATE_FLOW_CORE_DIR} - ${ANDROID_JNI_DIR} - ${HDIFFPATCH_DIR} - ${HDIFFPATCH_DIR}/libHDiffPatch/HPatch - ${LZMA_DIR}/C ) target_link_libraries(rnpushy PUBLIC diff --git a/react-native-update.podspec b/react-native-update.podspec index 4e9c3796..3ca5cf6b 100644 --- a/react-native-update.podspec +++ b/react-native-update.podspec @@ -181,15 +181,12 @@ Pod::Spec.new do |s| # Objective-C classes in the RCTPushy subspec keep default visibility, which # the host app and the Expo module need to link against RCTPushy. s.subspec 'Core' do |ss| - ss.source_files = ['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}', - 'cpp/patch_core/hbc_transform_wire.{h,cpp}', - 'cpp/patch_core/patch_core.{h,cpp}', - 'cpp/patch_core/state_core.{h,cpp}', - 'cpp/hpatch/hpatch.{h,c}', + # The cores are picked up by directory (non-recursive: tests/ stays out, + # and the Android JNI glue lives in cpp/android_jni), so a new + # platform-neutral source needs no edit here. + ss.source_files = ['cpp/patch_core/*.{h,cpp}', + 'cpp/update_flow_core/*.{h,cpp}', + 'cpp/hpatch/*.{h,c}', 'android/jni/HDiffPatch/libHDiffPatch/HPatch/*.{h,c}', 'android/jni/HDiffPatch/file_for_patch.{h,c}', 'android/jni/lzma/C/LzmaDec.{h,c}', diff --git a/scripts/test-patch-core.sh b/scripts/test-patch-core.sh index 508145a5..eabacaba 100755 --- a/scripts/test-patch-core.sh +++ b/scripts/test-patch-core.sh @@ -16,34 +16,29 @@ mkdir -p "$BUILD_DIR" . "$ROOT_DIR/scripts/lib/cpp-test-common.sh" cpp_test_sanitize_flags "patch core tests" +# The cores and hpatch include everything file-relative; only the test itself +# includes HDiffPatch's patch.h by name. COMMON_INCLUDES=" - -I$ROOT_DIR/cpp/patch_core - -I$ROOT_DIR/android/jni - -I$ROOT_DIR/android/jni/HDiffPatch -I$ROOT_DIR/android/jni/HDiffPatch/libHDiffPatch/HPatch - -I$ROOT_DIR/android/jni/lzma/C " -# The vendored HDiffPatch / lzma C sources are compiled with -Wall -Wextra -# only (no -Werror). +# hpatch.c and the vendored HDiffPatch / lzma C sources are compiled with +# -Wall -Wextra only (no -Werror). cc -Wall -Wextra $SANITIZE_FLAGS $COMMON_INCLUDES -c "$ROOT_DIR/cpp/hpatch/hpatch.c" -o "$BUILD_DIR/hpatch.o" cc -Wall -Wextra $SANITIZE_FLAGS $COMMON_INCLUDES -c "$ROOT_DIR/android/jni/HDiffPatch/libHDiffPatch/HPatch/patch.c" -o "$BUILD_DIR/patch.o" cc -Wall -Wextra $SANITIZE_FLAGS $COMMON_INCLUDES -c "$ROOT_DIR/android/jni/HDiffPatch/file_for_patch.c" -o "$BUILD_DIR/file_for_patch.o" cc -Wall -Wextra $SANITIZE_FLAGS $COMMON_INCLUDES -c "$ROOT_DIR/android/jni/lzma/C/LzmaDec.c" -o "$BUILD_DIR/LzmaDec.o" cc -Wall -Wextra $SANITIZE_FLAGS $COMMON_INCLUDES -c "$ROOT_DIR/android/jni/lzma/C/Lzma2Dec.c" -o "$BUILD_DIR/Lzma2Dec.o" +# Every patch core source, picked up by directory like the platform builds +# (tests/ is a subdirectory and stays out). c++ \ -std=c++17 \ $CORE_WARNINGS \ $SANITIZE_FLAGS \ $COMMON_INCLUDES \ "$ROOT_DIR/cpp/patch_core/tests/patch_core_test.cpp" \ - "$ROOT_DIR/cpp/patch_core/archive_patch_core.cpp" \ - "$ROOT_DIR/cpp/patch_core/digest.cpp" \ - "$ROOT_DIR/cpp/patch_core/patch_core.cpp" \ - "$ROOT_DIR/cpp/patch_core/state_core.cpp" \ - "$ROOT_DIR/cpp/patch_core/hbc_transform.cpp" \ - "$ROOT_DIR/cpp/patch_core/hbc_transform_wire.cpp" \ + "$ROOT_DIR"/cpp/patch_core/*.cpp \ "$BUILD_DIR/hpatch.o" \ "$BUILD_DIR/patch.o" \ "$BUILD_DIR/file_for_patch.o" \ diff --git a/scripts/test-update-flow-core.sh b/scripts/test-update-flow-core.sh index bdee4811..a1acdeac 100755 --- a/scripts/test-update-flow-core.sh +++ b/scripts/test-update-flow-core.sh @@ -20,8 +20,7 @@ c++ \ -std=c++17 \ $CORE_WARNINGS \ $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/*.cpp \ "$ROOT_DIR/cpp/update_flow_core/tests/update_flow_core_test.cpp" \ -o "$BUILD_DIR/update_flow_core_test" @@ -37,8 +36,7 @@ if cpp_test_fuzz_compiler; then $CORE_WARNINGS \ -g -O1 -fno-omit-frame-pointer \ -fsanitize=fuzzer,address,undefined \ - "$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/*.cpp \ "$ROOT_DIR/cpp/update_flow_core/tests/fuzz_flow_json.cpp" \ -o "$BUILD_DIR/fuzz_flow_json" CORPUS_DIR="$BUILD_DIR/fuzz-corpus-flow-json" From 9d7fd82773590b697955646869934ae6d8b49f6e Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 15:42:32 +0800 Subject: [PATCH 074/104] chore(package): ship cpp/ as a whole and scan it in the native source tests package.json ships cpp/ (minus tests) instead of listing each core; check-packlist requires cpp/hpatch and, for releases, the HDiffPatch/lzma sources iOS and Harmony compile from the package. The native wording/encoding tests scan all of cpp/, which now includes cpp/android_jni and cpp/hpatch. Co-Authored-By: Claude Opus 5.5 --- package.json | 5 +---- scripts/check-packlist.js | 17 +++++++++++++++-- src/__tests__/nativeHostApiNaming.test.ts | 6 ++---- src/__tests__/nativeTextEncoding.test.ts | 3 +-- 4 files changed, 19 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index 6e32d825..28689505 100644 --- a/package.json +++ b/package.json @@ -15,10 +15,7 @@ "android/build.gradle", "android/proguard.pro", "ios", - "cpp/patch_core", - "cpp/update_flow_core", - "cpp/android_jni", - "cpp/hpatch", + "cpp", "!cpp/**/tests", "harmony/pushy.har", "harmony/hvigor-plugin.ts", diff --git a/scripts/check-packlist.js b/scripts/check-packlist.js index 7d3b10e3..c11ead31 100644 --- a/scripts/check-packlist.js +++ b/scripts/check-packlist.js @@ -55,19 +55,32 @@ const required = [ 'src/NativePushy.ts', 'android/build.gradle', 'android/src/main/java/cn/reactnative/modules/update/UpdateContext.java', - 'cpp/hpatch/hpatch.c', 'ios/RCTPushy/RCTPushy.mm', 'ios/RCTPushy/RCTPushyOrchestrator.mm', 'react-native-update.podspec', + // The shared cores iOS (podspec Core) and Harmony (CMakeLists.txt) compile + // from the package by directory. 'cpp/patch_core/patch_core.cpp', 'cpp/patch_core/archive_limits.h', 'cpp/patch_core/install_record.h', 'cpp/update_flow_core/update_flow_core.cpp', + 'cpp/hpatch/hpatch.c', + 'cpp/hpatch/hpatch.h', 'harmony/pushy/src/main/ets/PushyTurboModule.ts', 'harmony/hvigor-plugin.ts', 'expo-module.config.json', 'react-native.config.js', - ...(release ? ['harmony/pushy.har'] : []), + ...(release + ? [ + 'harmony/pushy.har', + // Submodule sources hpatch.c decodes with (lint CI checks out + // without submodules; the publish jobs check them out). + 'android/jni/HDiffPatch/libHDiffPatch/HPatch/patch.c', + 'android/jni/HDiffPatch/file_for_patch.c', + 'android/jni/lzma/C/LzmaDec.c', + 'android/jni/lzma/C/Lzma2Dec.c', + ] + : []), ]; const requiredAbis = ['arm64-v8a', 'armeabi-v7a', 'x86', 'x86_64']; diff --git a/src/__tests__/nativeHostApiNaming.test.ts b/src/__tests__/nativeHostApiNaming.test.ts index 8a1adb0f..ffeed111 100644 --- a/src/__tests__/nativeHostApiNaming.test.ts +++ b/src/__tests__/nativeHostApiNaming.test.ts @@ -91,8 +91,7 @@ describe('native host API naming', () => { 'ios/', 'android/src/main/', 'harmony/pushy/src/main/', - 'cpp/patch_core/', - 'cpp/update_flow_core/', + 'cpp/', ].flatMap(shippedNativeSources); expect(paths).toContain(`${android}PushyRuntime.java`); const offenders = paths.filter((path) => { @@ -107,8 +106,7 @@ describe('native host API naming', () => { 'ios/', 'android/src/main/', 'harmony/pushy/src/main/', - 'cpp/patch_core/', - 'cpp/update_flow_core/', + 'cpp/', ].flatMap(shippedNativeSources); const offenders = paths.flatMap((path) => stripComments(source(path)) diff --git a/src/__tests__/nativeTextEncoding.test.ts b/src/__tests__/nativeTextEncoding.test.ts index c162ac10..3275a21c 100644 --- a/src/__tests__/nativeTextEncoding.test.ts +++ b/src/__tests__/nativeTextEncoding.test.ts @@ -21,8 +21,7 @@ const paths = [ 'ios/', 'android/src/main/', 'harmony/pushy/src/main/', - 'cpp/patch_core/', - 'cpp/update_flow_core/', + 'cpp/', ].flatMap(shippedNativeSources); const encodedIn = (text: string) => From 761d40d0443fafea911c6e434fb592db1863844e Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 15:59:33 +0800 Subject: [PATCH 075/104] feat(flow-core): shared native config gate, equivalence and normalizer cpp/update_flow_core/native_config.{h,cpp} hosts the three rule sets the Android, iOS and Harmony orchestrators each carried their own copy of: ParseRunnableConfig/RunnableConfigJson (may the persisted config run a round), ConfigsEquivalent (contract C1) and NormalizeConfiguration (the host configure() validation). flowjson gains DeepEquals. The gate takes JS semantics: disabled is JS-truthy (Android used to accept only true/"true") and appKey is trimmed like String.prototype.trim (Android's String.trim also ate C0 controls and kept NBSP/U+3000). The C++ test replays the shared C1 vectors and the new native-config-vectors.json, and the fuzz target covers the new entry points. Co-Authored-By: Claude Opus 5.5 --- cpp/update_flow_core/flow_json.cpp | 34 ++ cpp/update_flow_core/flow_json.h | 6 + cpp/update_flow_core/native_config.cpp | 460 ++++++++++++++++++ cpp/update_flow_core/native_config.h | 84 ++++ .../tests/config-equivalence-vectors.json | 26 +- cpp/update_flow_core/tests/fuzz_flow_json.cpp | 22 + .../tests/native-config-vectors.json | 451 +++++++++++++++++ .../tests/update_flow_core_test.cpp | 192 +++++++- scripts/test-update-flow-core.sh | 5 +- 9 files changed, 1263 insertions(+), 17 deletions(-) create mode 100644 cpp/update_flow_core/native_config.cpp create mode 100644 cpp/update_flow_core/native_config.h create mode 100644 cpp/update_flow_core/tests/native-config-vectors.json diff --git a/cpp/update_flow_core/flow_json.cpp b/cpp/update_flow_core/flow_json.cpp index eb5e4ea5..edcf2797 100644 --- a/cpp/update_flow_core/flow_json.cpp +++ b/cpp/update_flow_core/flow_json.cpp @@ -86,6 +86,40 @@ bool Value::StrictEquals(const Value& a, const Value& b) { return false; } +bool Value::DeepEquals(const Value& a, const Value& b) { + if (a.kind_ == Kind::Array && b.kind_ == Kind::Array) { + if (a.elements_.size() != b.elements_.size()) { + return false; + } + for (size_t i = 0; i < a.elements_.size(); i++) { + if (!DeepEquals(a.elements_[i], b.elements_[i])) { + return false; + } + } + return true; + } + if (a.kind_ == Kind::Object && b.kind_ == Kind::Object) { + size_t defined = 0; + for (const auto& member : a.members_) { + if (member.second.IsUndefined()) { + continue; + } + defined++; + const Value& other = b.Get(member.first); + if (other.IsUndefined() || !DeepEquals(member.second, other)) { + return false; + } + } + for (const auto& member : b.members_) { + if (!member.second.IsUndefined()) { + defined--; + } + } + return defined == 0; + } + return StrictEquals(a, b); +} + namespace { void AppendEscaped(const std::string& s, std::string* out) { diff --git a/cpp/update_flow_core/flow_json.h b/cpp/update_flow_core/flow_json.h index 7bed9366..d7886250 100644 --- a/cpp/update_flow_core/flow_json.h +++ b/cpp/update_flow_core/flow_json.h @@ -81,6 +81,12 @@ class Value { static bool StrictEquals(const Value& a, const Value& b); + // Structural equality of JSON values (src/updateFlowCore.ts + // jsonDeepEqual): object key order is ignored and undefined members count + // as absent; arrays compare element-wise in order; primitives use === + // (so 1 equals 1.0, false is not 0, null equals only null). + static bool DeepEquals(const Value& a, const Value& b); + private: explicit Value(Kind kind) : kind_(kind) {} diff --git a/cpp/update_flow_core/native_config.cpp b/cpp/update_flow_core/native_config.cpp new file mode 100644 index 00000000..604a1922 --- /dev/null +++ b/cpp/update_flow_core/native_config.cpp @@ -0,0 +1,460 @@ +#include "native_config.h" + +#include +#include +#include + +#include "../patch_core/obscured_text.h" + +namespace flowcore { + +using flowjson::Value; + +namespace { + +// Length of the ECMAScript whitespace/line terminator encoded at text[i], or +// 0 when there is none. +size_t WhitespaceAt(const std::string& text, size_t i) { + const size_t left = text.size() - i; + const auto byte = [&](size_t k) { + return static_cast(text[i + k]); + }; + const unsigned char b0 = byte(0); + if (b0 == ' ' || (b0 >= 0x09 && b0 <= 0x0d)) { + return 1; + } + if (b0 == 0xc2 && left >= 2 && byte(1) == 0xa0) { + return 2; // U+00A0 + } + if (left < 3) { + return 0; + } + const unsigned char b1 = byte(1); + const unsigned char b2 = byte(2); + if (b0 == 0xe1 && b1 == 0x9a && b2 == 0x80) { + return 3; // U+1680 + } + if (b0 == 0xe2 && b1 == 0x80 && + (b2 <= 0x8a || b2 == 0xa8 || b2 == 0xa9 || b2 == 0xaf) && b2 >= 0x80) { + return 3; // U+2000-U+200A, U+2028, U+2029, U+202F + } + if (b0 == 0xe2 && b1 == 0x81 && b2 == 0x9f) { + return 3; // U+205F + } + if (b0 == 0xe3 && b1 == 0x80 && b2 == 0x80) { + return 3; // U+3000 + } + if (b0 == 0xef && b1 == 0xbb && b2 == 0xbf) { + return 3; // U+FEFF + } + return 0; +} + +bool ContainsJsWhitespace(const std::string& text) { + for (size_t i = 0; i < text.size(); i++) { + if (WhitespaceAt(text, i) != 0) { + return true; + } + } + return false; +} + +std::string StripTrailingSlashes(std::string text) { + while (!text.empty() && text.back() == '/') { + text.pop_back(); + } + return text; +} + +// Strings lose their trailing '/' first; then every entry (string or not) +// already present by value is dropped. +Value CanonicalUrlList(const Value& list) { + if (!list.IsArray()) { + return list; + } + Value result = Value::Array(); + std::unordered_set strings; + std::vector others; + for (const Value& item : list.elements()) { + if (item.IsString()) { + std::string url = StripTrailingSlashes(item.AsString()); + if (strings.insert(url).second) { + result.Push(Value::String(std::move(url))); + } + continue; + } + bool seen = false; + for (const Value* other : others) { + if (Value::DeepEquals(*other, item)) { + seen = true; + break; + } + } + if (!seen) { + others.push_back(&item); + result.Push(item); + } + } + return result; +} + +Value ParseObject(const std::string& json) { + bool ok = false; + Value value = flowjson::Parse(json, &ok); + return ok && value.IsObject() ? value : Value::Undefined(); +} + +const std::string& PolicyNextLaunch() { + static const std::string value = + pushy::text::Reveal("2912e0ffab8e6c70323b1dedd3"); + return value; +} + +// Pushy's own service addresses, encoded by scripts/encode-native-text.ts. +std::vector DefaultEndpoints() { + return { + pushy::text::Reveal( + "3203e0c1bdd1270a372f18f8c2b6de7f4f2607f5b3d5b9817b592947e5cdefbc8a7e"), + pushy::text::Reveal( + "3203e0c1bdd1270a372f18f8c2b6de7f4f2607f5f0daac9c644a620ae88ca1ad93"), + }; +} + +std::vector DefaultQueryUrls() { + return { + pushy::text::Reveal( + "3203e0c1bdd1270a253608fcd3fd9362476817f4f0d5a1996342631be3c2a3a9d779" + "552507fdcde8928a6f512f5ce2ccbdc869404d2f1de79daa826d562c0913eec4fa9b" + "7d4426"), + pushy::text::Reveal( + "3203e0c1bdd1270a213b12b7dca09468462e12f3b0d5bd813d482446f4c6a1be8e79" + "552507fdcda68cd06e5c3710e480a4867048483e55e0c2ab8d7d43030d1ce9c3b183" + "214e2601f2f0d5b782601e2719e8ca"), + }; +} + +Value StringArray(const std::vector& values) { + Value array = Value::Array(); + for (const std::string& value : values) { + array.Push(Value::String(value)); + } + return array; +} + +bool IsHexDigit(char c) { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || + (c >= 'A' && c <= 'F'); +} + +bool IsDigit(char c) { return c >= '0' && c <= '9'; } + +bool IsHostChar(char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || IsDigit(c) || + c == '.' || c == '-'; +} + +// ^https?://(\[[0-9a-fA-F:]+\]|[a-zA-Z0-9.-]+)(:[0-9]+)?([/?#]|$) — the +// scheme and authority check; anything else in the authority (userinfo, +// spaces, a missing host) fails it. Every quantifier is greedy and its +// continuation cannot start with a character it consumes, so a single +// left-to-right scan matches exactly what the backtracking regex does. +bool HasHttpAuthority(const std::string& url) { + size_t i = 0; + const std::string http = "http"; + if (url.compare(0, http.size(), http) != 0) { + return false; + } + i = http.size(); + if (i < url.size() && url[i] == 's') { + i++; + } + if (url.compare(i, 3, "://") != 0) { + return false; + } + i += 3; + const size_t host = i; + if (i < url.size() && url[i] == '[') { + i++; + while (i < url.size() && (IsHexDigit(url[i]) || url[i] == ':')) { + i++; + } + if (i == host + 1 || i >= url.size() || url[i] != ']') { + return false; + } + i++; + } else { + while (i < url.size() && IsHostChar(url[i])) { + i++; + } + if (i == host) { + return false; + } + } + if (i < url.size() && url[i] == ':') { + const size_t port = ++i; + while (i < url.size() && IsDigit(url[i])) { + i++; + } + if (i == port) { + return false; + } + } + return i == url.size() || url[i] == '/' || url[i] == '?' || url[i] == '#'; +} + +// The normalizer's checks report the first problem in *error (without the +// prefix) and return false; no exceptions, the cores build without unwind +// tables on Android. +bool ConfigString(const Value& value, const std::string& name, bool allowEmpty, + std::string* out, std::string* error) { + if (!value.IsString() || + (!allowEmpty && TrimJsWhitespace(value.AsString()).empty())) { + *error = name + " must be a string" + + (allowEmpty ? "" : " and must not be blank"); + return false; + } + *out = value.AsString(); + return true; +} + +// `base` lists (endpoints) are request bases: non-empty, no query or +// fragment, trailing '/' dropped. Discovery lists keep their URLs whole. +bool ConfigUrls(const Value& values, const std::string& name, bool base, + Value* out, std::string* error) { + if (!values.IsArray() || (base && values.Size() == 0)) { + *error = name + " must be " + (base ? "a non-empty" : "an") + " array"; + return false; + } + Value result = Value::Array(); + std::unordered_set seen; + for (const Value& item : values.elements()) { + std::string url; + if (!ConfigString(item, name, false, &url, error)) { + return false; + } + url = TrimJsWhitespace(url); + if (!HasHttpAuthority(url) || ContainsJsWhitespace(url) || + url.find('\\') != std::string::npos || + (base && url.find_first_of("?#") != std::string::npos)) { + *error = name + " requires absolute HTTP(S) URLs without credentials" + + (base ? ", queries or fragments" : ""); + return false; + } + if (base) { + url = StripTrailingSlashes(std::move(url)); + } + if (seen.insert(url).second) { + result.Push(Value::String(std::move(url))); + } + } + *out = std::move(result); + return true; +} + +bool Normalize(const std::string& optionsJson, Value* out, + std::string* error) { + const Value options = ParseObject(optionsJson); + if (!options.IsObject()) { + *error = "expected an object"; + return false; + } + static const char* const kKeys[] = { + "appKey", "endpoints", "queryUrls", "afterDownload", + "disabled", "packageVersion", "rnu", "rn"}; + for (const auto& member : options.members()) { + bool known = false; + for (const char* key : kKeys) { + known = known || member.first == key; + } + if (!known) { + *error = "unknown option " + member.first; + return false; + } + } + const auto has = [&](const char* key) { + return !options.Get(key).IsUndefined(); + }; + // A string option: absent takes `fallback`, present must pass ConfigString. + const auto string = [&](const char* key, bool allowEmpty, + const std::string& fallback, Value* result) { + std::string value = fallback; + if (has(key) && + !ConfigString(options.Get(key), key, allowEmpty, &value, error)) { + return false; + } + *result = Value::String(std::move(value)); + return true; + }; + + Value appKey, endpoints, queryUrls, rnu, rn, packageVersion; + if (!has("appKey")) { + *error = "appKey must be a string and must not be blank"; + return false; + } + if (!string("appKey", false, "", &appKey)) { + return false; + } + const bool customEndpoints = has("endpoints"); + if (!ConfigUrls(customEndpoints ? options.Get("endpoints") + : StringArray(DefaultEndpoints()), + "endpoints", true, &endpoints, error) || + !ConfigUrls(has("queryUrls") ? options.Get("queryUrls") + : customEndpoints ? Value::Array() + : StringArray(DefaultQueryUrls()), + "queryUrls", false, &queryUrls, error)) { + return false; + } + Value afterDownload = Value::String("none"); + if (has("afterDownload")) { + afterDownload = options.Get("afterDownload"); + if (!afterDownload.IsString() || (afterDownload.AsString() != "none" && + afterDownload.AsString() != + PolicyNextLaunch())) { + *error = "afterDownload must be none or " + PolicyNextLaunch(); + return false; + } + } + const Value& disabled = options.Get("disabled"); + if (!disabled.IsUndefined() && !disabled.IsBool()) { + *error = "disabled must be a boolean"; + return false; + } + if (!string("rnu", true, "", &rnu) || !string("rn", true, "", &rn) || + (has("packageVersion") && + !string("packageVersion", false, "", &packageVersion))) { + return false; + } + Value result = Value::Object(); + result.Set("appKey", std::move(appKey)); + result.Set("endpoints", std::move(endpoints)); + result.Set("queryUrls", std::move(queryUrls)); + result.Set("afterDownload", std::move(afterDownload)); + result.Set("disabled", Value::Bool(disabled.IsBool() && disabled.AsBool())); + result.Set("rnu", std::move(rnu)); + result.Set("rn", std::move(rn)); + if (!packageVersion.IsUndefined()) { + result.Set("packageVersion", std::move(packageVersion)); + } + *out = std::move(result); + return true; +} + +} // namespace + +std::string TrimJsWhitespace(const std::string& text) { + size_t begin = 0; + while (begin < text.size()) { + const size_t length = WhitespaceAt(text, begin); + if (length == 0) { + break; + } + begin += length; + } + size_t end = text.size(); + while (end > begin) { + // A whitespace sequence is 1-3 bytes; try each length ending at `end`. + size_t length = 0; + for (size_t candidate = 1; candidate <= 3 && candidate <= end - begin; + candidate++) { + if (WhitespaceAt(text, end - candidate) == candidate) { + length = candidate; + break; + } + } + if (length == 0) { + break; + } + end -= length; + } + return text.substr(begin, end - begin); +} + +Value CanonicalNativeConfig(const std::string& json) { + Value config = ParseObject(json); + if (!config.IsObject()) { + return config; + } + const std::pair defaults[] = { + {"disabled", Value::Bool(false)}, {"queryUrls", Value::Array()}, + {"rnu", Value::String("")}, {"rn", Value::String("")}, + {"afterDownload", Value::String("none")}, + }; + for (const auto& entry : defaults) { + if (config.Get(entry.first).IsUndefined()) { + config.Set(entry.first, entry.second); + } + } + for (const char* key : {"endpoints", "queryUrls"}) { + const Value& list = config.Get(key); + if (list.IsArray()) { + config.Set(key, CanonicalUrlList(list)); + } + } + return config; +} + +bool ConfigsEquivalent(const std::string& a, const std::string& b) { + const Value left = CanonicalNativeConfig(a); + if (!left.IsObject()) { + return false; + } + const Value right = CanonicalNativeConfig(b); + return right.IsObject() && Value::DeepEquals(left, right); +} + +RunnableConfig ParseRunnableConfig(const std::string& json) { + RunnableConfig result; + if (json.empty()) { + result.status = "skipped"; + result.reason = "not_configured"; + return result; + } + Value config = ParseObject(json); + if (!config.IsObject()) { + result.status = "failed"; + result.reason = "invalid_config"; + return result; + } + if (config.Get("disabled").Truthy()) { + result.status = "skipped"; + result.reason = "disabled"; + return result; + } + const Value& appKey = config.Get("appKey"); + std::string trimmed = + appKey.IsString() ? TrimJsWhitespace(appKey.AsString()) : std::string(); + if (trimmed.empty()) { + result.status = "failed"; + result.reason = "invalid_config"; + return result; + } + result.appKey = std::move(trimmed); + result.config = std::move(config); + return result; +} + +std::string RunnableConfigJson(const std::string& json) { + const RunnableConfig parsed = ParseRunnableConfig(json); + Value result = Value::Object(); + if (parsed.runnable()) { + result.Set("appKey", Value::String(parsed.appKey)); + } else { + result.Set("status", Value::String(parsed.status)); + result.Set("reason", Value::String(parsed.reason)); + } + return flowjson::Stringify(result); +} + +bool NormalizeConfiguration(const std::string& optionsJson, std::string* out, + std::string* error) { + Value result; + std::string why; + if (!Normalize(optionsJson, &result, &why)) { + *error = "Invalid native configuration: " + why; + return false; + } + *out = flowjson::Stringify(result); + return true; +} + +} // namespace flowcore diff --git a/cpp/update_flow_core/native_config.h b/cpp/update_flow_core/native_config.h new file mode 100644 index 00000000..19d2954b --- /dev/null +++ b/cpp/update_flow_core/native_config.h @@ -0,0 +1,84 @@ +#pragma once + +#include + +#include "flow_json.h" + +// The persisted native check config: its validation when a host configures +// it, the gate that decides whether it can run a round, and the equivalence +// that decides whether a new one is a change. One implementation for the +// Android, iOS and Harmony orchestrators (each used to carry its own copy); +// JSON strings in and out so the bindings stay thin. +// +// The JS client keeps its own TS copy of the equivalence +// (src/updateFlowCore.ts nativeConfigsEquivalent — it must also run on web and +// on natives that predate this file); both replay +// tests/config-equivalence-vectors.json. The gate and the normalizer have no +// JS counterpart; tests/native-config-vectors.json pins them. +// Keep the core internal to whichever binary links it (see podspec Core). +#pragma GCC visibility push(hidden) + +namespace flowcore { + +// ECMAScript String.prototype.trim: strips WhiteSpace and LineTerminator +// code points (TAB, LF, VT, FF, CR, SPACE, NBSP, U+1680, U+2000-U+200A, +// U+2028, U+2029, U+202F, U+205F, U+3000, U+FEFF) from both ends of a UTF-8 +// string. Other control characters are kept. +std::string TrimJsWhitespace(const std::string& text); + +// Contract C1. Canonical form of a persisted config: the parsed object with +// absent keys defaulted ({disabled:false, queryUrls:[], rnu:"", rn:"", +// afterDownload:"none"}) and each `endpoints`/`queryUrls` array's strings +// stripped of every trailing '/', then deduplicated keeping the first +// occurrence. Present values are never coerced (0 is not false, null is not +// a default). Undefined when the text is not a JSON object. +flowjson::Value CanonicalNativeConfig(const std::string& json); + +// Two configs are equivalent iff both are JSON objects whose canonical forms +// are DeepEquals (key order ignored). Anything else is never equivalent, not +// even to itself — callers pass "" for an absent config. +bool ConfigsEquivalent(const std::string& a, const std::string& b); + +// The gate every entry point (round start, crash-hold install, the host's +// prepareBundle, the round itself) applies to the persisted config, so they +// agree on what is runnable: +// "" -> skipped / not_configured +// not a JSON object -> failed / invalid_config +// `disabled` truthy (JS truthiness) -> skipped / disabled +// `appKey` not a string, or blank -> failed / invalid_config +// after TrimJsWhitespace +// otherwise runnable, with the trimmed appKey and the parsed config. +struct RunnableConfig { + std::string status; // empty when runnable, else "skipped" / "failed" + std::string reason; + std::string appKey; + flowjson::Value config; + + bool runnable() const { return status.empty(); } +}; +RunnableConfig ParseRunnableConfig(const std::string& json); + +// Binding form of ParseRunnableConfig: {"appKey": ""} when runnable, +// otherwise {"status": "...", "reason": "..."}. The binding re-parses the +// config itself when it needs more fields. +std::string RunnableConfigJson(const std::string& json); + +// Validates the options a host passes to configure() (a JSON object) and +// returns the config to persist: +// {appKey, endpoints, queryUrls, afterDownload, disabled, rnu, rn, +// packageVersion?} +// Only those keys are accepted. appKey/packageVersion are non-blank strings +// (kept as given), rnu/rn strings. endpoints default to Pushy's own and must +// be a non-empty array of absolute http(s) URLs without credentials, queries +// or fragments; each is trimmed, stripped of trailing '/' and deduplicated. +// queryUrls default to Pushy's discovery URLs, or to [] once endpoints are +// custom; each is trimmed and deduplicated (queries allowed). afterDownload +// is "none" (default) or the next-launch policy; disabled a boolean. A +// present null is invalid, never a default. On failure returns false and +// sets *error to "Invalid native configuration: ". +bool NormalizeConfiguration(const std::string& optionsJson, std::string* out, + std::string* error); + +} // namespace flowcore + +#pragma GCC visibility pop diff --git a/cpp/update_flow_core/tests/config-equivalence-vectors.json b/cpp/update_flow_core/tests/config-equivalence-vectors.json index e93b72b8..bdbc0fb9 100644 --- a/cpp/update_flow_core/tests/config-equivalence-vectors.json +++ b/cpp/update_flow_core/tests/config-equivalence-vectors.json @@ -1,5 +1,5 @@ { - "description": "Contract C1: native config JSON equivalence, replayed by the JS (src/updateFlowCore.ts), Harmony (UpdateContext.ts), Android (ConfigEquivalence.java) and iOS (PushyConfigsEquivalent) implementations. Two strings are equivalent iff both parse to JSON objects whose canonical forms deep-equal (key order ignored). Canonical: absent keys take {disabled:false, queryUrls:[], rnu:\"\", rn:\"\", afterDownload:\"none\"}; each string in endpoints/queryUrls (when an array) loses ALL trailing \"/\", then entries are deduplicated keeping the first occurrence. Present values are never coerced (0 is not false, null is not a default). Anything that is not a JSON object is never equivalent, not even to itself. Every case must hold in both argument orders. Hand-maintained: edit this file, then keep every platform green.", + "description": "Contract C1: native config JSON equivalence, replayed by the JS (src/updateFlowCore.ts) and the shared native (cpp/update_flow_core/native_config.h ConfigsEquivalent, used by Android, iOS and Harmony) implementations. Two strings are equivalent iff both parse to JSON objects whose canonical forms deep-equal (key order ignored). Canonical: absent keys take {disabled:false, queryUrls:[], rnu:\"\", rn:\"\", afterDownload:\"none\"}; each string in endpoints/queryUrls (when an array) loses ALL trailing \"/\", then entries are deduplicated keeping the first occurrence. Present values are never coerced (0 is not false, null is not a default). Anything that is not a JSON object is never equivalent, not even to itself. Every case must hold in both argument orders. Hand-maintained: edit this file, then keep every platform green.", "cases": [ { "name": "identical", @@ -348,6 +348,30 @@ "a": "{\"appKey\":\"k\"}{\"appKey\":\"k\"}", "b": "{\"appKey\":\"k\"}{\"appKey\":\"k\"}", "equivalent": false + }, + { + "name": "exponent notation equals the integer", + "a": "{\"appKey\":\"k\",\"n\":1e2}", + "b": "{\"appKey\":\"k\",\"n\":100}", + "equivalent": true + }, + { + "name": "negative zero equals zero", + "a": "{\"appKey\":\"k\",\"n\":-0}", + "b": "{\"appKey\":\"k\",\"n\":0}", + "equivalent": true + }, + { + "name": "appKey whitespace is significant (only the run gate trims)", + "a": "{\"appKey\":\" k\"}", + "b": "{\"appKey\":\"k\"}", + "equivalent": false + }, + { + "name": "disabled 1 is not true", + "a": "{\"appKey\":\"k\",\"disabled\":1}", + "b": "{\"appKey\":\"k\",\"disabled\":true}", + "equivalent": false } ] } diff --git a/cpp/update_flow_core/tests/fuzz_flow_json.cpp b/cpp/update_flow_core/tests/fuzz_flow_json.cpp index 36c420ff..386094f1 100644 --- a/cpp/update_flow_core/tests/fuzz_flow_json.cpp +++ b/cpp/update_flow_core/tests/fuzz_flow_json.cpp @@ -9,6 +9,7 @@ #include #include "../flow_json.h" +#include "../native_config.h" #include "../update_flow_core.h" extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { @@ -41,5 +42,26 @@ extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { flowjson::Stringify( flowcore::HandleResponse(text, identity, false, "none")); flowcore::IsValidResponse(text); + + // The persisted-config layer reads host/JS-written text: equivalence must + // be reflexive for every object, and a normalized config must be runnable + // (or disabled) and stable under its own normalization. + flowcore::RunnableConfigJson(text); + if (flowcore::CanonicalNativeConfig(text).IsObject() && + !flowcore::ConfigsEquivalent(text, text)) { + std::abort(); + } + std::string normalized; + std::string error; + if (flowcore::NormalizeConfiguration(text, &normalized, &error)) { + const flowcore::RunnableConfig gate = + flowcore::ParseRunnableConfig(normalized); + std::string again; + if (!(gate.runnable() || gate.reason == "disabled") || + !flowcore::NormalizeConfiguration(normalized, &again, &error) || + again != normalized) { + std::abort(); + } + } return 0; } diff --git a/cpp/update_flow_core/tests/native-config-vectors.json b/cpp/update_flow_core/tests/native-config-vectors.json new file mode 100644 index 00000000..ffea12b9 --- /dev/null +++ b/cpp/update_flow_core/tests/native-config-vectors.json @@ -0,0 +1,451 @@ +{ + "description": "Native config gate and normalizer (cpp/update_flow_core/native_config.h), shared by the Android, iOS and Harmony orchestrators and replayed by scripts/test-update-flow-core.sh. `runnable`: the persisted config text -> RunnableConfigJson ({appKey} when a round may run, else {status, reason}); disabled follows JS truthiness and appKey is trimmed like String.prototype.trim. `normalize`: the options JSON a host passes to configure() -> the persisted config (`expected`, compared as text) or the error message (`error`). Hand-maintained.", + "runnable": [ + { + "name": "absent config is not configured", + "config": "", + "expected": { + "status": "skipped", + "reason": "not_configured" + } + }, + { + "name": "whitespace-only text is invalid JSON", + "config": " ", + "expected": { + "status": "failed", + "reason": "invalid_config" + } + }, + { + "name": "unterminated object", + "config": "{", + "expected": { + "status": "failed", + "reason": "invalid_config" + } + }, + { + "name": "trailing content after the object is invalid", + "config": "{\"appKey\":\"k\"} x", + "expected": { + "status": "failed", + "reason": "invalid_config" + } + }, + { + "name": "JSON array is not a config", + "config": "[]", + "expected": { + "status": "failed", + "reason": "invalid_config" + } + }, + { + "name": "JSON null is not a config", + "config": "null", + "expected": { + "status": "failed", + "reason": "invalid_config" + } + }, + { + "name": "JSON string is not a config", + "config": "\"{}\"", + "expected": { + "status": "failed", + "reason": "invalid_config" + } + }, + { + "name": "appKey alone is runnable", + "config": "{\"appKey\":\"k\"}", + "expected": { + "appKey": "k" + } + }, + { + "name": "full persisted config is runnable", + "config": "{\"appKey\":\"k\",\"endpoints\":[\"https://update.react-native.cn/api\",\"https://update.reactnative.cn/api\"],\"queryUrls\":[\"https://gitee.com/sunnylqm/react-native-pushy/raw/master/endpoints.json\",\"https://cdn.jsdelivr.net/gh/reactnativecn/react-native-update@master/endpoints.json\"],\"afterDownload\":\"none\",\"disabled\":false,\"rnu\":\"\",\"rn\":\"\"}", + "expected": { + "appKey": "k" + } + }, + { + "name": "appKey is trimmed", + "config": "{\"appKey\":\" \\t k \\n\"}", + "expected": { + "appKey": "k" + } + }, + { + "name": "appKey trim follows String.prototype.trim (NBSP, ideographic space, BOM)", + "config": "{\"appKey\":\"\u00a0\ufeffk\u3000\u2028\"}", + "expected": { + "appKey": "k" + } + }, + { + "name": "control characters other than TAB/LF/VT/FF/CR are not trimmed", + "config": "{\"appKey\":\"\\u0001k\"}", + "expected": { + "appKey": "\u0001k" + } + }, + { + "name": "inner whitespace is kept", + "config": "{\"appKey\":\" a b \"}", + "expected": { + "appKey": "a b" + } + }, + { + "name": "blank appKey is invalid", + "config": "{\"appKey\":\" \\n\u3000\"}", + "expected": { + "status": "failed", + "reason": "invalid_config" + } + }, + { + "name": "empty appKey is invalid", + "config": "{\"appKey\":\"\"}", + "expected": { + "status": "failed", + "reason": "invalid_config" + } + }, + { + "name": "missing appKey is invalid", + "config": "{}", + "expected": { + "status": "failed", + "reason": "invalid_config" + } + }, + { + "name": "null appKey is invalid", + "config": "{\"appKey\":null}", + "expected": { + "status": "failed", + "reason": "invalid_config" + } + }, + { + "name": "numeric appKey is invalid", + "config": "{\"appKey\":1}", + "expected": { + "status": "failed", + "reason": "invalid_config" + } + }, + { + "name": "duplicate keys: the last one wins", + "config": "{\"appKey\":\"\",\"appKey\":\"k\"}", + "expected": { + "appKey": "k" + } + }, + { + "name": "disabled true", + "config": "{\"appKey\":\"k\",\"disabled\":true}", + "expected": { + "status": "skipped", + "reason": "disabled" + } + }, + { + "name": "disabled wins over a missing appKey", + "config": "{\"disabled\":true}", + "expected": { + "status": "skipped", + "reason": "disabled" + } + }, + { + "name": "disabled 1 is truthy", + "config": "{\"appKey\":\"k\",\"disabled\":1}", + "expected": { + "status": "skipped", + "reason": "disabled" + } + }, + { + "name": "disabled \"true\" is truthy", + "config": "{\"appKey\":\"k\",\"disabled\":\"true\"}", + "expected": { + "status": "skipped", + "reason": "disabled" + } + }, + { + "name": "disabled \"false\" is a non-empty string, so truthy", + "config": "{\"appKey\":\"k\",\"disabled\":\"false\"}", + "expected": { + "status": "skipped", + "reason": "disabled" + } + }, + { + "name": "disabled [] is truthy", + "config": "{\"appKey\":\"k\",\"disabled\":[]}", + "expected": { + "status": "skipped", + "reason": "disabled" + } + }, + { + "name": "disabled {} is truthy", + "config": "{\"appKey\":\"k\",\"disabled\":{}}", + "expected": { + "status": "skipped", + "reason": "disabled" + } + }, + { + "name": "disabled false runs", + "config": "{\"appKey\":\"k\",\"disabled\":false}", + "expected": { + "appKey": "k" + } + }, + { + "name": "disabled 0 runs", + "config": "{\"appKey\":\"k\",\"disabled\":0}", + "expected": { + "appKey": "k" + } + }, + { + "name": "disabled \"\" runs", + "config": "{\"appKey\":\"k\",\"disabled\":\"\"}", + "expected": { + "appKey": "k" + } + }, + { + "name": "disabled null runs", + "config": "{\"appKey\":\"k\",\"disabled\":null}", + "expected": { + "appKey": "k" + } + } + ], + "normalize": [ + { + "name": "appKey alone takes Pushy's endpoints and discovery", + "options": "{\"appKey\":\"test-app\"}", + "expected": "{\"appKey\":\"test-app\",\"endpoints\":[\"https://update.react-native.cn/api\",\"https://update.reactnative.cn/api\"],\"queryUrls\":[\"https://gitee.com/sunnylqm/react-native-pushy/raw/master/endpoints.json\",\"https://cdn.jsdelivr.net/gh/reactnativecn/react-native-update@master/endpoints.json\"],\"afterDownload\":\"none\",\"disabled\":false,\"rnu\":\"\",\"rn\":\"\"}" + }, + { + "name": "appKey is persisted as given", + "options": "{\"appKey\":\" k \"}", + "expected": "{\"appKey\":\" k \",\"endpoints\":[\"https://update.react-native.cn/api\",\"https://update.reactnative.cn/api\"],\"queryUrls\":[\"https://gitee.com/sunnylqm/react-native-pushy/raw/master/endpoints.json\",\"https://cdn.jsdelivr.net/gh/reactnativecn/react-native-update@master/endpoints.json\"],\"afterDownload\":\"none\",\"disabled\":false,\"rnu\":\"\",\"rn\":\"\"}" + }, + { + "name": "custom endpoints get no default discovery and are deduplicated", + "options": "{\"appKey\":\"a\",\"endpoints\":[\"https://updates.example/api/\",\" https://updates.example/api \",\"https://updates.example/api//\"],\"afterDownload\":\"setNeedUpdate\"}", + "expected": "{\"appKey\":\"a\",\"endpoints\":[\"https://updates.example/api\"],\"queryUrls\":[],\"afterDownload\":\"setNeedUpdate\",\"disabled\":false,\"rnu\":\"\",\"rn\":\"\"}" + }, + { + "name": "explicit discovery, identity overrides and disabled", + "options": "{\"appKey\":\"a\",\"endpoints\":[\"http://localhost:8080/api\"],\"queryUrls\":[\"https://updates.example/endpoints.json?v=1\",\"https://updates.example/endpoints.json?v=1\"],\"packageVersion\":\"2.0\",\"rn\":\"0.77.3\",\"rnu\":\"test-sdk\",\"disabled\":true}", + "expected": "{\"appKey\":\"a\",\"endpoints\":[\"http://localhost:8080/api\"],\"queryUrls\":[\"https://updates.example/endpoints.json?v=1\"],\"afterDownload\":\"none\",\"disabled\":true,\"rnu\":\"test-sdk\",\"rn\":\"0.77.3\",\"packageVersion\":\"2.0\"}" + }, + { + "name": "discovery URLs keep their trailing slash", + "options": "{\"appKey\":\"a\",\"endpoints\":[\"https://e.example\"],\"queryUrls\":[\"https://q.example/list/\"]}", + "expected": "{\"appKey\":\"a\",\"endpoints\":[\"https://e.example\"],\"queryUrls\":[\"https://q.example/list/\"],\"afterDownload\":\"none\",\"disabled\":false,\"rnu\":\"\",\"rn\":\"\"}" + }, + { + "name": "IPv6 host with port", + "options": "{\"appKey\":\"a\",\"endpoints\":[\"http://[::1]:8080\"]}", + "expected": "{\"appKey\":\"a\",\"endpoints\":[\"http://[::1]:8080\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"disabled\":false,\"rnu\":\"\",\"rn\":\"\"}" + }, + { + "name": "empty rnu and rn are allowed", + "options": "{\"appKey\":\"a\",\"rnu\":\"\",\"rn\":\"\"}", + "expected": "{\"appKey\":\"a\",\"endpoints\":[\"https://update.react-native.cn/api\",\"https://update.reactnative.cn/api\"],\"queryUrls\":[\"https://gitee.com/sunnylqm/react-native-pushy/raw/master/endpoints.json\",\"https://cdn.jsdelivr.net/gh/reactnativecn/react-native-update@master/endpoints.json\"],\"afterDownload\":\"none\",\"disabled\":false,\"rnu\":\"\",\"rn\":\"\"}" + }, + { + "name": "explicit defaults equal the omitted ones", + "options": "{\"appKey\":\"a\",\"afterDownload\":\"none\",\"disabled\":false}", + "expected": "{\"appKey\":\"a\",\"endpoints\":[\"https://update.react-native.cn/api\",\"https://update.reactnative.cn/api\"],\"queryUrls\":[\"https://gitee.com/sunnylqm/react-native-pushy/raw/master/endpoints.json\",\"https://cdn.jsdelivr.net/gh/reactnativecn/react-native-update@master/endpoints.json\"],\"afterDownload\":\"none\",\"disabled\":false,\"rnu\":\"\",\"rn\":\"\"}" + }, + { + "name": "not JSON", + "options": "{", + "error": "Invalid native configuration: expected an object" + }, + { + "name": "JSON array", + "options": "[]", + "error": "Invalid native configuration: expected an object" + }, + { + "name": "JSON null", + "options": "null", + "error": "Invalid native configuration: expected an object" + }, + { + "name": "unknown option", + "options": "{\"appKey\":\"a\",\"endponts\":[\"https://example.com\"]}", + "error": "Invalid native configuration: unknown option endponts" + }, + { + "name": "missing appKey", + "options": "{}", + "error": "Invalid native configuration: appKey must be a string and must not be blank" + }, + { + "name": "blank appKey", + "options": "{\"appKey\":\" \u3000\"}", + "error": "Invalid native configuration: appKey must be a string and must not be blank" + }, + { + "name": "numeric appKey", + "options": "{\"appKey\":12}", + "error": "Invalid native configuration: appKey must be a string and must not be blank" + }, + { + "name": "null appKey", + "options": "{\"appKey\":null}", + "error": "Invalid native configuration: appKey must be a string and must not be blank" + }, + { + "name": "empty endpoints", + "options": "{\"appKey\":\"a\",\"endpoints\":[]}", + "error": "Invalid native configuration: endpoints must be a non-empty array" + }, + { + "name": "null endpoints", + "options": "{\"appKey\":\"a\",\"endpoints\":null}", + "error": "Invalid native configuration: endpoints must be a non-empty array" + }, + { + "name": "string endpoints", + "options": "{\"appKey\":\"a\",\"endpoints\":\"https://example.com\"}", + "error": "Invalid native configuration: endpoints must be a non-empty array" + }, + { + "name": "unsafe scheme", + "options": "{\"appKey\":\"a\",\"endpoints\":[\"file:///tmp/update\"]}", + "error": "Invalid native configuration: endpoints requires absolute HTTP(S) URLs without credentials, queries or fragments" + }, + { + "name": "upper-case scheme", + "options": "{\"appKey\":\"a\",\"endpoints\":[\"HTTPS://example.com\"]}", + "error": "Invalid native configuration: endpoints requires absolute HTTP(S) URLs without credentials, queries or fragments" + }, + { + "name": "credentials", + "options": "{\"appKey\":\"a\",\"endpoints\":[\"https://user:pass@example.com\"]}", + "error": "Invalid native configuration: endpoints requires absolute HTTP(S) URLs without credentials, queries or fragments" + }, + { + "name": "relative URL", + "options": "{\"appKey\":\"a\",\"endpoints\":[\"/api\"]}", + "error": "Invalid native configuration: endpoints requires absolute HTTP(S) URLs without credentials, queries or fragments" + }, + { + "name": "missing host", + "options": "{\"appKey\":\"a\",\"endpoints\":[\"https:///api\"]}", + "error": "Invalid native configuration: endpoints requires absolute HTTP(S) URLs without credentials, queries or fragments" + }, + { + "name": "empty port", + "options": "{\"appKey\":\"a\",\"endpoints\":[\"https://example.com:/api\"]}", + "error": "Invalid native configuration: endpoints requires absolute HTTP(S) URLs without credentials, queries or fragments" + }, + { + "name": "inner whitespace", + "options": "{\"appKey\":\"a\",\"endpoints\":[\"https://example.com/a b\"]}", + "error": "Invalid native configuration: endpoints requires absolute HTTP(S) URLs without credentials, queries or fragments" + }, + { + "name": "inner ideographic space", + "options": "{\"appKey\":\"a\",\"endpoints\":[\"https://example.com/a\u3000b\"]}", + "error": "Invalid native configuration: endpoints requires absolute HTTP(S) URLs without credentials, queries or fragments" + }, + { + "name": "backslash", + "options": "{\"appKey\":\"a\",\"endpoints\":[\"https://example.com\\\\@evil.example\"]}", + "error": "Invalid native configuration: endpoints requires absolute HTTP(S) URLs without credentials, queries or fragments" + }, + { + "name": "base query", + "options": "{\"appKey\":\"a\",\"endpoints\":[\"https://example.com/api?x=1\"]}", + "error": "Invalid native configuration: endpoints requires absolute HTTP(S) URLs without credentials, queries or fragments" + }, + { + "name": "base fragment", + "options": "{\"appKey\":\"a\",\"endpoints\":[\"https://example.com#x\"]}", + "error": "Invalid native configuration: endpoints requires absolute HTTP(S) URLs without credentials, queries or fragments" + }, + { + "name": "blank endpoint", + "options": "{\"appKey\":\"a\",\"endpoints\":[\" \"]}", + "error": "Invalid native configuration: endpoints must be a string and must not be blank" + }, + { + "name": "wrong discovery entry type", + "options": "{\"appKey\":\"a\",\"queryUrls\":[42]}", + "error": "Invalid native configuration: queryUrls must be a string and must not be blank" + }, + { + "name": "null discovery", + "options": "{\"appKey\":\"a\",\"queryUrls\":null}", + "error": "Invalid native configuration: queryUrls must be an array" + }, + { + "name": "discovery with credentials", + "options": "{\"appKey\":\"a\",\"queryUrls\":[\"https://u@example.com/x.json\"]}", + "error": "Invalid native configuration: queryUrls requires absolute HTTP(S) URLs without credentials" + }, + { + "name": "wrong activation", + "options": "{\"appKey\":\"a\",\"afterDownload\":\"immediate\"}", + "error": "Invalid native configuration: afterDownload must be none or setNeedUpdate" + }, + { + "name": "null activation", + "options": "{\"appKey\":\"a\",\"afterDownload\":null}", + "error": "Invalid native configuration: afterDownload must be none or setNeedUpdate" + }, + { + "name": "string disabled", + "options": "{\"appKey\":\"a\",\"disabled\":\"false\"}", + "error": "Invalid native configuration: disabled must be a boolean" + }, + { + "name": "numeric disabled", + "options": "{\"appKey\":\"a\",\"disabled\":1}", + "error": "Invalid native configuration: disabled must be a boolean" + }, + { + "name": "null disabled", + "options": "{\"appKey\":\"a\",\"disabled\":null}", + "error": "Invalid native configuration: disabled must be a boolean" + }, + { + "name": "numeric rn", + "options": "{\"appKey\":\"a\",\"rn\":77}", + "error": "Invalid native configuration: rn must be a string" + }, + { + "name": "null rnu", + "options": "{\"appKey\":\"a\",\"rnu\":null}", + "error": "Invalid native configuration: rnu must be a string" + }, + { + "name": "blank package version", + "options": "{\"appKey\":\"a\",\"packageVersion\":\"\"}", + "error": "Invalid native configuration: packageVersion must be a string and must not be blank" + }, + { + "name": "null package version", + "options": "{\"appKey\":\"a\",\"packageVersion\":null}", + "error": "Invalid native configuration: packageVersion must be a string and must not be blank" + } + ] +} diff --git a/cpp/update_flow_core/tests/update_flow_core_test.cpp b/cpp/update_flow_core/tests/update_flow_core_test.cpp index 4644a1ce..99566a10 100644 --- a/cpp/update_flow_core/tests/update_flow_core_test.cpp +++ b/cpp/update_flow_core/tests/update_flow_core_test.cpp @@ -12,6 +12,7 @@ #include #include "../flow_json.h" +#include "../native_config.h" #include "../update_flow_core.h" using flowjson::Parse; @@ -418,25 +419,178 @@ int RunHandleCheckResponse() { return failures; } -} // namespace +// Contract C1: every case must hold in both argument orders (shared with +// the JS implementation, src/__tests__/configEquivalenceVectors.test.ts). +int RunConfigEquivalence(const Value& doc) { + int failures = 0; + const Value& cases = doc.Get("cases"); + if (!cases.IsArray() || cases.Size() < 30) { + std::fprintf(stderr, "config equivalence vectors: too few cases\n"); + return 1; + } + for (const Value& c : cases.elements()) { + const std::string& a = c.Get("a").AsString(); + const std::string& b = c.Get("b").AsString(); + const bool expected = c.Get("equivalent").AsBool(); + if (flowcore::ConfigsEquivalent(a, b) != expected || + flowcore::ConfigsEquivalent(b, a) != expected) { + std::fprintf(stderr, "config equivalence \"%s\" MISMATCH (expected %s)\n", + c.Get("name").AsString().c_str(), + expected ? "equivalent" : "different"); + failures++; + } + } + return failures; +} -int main(int argc, char* argv[]) { - if (argc < 2) { - std::fprintf(stderr, "usage: %s \n", argv[0]); - return 2; +int RunNativeConfig(const Value& doc) { + int failures = 0; + const Value& runnable = doc.Get("runnable"); + const Value& normalize = doc.Get("normalize"); + if (!runnable.IsArray() || runnable.Size() == 0 || !normalize.IsArray() || + normalize.Size() == 0) { + std::fprintf(stderr, "native config vectors: missing cases\n"); + return 1; + } + for (const Value& c : runnable.elements()) { + const std::string actual = + flowcore::RunnableConfigJson(c.Get("config").AsString()); + const std::string expected = Stringify(c.Get("expected")); + if (actual != expected) { + std::fprintf(stderr, + "runnable \"%s\" MISMATCH\n expected: %s\n actual: %s\n", + c.Get("name").AsString().c_str(), expected.c_str(), + actual.c_str()); + failures++; + } } - std::ifstream file(argv[1]); + for (const Value& c : normalize.elements()) { + std::string out; + std::string error; + const bool ok = flowcore::NormalizeConfiguration( + c.Get("options").AsString(), &out, &error); + const std::string actual = ok ? out : "error: " + error; + const Value& expectedValue = c.Get("expected"); + const std::string expected = expectedValue.IsString() + ? expectedValue.AsString() + : "error: " + c.Get("error").AsString(); + if (actual != expected) { + std::fprintf(stderr, + "normalize \"%s\" MISMATCH\n expected: %s\n actual: %s\n", + c.Get("name").AsString().c_str(), expected.c_str(), + actual.c_str()); + failures++; + continue; + } + // A normalized config is what the gate later reads: it must be + // runnable (or disabled) and equivalent to itself. + if (ok) { + const flowcore::RunnableConfig gate = flowcore::ParseRunnableConfig(out); + if (!(gate.runnable() || gate.reason == "disabled") || + !flowcore::ConfigsEquivalent(out, out)) { + std::fprintf(stderr, "normalize \"%s\": output not runnable/stable\n", + c.Get("name").AsString().c_str()); + failures++; + } + } + } + return failures; +} + +int RunTrimAndDeepEquals() { + int failures = 0; + const struct { + const char* input; + const char* expected; + } trims[] = { + {"", ""}, + {" ", ""}, + {"\t\n\v\f\r k \r\n", "k"}, + {"\xc2\xa0k\xe3\x80\x80", "k"}, // NBSP, U+3000 + {"\xef\xbb\xbfk\xe2\x80\xa8", "k"}, // BOM, U+2028 + {"\xe2\x80\x8ak\xe2\x80\xaf\xe2\x81\x9f", "k"}, // U+200A U+202F U+205F + {"\xe1\x9a\x80k", "k"}, // U+1680 + {"\xe2\x80\x8bk", "\xe2\x80\x8bk"}, // U+200B is not whitespace + {"\x01k\x1f", "\x01k\x1f"}, // C0 controls are kept + {"\xc2\x85k", "\xc2\x85k"}, // U+0085 (NEL) is kept + {"a b", "a b"}, + {"\xc2", "\xc2"}, // truncated sequence + }; + for (const auto& t : trims) { + if (flowcore::TrimJsWhitespace(t.input) != t.expected) { + std::fprintf(stderr, "TrimJsWhitespace(\"%s\") MISMATCH\n", t.input); + failures++; + } + } + const auto parse = [](const char* text) { + bool ok = false; + return Parse(text, &ok); + }; + const struct { + const char* a; + const char* b; + bool equal; + } pairs[] = { + {"{\"a\":1,\"b\":[1,{\"c\":null}]}", "{\"b\":[1,{\"c\":null}],\"a\":1}", true}, + {"{\"a\":1}", "{\"a\":1,\"b\":2}", false}, + {"{\"a\":1,\"b\":2}", "{\"a\":1}", false}, + {"[1,2]", "[2,1]", false}, + {"1", "1.0", true}, + {"0", "false", false}, + {"null", "null", true}, + {"\"1\"", "1", false}, + {"[]", "{}", false}, + }; + for (const auto& p : pairs) { + if (Value::DeepEquals(parse(p.a), parse(p.b)) != p.equal) { + std::fprintf(stderr, "DeepEquals(%s, %s) MISMATCH\n", p.a, p.b); + failures++; + } + } + // Undefined members count as absent. + Value withUndefined = Value::Object(); + withUndefined.Set("a", Value::Number(1)); + withUndefined.Set("b", Value::Undefined()); + if (!Value::DeepEquals(withUndefined, parse("{\"a\":1}")) || + !Value::DeepEquals(parse("{\"a\":1}"), withUndefined)) { + std::fprintf(stderr, "DeepEquals: undefined member is not absent\n"); + failures++; + } + return failures; +} + +bool LoadJson(const char* path, Value* doc) { + std::ifstream file(path); if (!file) { - std::fprintf(stderr, "cannot open %s\n", argv[1]); - return 2; + std::fprintf(stderr, "cannot open %s\n", path); + return false; } std::stringstream buffer; buffer << file.rdbuf(); - bool ok = false; - Value doc = Parse(buffer.str(), &ok); + *doc = Parse(buffer.str(), &ok); if (!ok) { - std::fprintf(stderr, "failed to parse %s\n", argv[1]); + std::fprintf(stderr, "failed to parse %s\n", path); + } + return ok; +} + +} // namespace + +int main(int argc, char* argv[]) { + if (argc < 4) { + std::fprintf(stderr, + "usage: %s " + " " + "\n", + argv[0]); + return 2; + } + Value doc; + Value equivalenceDoc; + Value nativeConfigDoc; + if (!LoadJson(argv[1], &doc) || !LoadJson(argv[2], &equivalenceDoc) || + !LoadJson(argv[3], &nativeConfigDoc)) { return 2; } @@ -446,7 +600,9 @@ int main(int argc, char* argv[]) { return 2; } int failures = RunParserRobustness() + RunNumberConversion() + - RunInputCaps() + RunHandleCheckResponse(); + RunInputCaps() + RunHandleCheckResponse() + + RunConfigEquivalence(equivalenceDoc) + + RunNativeConfig(nativeConfigDoc) + RunTrimAndDeepEquals(); for (size_t i = 0; i < cases.Size(); i++) { const Value& testCase = cases.At(i); const std::string& fn = testCase.Get("fn").AsString(); @@ -470,11 +626,17 @@ int main(int argc, char* argv[]) { } if (failures) { - std::fprintf(stderr, "%d failures (%zu vectors + parser robustness)\n", + std::fprintf(stderr, + "%d failures (%zu flow vectors, config vectors + parser " + "robustness)\n", failures, cases.Size()); return 1; } - std::printf("all %zu flow vectors + parser robustness passed\n", - cases.Size()); + std::printf( + "all %zu flow vectors, %zu config equivalence vectors, %zu+%zu native " + "config vectors + parser robustness passed\n", + cases.Size(), equivalenceDoc.Get("cases").Size(), + nativeConfigDoc.Get("runnable").Size(), + nativeConfigDoc.Get("normalize").Size()); return 0; } diff --git a/scripts/test-update-flow-core.sh b/scripts/test-update-flow-core.sh index a1acdeac..a1ab84d2 100755 --- a/scripts/test-update-flow-core.sh +++ b/scripts/test-update-flow-core.sh @@ -25,7 +25,9 @@ c++ \ -o "$BUILD_DIR/update_flow_core_test" "$BUILD_DIR/update_flow_core_test" \ - "$ROOT_DIR/cpp/update_flow_core/tests/flow_vectors.json" + "$ROOT_DIR/cpp/update_flow_core/tests/flow_vectors.json" \ + "$ROOT_DIR/cpp/update_flow_core/tests/config-equivalence-vectors.json" \ + "$ROOT_DIR/cpp/update_flow_core/tests/native-config-vectors.json" # Opt-in libFuzzer run of the JSON parser + decision pipeline (raw server # bytes): FUZZ=1 bun run test:flow-core. @@ -44,6 +46,7 @@ if cpp_test_fuzz_compiler; then printf '{"update":true,"hash":"h2","full":"h2.ppk","diff":"d.hdiff","paths":["cdn.x.com","https://m.x.com/"],"name":"v2","config":{"forceBoot":true}}' > "$CORPUS_DIR/seed-download.json" printf '{"update":true,"hash":"root","expVersion":{"hash":"gray","full":"g.ppk","config":{"rollout":{"2.3.4":63}}},"paths":["cdn"]}' > "$CORPUS_DIR/seed-rollout.json" printf '{"upToDate":true,"s":"\\ud83d\\ude00 \\u00e9 \\n","n":[1,-0.5,1e2,null,false]}' > "$CORPUS_DIR/seed-scalars.json" + printf '{"appKey":" k ","endpoints":["https://a.example/","https://a.example"],"disabled":false,"rn":""}' > "$CORPUS_DIR/seed-config.json" "$BUILD_DIR/fuzz_flow_json" \ -max_total_time="${FUZZ_SECONDS:-20}" \ -max_len=4096 \ From f4b78e1962aa512eb9370ef5a78c253752bfe7fd Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 16:02:42 +0800 Subject: [PATCH 076/104] refactor(android): use the shared config gate, equivalence and normalizer FlowBridge gains configsEquivalent / runnableConfig / normalizeConfiguration (JNI over cpp/update_flow_core/native_config.h). ConfigEquivalence.java, PushyConfiguration.java and their JUnit test are gone; the C1 and native-config vectors are replayed by the C++ test. SyncCoordinator.parseRunnableConfig keeps its shape but asks the core for the verdict, so disabled now follows JS truthiness (disabled:1 or "false" skips) and appKey is trimmed like String.prototype.trim. PushyRuntime.configure loads the native library itself, since it validates before touching UpdateContext. The default service addresses now live encoded in the shared core only; nativeTextEncoding checks them there and the request path per platform. Co-Authored-By: Claude Opus 5.5 --- CODE_AUDIT.md | 2 +- .../modules/update/ConfigEquivalence.java | 126 ------------------ .../modules/update/FlowBridge.java | 19 +++ .../modules/update/NativeCore.java | 4 +- .../modules/update/PushyConfiguration.java | 104 --------------- .../modules/update/PushyRuntime.java | 6 +- .../modules/update/SyncCoordinator.java | 36 ++--- .../modules/update/UpdateContext.java | 8 +- .../modules/update/ConfigEquivalenceTest.java | 63 --------- cpp/android_jni/jni_natives.h | 3 + cpp/android_jni/jni_registration.cpp | 6 + cpp/android_jni/update_flow_jni.cpp | 50 +++++++ cpp/update_flow_core/native_config.cpp | 12 +- src/__tests__/jniRegistration.test.ts | 2 +- src/__tests__/nativeHostApiNaming.test.ts | 5 +- src/__tests__/nativeTextEncoding.test.ts | 40 +++--- 16 files changed, 141 insertions(+), 345 deletions(-) delete mode 100644 android/src/main/java/cn/reactnative/modules/update/ConfigEquivalence.java delete mode 100644 android/src/main/java/cn/reactnative/modules/update/PushyConfiguration.java delete mode 100644 android/src/test/java/cn/reactnative/modules/update/ConfigEquivalenceTest.java diff --git a/CODE_AUDIT.md b/CODE_AUDIT.md index de67145f..baee851b 100644 --- a/CODE_AUDIT.md +++ b/CODE_AUDIT.md @@ -78,7 +78,7 @@ 剩余:两份 `UpdateModule.java`(oldarch/newarch,均已是薄转发层,只差基类与注解);`buildToolsVersion` 仍设置。 **单测建议**(部分) -已有 `android/src/test` 6 个 JVM 测试类(CleanupKeepNames、ConfigEquivalence、HttpUtils、InstallRecord、SafeZipFile、UpdateFileUtils;CI 不跑,本地 `gradle testReleaseUnitTest`)。未覆盖:`ArchiveLimits.ensureFreeSpace`、`runCheckRequest` 次数上限、`BundledResourceCopier` 的路径/密度解析。 +已有 `android/src/test` 5 个 JVM 测试类(CleanupKeepNames、HttpUtils、InstallRecord、SafeZipFile、UpdateFileUtils;CI 不跑,本地 `gradle testReleaseUnitTest`)。未覆盖:`ArchiveLimits.ensureFreeSpace`、`runCheckRequest` 次数上限、`BundledResourceCopier` 的路径/密度解析。 --- diff --git a/android/src/main/java/cn/reactnative/modules/update/ConfigEquivalence.java b/android/src/main/java/cn/reactnative/modules/update/ConfigEquivalence.java deleted file mode 100644 index 8ff267ad..00000000 --- a/android/src/main/java/cn/reactnative/modules/update/ConfigEquivalence.java +++ /dev/null @@ -1,126 +0,0 @@ -package cn.reactnative.modules.update; - -import androidx.annotation.Nullable; -import java.math.BigDecimal; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.Iterator; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; -import org.json.JSONTokener; - -/** - * Cross-platform canonical equality of native-check config JSON strings - * (shared contract with the JS / iOS / Harmony sides). Two configs are - * equivalent iff their canonical forms deep-equal: - *
    - *
  • absent keys take the defaults {disabled: false, queryUrls: [], rnu: "", - * rn: "", afterDownload: "none"};
  • - *
  • each string in endpoints / queryUrls loses its trailing '/', then - * duplicates are dropped keeping the first occurrence;
  • - *
  • key order is irrelevant.
  • - *
- * Anything that is not a JSON object string is never equivalent to anything. - */ -final class ConfigEquivalence { - private static final Object NULL_VALUE = new Object(); - - private ConfigEquivalence() { - } - - static boolean equivalent(@Nullable String a, @Nullable String b) { - Map left = canonical(a); - if (left == null) { - return false; - } - Map right = canonical(b); - return right != null && left.equals(right); - } - - @Nullable - static Map canonical(@Nullable String json) { - if (json == null) { - return null; - } - Object parsed; - try { - JSONTokener tokener = new JSONTokener(json); - parsed = tokener.nextValue(); - // nextValue() stops after the first value; anything but trailing - // whitespace after it is not JSON (as JSON.parse/NSJSONSerialization). - if (tokener.nextClean() != 0) { - return null; - } - } catch (JSONException e) { - return null; - } - if (!(parsed instanceof JSONObject)) { - return null; - } - @SuppressWarnings("unchecked") - Map result = (Map) toCanonical(parsed); - putDefault(result, "disabled", Boolean.FALSE); - putDefault(result, "queryUrls", new ArrayList()); - putDefault(result, "rnu", ""); - putDefault(result, "rn", ""); - putDefault(result, "afterDownload", "none"); - normalizeUrls(result, "endpoints"); - normalizeUrls(result, "queryUrls"); - return result; - } - - private static void putDefault(Map map, String key, Object value) { - if (!map.containsKey(key)) { - map.put(key, value); - } - } - - private static void normalizeUrls(Map map, String key) { - Object value = map.get(key); - if (!(value instanceof List)) { - return; - } - LinkedHashSet unique = new LinkedHashSet<>(); - for (Object item : (List) value) { - unique.add(item instanceof String ? HttpUtils.stripTrailingSlashes((String) item) : item); - } - map.put(key, new ArrayList(unique)); - } - - // JSONObject/JSONArray have identity equals; convert to Map/List with - // value semantics. Numbers compare by value (1 == 1.0). - private static Object toCanonical(Object value) { - if (value instanceof JSONObject) { - JSONObject object = (JSONObject) value; - Map map = new HashMap<>(); - for (Iterator keys = object.keys(); keys.hasNext();) { - String key = keys.next(); - map.put(key, toCanonical(object.opt(key))); - } - return map; - } - if (value instanceof JSONArray) { - JSONArray array = (JSONArray) value; - List list = new ArrayList<>(array.length()); - for (int i = 0; i < array.length(); i++) { - list.add(toCanonical(array.opt(i))); - } - return list; - } - if (value instanceof Number) { - try { - return new BigDecimal(value.toString()).stripTrailingZeros(); - } catch (NumberFormatException e) { - return value.toString(); - } - } - if (value == null || value == JSONObject.NULL) { - return NULL_VALUE; - } - return value; - } -} diff --git a/android/src/main/java/cn/reactnative/modules/update/FlowBridge.java b/android/src/main/java/cn/reactnative/modules/update/FlowBridge.java index 091f5bde..b0db9e77 100644 --- a/android/src/main/java/cn/reactnative/modules/update/FlowBridge.java +++ b/android/src/main/java/cn/reactnative/modules/update/FlowBridge.java @@ -18,4 +18,23 @@ private FlowBridge() { static native String handleResponse( String responseText, String identityJson, String afterDownload); + + // Persisted-config layer (cpp/update_flow_core/native_config.h), shared + // with iOS and Harmony. + + /** Contract C1: same config in another serialization. Null is never equivalent. */ + static native boolean configsEquivalent(String a, String b); + + /** + * The run gate: {"appKey": trimmed} when the config may run a round, + * otherwise {"status", "reason"}. Null config text = not configured. + */ + static native String runnableConfig(String configJson); + + /** + * Validates host configure() options (a JSON object) into the config to + * persist. Throws IllegalArgumentException("Invalid native configuration: + * ...") for options the host must fix. + */ + static native String normalizeConfiguration(String optionsJson); } diff --git a/android/src/main/java/cn/reactnative/modules/update/NativeCore.java b/android/src/main/java/cn/reactnative/modules/update/NativeCore.java index 5bb779fd..0fd482f0 100644 --- a/android/src/main/java/cn/reactnative/modules/update/NativeCore.java +++ b/android/src/main/java/cn/reactnative/modules/update/NativeCore.java @@ -5,7 +5,9 @@ * (cpp/android_jni/jni_registration.cpp), so the library only has to be * loaded before the first native call: UpdateContext's static initializer * does that, and DownloadTask, FlowBridge and supportedDiffVersion are only - * ever reached through an UpdateContext instance. + * ever reached through an UpdateContext instance — except + * PushyRuntime.configure, which validates options before touching + * UpdateContext and so loads the library itself. */ final class NativeCore { private static boolean loaded = false; diff --git a/android/src/main/java/cn/reactnative/modules/update/PushyConfiguration.java b/android/src/main/java/cn/reactnative/modules/update/PushyConfiguration.java deleted file mode 100644 index 88dba6ae..00000000 --- a/android/src/main/java/cn/reactnative/modules/update/PushyConfiguration.java +++ /dev/null @@ -1,104 +0,0 @@ -package cn.reactnative.modules.update; - -import java.util.Arrays; -import java.util.HashSet; -import java.util.Iterator; -import java.util.Set; -import java.util.regex.Pattern; -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; - -/** Internal normalizer for the JSONObject accepted by PushyRuntime.configure. */ -final class PushyConfiguration { - private static final Set KEYS = new HashSet<>(Arrays.asList( - "appKey", "endpoints", "queryUrls", "afterDownload", "disabled", - "packageVersion", "rnu", "rn")); - private static final Pattern URL = Pattern.compile( - "^https?://(\\[[0-9a-fA-F:]+\\]|[a-zA-Z0-9.-]+)(:[0-9]+)?([/?#]|$)"); - // Default service addresses, encoded by scripts/encode-native-text.ts. - private static final String[] ENDPOINTS = { - Texts.reveal("3203e0c1bdd1270a372f18f8c2b6de7f4f2607f5b3d5b9817b592947e5cdefbc8a7e"), - Texts.reveal("3203e0c1bdd1270a372f18f8c2b6de7f4f2607f5f0daac9c644a620ae88ca1ad93") - }; - private static final String[] QUERY_URLS = { - Texts.reveal("3203e0c1bdd1270a253608fcd3fd9362476817f4f0d5a1996342631be3c2a3a9d779552507fdcde8928a6f512f5ce2ccbdc869404d2f1de79daa826d562c0913eec4fa9b7d4426"), - Texts.reveal("3203e0c1bdd1270a213b12b7dca09468462e12f3b0d5bd813d482446f4c6a1be8e79552507fdcda68cd06e5c3710e480a4867048483e55e0c2ab8d7d43030d1ce9c3b183214e2601f2f0d5b782601e2719e8ca") - }; - - // afterDownload value that selects the bundle for the next launch, encoded. - static final String POLICY_NEXT_LAUNCH = Texts.reveal("2912e0ffab8e6c70323b1dedd3"); - - private PushyConfiguration() {} - - static String normalize(String json) throws JSONException { - JSONObject options = new JSONObject(json); - for (Iterator keys = options.keys(); keys.hasNext();) { - String key = keys.next(); - if (!KEYS.contains(key)) { - throw invalid("unknown option " + key); - } - } - String appKey = string(options.opt("appKey"), "appKey", false); - boolean customEndpoints = options.has("endpoints"); - JSONArray endpoints = urls(customEndpoints ? options.get("endpoints") - : new JSONArray(Arrays.asList(ENDPOINTS)), "endpoints", true); - JSONArray queryUrls = urls(options.has("queryUrls") ? options.get("queryUrls") - : new JSONArray(Arrays.asList(customEndpoints ? new String[0] : QUERY_URLS)), - "queryUrls", false); - String afterDownload = options.has("afterDownload") - ? string(options.get("afterDownload"), "afterDownload", false) : "none"; - if (!"none".equals(afterDownload) && !POLICY_NEXT_LAUNCH.equals(afterDownload)) { - throw invalid("afterDownload must be none or " + POLICY_NEXT_LAUNCH); - } - Object disabled = options.has("disabled") ? options.get("disabled") : Boolean.FALSE; - if (!(disabled instanceof Boolean)) { - throw invalid("disabled must be a boolean"); - } - JSONObject result = new JSONObject(); - result.put("appKey", appKey); - result.put("endpoints", endpoints); - result.put("queryUrls", queryUrls); - result.put("afterDownload", afterDownload); - result.put("disabled", disabled); - result.put("rnu", options.has("rnu") ? string(options.get("rnu"), "rnu", true) : ""); - result.put("rn", options.has("rn") ? string(options.get("rn"), "rn", true) : ""); - if (options.has("packageVersion")) { - result.put("packageVersion", string(options.get("packageVersion"), "packageVersion", false)); - } - return result.toString(); - } - - private static String string(Object value, String name, boolean allowEmpty) { - if (!(value instanceof String) || (!allowEmpty && ((String) value).trim().isEmpty())) { - throw invalid(name + " must be a string" + (allowEmpty ? "" : " and must not be blank")); - } - return (String) value; - } - - private static JSONArray urls(Object value, String name, boolean base) throws JSONException { - if (!(value instanceof JSONArray) || (base && ((JSONArray) value).length() == 0)) { - throw invalid(name + " must be " + (base ? "a non-empty" : "an") + " array"); - } - JSONArray values = (JSONArray) value; - JSONArray result = new JSONArray(); - Set seen = new HashSet<>(); - for (int i = 0; i < values.length(); i++) { - String url = string(values.get(i), name, false).trim(); - if (!URL.matcher(url).find() || url.matches("(?s).*\\s.*") || url.contains("\\") - || (base && (url.contains("?") || url.contains("#")))) { - throw invalid(name + " requires absolute HTTP(S) URLs without credentials" - + (base ? ", queries or fragments" : "")); - } - String normalized = base ? HttpUtils.stripTrailingSlashes(url) : url; - if (seen.add(normalized)) { - result.put(normalized); - } - } - return result; - } - - private static IllegalArgumentException invalid(String message) { - return new IllegalArgumentException("Invalid native configuration: " + message); - } -} diff --git a/android/src/main/java/cn/reactnative/modules/update/PushyRuntime.java b/android/src/main/java/cn/reactnative/modules/update/PushyRuntime.java index 6e10653e..7c791380 100644 --- a/android/src/main/java/cn/reactnative/modules/update/PushyRuntime.java +++ b/android/src/main/java/cn/reactnative/modules/update/PushyRuntime.java @@ -43,7 +43,11 @@ public static void configure(Context context, JSONObject options, final Configur CONFIG_WORKER.execute(() -> { Exception failure = null; try { - String config = PushyConfiguration.normalize(snapshot); + NativeCore.ensureLoaded(); + String config = FlowBridge.normalizeConfiguration(snapshot); + if (config == null) { + throw new IllegalStateException("Native configuration failed"); + } UpdateContext.getInstance(applicationContext).setNativeConfig(config); } catch (Exception e) { failure = e; diff --git a/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java b/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java index 0d1fa05c..1816df44 100644 --- a/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java +++ b/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java @@ -104,8 +104,8 @@ static BundlePreparationResult prepareBundle(UpdateContext context) throws Inter } roundDone.await(); if (roundConfigGeneration != UpdateContext.getNativeConfigGeneration() - || !ConfigEquivalence.equivalent(configJson, roundConfigJson) - || !ConfigEquivalence.equivalent(configJson, context.getKv(KEY_CONFIG))) { + || !FlowBridge.configsEquivalent(configJson, roundConfigJson) + || !FlowBridge.configsEquivalent(configJson, context.getKv(KEY_CONFIG))) { return BundlePreparationResult.of(BundlePreparationResult.CANCELLED, "config_changed"); } if (roundGeneration != UpdateContext.getResetGeneration()) { @@ -121,7 +121,7 @@ static void recordJsRound(String config) { /** * True when JS already obtained a valid response in this process for a - * config equivalent (ConfigEquivalence, the cross-platform contract) to + * config equivalent (FlowBridge.configsEquivalent, contract C1) to * the one the native round would use: the delayed round is then a * duplicate request. JS and a host may serialize the same config * differently, so exact string equality would almost never match. Only @@ -131,7 +131,7 @@ static void recordJsRound(String config) { private static boolean hasJsRound(UpdateContext context) { String jsConfig = sJsCompletedConfig; return jsConfig != null - && ConfigEquivalence.equivalent(jsConfig, context.getKv(KEY_CONFIG)); + && FlowBridge.configsEquivalent(jsConfig, context.getKv(KEY_CONFIG)); } /** @@ -161,25 +161,29 @@ static RunnableConfig rejected(String status, String reason) { } } + /** + * The shared gate (FlowBridge.runnableConfig, update_flow_core): absent is + * skipped/not_configured, a non-object failed/invalid_config, a truthy + * disabled skipped/disabled, a blank appKey failed/invalid_config. + */ static RunnableConfig parseRunnableConfig(@Nullable String json) { - if (json == null || json.isEmpty()) { - return RunnableConfig.rejected(BundlePreparationResult.SKIPPED, "not_configured"); - } + JSONObject verdict; JSONObject config; try { + String result = FlowBridge.runnableConfig(json); + if (result == null) { + return RunnableConfig.rejected(BundlePreparationResult.FAILED, "internal_error"); + } + verdict = new JSONObject(result); + if (verdict.has("status")) { + return RunnableConfig.rejected( + verdict.getString("status"), verdict.getString("reason")); + } config = new JSONObject(json); } catch (JSONException e) { return RunnableConfig.rejected(BundlePreparationResult.FAILED, "invalid_config"); } - if (config.optBoolean("disabled", false)) { - return RunnableConfig.rejected(BundlePreparationResult.SKIPPED, "disabled"); - } - Object appKey = config.opt("appKey"); - String trimmed = appKey instanceof String ? ((String) appKey).trim() : ""; - if (trimmed.isEmpty()) { - return RunnableConfig.rejected(BundlePreparationResult.FAILED, "invalid_config"); - } - return new RunnableConfig(json, config, trimmed, null); + return new RunnableConfig(json, config, verdict.optString("appKey"), null); } private SyncCoordinator() { 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 d982f86b..d97f6eeb 100644 --- a/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +++ b/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java @@ -785,10 +785,10 @@ static long getNativeConfigGeneration() { void setNativeConfig(String config) { synchronized (commitLock) { - // Canonical comparison (ConfigEquivalence): re-sending the same - // config with another key order or trailing slashes must not - // cancel an in-flight round or drop its cache. - boolean changed = !ConfigEquivalence.equivalent( + // Canonical comparison (C1, FlowBridge.configsEquivalent): + // re-sending the same config with another key order or trailing + // slashes must not cancel an in-flight round or drop its cache. + boolean changed = !FlowBridge.configsEquivalent( config, sp.getString(SyncCoordinator.KEY_CONFIG, null)); SharedPreferences.Editor editor = sp.edit(); if (changed) { diff --git a/android/src/test/java/cn/reactnative/modules/update/ConfigEquivalenceTest.java b/android/src/test/java/cn/reactnative/modules/update/ConfigEquivalenceTest.java deleted file mode 100644 index 6508947b..00000000 --- a/android/src/test/java/cn/reactnative/modules/update/ConfigEquivalenceTest.java +++ /dev/null @@ -1,63 +0,0 @@ -package cn.reactnative.modules.update; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; - -import java.io.File; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import org.json.JSONArray; -import org.json.JSONObject; -import org.junit.Test; - -public class ConfigEquivalenceTest { - private static final String VECTORS = - "cpp/update_flow_core/tests/config-equivalence-vectors.json"; - - // The shared C1 vectors live at the package root (next to android/). Gradle - // runs unit tests from the module directory, which is android/ in this repo - // and node_modules/react-native-update/android in a host app; walk up from - // there so both layouts (and symlinked copies) resolve. - private static File vectorsFile() { - String override = System.getProperty("pushy.configVectors"); - if (override != null) { - return new File(override); - } - for (File dir = new File(System.getProperty("user.dir")).getAbsoluteFile(); - dir != null; dir = dir.getParentFile()) { - File candidate = new File(dir, VECTORS); - if (candidate.isFile()) { - return candidate; - } - } - return null; - } - - @Test - public void sharedVectors() throws Exception { - File file = vectorsFile(); - assertNotNull("cannot locate " + VECTORS + " above " + System.getProperty("user.dir"), file); - JSONArray cases = new JSONObject( - new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8)) - .getJSONArray("cases"); - assertTrue("vector file has cases", cases.length() >= 30); - for (int i = 0; i < cases.length(); i++) { - JSONObject vector = cases.getJSONObject(i); - String name = vector.getString("name"); - String a = vector.getString("a"); - String b = vector.getString("b"); - boolean equivalent = vector.getBoolean("equivalent"); - assertEquals(name, equivalent, ConfigEquivalence.equivalent(a, b)); - assertEquals(name + " (reversed)", equivalent, ConfigEquivalence.equivalent(b, a)); - } - } - - @Test - public void missingConfigIsNeverEquivalent() { - assertFalse(ConfigEquivalence.equivalent(null, null)); - assertFalse(ConfigEquivalence.equivalent("{\"appKey\":\"k\"}", null)); - assertFalse(ConfigEquivalence.equivalent(null, "{\"appKey\":\"k\"}")); - } -} diff --git a/cpp/android_jni/jni_natives.h b/cpp/android_jni/jni_natives.h index 7ecb9443..6efb5867 100644 --- a/cpp/android_jni/jni_natives.h +++ b/cpp/android_jni/jni_natives.h @@ -26,6 +26,9 @@ jstring FlowBuildRequestBody(JNIEnv* env, jclass, jstring inputJson); jstring FlowOrderEndpointCandidates(JNIEnv* env, jclass, jstring endpointsJson, jdouble randomSample); jboolean FlowIsValidResponse(JNIEnv* env, jclass, jstring responseText); jstring FlowHandleResponse(JNIEnv* env, jclass, jstring responseText, jstring identityJson, jstring afterDownload); +jboolean FlowConfigsEquivalent(JNIEnv* env, jclass, jstring a, jstring b); +jstring FlowRunnableConfig(JNIEnv* env, jclass, jstring configJson); +jstring FlowNormalizeConfiguration(JNIEnv* env, jclass, jstring optionsJson); } // namespace jni_natives } // namespace pushy diff --git a/cpp/android_jni/jni_registration.cpp b/cpp/android_jni/jni_registration.cpp index fd4be930..e4a592bc 100644 --- a/cpp/android_jni/jni_registration.cpp +++ b/cpp/android_jni/jni_registration.cpp @@ -61,6 +61,12 @@ const std::vector& NativeClasses() { reinterpret_cast(&pushy::jni_natives::FlowIsValidResponse)}, {"3216fad5a28e5a40312f13f7c5b6", "723bfed0b88a274923311bb6e5a7826444205fcdf4daae943d432d07e18c93a9887e5a3655c7c2a4949e3355371df78299937648503c43bcfea58d7f476c0c1cf4d0fba27a59210be5a4", reinterpret_cast(&pushy::jni_natives::FlowHandleResponse)}, + {"3918fad7a78c7b60332a15efd7bf95635e", "723bfed0b88a274923311bb6e5a7826444205fcdf4daae943d432d07e18c93a9887e5a3655a2f2", + reinterpret_cast(&pushy::jni_natives::FlowConfigsEquivalent)}, + {"2802fadfaf896440013012ffdfb4", "723bfed0b88a274923311bb6e5a7826444205fa8d2d1b98373002008e8c4ef8e8e655d3f09b0", + reinterpret_cast(&pushy::jni_natives::FlowRunnableConfig)}, + {"3418e6dcaf87615f271c13f7d0ba9778582610e8f1d5", "723bfed0b88a274923311bb6e5a7826444205fa8d2d1b98373002008e8c4ef8e8e655d3f09b0", + reinterpret_cast(&pushy::jni_natives::FlowNormalizeConfiguration)}, }}, }; return classes; diff --git a/cpp/android_jni/update_flow_jni.cpp b/cpp/android_jni/update_flow_jni.cpp index baeb1834..b0ee7188 100644 --- a/cpp/android_jni/update_flow_jni.cpp +++ b/cpp/android_jni/update_flow_jni.cpp @@ -12,6 +12,7 @@ #include "jni_util.h" #include "../update_flow_core/flow_json.h" +#include "../update_flow_core/native_config.h" #include "../update_flow_core/update_flow_core.h" using pushy::jni_util::ToJString; @@ -81,3 +82,52 @@ jstring pushy::jni_natives::FlowHandleResponse( return nullptr; } } + +// Persisted-config layer (update_flow_core/native_config.h). A null Java +// string is an absent config: never equivalent, not_configured at the gate. +jboolean pushy::jni_natives::FlowConfigsEquivalent( + JNIEnv* env, jclass, jstring a, jstring b) { + if (a == nullptr || b == nullptr) { + return JNI_FALSE; + } + try { + return flowcore::ConfigsEquivalent(pushy::jni_util::JStringToString(env, a), + pushy::jni_util::JStringToString(env, b)) + ? JNI_TRUE + : JNI_FALSE; + } catch (...) { + return JNI_FALSE; + } +} + +jstring pushy::jni_natives::FlowRunnableConfig(JNIEnv* env, jclass, + jstring configJson) { + try { + return ToJString(env, flowcore::RunnableConfigJson( + pushy::jni_util::JStringToString(env, configJson))); + } catch (...) { + return nullptr; + } +} + +// Throws IllegalArgumentException("Invalid native configuration: ...") for +// options the host must fix; null only when memory ran out. +jstring pushy::jni_natives::FlowNormalizeConfiguration(JNIEnv* env, jclass, + jstring optionsJson) { + try { + std::string config; + std::string error; + if (!flowcore::NormalizeConfiguration( + pushy::jni_util::JStringToString(env, optionsJson), &config, + &error)) { + jclass exception = env->FindClass("java/lang/IllegalArgumentException"); + if (exception != nullptr) { + env->ThrowNew(exception, error.c_str()); + } + return nullptr; + } + return ToJString(env, config); + } catch (...) { + return nullptr; + } +} diff --git a/cpp/update_flow_core/native_config.cpp b/cpp/update_flow_core/native_config.cpp index 604a1922..a94525e0 100644 --- a/cpp/update_flow_core/native_config.cpp +++ b/cpp/update_flow_core/native_config.cpp @@ -120,16 +120,12 @@ std::vector DefaultEndpoints() { }; } +// One literal per address: src/__tests__/nativeTextEncoding.test.ts finds +// them by pattern. std::vector DefaultQueryUrls() { return { - pushy::text::Reveal( - "3203e0c1bdd1270a253608fcd3fd9362476817f4f0d5a1996342631be3c2a3a9d779" - "552507fdcde8928a6f512f5ce2ccbdc869404d2f1de79daa826d562c0913eec4fa9b" - "7d4426"), - pushy::text::Reveal( - "3203e0c1bdd1270a213b12b7dca09468462e12f3b0d5bd813d482446f4c6a1be8e79" - "552507fdcda68cd06e5c3710e480a4867048483e55e0c2ab8d7d43030d1ce9c3b183" - "214e2601f2f0d5b782601e2719e8ca"), + pushy::text::Reveal("3203e0c1bdd1270a253608fcd3fd9362476817f4f0d5a1996342631be3c2a3a9d779552507fdcde8928a6f512f5ce2ccbdc869404d2f1de79daa826d562c0913eec4fa9b7d4426"), + pushy::text::Reveal("3203e0c1bdd1270a213b12b7dca09468462e12f3b0d5bd813d482446f4c6a1be8e79552507fdcda68cd06e5c3710e480a4867048483e55e0c2ab8d7d43030d1ce9c3b183214e2601f2f0d5b782601e2719e8ca"), }; } diff --git a/src/__tests__/jniRegistration.test.ts b/src/__tests__/jniRegistration.test.ts index f9a463b6..71fcdbe7 100644 --- a/src/__tests__/jniRegistration.test.ts +++ b/src/__tests__/jniRegistration.test.ts @@ -72,7 +72,7 @@ const registered = () => { describe('JNI registration table', () => { test('matches every Java native declaration', () => { const natives = javaNatives(); - expect(natives.length).toBe(11); + expect(natives.length).toBe(14); expect(registered()).toEqual(natives); }); diff --git a/src/__tests__/nativeHostApiNaming.test.ts b/src/__tests__/nativeHostApiNaming.test.ts index ffeed111..5c47241d 100644 --- a/src/__tests__/nativeHostApiNaming.test.ts +++ b/src/__tests__/nativeHostApiNaming.test.ts @@ -32,9 +32,8 @@ describe('native host API naming', () => { expect(source(`${android}BundlePreparationResult.java`)).toContain( 'class BundlePreparationResult' ); - expect(source(`${android}PushyConfiguration.java`)).toContain( - 'class PushyConfiguration' - ); + // Options are validated by the shared core (native_config.h). + expect(entry).toContain('FlowBridge.normalizeConfiguration('); for (const name of [ 'PushyNativeUpdate', 'NativeUpdateResult', diff --git a/src/__tests__/nativeTextEncoding.test.ts b/src/__tests__/nativeTextEncoding.test.ts index 3275a21c..ea70104c 100644 --- a/src/__tests__/nativeTextEncoding.test.ts +++ b/src/__tests__/nativeTextEncoding.test.ts @@ -39,24 +39,30 @@ describe('native text encoding', () => { } }); - test('each platform encodes the service addresses and path', () => { - const platforms = { - android: paths.filter((path) => path.startsWith('android/')), - ios: paths.filter((path) => path.startsWith('ios/')), - harmony: paths.filter((path) => path.startsWith('harmony/')), - }; - for (const [platform, files] of Object.entries(platforms)) { - const decoded = files + // The request path is appended by each platform's HTTP glue; the default + // service addresses only live in the shared normalizer + // (cpp/update_flow_core/native_config.cpp). + test('the request path is encoded per platform, the addresses in cpp/', () => { + const decodedIn = (prefix: string) => + paths + .filter((path) => path.startsWith(prefix)) .flatMap((path) => encodedIn(source(path))) - .map(decodeNativeText) - .sort(); - for (const text of plainTexts) { - expect({ platform, text, found: decoded.includes(text) }).toEqual({ - platform, - text, - found: true, - }); - } + .map(decodeNativeText); + for (const platform of ['android/', 'ios/', 'harmony/']) { + expect({ + platform, + found: decodedIn(platform).includes(plainTexts[0]), + }).toEqual({ + platform, + found: true, + }); + } + const shared = decodedIn('cpp/'); + for (const text of plainTexts.slice(1)) { + expect({ text, found: shared.includes(text) }).toEqual({ + text, + found: true, + }); } }); From 1d95ca4f606971e5f15465761c5e7afd41dd4ee3 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 16:04:07 +0800 Subject: [PATCH 077/104] refactor(ios): use the shared config gate, equivalence and normalizer PushyConfigsEquivalent is now a nil-guarding binding over flowcore::ConfigsEquivalent (the NSDictionary-based canonicalizer is gone), PushyLoadRunnableConfig hands back the host result built from flowcore::ParseRunnableConfig (appKey now trimmed like String.prototype.trim instead of NSCharacterSet whitespace), and RCTPushyNormalizeConfiguration serializes the options and defers to flowcore::NormalizeConfiguration. The purge-restore harness compiles the flow core and keeps replaying the C1 vectors through the binding. Co-Authored-By: Claude Opus 5.5 --- ios/RCTPushy/RCTPushyConfiguration.mm | 131 ++++----------- ios/RCTPushy/RCTPushyOrchestrator.mm | 158 ++++-------------- scripts/test-ios-purge-restore.sh | 1 + scripts/tests/ios-purge-restore/README.md | 5 +- scripts/tests/ios-purge-restore/extract.py | 6 +- .../ios-purge-restore/purge_restore_test.mm | 10 +- 6 files changed, 74 insertions(+), 237 deletions(-) diff --git a/ios/RCTPushy/RCTPushyConfiguration.mm b/ios/RCTPushy/RCTPushyConfiguration.mm index 1b1c38e9..fdf00f17 100644 --- a/ios/RCTPushy/RCTPushyConfiguration.mm +++ b/ios/RCTPushy/RCTPushyConfiguration.mm @@ -2,6 +2,7 @@ #import "RCTPushyCommon.h" #include "../../cpp/patch_core/error_codes.h" #include "../../cpp/patch_core/obscured_text.h" +#include "../../cpp/update_flow_core/native_config.h" NSString *RCTPushyRevealText(const char *hex) { // The texts are ASCII (scripts/encode-native-text.ts). @@ -16,106 +17,38 @@ return path; } -static void PushyConfigInvalid(NSString *message) { - @throw [NSException exceptionWithName:NSInvalidArgumentException - reason:[@"Invalid native configuration: " stringByAppendingString:message] - userInfo:nil]; -} - -static NSString *PushyConfigString(id value, NSString *name, BOOL allowEmpty) { - if (![value isKindOfClass:NSString.class] - || (!allowEmpty && [[value stringByTrimmingCharactersInSet: - NSCharacterSet.whitespaceAndNewlineCharacterSet] length] == 0)) { - PushyConfigInvalid([NSString stringWithFormat:@"%@ must be a string%@", - name, allowEmpty ? @"" : @" and must not be blank"]); - } - return value; -} - -static NSArray *PushyConfigUrls(id value, NSString *name, BOOL base) { - if (![value isKindOfClass:NSArray.class] || (base && [value count] == 0)) { - PushyConfigInvalid([NSString stringWithFormat:@"%@ must be %@ array", - name, base ? @"a non-empty" : @"an"]); - } - NSMutableArray *result = [NSMutableArray array]; - static NSRegularExpression *authority = [NSRegularExpression regularExpressionWithPattern: - @"^https?://(\\[[0-9a-fA-F:]+\\]|[a-zA-Z0-9.-]+)(:[0-9]+)?([/?#]|$)" - options:0 error:nil]; - for (id item in value) { - NSString *url = [PushyConfigString(item, name, NO) stringByTrimmingCharactersInSet: - NSCharacterSet.whitespaceAndNewlineCharacterSet]; - if ([authority firstMatchInString:url options:0 range:NSMakeRange(0, url.length)] == nil - || [url rangeOfCharacterFromSet:NSCharacterSet.whitespaceAndNewlineCharacterSet].location != NSNotFound - || [url containsString:@"\\"] - || (base && ([url containsString:@"?"] || [url containsString:@"#"]))) { - PushyConfigInvalid([NSString stringWithFormat: - @"%@ requires absolute HTTP(S) URLs without credentials%@", - name, base ? @", queries or fragments" : @""]); - } - if (base) { - while ([url hasSuffix:@"/"]) { - url = [url substringToIndex:url.length - 1]; - } - } - if (![result containsObject:url]) { - [result addObject:url]; - } - } - return result; -} - +// Validation and defaults are the shared core's (flowcore::NormalizeConfiguration, +// also used by Android and Harmony); this only bridges the dictionary. NSString *RCTPushyNormalizeConfiguration(NSDictionary *options, NSError **error) { - @try { - if (![options isKindOfClass:NSDictionary.class]) { - PushyConfigInvalid(@"expected an object"); - } - NSArray *keys = @[@"appKey", @"endpoints", @"queryUrls", @"afterDownload", - @"disabled", @"packageVersion", @"rnu", @"rn"]; - for (id key in options) { - if (![keys containsObject:key]) { - PushyConfigInvalid([NSString stringWithFormat:@"unknown option %@", key]); - } - } - NSString *appKey = PushyConfigString(options[@"appKey"], @"appKey", NO); - BOOL customEndpoints = options[@"endpoints"] != nil; - NSArray *endpoints = PushyConfigUrls(customEndpoints ? options[@"endpoints"] - : @[RCTPushyRevealText("3203e0c1bdd1270a372f18f8c2b6de7f4f2607f5b3d5b9817b592947e5cdefbc8a7e"), - RCTPushyRevealText("3203e0c1bdd1270a372f18f8c2b6de7f4f2607f5f0daac9c644a620ae88ca1ad93")], - @"endpoints", YES); - NSArray *queryUrls = PushyConfigUrls(options[@"queryUrls"] ?: (customEndpoints ? @[] - : @[RCTPushyRevealText("3203e0c1bdd1270a253608fcd3fd9362476817f4f0d5a1996342631be3c2a3a9d779552507fdcde8928a6f512f5ce2ccbdc869404d2f1de79daa826d562c0913eec4fa9b7d4426"), - RCTPushyRevealText("3203e0c1bdd1270a213b12b7dca09468462e12f3b0d5bd813d482446f4c6a1be8e79552507fdcda68cd06e5c3710e480a4867048483e55e0c2ab8d7d43030d1ce9c3b183214e2601f2f0d5b782601e2719e8ca")]), - @"queryUrls", NO); - NSString *afterDownload = options[@"afterDownload"] - ? PushyConfigString(options[@"afterDownload"], @"afterDownload", NO) : @"none"; - NSString *nextLaunch = RCTPushyRevealText("2912e0ffab8e6c70323b1dedd3"); - if (![@[@"none", nextLaunch] containsObject:afterDownload]) { - PushyConfigInvalid([@"afterDownload must be none or " stringByAppendingString:nextLaunch]); - } - id disabled = options[@"disabled"] ?: @NO; - if (![disabled isKindOfClass:NSNumber.class] || CFGetTypeID((__bridge CFTypeRef)disabled) != CFBooleanGetTypeID()) { - PushyConfigInvalid(@"disabled must be a boolean"); + // Anything NSJSONSerialization cannot write is not a JSON object; the core + // reports it as such. + std::string optionsJson; + if ([options isKindOfClass:NSDictionary.class] + && [NSJSONSerialization isValidJSONObject:options]) { + NSData *data = [NSJSONSerialization dataWithJSONObject:options options:0 error:NULL]; + if (data != nil) { + optionsJson.assign(static_cast(data.bytes), data.length); } - NSMutableDictionary *result = [@{ - @"appKey": appKey, @"endpoints": endpoints, @"queryUrls": queryUrls, - @"afterDownload": afterDownload, @"disabled": disabled, - @"rnu": options[@"rnu"] ? PushyConfigString(options[@"rnu"], @"rnu", YES) : @"", - @"rn": options[@"rn"] ? PushyConfigString(options[@"rn"], @"rn", YES) : @"" - } mutableCopy]; - if (options[@"packageVersion"] != nil) { - result[@"packageVersion"] = PushyConfigString(options[@"packageVersion"], @"packageVersion", NO); - } - // Stable ordering makes repeated identical configure calls idempotent. - NSData *data = [NSJSONSerialization dataWithJSONObject:result - options:NSJSONWritingSortedKeys error:error]; - return data == nil ? nil : [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; - } @catch (NSException *exception) { - if (error != NULL) { - *error = [NSError errorWithDomain:RCTPushyErrorDomain code:1 userInfo:@{ - NSLocalizedDescriptionKey: exception.reason ?: @"Invalid native configuration", - RCTPushyErrorCodeKey: @(pushy::error_codes::kInvalidOptions) - }]; - } - return nil; } + std::string config; + std::string message; + if (flowcore::NormalizeConfiguration(optionsJson, &config, &message)) { + NSString *result = [[NSString alloc] initWithBytes:config.data() + length:config.size() + encoding:NSUTF8StringEncoding]; + if (result != nil) { + return result; + } + message = "Invalid native configuration: not UTF-8"; + } + if (error != NULL) { + *error = [NSError errorWithDomain:RCTPushyErrorDomain code:1 userInfo:@{ + NSLocalizedDescriptionKey: [[NSString alloc] initWithBytes:message.data() + length:message.size() + encoding:NSUTF8StringEncoding] + ?: @"Invalid native configuration", + RCTPushyErrorCodeKey: @(pushy::error_codes::kInvalidOptions) + }]; + } + return nil; } diff --git a/ios/RCTPushy/RCTPushyOrchestrator.mm b/ios/RCTPushy/RCTPushyOrchestrator.mm index 88ac7969..922956a8 100644 --- a/ios/RCTPushy/RCTPushyOrchestrator.mm +++ b/ios/RCTPushy/RCTPushyOrchestrator.mm @@ -2,6 +2,7 @@ #include "../../cpp/patch_core/error_codes.h" #include "../../cpp/patch_core/hbc_transform_wire.h" #include "../../cpp/update_flow_core/flow_json.h" +#include "../../cpp/update_flow_core/native_config.h" #include "../../cpp/update_flow_core/update_flow_core.h" #if TARGET_OS_TV @@ -352,144 +353,43 @@ static BOOL PushyIsValidResponse(NSString *responseText) { return flowcore::IsValidResponse(PushyToStdString(responseText)) ? YES : NO; } -// Canonical config equality, shared with the JS, Android and Harmony sides: -// two config JSON strings are equivalent iff their canonical forms deep-equal. -// Canonical form: the parsed object with absent keys defaulted -// (disabled:false, queryUrls:[], rnu:"", rn:"", afterDownload:"none") and the -// endpoints/queryUrls entries stripped of trailing '/' and deduplicated in -// first-occurrence order; key order is irrelevant. Unparseable is never -// equivalent. Lets a host-owned config (sorted keys) and the same config as JS -// serializes it count as one, instead of a "change" that cancels the round. -static BOOL PushyJsonIsBool(id value) { - return [value isKindOfClass:NSNumber.class] - && CFGetTypeID((__bridge CFTypeRef)value) == CFBooleanGetTypeID(); -} - -static BOOL PushyJsonEqual(id a, id b) { - if (PushyJsonIsBool(a) != PushyJsonIsBool(b)) { - return NO; // false is not 0 - } - if ([a isKindOfClass:NSDictionary.class] && [b isKindOfClass:NSDictionary.class]) { - if ([a count] != [b count]) { - return NO; - } - for (id key in (NSDictionary *)a) { - id other = ((NSDictionary *)b)[key]; - if (other == nil || !PushyJsonEqual(((NSDictionary *)a)[key], other)) { - return NO; - } - } - return YES; - } - if ([a isKindOfClass:NSArray.class] && [b isKindOfClass:NSArray.class]) { - if ([a count] != [b count]) { - return NO; - } - for (NSUInteger i = 0; i < [a count]; i++) { - if (!PushyJsonEqual(((NSArray *)a)[i], ((NSArray *)b)[i])) { - return NO; - } - } - return YES; - } - return [a isEqual:b]; -} - -static id PushyCanonicalUrlList(id value) { - if (![value isKindOfClass:NSArray.class]) { - return value; - } - NSMutableArray *result = [NSMutableArray array]; - for (id item in (NSArray *)value) { - id entry = [item isKindOfClass:NSString.class] ? PushyNormalizeEndpointBase(item) : item; - BOOL seen = NO; - for (id existing in result) { - if (PushyJsonEqual(existing, entry)) { - seen = YES; - break; - } - } - if (!seen) { - [result addObject:entry]; - } - } - return result; -} - -static NSDictionary *PushyCanonicalConfig(NSString *json) { - NSMutableDictionary *config = [PushyJsonDictionary(json, NULL) mutableCopy]; - if (config == nil) { - return nil; - } - NSDictionary *defaults = @{@"disabled": @NO, @"queryUrls": @[], @"rnu": @"", - @"rn": @"", @"afterDownload": @"none"}; - for (NSString *key in defaults) { - if (config[key] == nil) { - config[key] = defaults[key]; - } - } - for (NSString *key in @[@"endpoints", @"queryUrls"]) { - if (config[key] != nil) { - config[key] = PushyCanonicalUrlList(config[key]); - } - } - return config; -} - +// Contract C1 (flowcore::ConfigsEquivalent, shared with Android and Harmony; +// JS has its own copy): the same config in another serialization — key order, +// trailing slashes, defaulted keys — is not a change. Lets a host-owned +// config and the same config as JS serializes it count as one, instead of a +// "change" that cancels the round. nil (no stored config) never matches. static BOOL PushyConfigsEquivalent(NSString *a, NSString *b) { - NSDictionary *left = a == nil ? nil : PushyCanonicalConfig(a); - NSDictionary *right = b == nil ? nil : PushyCanonicalConfig(b); - return left != nil && right != nil && PushyJsonEqual(left, right); + return a != nil && b != nil + && flowcore::ConfigsEquivalent(PushyToStdString(a), PushyToStdString(b)); } // The persisted config a round may run with. Every gate (crash-hold handler // install, round start, prepareBundle, the round itself) reads it through -// PushyLoadRunnableConfig, so they agree on what "configured" and "disabled" -// mean. disabled follows JS truthiness, like the flow core; appKey is trimmed -// like Android and Harmony do. +// PushyLoadRunnableConfig, which applies the shared gate +// (flowcore::ParseRunnableConfig), so they agree on what "configured" and +// "disabled" mean. struct PushyRunnableConfig { NSString *json; flowjson::Value value; NSString *appKey; }; -// YES when a round may run; otherwise *reason is not_configured, disabled or -// invalid_config. -static BOOL PushyLoadRunnableConfig(PushyRunnableConfig *config, NSString **reason) { - NSString *failure = nil; +// YES when a round may run; otherwise *rejection is the host-facing result +// (skipped: not_configured/disabled, failed: invalid_config). +static BOOL PushyLoadRunnableConfig(PushyRunnableConfig *config, NSDictionary **rejection) { NSString *json = [PushyDefaults() stringForKey:keyNativeConfig]; config->json = [json copy]; - if (json.length == 0) { - failure = @"not_configured"; - } else { - bool ok = false; - flowjson::Value value = flowjson::Parse(PushyToStdString(json), &ok); - NSString *appKey = ok && value.IsObject() - ? [PushyFromStdString(value.Get("appKey").AsString()) - stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] - : nil; - if (!ok || !value.IsObject()) { - failure = @"invalid_config"; - } else if (value.Get("disabled").Truthy()) { - failure = @"disabled"; - } else if (appKey.length == 0) { - failure = @"invalid_config"; - } else { - config->value = value; - config->appKey = appKey; + flowcore::RunnableConfig gate = flowcore::ParseRunnableConfig(PushyToStdString(json)); + if (!gate.runnable()) { + if (rejection != NULL) { + *rejection = PushyHostResult(PushyFromStdString(gate.status), + PushyFromStdString(gate.reason)); } + return NO; } - if (reason != NULL) { - *reason = failure; - } - return failure == nil; -} - -// Host-facing result for a config that cannot run: absent or disabled is a -// skip, a malformed one a failure. -static NSDictionary *PushyUnrunnableConfigResult(NSString *reason) { - BOOL skip = [reason isEqualToString:@"not_configured"] || [reason isEqualToString:@"disabled"]; - return PushyHostResult(skip ? @"skipped" : @"failed", reason); + config->value = std::move(gate.config); + config->appKey = PushyFromStdString(gate.appKey); + return YES; } @implementation RCTPushyOrchestrator @@ -650,9 +550,9 @@ + (NSDictionary *)prepareBundleWithin:(NSTimeInterval)timeout { return PushyHostResult(@"skipped", @"not_initialized"); } PushyRunnableConfig runnable; - NSString *reason = nil; - if (!PushyLoadRunnableConfig(&runnable, &reason)) { - return PushyUnrunnableConfigResult(reason); + NSDictionary *rejection = nil; + if (!PushyLoadRunnableConfig(&runnable, &rejection)) { + return rejection; } NSString *configJson = runnable.json; // Run the round on its own queue rather than this caller's thread, so the @@ -818,11 +718,11 @@ + (void)runOnce:(NSString *)launchRolledBackVersion deadline:(NSTimeInterval)dea pushyHostRoundResult = PushyHostResult(@"failed", @"check_failed"); NSUserDefaults *defaults = PushyDefaults(); PushyRunnableConfig config; - NSString *reason = nil; - BOOL runnable = PushyLoadRunnableConfig(&config, &reason); + NSDictionary *rejection = nil; + BOOL runnable = PushyLoadRunnableConfig(&config, &rejection); pushyHostRoundConfig = config.json; if (!runnable) { - pushyHostRoundResult = PushyUnrunnableConfigResult(reason); + pushyHostRoundResult = rejection; return; } // Preserve the interrupted-round breadcrumb and reset-safe atomic commit. diff --git a/scripts/test-ios-purge-restore.sh b/scripts/test-ios-purge-restore.sh index f9a806fa..d799270b 100755 --- a/scripts/test-ios-purge-restore.sh +++ b/scripts/test-ios-purge-restore.sh @@ -36,6 +36,7 @@ build() { -I"$ROOT_DIR" -I"$destination" \ "$TEST_DIR/purge_restore_test.mm" "$ROOT_DIR/cpp/patch_core/state_core.cpp" \ "$ROOT_DIR/cpp/patch_core/digest.cpp" \ + "$ROOT_DIR"/cpp/update_flow_core/*.cpp \ "$ROOT_DIR/ios/RCTPushy/RCTPushyConfiguration.mm" \ -framework Foundation -o "$destination/purge_restore_test" } diff --git a/scripts/tests/ios-purge-restore/README.md b/scripts/tests/ios-purge-restore/README.md index 58d30499..7950bf58 100644 --- a/scripts/tests/ios-purge-restore/README.md +++ b/scripts/tests/ios-purge-restore/README.md @@ -91,8 +91,9 @@ preferences domain. `config_equivalence` additionally replays the cross-platform canonical config equality vectors in `cpp/update_flow_core/tests/config-equivalence-vectors.json` -(contract C1, shared with the JS/Harmony bun test and the Android JUnit test) -against the extracted `PushyConfigsEquivalent`, in both argument orders. +(contract C1, also replayed by the JS bun test and the flow-core C++ test) +through the extracted `PushyConfigsEquivalent` binding over +`flowcore::ConfigsEquivalent`, in both argument orders. A real GCD worker captures the request generation and blocks at a controlled response boundary. Semaphores establish each ordering. Reset races are exercised diff --git a/scripts/tests/ios-purge-restore/extract.py b/scripts/tests/ios-purge-restore/extract.py index eb178551..3de0c089 100644 --- a/scripts/tests/ios-purge-restore/extract.py +++ b/scripts/tests/ios-purge-restore/extract.py @@ -96,9 +96,9 @@ def declaration(source: str, name: str) -> tuple[int, str]: 'PushyJsonDictionary', 'PushyJsonString', 'PushyRejectException', 'PushyFileQueue', 'PushySetNullableString', 'PushyHashInfoKey', 'PushyBinaryIdentityValue', 'PushyStateFromDefaults', 'PushyApplyStateToDefaults', 'PushySwitchVersionLocked', - # Canonical config equality (cross-platform contract), tested directly. - 'PushyNormalizeEndpointBase', 'PushyJsonIsBool', 'PushyJsonEqual', - 'PushyCanonicalUrlList', 'PushyCanonicalConfig', 'PushyConfigsEquivalent', + # Canonical config equality (contract C1): the binding over + # flowcore::ConfigsEquivalent, tested directly. + 'PushyConfigsEquivalent', # Install record activation check (cpp/patch_core/install_record.h) and # what switchVersion:error: needs around it. 'PushyInstallRecordFileName', 'PushyVersionDir', 'PushyReadInstallRecord', 'PushyInstallRecordMatches', 'PushyHasInstallRecordFile', diff --git a/scripts/tests/ios-purge-restore/purge_restore_test.mm b/scripts/tests/ios-purge-restore/purge_restore_test.mm index 4e72d6f2..269a8e66 100644 --- a/scripts/tests/ios-purge-restore/purge_restore_test.mm +++ b/scripts/tests/ios-purge-restore/purge_restore_test.mm @@ -17,6 +17,7 @@ #include "cpp/patch_core/error_codes.h" #include "cpp/patch_core/digest.h" #include "cpp/patch_core/install_record.h" +#include "cpp/update_flow_core/native_config.h" #include "ios/RCTPushy/RCTPushyCommon.h" #undef TARGET_OS_TV @@ -516,10 +517,11 @@ static void DefaultsIsolation(void) { }); } -// Canonical config equality shared with JS/Android/Harmony: equal canonical -// forms are the same config (persistConfiguration no-op, hasJsRound match). -// Replays the cross-platform vectors every implementation consumes; the file -// is located from this source file (/scripts/tests/ios-purge-restore/). +// Canonical config equality (contract C1, flowcore::ConfigsEquivalent): equal +// canonical forms are the same config (persistConfiguration no-op, +// hasJsRound match). Replays the shared vectors through the NSString binding; +// the file is located from this source file +// (/scripts/tests/ios-purge-restore/). static void ConfigEquivalence(void) { NSString *repo = [[[[@__FILE__ stringByDeletingLastPathComponent] stringByDeletingLastPathComponent] stringByDeletingLastPathComponent] From 6a606a36cccd041f8df2c9624ae0ae33f64afd7e Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 16:06:14 +0800 Subject: [PATCH 078/104] refactor(harmony): use the shared config gate, equivalence and normalizer pushy.cpp exports configsEquivalent / runnableConfig / normalizeConfiguration over cpp/update_flow_core/native_config.h. UpdateContext's ArkTS canonicalizer, SyncCoordinator's hand-rolled gate and PushyConfiguration's validator (with the default addresses and POLICY_NEXT_LAUNCH) are gone; the public PushyConfiguration type stays. The bun tests no longer evaluate those ArkTS copies: the C1 vectors run against the JS client only (the core replays them in C++), the normalization cases moved to native-config-vectors.json, and the evaluated Harmony modules get a NativePatchCore stand-in whose gate is checked against the same vectors. Co-Authored-By: Claude Opus 5.5 --- harmony/pushy/src/main/cpp/pushy.cpp | 70 +++++++++++++- harmony/pushy/src/main/ets/NativePatchCore.ts | 15 +++ .../pushy/src/main/ets/PushyConfiguration.ts | 93 ++----------------- harmony/pushy/src/main/ets/SyncCoordinator.ts | 45 +++------ harmony/pushy/src/main/ets/Texts.ts | 1 - harmony/pushy/src/main/ets/UpdateContext.ts | 87 ++--------------- .../configEquivalenceVectors.test.ts | 37 +++----- src/__tests__/nativeConfiguration.test.ts | 86 ++--------------- src/__tests__/nativeHostApi.test.ts | 15 ++- src/__tests__/nativeSources.ts | 37 ++++++++ src/__tests__/nativeTextEncoding.test.ts | 6 +- 11 files changed, 181 insertions(+), 311 deletions(-) diff --git a/harmony/pushy/src/main/cpp/pushy.cpp b/harmony/pushy/src/main/cpp/pushy.cpp index 58c40953..aa5487a7 100644 --- a/harmony/pushy/src/main/cpp/pushy.cpp +++ b/harmony/pushy/src/main/cpp/pushy.cpp @@ -1,6 +1,7 @@ #include #include "flow_json.h" +#include "native_config.h" #include "update_flow_core.h" #include #include @@ -1201,6 +1202,70 @@ static napi_value FlowHandleResponse(napi_env env, response_text, identity, false, after_download))); } +// ---- persisted native config (update_flow_core/native_config.h) ---- +// null/undefined arguments read as "" (an absent config): never equivalent, +// not_configured at the gate, not an object for the normalizer. + +static napi_value FlowConfigsEquivalent(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, "configsEquivalent: missing arguments"); + return nullptr; + } + bool ok = false; + std::string a = GetString(env, args[0], &ok); + if (!ok) { + return nullptr; + } + std::string b = GetString(env, args[1], &ok); + if (!ok) { + return nullptr; + } + napi_value result = nullptr; + napi_get_boolean(env, flowcore::ConfigsEquivalent(a, b), &result); + return result; +} + +static napi_value FlowRunnableConfig(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, "runnableConfig: missing argument"); + return nullptr; + } + bool ok = false; + std::string config_json = GetString(env, args[0], &ok); + if (!ok) { + return nullptr; + } + return NewString(env, flowcore::RunnableConfigJson(config_json)); +} + +// Throws Error("Invalid native configuration: ...") for options the host +// must fix. +static napi_value FlowNormalizeConfiguration(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, "normalizeConfiguration: missing argument"); + return nullptr; + } + bool ok = false; + std::string options_json = GetString(env, args[0], &ok); + if (!ok) { + return nullptr; + } + std::string config; + std::string error; + if (!flowcore::NormalizeConfiguration(options_json, &config, &error)) { + ThrowError(env, error); + return nullptr; + } + return NewString(env, config); +} + napi_value Init(napi_env env, napi_value exports) { if (!ExportFunction(env, exports, "syncStateWithBinaryVersion", SyncStateWithBinaryVersion) || !ExportFunction(env, exports, "runStateCore", RunStateCore) || @@ -1215,7 +1280,10 @@ napi_value Init(napi_env env, napi_value exports) { !ExportFunction(env, exports, "buildRequestBody", FlowBuildRequestBody) || !ExportFunction(env, exports, "orderEndpointCandidates", FlowOrderEndpointCandidates) || !ExportFunction(env, exports, "isValidResponse", FlowIsValidResponse) || - !ExportFunction(env, exports, "handleResponse", FlowHandleResponse)) { + !ExportFunction(env, exports, "handleResponse", FlowHandleResponse) || + !ExportFunction(env, exports, "configsEquivalent", FlowConfigsEquivalent) || + !ExportFunction(env, exports, "runnableConfig", FlowRunnableConfig) || + !ExportFunction(env, exports, "normalizeConfiguration", FlowNormalizeConfiguration)) { return nullptr; } return exports; diff --git a/harmony/pushy/src/main/ets/NativePatchCore.ts b/harmony/pushy/src/main/ets/NativePatchCore.ts index 93d84a98..4e830f7b 100644 --- a/harmony/pushy/src/main/ets/NativePatchCore.ts +++ b/harmony/pushy/src/main/ets/NativePatchCore.ts @@ -109,6 +109,21 @@ interface NativePatchCoreBindings { // 响应 schema 门槛(update_flow_core::IsValidResponse):200 但只带 // `{"error":...}` 的节点算失败,不得阻止继续 fallback。 isValidResponse(responseText: string): boolean; + + // 持久化的原生配置(cpp/update_flow_core/native_config.h,与 Android/iOS + // 共用)。undefined 视同空串:缺省配置。 + /** C1 配置等价:同一配置的不同序列化。任一缺省即不等价。 */ + configsEquivalent(a: string | undefined, b: string | undefined): boolean; + /** + * 轮次门槛:可运行时为 {"appKey": 去空白后的值},否则 + * {"status": "skipped" | "failed", "reason": ...}。 + */ + runnableConfig(configJson: string | undefined): string; + /** + * 校验宿主 configure() 的选项(JSON 对象文本),返回要持久化的配置; + * 不合法时抛出 Error("Invalid native configuration: ...")。 + */ + normalizeConfiguration(optionsJson: string | undefined): string; } export default NativeCore as unknown as NativePatchCoreBindings; diff --git a/harmony/pushy/src/main/ets/PushyConfiguration.ts b/harmony/pushy/src/main/ets/PushyConfiguration.ts index ce8b1537..e4b9271b 100644 --- a/harmony/pushy/src/main/ets/PushyConfiguration.ts +++ b/harmony/pushy/src/main/ets/PushyConfiguration.ts @@ -1,4 +1,4 @@ -import { POLICY_NEXT_LAUNCH, revealText } from './Texts'; +import NativePatchCore from './NativePatchCore'; /** Options accepted by PushyFileJSBundleProvider.configure(). */ export interface PushyConfiguration { @@ -16,89 +16,12 @@ export interface PushyConfiguration { rn?: string; } -interface PersistedPushyConfiguration { - appKey: string; - endpoints: string[]; - queryUrls: string[]; - afterDownload: string; - disabled: boolean; - packageVersion?: string; - rnu: string; - rn: string; -} - -const DEFAULT_ENDPOINTS: string[] = [ - revealText('3203e0c1bdd1270a372f18f8c2b6de7f4f2607f5b3d5b9817b592947e5cdefbc8a7e'), - revealText('3203e0c1bdd1270a372f18f8c2b6de7f4f2607f5f0daac9c644a620ae88ca1ad93'), -]; -const DEFAULT_QUERY_URLS: string[] = [ - revealText('3203e0c1bdd1270a253608fcd3fd9362476817f4f0d5a1996342631be3c2a3a9d779552507fdcde8928a6f512f5ce2ccbdc869404d2f1de79daa826d562c0913eec4fa9b7d4426'), - revealText('3203e0c1bdd1270a213b12b7dca09468462e12f3b0d5bd813d482446f4c6a1be8e79552507fdcda68cd06e5c3710e480a4867048483e55e0c2ab8d7d43030d1ce9c3b183214e2601f2f0d5b782601e2719e8ca'), -]; -const CONFIG_KEYS: string[] = [ - 'appKey', 'endpoints', 'queryUrls', 'afterDownload', 'disabled', - 'packageVersion', 'rnu', 'rn', -]; - -function configString(value: string, name: string, allowEmpty: boolean): string { - if (typeof value !== 'string' || (!allowEmpty && value.trim().length === 0)) { - throw new Error(`Invalid native configuration: ${name} must be a string${allowEmpty ? '' : ' and must not be blank'}`); - } - return value; -} - -function configUrls(values: string[], name: string, base: boolean): string[] { - if (!Array.isArray(values) || (base && values.length === 0)) { - throw new Error(`Invalid native configuration: ${name} must be ${base ? 'a non-empty' : 'an'} array`); - } - const result: string[] = []; - for (const value of values) { - const url = configString(value, name, false).trim(); - // Restrict the authority and scheme, without relying on browser URL globals - // unavailable in ArkTS. Platform networking performs the final URL parsing. - if (!/^https?:\/\/(\[[0-9a-fA-F:]+\]|[a-zA-Z0-9.-]+)(:[0-9]+)?([/?#]|$)/.test(url) - || /\s|\\/.test(url) || (base && /[?#]/.test(url))) { - throw new Error(`Invalid native configuration: ${name} requires absolute HTTP(S) URLs without credentials${base ? ', queries or fragments' : ''}`); - } - const normalized = base ? url.replace(/\/+$/, '') : url; - if (!result.includes(normalized)) { - result.push(normalized); - } - } - return result; -} - -/** Pure validation, before any native state is touched. Does not mutate options. */ +/** + * Pure validation, before any native state is touched. Does not mutate options. + * The rules and defaults are the shared core's (cpp/update_flow_core + * NormalizeConfiguration, also used by Android and iOS); it throws + * Error("Invalid native configuration: ...") for options the host must fix. + */ export function normalizePushyConfiguration(options: PushyConfiguration): string { - if (!options || typeof options !== 'object' || Array.isArray(options)) { - throw new Error('Invalid native configuration: expected an object'); - } - for (const key of Object.keys(options)) { - if (!CONFIG_KEYS.includes(key)) { - throw new Error(`Invalid native configuration: unknown option ${key}`); - } - } - const appKey = configString(options.appKey, 'appKey', false); - const customEndpoints = options.endpoints !== undefined; - const endpoints = configUrls(customEndpoints ? options.endpoints! : DEFAULT_ENDPOINTS, 'endpoints', true); - const queryUrls = configUrls( - options.queryUrls !== undefined ? options.queryUrls : (customEndpoints ? [] : DEFAULT_QUERY_URLS), - 'queryUrls', false, - ); - const afterDownload = options.afterDownload === undefined ? 'none' : options.afterDownload; - if (afterDownload !== 'none' && afterDownload !== POLICY_NEXT_LAUNCH) { - throw new Error(`Invalid native configuration: afterDownload must be none or ${POLICY_NEXT_LAUNCH}`); - } - if (options.disabled !== undefined && typeof options.disabled !== 'boolean') { - throw new Error('Invalid native configuration: disabled must be a boolean'); - } - const result: PersistedPushyConfiguration = { - appKey, endpoints, queryUrls, afterDownload, disabled: options.disabled ?? false, - rnu: options.rnu === undefined ? '' : configString(options.rnu, 'rnu', true), - rn: options.rn === undefined ? '' : configString(options.rn, 'rn', true), - }; - if (options.packageVersion !== undefined) { - result.packageVersion = configString(options.packageVersion, 'packageVersion', false); - } - return JSON.stringify(result); + return NativePatchCore.normalizeConfiguration(JSON.stringify(options)); } diff --git a/harmony/pushy/src/main/ets/SyncCoordinator.ts b/harmony/pushy/src/main/ets/SyncCoordinator.ts index 20b0d766..a0678866 100644 --- a/harmony/pushy/src/main/ets/SyncCoordinator.ts +++ b/harmony/pushy/src/main/ets/SyncCoordinator.ts @@ -150,45 +150,26 @@ interface ConfigRead { // The single configuration gate shared by the preflight, the host API and // the round itself, so the three can never disagree about what is runnable. +// The verdict is the shared core's (cpp/update_flow_core ParseRunnableConfig, +// also used by Android and iOS). +interface RunnableVerdict { + appKey?: string; + status?: string; + reason?: string; +} + function readNativeConfig(context: UpdateContext): ConfigRead { const json = context.getKv(KEY_CONFIG); - const empty: NativeConfig = {}; - if (!json) { - return { - json: '', - config: empty, - appKey: '', - rejection: bundlePreparationResult('skipped', 'not_configured'), - }; - } - const invalid: ConfigRead = { - json, - config: empty, - appKey: '', - rejection: bundlePreparationResult('failed', 'invalid_config'), - }; - let config: NativeConfig; - try { - config = JSON.parse(json) as NativeConfig; - } catch (e) { - return invalid; - } - if (!config || typeof config !== 'object' || Array.isArray(config)) { - return invalid; - } - if (config.disabled) { + const verdict = JSON.parse(NativePatchCore.runnableConfig(json)) as RunnableVerdict; + if (verdict.status !== undefined) { return { json, - config, + config: {}, appKey: '', - rejection: bundlePreparationResult('skipped', 'disabled'), + rejection: bundlePreparationResult(verdict.status, verdict.reason ?? ''), }; } - const appKey = typeof config.appKey === 'string' ? config.appKey.trim() : ''; - if (!appKey) { - return invalid; - } - return { json, config, appKey }; + return { json, config: JSON.parse(json) as NativeConfig, appKey: verdict.appKey ?? '' }; } // A preflight skip must not consume the process's only round: configuration diff --git a/harmony/pushy/src/main/ets/Texts.ts b/harmony/pushy/src/main/ets/Texts.ts index c2faa9fc..9cb90f62 100644 --- a/harmony/pushy/src/main/ets/Texts.ts +++ b/harmony/pushy/src/main/ets/Texts.ts @@ -18,7 +18,6 @@ export const QUERY_PATH: string = revealText('7514fcd4ad805d55263e08fc99'); // Protocol values, version-info flags and storage names, stored encoded. export const STATUS_NONE: string = revealText('3418c1c1aa8a7c40'); -export const POLICY_NEXT_LAUNCH: string = revealText('2912e0ffab8e6c70323b1dedd3'); export const STORAGE_DIR_NAME: string = revealText('0502e4d5af9f6d'); export const PREFERENCES_NAME: string = revealText('2f07f0d0ba8e'); export const PPK_DELTA_SUFFIX: string = revealText('7407e4dae09b69512137'); diff --git a/harmony/pushy/src/main/ets/UpdateContext.ts b/harmony/pushy/src/main/ets/UpdateContext.ts index fe696bb3..4654befb 100644 --- a/harmony/pushy/src/main/ets/UpdateContext.ts +++ b/harmony/pushy/src/main/ets/UpdateContext.ts @@ -49,87 +49,12 @@ interface OptionalPreferencesApi { flushSync?: () => void; } -// C1 跨平台配置等价(JS/Android/iOS 同一规则):缺省键补默认值、端点/ -// 发现地址去尾斜杠并保序去重、忽略键序。等价的配置不算"变更"——宿主重复 -// configure、或 JS 同步一份只差格式的同一配置,都不应取消在飞轮次或丢弃 -// 响应缓存。放在本文件(而非独立模块):本文件被测试原样求值,只能依赖 -// 自身源码。 -const NATIVE_CONFIG_DEFAULTS: Record = { - disabled: false, - queryUrls: [], - rnu: '', - rn: '', - afterDownload: 'none', -}; -const NATIVE_CONFIG_URL_LISTS: string[] = ['endpoints', 'queryUrls']; - -function canonicalJson(value: unknown): string { - if (Array.isArray(value)) { - return `[${value.map(item => canonicalJson(item)).join(',')}]`; - } - if (value !== null && typeof value === 'object') { - const record = value as Record; - const keys = Object.keys(record) - .filter(key => record[key] !== undefined) - .sort(); - return `{${keys - .map(key => `${JSON.stringify(key)}:${canonicalJson(record[key])}`) - .join(',')}}`; - } - return JSON.stringify(value) ?? 'null'; -} - -/** 配置 JSON 的规范形式;不是可解析的 JSON 对象时返回 undefined。 */ -export function canonicalNativeConfig(json: string): string | undefined { - let parsed: unknown; - try { - parsed = JSON.parse(json); - } catch (e) { - return undefined; - } - if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { - return undefined; - } - const config: Record = {}; - const source = parsed as Record; - for (const key of Object.keys(source)) { - config[key] = source[key]; - } - for (const key of Object.keys(NATIVE_CONFIG_DEFAULTS)) { - if (config[key] === undefined) { - config[key] = NATIVE_CONFIG_DEFAULTS[key]; - } - } - for (const key of NATIVE_CONFIG_URL_LISTS) { - const list = config[key]; - if (!Array.isArray(list)) { - continue; - } - const seen = new Set(); - const normalized: unknown[] = []; - for (const item of list as unknown[]) { - const value = typeof item === 'string' ? item.replace(/\/+$/, '') : item; - const identity = canonicalJson(value); - if (!seen.has(identity)) { - seen.add(identity); - normalized.push(value); - } - } - config[key] = normalized; - } - return canonicalJson(config); -} - -/** 两份配置 JSON 是否等价(C1)。任一不是可解析的 JSON 对象即不等价。 */ -export function nativeConfigsEquivalent( - a: string | undefined, - b: string | undefined, -): boolean { - if (a === undefined || b === undefined) { - return false; - } - const left = canonicalNativeConfig(a); - return left !== undefined && left === canonicalNativeConfig(b); +// C1 配置等价(cpp/update_flow_core ConfigsEquivalent,与 Android/iOS 共用): +// 缺省键补默认值、端点/发现地址去尾斜杠并保序去重、忽略键序。等价的配置不算 +// "变更"——宿主重复 configure、或 JS 同步一份只差格式的同一配置,都不应取消 +// 在飞轮次或丢弃响应缓存。任一缺省即不等价。 +function nativeConfigsEquivalent(a: string | undefined, b: string | undefined): boolean { + return NativePatchCore.configsEquivalent(a, b); } interface PersistOptions { diff --git a/src/__tests__/configEquivalenceVectors.test.ts b/src/__tests__/configEquivalenceVectors.test.ts index 3505a5de..71445584 100644 --- a/src/__tests__/configEquivalenceVectors.test.ts +++ b/src/__tests__/configEquivalenceVectors.test.ts @@ -1,13 +1,11 @@ import { describe, expect, test } from 'bun:test'; import vectorsFile from '../../cpp/update_flow_core/tests/config-equivalence-vectors.json'; import { nativeConfigsEquivalent } from '../updateFlowCore'; -import { evalHarmonyModule } from './nativeSources'; -// Contract C1 (native config equivalence) is implemented four times: here, -// Harmony UpdateContext.ts, Android ConfigEquivalence.java and iOS -// PushyConfigsEquivalent. All four replay the same committed vectors; this -// file covers the two TypeScript ones, ConfigEquivalenceTest (JUnit) and -// scripts/test-ios-purge-restore.sh the natives. +// Contract C1 (native config equivalence) is implemented twice: here, and in +// the shared native core (cpp/update_flow_core ConfigsEquivalent, used by +// Android, iOS and Harmony). Both replay the same committed vectors; this file +// covers the TypeScript one, scripts/test-update-flow-core.sh the core. interface Vector { name: string; a: string; @@ -16,17 +14,6 @@ interface Vector { } const vectors = vectorsFile.cases as Vector[]; -// UpdateContext.ts imports @ohos modules, so it is evaluated with its imports -// stripped (as in nativeConfiguration.test.ts); the C1 functions only depend -// on the file's own source. -const harmonyEquivalence = (): ((a: string, b: string) => boolean) => - evalHarmonyModule('UpdateContext.ts', 'nativeConfigsEquivalent;'); - -const implementations: Array<[string, (a: string, b: string) => boolean]> = [ - ['js', nativeConfigsEquivalent], - ['harmony', harmonyEquivalence()], -]; - describe('C1 config equivalence vectors', () => { test('vector file is well formed', () => { expect(vectors.length).toBeGreaterThanOrEqual(30); @@ -35,14 +22,14 @@ describe('C1 config equivalence vectors', () => { expect(vectors.some((v) => !v.equivalent)).toBe(true); }); - for (const [platform, equivalent] of implementations) { - describe(platform, () => { - for (const vector of vectors) { - test(vector.name, () => { - expect(equivalent(vector.a, vector.b)).toBe(vector.equivalent); - expect(equivalent(vector.b, vector.a)).toBe(vector.equivalent); - }); - } + for (const vector of vectors) { + test(vector.name, () => { + expect(nativeConfigsEquivalent(vector.a, vector.b)).toBe( + vector.equivalent + ); + expect(nativeConfigsEquivalent(vector.b, vector.a)).toBe( + vector.equivalent + ); }); } }); diff --git a/src/__tests__/nativeConfiguration.test.ts b/src/__tests__/nativeConfiguration.test.ts index 63b67504..b8c252e7 100644 --- a/src/__tests__/nativeConfiguration.test.ts +++ b/src/__tests__/nativeConfiguration.test.ts @@ -1,7 +1,9 @@ import { describe, expect, test } from 'bun:test'; -import type { PushyConfiguration } from '../../harmony/pushy/src/main/ets/PushyConfiguration'; -import { normalizePushyConfiguration } from '../../harmony/pushy/src/main/ets/PushyConfiguration'; -import { evalHarmonyModule } from './nativeSources'; +import { evalHarmonyModule, harmonyConfigBindings } from './nativeSources'; + +// Option validation (normalizePushyConfiguration) is the shared core's +// NormalizeConfiguration, covered by cpp/update_flow_core/tests/ +// native-config-vectors.json; this file exercises the Harmony store around it. interface ConfigStore { setNativeConfig: (config: string) => Promise; @@ -40,6 +42,7 @@ function storeHarness() { recordJsRound: (config: string) => clearedSignals.push(config), logger: { error() {} }, getErrorMessage: (error: unknown) => String(error), + NativePatchCore: harmonyConfigBindings, } ) as ConfigStore; return { @@ -53,83 +56,6 @@ function storeHarness() { }; } -describe('native configuration normalization', () => { - test('appKey alone supplies Pushy endpoints without automatically activating', () => { - const config = JSON.parse( - normalizePushyConfiguration({ appKey: 'test-app' }) - ); - expect(config.endpoints).toEqual([ - 'https://update.react-native.cn/api', - 'https://update.reactnative.cn/api', - ]); - expect(config.queryUrls).toHaveLength(2); - expect(config.afterDownload).toBe('none'); - expect(config.disabled).toBe(false); - expect(config.packageVersion).toBeUndefined(); - }); - - test('custom endpoints do not inherit public discovery, and are deduplicated', () => { - const options: PushyConfiguration = { - appKey: 'test-app', - endpoints: [ - 'https://updates.example/api/', - 'https://updates.example/api', - ], - afterDownload: 'setNeedUpdate', - }; - const original = JSON.stringify(options); - const config = JSON.parse(normalizePushyConfiguration(options)); - expect(config.endpoints).toEqual(['https://updates.example/api']); - expect(config.queryUrls).toEqual([]); - expect(config.afterDownload).toBe('setNeedUpdate'); - expect(JSON.stringify(options)).toBe(original); - }); - - test('allows explicit discovery URLs and version identity overrides', () => { - const config = JSON.parse( - normalizePushyConfiguration({ - appKey: 'test-app', - endpoints: ['http://localhost:8080/api'], - queryUrls: ['https://updates.example/endpoints.json?v=1'], - packageVersion: '2.0', - rn: '0.77.3', - rnu: 'test-sdk', - disabled: true, - }) - ); - expect(config.packageVersion).toBe('2.0'); - expect(config.disabled).toBe(true); - expect(config.queryUrls[0]).toContain('?v=1'); - }); - - for (const [name, options] of [ - ['missing key', {}], - ['blank key', { appKey: ' ' }], - ['wrong key type', { appKey: 12 }], - ['empty endpoints', { appKey: 'a', endpoints: [] }], - ['null endpoints', { appKey: 'a', endpoints: null }], - ['non-array endpoints', { appKey: 'a', endpoints: 'https://example.com' }], - ['unsafe scheme', { appKey: 'a', endpoints: ['file:///tmp/update'] }], - [ - 'credentials', - { appKey: 'a', endpoints: ['https://user:pass@example.com'] }, - ], - ['relative URL', { appKey: 'a', endpoints: ['/api'] }], - ['base query', { appKey: 'a', endpoints: ['https://example.com/api?x=1'] }], - ['wrong discovery type', { appKey: 'a', queryUrls: [42] }], - ['wrong activation', { appKey: 'a', afterDownload: 'immediate' }], - ['wrong disabled type', { appKey: 'a', disabled: 'false' }], - ['blank package version', { appKey: 'a', packageVersion: '' }], - ['unknown option', { appKey: 'a', endponts: ['https://example.com'] }], - ] as const) { - test(`rejects ${name} before storage is touched`, () => { - expect(() => - normalizePushyConfiguration(options as unknown as PushyConfiguration) - ).toThrow(); - }); - } -}); - describe('actual native configuration store', () => { test('configuration invalidates cache and old in-flight commits, even across A-B-A', async () => { const h = storeHarness(); diff --git a/src/__tests__/nativeHostApi.test.ts b/src/__tests__/nativeHostApi.test.ts index 261c5772..fea3b554 100644 --- a/src/__tests__/nativeHostApi.test.ts +++ b/src/__tests__/nativeHostApi.test.ts @@ -1,11 +1,12 @@ import { describe, expect, test } from 'bun:test'; +import vectorsFile from '../../cpp/update_flow_core/tests/native-config-vectors.json'; import type { BundlePreparationResult } from '../../harmony/pushy/src/main/ets/BundlePreparationResult'; import { BundlePreparationRound, bundlePreparationResult, } from '../../harmony/pushy/src/main/ets/BundlePreparationResult'; import { STATUS_NONE } from '../../harmony/pushy/src/main/ets/Texts'; -import { evalHarmonyModule } from './nativeSources'; +import { evalHarmonyModule, harmonyConfigBindings } from './nativeSources'; // Evaluate the actual Harmony orchestrator in an isolated VM per test. Only // platform imports, HTTP and download IO are substituted; entry points, @@ -70,6 +71,7 @@ function harness() { getErrorMessage: (error: unknown) => String(error), STATUS_NONE, NativePatchCore: { + ...harmonyConfigBindings, getSupportedDiffVersion: () => 2, buildRequestBody: (input: string) => input, handleResponse: (response: string) => response, @@ -98,6 +100,17 @@ function harness() { } describe('native host API orchestration', () => { + test('the config gate stand-in matches the shared core vectors', () => { + for (const vector of vectorsFile.runnable) { + expect({ + name: vector.name, + verdict: JSON.parse( + harmonyConfigBindings.runnableConfig(vector.config) + ), + }).toEqual({ name: vector.name, verdict: vector.expected }); + } + }); + test('requires real launch initialization, without resolving the bundle', async () => { const h = harness(); expect((await h.check()).reason).toBe('not_initialized'); diff --git a/src/__tests__/nativeSources.ts b/src/__tests__/nativeSources.ts index 4515856e..35a8fb0a 100644 --- a/src/__tests__/nativeSources.ts +++ b/src/__tests__/nativeSources.ts @@ -3,6 +3,7 @@ import { readdirSync, readFileSync } from 'node:fs'; import { URL } from 'node:url'; import { runInNewContext } from 'node:vm'; +import { nativeConfigsEquivalent } from '../updateFlowCore'; /** The repository root. */ export const root = new URL('../../', import.meta.url); @@ -53,3 +54,39 @@ export const evalHarmonyModule = ( } return runInNewContext(`${js}\n${expression}`, context); }; + +/** + * Stand-in for the NAPI persisted-config bindings (cpp/update_flow_core + * native_config.h) when a Harmony module is evaluated under bun. The gate is + * a JS restatement of ParseRunnableConfig, held to the shared + * native-config-vectors.json by nativeHostApi.test.ts; equivalence reuses the + * JS client's C1 implementation, which replays the same vectors as the core. + */ +export const harmonyConfigBindings = { + configsEquivalent: (a?: string, b?: string): boolean => + a !== undefined && b !== undefined && nativeConfigsEquivalent(a, b), + runnableConfig: (json?: string): string => { + if (!json) { + return JSON.stringify({ status: 'skipped', reason: 'not_configured' }); + } + const invalid = JSON.stringify({ + status: 'failed', + reason: 'invalid_config', + }); + let config: unknown; + try { + config = JSON.parse(json); + } catch { + return invalid; + } + if (!config || typeof config !== 'object' || Array.isArray(config)) { + return invalid; + } + const { appKey, disabled } = config as Record; + if (disabled) { + return JSON.stringify({ status: 'skipped', reason: 'disabled' }); + } + const trimmed = typeof appKey === 'string' ? appKey.trim() : ''; + return trimmed ? JSON.stringify({ appKey: trimmed }) : invalid; + }, +}; diff --git a/src/__tests__/nativeTextEncoding.test.ts b/src/__tests__/nativeTextEncoding.test.ts index ea70104c..0f5d1eab 100644 --- a/src/__tests__/nativeTextEncoding.test.ts +++ b/src/__tests__/nativeTextEncoding.test.ts @@ -1,5 +1,4 @@ import { describe, expect, test } from 'bun:test'; -import { normalizePushyConfiguration } from '../../harmony/pushy/src/main/ets/PushyConfiguration'; import { QUERY_PATH, revealText } from '../../harmony/pushy/src/main/ets/Texts'; import { decodeNativeText, @@ -76,11 +75,8 @@ describe('native text encoding', () => { expect(offenders).toEqual([]); }); - test('Harmony resolves the path and default addresses at runtime', () => { + test('Harmony resolves the path at runtime', () => { expect(QUERY_PATH).toBe('/checkUpdate/'); - const config = JSON.parse(normalizePushyConfiguration({ appKey: 'k' })); - expect(config.endpoints).toEqual(plainTexts.slice(1, 3)); - expect(config.queryUrls).toEqual(plainTexts.slice(3)); }); // Every string literal in shipped native code, comments excluded: none may From 24593881ce14fc2ddbd6aa1de03d747227fcd0a9 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 16:33:26 +0800 Subject: [PATCH 079/104] feat(flow-core): caller-chosen parse limits and linear object parsing flowjson::Parse gains a ParseLimits overload (input bytes, node count) for documents bounded by something other than a checkUpdate response; the patch core's __diff.json parser needs its 16 MB manifest cap. Objects with more than 16 members are deduplicated through a hash index while parsing instead of Value::Set's linear scan, so a manifest's copies map of thousands of members no longer parses in quadratic time. Duplicate keys keep the first position and the last value as before. Co-Authored-By: Claude Opus 5.5 --- cpp/update_flow_core/flow_json.cpp | 56 ++++++++++++++++--- cpp/update_flow_core/flow_json.h | 15 +++++ .../tests/update_flow_core_test.cpp | 56 +++++++++++++++++++ 3 files changed, 119 insertions(+), 8 deletions(-) diff --git a/cpp/update_flow_core/flow_json.cpp b/cpp/update_flow_core/flow_json.cpp index edcf2797..7cf40568 100644 --- a/cpp/update_flow_core/flow_json.cpp +++ b/cpp/update_flow_core/flow_json.cpp @@ -3,6 +3,7 @@ #include #include #include +#include namespace flowjson { @@ -266,6 +267,8 @@ namespace { // stack limit (size and node count are capped in flow_json.h). Real // responses nest ~4 levels. constexpr int kMaxDepth = 64; +// Objects with more members than this get a hash index while parsing. +constexpr size_t kIndexedObjectMembers = 16; // Locale-independent decimal -> double. strtod() honours LC_NUMERIC, so a // host process that called setlocale() into a comma-decimal locale would stop @@ -361,7 +364,8 @@ double DecimalToDouble(const std::string& token) { class Parser { public: - Parser(const std::string& text) : text_(text) {} + Parser(const std::string& text, size_t max_nodes) + : text_(text), max_nodes_(max_nodes) {} Value Run(bool* ok) { Value v = ParseValue(); @@ -410,7 +414,7 @@ class Parser { ok_ = false; return Value::Undefined(); } - if (++nodes_ > kMaxNodes) { + if (++nodes_ > max_nodes_) { ok_ = false; return Value::Undefined(); } @@ -443,20 +447,51 @@ class Parser { } } + // Duplicate keys keep the first position and the last value (Value::Set + // semantics). Large objects (a patch manifest's copies map has thousands + // of members) look keys up through a hash index instead of Set's linear + // scan, which would make parsing quadratic in the member count. Value ParseObject() { - Value obj = Value::Object(); Consume('{'); SkipWs(); if (pos_ < text_.size() && text_[pos_] == '}') { pos_++; - return obj; + return Value::Object(); } + Members members; + std::unordered_map index; while (ok_) { SkipWs(); std::string key = ParseString(); SkipWs(); Consume(':'); - obj.Set(key, ParseValue()); + Value value = ParseValue(); + if (members.size() == kIndexedObjectMembers && index.empty()) { + for (size_t i = 0; i < members.size(); i++) { + index.emplace(members[i].first, i); + } + } + size_t existing = members.size(); + if (index.empty()) { + for (size_t i = 0; i < members.size(); i++) { + if (members[i].first == key) { + existing = i; + break; + } + } + } else { + auto found = index.find(key); + if (found != index.end()) { + existing = found->second; + } else { + index.emplace(key, members.size()); + } + } + if (existing < members.size()) { + members[existing].second = std::move(value); + } else { + members.emplace_back(std::move(key), std::move(value)); + } SkipWs(); if (pos_ < text_.size() && text_[pos_] == ',') { pos_++; @@ -465,7 +500,7 @@ class Parser { Consume('}'); break; } - return obj; + return Value::Object(std::move(members)); } Value ParseArray() { @@ -669,6 +704,7 @@ class Parser { } const std::string& text_; + const size_t max_nodes_; size_t pos_ = 0; int depth_ = 0; size_t nodes_ = 0; @@ -678,11 +714,15 @@ class Parser { } // namespace Value Parse(const std::string& text, bool* ok) { - if (text.size() > kMaxInputBytes) { + return Parse(text, ParseLimits(), ok); +} + +Value Parse(const std::string& text, const ParseLimits& limits, bool* ok) { + if (text.size() > limits.max_input_bytes) { *ok = false; return Value::Undefined(); } - Parser parser(text); + Parser parser(text, limits.max_nodes); return parser.Run(ok); } diff --git a/cpp/update_flow_core/flow_json.h b/cpp/update_flow_core/flow_json.h index d7886250..44d41263 100644 --- a/cpp/update_flow_core/flow_json.h +++ b/cpp/update_flow_core/flow_json.h @@ -51,6 +51,12 @@ class Value { } static Value Array() { return Value(Kind::Array); } static Value Object() { return Value(Kind::Object); } + // The members must have distinct keys (Set's invariant). + static Value Object(Members members) { + Value v(Kind::Object); + v.members_ = std::move(members); + return v; + } Kind kind() const { return kind_; } bool IsUndefined() const { return kind_ == Kind::Undefined; } @@ -122,6 +128,15 @@ constexpr size_t kMaxNodes = 65536; // Lone surrogate escapes decode to U+FFFD so the output is always valid UTF-8. Value Parse(const std::string& text, bool* ok); +// Same parser under caller-chosen caps, for documents whose size is bounded +// by something other than a checkUpdate response (the patch core's +// __diff.json manifest, cpp/patch_core/patch_manifest.h). +struct ParseLimits { + size_t max_input_bytes = kMaxInputBytes; + size_t max_nodes = kMaxNodes; +}; +Value Parse(const std::string& text, const ParseLimits& limits, bool* ok); + } // namespace flowjson #pragma GCC visibility pop diff --git a/cpp/update_flow_core/tests/update_flow_core_test.cpp b/cpp/update_flow_core/tests/update_flow_core_test.cpp index 99566a10..fefcc220 100644 --- a/cpp/update_flow_core/tests/update_flow_core_test.cpp +++ b/cpp/update_flow_core/tests/update_flow_core_test.cpp @@ -174,6 +174,29 @@ int RunParserRobustness() { } } + // Same duplicate-key rule once an object is large enough to be parsed + // through the hash index: k0..k19, then k0 and k17 again. + { + std::string input = "{"; + std::string expected = "{"; + for (int i = 0; i < 20; i++) { + const std::string key = "\"k" + std::to_string(i) + "\":"; + input += key + std::to_string(i) + ","; + expected += key + + (i == 0 ? std::string("100") : i == 17 ? std::string("117") + : std::to_string(i)) + + (i == 19 ? "}" : ","); + } + input += "\"k0\":100,\"k17\":117}"; + bool ok = false; + std::string actual = Stringify(Parse(input, &ok)); + if (!ok || actual != expected) { + std::fprintf(stderr, "robustness: large duplicate-key object -> %s\n", + actual.c_str()); + failures++; + } + } + return failures; } @@ -299,6 +322,39 @@ int RunInputCaps() { } } } + { + // Caller-chosen caps (ParseLimits) replace both defaults, in either + // direction. + flowjson::ParseLimits limits; + limits.max_input_bytes = 8; + limits.max_nodes = 3; + bool ok = false; + Parse("[1,2]", limits, &ok); + if (!ok) { + std::fprintf(stderr, "caps: 3 nodes within an 8-byte cap must parse\n"); + failures++; + } + ok = true; + Parse("[1,2,3]", limits, &ok); + if (ok) { + std::fprintf(stderr, "caps: a 4th node over max_nodes must be rejected\n"); + failures++; + } + ok = true; + Parse("\"abcdefg\"", limits, &ok); + if (ok) { + std::fprintf(stderr, "caps: 9 bytes over max_input_bytes must be rejected\n"); + failures++; + } + limits.max_input_bytes = flowjson::kMaxInputBytes + 16; + limits.max_nodes = 1; + ok = false; + Parse("\"" + std::string(flowjson::kMaxInputBytes, 'a') + "\"", limits, &ok); + if (!ok) { + std::fprintf(stderr, "caps: a raised max_input_bytes must admit the document\n"); + failures++; + } + } { // Through the pipeline an oversized body is an invalid response. std::string body = "{\"update\":true,\"hash\":\"h\",\"pad\":\"" + From 52b7d84f791757ddfef53083962ec368f66b5f84 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 16:34:02 +0800 Subject: [PATCH 080/104] feat(patch-core): one __diff.json parser and an ApplyArchivePatch entry point cpp/patch_core/patch_manifest.h: ParsePatchManifest(text, options) is the single parser of a patch archive's manifest, taking the shape pushy-go and the CLI emit (copies to->from, copiesCrc per target, deletes object, hbcTransform per bundle patch entry) under these rules, where the three platform parsers used to differ: - deletes: an object's keys or an array of strings (Android ignored arrays); any other type, or a non-string element, is an error; - copiesCrc: keyed by copy target, an integer in [0, 2^32) or null. Android keyed it by source with the last target winning; now two targets of one source with different CRCs fail the manifest, and a string/fractional/oversized/boolean CRC fails instead of being coerced or skipping the content check; - types: copies must be an object of strings (Harmony coerced with String(), Android threw, iOS rejected); a null or empty source means the target's own path on every platform (Harmony ppk kept ""); - copy_source_prefix (HarmonyOS rawfiles) is stripped only at the start (Harmony replaced its first occurrence anywhere); - hbcTransform and its bundle entry must be objects when present; the platforms silently dropped other types and applied the patch untransformed. Every path still goes through ValidateManifest. The text is capped at kMaxManifestBytes and 4 * kMaxEntries values. PatchManifest now carries bundle_hbc_transform_meta (moved from FileSourcePatchOptions). archive_patch gains PlanArchivePatch (entry check, parse, merge plan) and ApplyArchivePatch (plan + apply; with host_copies_resources it only rebuilds the bundle and hands the copies back as CopyGroups, which now carry the source's CRC). BuildCopyGroups is linear in the number of copies. The test replays tests/patch-manifest-vectors.json (server shapes, the drift cases, hostile paths) and covers the caps, the plan and both apply modes, including a transform-domain patch driven by the manifest; FUZZ=1 adds a libFuzzer target for the parser. Co-Authored-By: Claude Opus 5.5 --- cpp/android_jni/update_core_android.cpp | 2 +- cpp/patch_core/archive_patch_core.cpp | 108 +++++- cpp/patch_core/archive_patch_core.h | 49 +++ cpp/patch_core/patch_core.cpp | 4 +- cpp/patch_core/patch_core.h | 12 +- cpp/patch_core/patch_manifest.cpp | 201 ++++++++++ cpp/patch_core/patch_manifest.h | 74 ++++ cpp/patch_core/tests/fuzz_patch_manifest.cpp | 58 +++ .../tests/patch-manifest-vectors.json | 327 +++++++++++++++++ cpp/patch_core/tests/patch_core_test.cpp | 342 +++++++++++++++++- harmony/pushy/src/main/cpp/pushy.cpp | 2 +- ios/RCTPushy/RCTPushyInstaller.mm | 2 +- scripts/test-patch-core.sh | 39 +- 13 files changed, 1190 insertions(+), 30 deletions(-) create mode 100644 cpp/patch_core/patch_manifest.cpp create mode 100644 cpp/patch_core/patch_manifest.h create mode 100644 cpp/patch_core/tests/fuzz_patch_manifest.cpp create mode 100644 cpp/patch_core/tests/patch-manifest-vectors.json diff --git a/cpp/android_jni/update_core_android.cpp b/cpp/android_jni/update_core_android.cpp index f637cb0e..b8e5cc28 100644 --- a/cpp/android_jni/update_core_android.cpp +++ b/cpp/android_jni/update_core_android.cpp @@ -503,7 +503,7 @@ void pushy::jni_natives::ApplyDeltaFromSource( options.bundle_output_path = JStringToString(env, bundle_output_path); options.merge_source_subdir = JStringToString(env, merge_source_subdir); options.enable_merge = enable_merge == JNI_TRUE; - options.bundle_hbc_transform_meta = JStringToString(env, hbc_transform_meta); + options.manifest.bundle_hbc_transform_meta = JStringToString(env, hbc_transform_meta); const pushy::delta::Status status = pushy::delta::ApplyPatchFromFileSource(options); diff --git a/cpp/patch_core/archive_patch_core.cpp b/cpp/patch_core/archive_patch_core.cpp index 92712b35..f904b853 100644 --- a/cpp/patch_core/archive_patch_core.cpp +++ b/cpp/patch_core/archive_patch_core.cpp @@ -1,5 +1,8 @@ #include "archive_patch_core.h" +#include +#include + #include "obscured_text.h" namespace pushy { @@ -100,24 +103,105 @@ delta::Status BuildCopyGroups( return manifest_status; } - out_groups->clear(); + // Groups in order of first appearance; the index keeps this linear in the + // number of copies (a package diff copies thousands of resources). + std::vector groups; + std::unordered_map group_by_source; for (const delta::CopyOperation& copy : manifest.copies) { - bool appended = false; - for (CopyGroup& group : *out_groups) { - if (group.from == copy.from) { - group.to_paths.push_back(copy.to); - appended = true; - break; - } - } - if (!appended) { + const auto inserted = group_by_source.emplace(copy.from, groups.size()); + if (inserted.second) { CopyGroup group; group.from = copy.from; - group.to_paths.push_back(copy.to); - out_groups->push_back(group); + groups.push_back(std::move(group)); + } + CopyGroup& group = groups[inserted.first->second]; + group.to_paths.push_back(copy.to); + if (copy.has_expected_crc) { + group.has_expected_crc = true; + group.expected_crc = copy.expected_crc; } } + *out_groups = std::move(groups); + + return delta::Status::Ok(); +} +delta::Status PlanArchivePatch( + const ArchivePatchInput& input, + ArchivePatchPlan* out_plan) { + if (out_plan == nullptr) { + return delta::Status::Error("Archive plan output is required"); + } + if (input.type == ArchivePatchType::kFull) { + return BuildArchivePatchPlan( + input.type, delta::PatchManifest(), input.entry_names, out_plan, + input.bundle_patch_entry_name); + } + // Missing entries first: an archive without __diff.json has no text to + // parse, and the entry error is the one worth reporting. + if (!HasEntry(input.entry_names, kManifestEntryName)) { + return delta::Status::Error("diff.json not found"); + } + if (!HasEntry(input.entry_names, input.bundle_patch_entry_name)) { + return delta::Status::Error("bundle delta not found"); + } + delta::PatchManifestOptions options; + options.bundle_patch_entry_name = input.bundle_patch_entry_name; + options.copy_source_prefix = input.copy_source_prefix; + delta::PatchManifest manifest; + delta::Status status = + delta::ParsePatchManifest(input.manifest_json, options, &manifest); + if (!status.ok) { + return status; + } + return BuildArchivePatchPlan( + input.type, manifest, input.entry_names, out_plan, + input.bundle_patch_entry_name); +} + +delta::Status ApplyArchivePatch( + const ArchivePatchRequest& request, + std::vector* out_host_copies, + const delta::BundleRebuilder& bundle_patcher) { + if (request.archive.type == ArchivePatchType::kFull) { + return delta::Status::Error("A full package has no delta to apply"); + } + if (request.host_copies_resources && out_host_copies == nullptr) { + return delta::Status::Error("Host copy groups output is required"); + } + if (out_host_copies != nullptr) { + out_host_copies->clear(); + } + ArchivePatchPlan plan; + delta::Status status = PlanArchivePatch(request.archive, &plan); + if (!status.ok) { + return status; + } + delta::FileSourcePatchOptions options; + status = BuildFileSourcePatchOptions( + plan, request.source_root, request.target_root, + request.origin_bundle_path, request.bundle_patch_path, + request.bundle_output_path, &options); + if (!status.ok) { + return status; + } + std::vector host_copies; + if (request.host_copies_resources) { + status = BuildCopyGroups(plan.manifest, &host_copies); + if (!status.ok) { + return status; + } + options.manifest.copies.clear(); + options.manifest.deletes.clear(); + options.enable_merge = false; + } + status = delta::ApplyPatchFromFileSource(options, bundle_patcher); + if (!status.ok) { + return status; + } + if (out_host_copies != nullptr) { + *out_host_copies = std::move(host_copies); + } return delta::Status::Ok(); } diff --git a/cpp/patch_core/archive_patch_core.h b/cpp/patch_core/archive_patch_core.h index 646070af..ac852cec 100644 --- a/cpp/patch_core/archive_patch_core.h +++ b/cpp/patch_core/archive_patch_core.h @@ -1,9 +1,11 @@ #pragma once +#include #include #include #include "patch_core.h" +#include "patch_manifest.h" // Keep the core internal to whichever binary links it (see podspec Core). #pragma GCC visibility push(hidden) @@ -22,9 +24,13 @@ enum class EntryAction { kExtract = 1, }; +// Every copy of one source. The manifest parser guarantees that copies of +// one source never declare different CRCs, so a group has at most one. struct CopyGroup { std::string from; std::vector to_paths; + bool has_expected_crc = false; + uint32_t expected_crc = 0; }; struct ArchivePatchPlan { @@ -58,6 +64,49 @@ delta::Status BuildCopyGroups( const delta::PatchManifest& manifest, std::vector* out_groups); +// What a staged patch archive declared: its entry names and the text of its +// __diff.json (empty when the archive has none). +struct ArchivePatchInput { + ArchivePatchType type = ArchivePatchType::kFull; + std::vector entry_names; + std::string manifest_json; + std::string bundle_patch_entry_name = DefaultBundleDeltaEntryName(); + // See PatchManifestOptions::copy_source_prefix (HarmonyOS rawfiles). + std::string copy_source_prefix; +}; + +// The patch types need the manifest and the bundle patch among the entries; +// the manifest is then parsed (ParsePatchManifest) and the merge planned. +// A full package plans nothing and ignores manifest_json. +delta::Status PlanArchivePatch( + const ArchivePatchInput& input, + ArchivePatchPlan* out_plan); + +struct ArchivePatchRequest { + ArchivePatchInput archive; + std::string source_root; + std::string target_root; + std::string origin_bundle_path; + std::string bundle_patch_path; + std::string bundle_output_path; + // The package's resources are not files under source_root (Android APK + // entries, HarmonyOS rawfiles): only the bundle is rebuilt here, there is + // nothing to merge, and the host performs the manifest's copies itself + // from the groups handed back. + bool host_copies_resources = false; +}; + +// PlanArchivePatch + ApplyPatchFromFileSource: the whole native half of a +// patch install, driven by the manifest text so the platforms never parse +// __diff.json themselves. With host_copies_resources, *out_host_copies (which +// must then be non-null) receives the grouped copies the host still owes; +// otherwise it is left empty. Full packages are rejected. +delta::Status ApplyArchivePatch( + const ArchivePatchRequest& request, + std::vector* out_host_copies, + const delta::BundleRebuilder& bundle_patcher = + delta::DefaultBundleRebuilder()); + delta::Status BuildFileSourcePatchOptions( const ArchivePatchPlan& plan, const std::string& source_root, diff --git a/cpp/patch_core/patch_core.cpp b/cpp/patch_core/patch_core.cpp index c81ecd8f..70b8ed76 100644 --- a/cpp/patch_core/patch_core.cpp +++ b/cpp/patch_core/patch_core.cpp @@ -603,7 +603,7 @@ Status ApplyBundlePatchWithHbcTransform( const FileSourcePatchOptions& options, const BundleRebuilder& bundle_patcher) { hbc::HbcTransformMeta meta; - if (!hbc::ParseHbcTransformMeta(options.bundle_hbc_transform_meta, &meta)) { + if (!hbc::ParseHbcTransformMeta(options.manifest.bundle_hbc_transform_meta, &meta)) { return Status::Error("Invalid hbcTransform metadata"); } if (meta.v != hbc::kHbcTransformSupportedVersion) { @@ -677,7 +677,7 @@ Status ApplyPatchFromFileSource( } Status bundle_status = - options.bundle_hbc_transform_meta.empty() + options.manifest.bundle_hbc_transform_meta.empty() ? bundle_patcher.Apply( options.origin_bundle_path, options.bundle_patch_path, diff --git a/cpp/patch_core/patch_core.h b/cpp/patch_core/patch_core.h index a410cf66..6558cd8c 100644 --- a/cpp/patch_core/patch_core.h +++ b/cpp/patch_core/patch_core.h @@ -33,9 +33,16 @@ struct CopyOperation { uint32_t expected_crc = 0; }; +// What a patch archive's __diff.json declares (ParsePatchManifest in +// patch_manifest.h is the one parser every platform uses). struct PatchManifest { std::vector copies; std::vector deletes; + // __diff.json 中该 bundle patch 条目的 hbcTransform 元数据(JSON 文本)。 + // 非空时 patch 走变换域:T(origin) → hpatch → T⁻¹。元数据不可解析或 + // 变换规范版本不受支持时返回错误(调用方回退整包),绝不忽略元数据 + // 直接应用——那会产出损坏的 bundle。 + std::string bundle_hbc_transform_meta; }; struct FileSourcePatchOptions { @@ -47,11 +54,6 @@ struct FileSourcePatchOptions { std::string bundle_output_path; std::string merge_source_subdir; bool enable_merge = true; - // __diff.json 中该 bundle patch 条目的 hbcTransform 元数据(原始 JSON)。 - // 非空时 patch 走变换域:T(origin) → hpatch → T⁻¹。元数据不可解析或 - // 变换规范版本不受支持时返回错误(调用方回退整包),绝不忽略元数据 - // 直接应用——那会产出损坏的 bundle。 - std::string bundle_hbc_transform_meta; }; class BundleRebuilder { diff --git a/cpp/patch_core/patch_manifest.cpp b/cpp/patch_core/patch_manifest.cpp new file mode 100644 index 00000000..bb574253 --- /dev/null +++ b/cpp/patch_core/patch_manifest.cpp @@ -0,0 +1,201 @@ +#include "patch_manifest.h" + +#include +#include +#include +#include + +// Relative like every other cross-core include (see patch_core.cpp's hpatch.h +// include): use_frameworks! flattens the pod's headers. +#include "../update_flow_core/flow_json.h" + +namespace pushy { +namespace delta { +namespace { + +constexpr const char* kErrorPrefix = "delta manifest: "; + +Status ManifestError(const std::string& message) { + return Status::Error(kErrorPrefix + message); +} + +// null counts as absent everywhere (JSON writers emit it for empty maps). +bool IsAbsent(const flowjson::Value& value) { + return value.IsUndefined() || + value.kind() == flowjson::Value::Kind::Null; +} + +// An integer in [0, 2^32): the only CRC value a client can compare. +bool ReadCrc32(const flowjson::Value& value, uint32_t* out) { + if (!value.IsNumber()) { + return false; + } + const double number = value.AsNumber(); + if (!std::isfinite(number) || number < 0 || number > 4294967295.0 || + std::floor(number) != number) { + return false; + } + *out = static_cast(number); + return true; +} + +Status ParseCopies( + const flowjson::Value& copies, + const flowjson::Value& copies_crc, + const PatchManifestOptions& options, + PatchManifest* manifest) { + if (IsAbsent(copies)) { + return Status::Ok(); + } + if (!copies.IsObject()) { + return ManifestError("copies is not an object"); + } + if (!IsAbsent(copies_crc) && !copies_crc.IsObject()) { + return ManifestError("copiesCrc is not an object"); + } + // Target -> declared CRC value; one pass, not a member scan per copy. + std::unordered_map crc_by_target; + if (copies_crc.IsObject()) { + crc_by_target.reserve(copies_crc.members().size()); + for (const auto& member : copies_crc.members()) { + crc_by_target.emplace(member.first, &member.second); + } + } + const std::string& prefix = options.copy_source_prefix; + // Source -> the CRC some copy of it declared, to catch contradictions. + std::unordered_map crc_by_source; + manifest->copies.reserve(copies.members().size()); + for (const auto& member : copies.members()) { + const std::string& to = member.first; + const flowjson::Value& from_value = member.second; + std::string from; + if (from_value.IsString()) { + from = from_value.AsString(); + } else if (!IsAbsent(from_value)) { + return ManifestError("copies entry is not a string: " + to); + } + if (!prefix.empty() && from.compare(0, prefix.size(), prefix) == 0) { + from.erase(0, prefix.size()); + } + if (from.empty()) { + from = to; + } + + CopyOperation copy; + copy.from = std::move(from); + copy.to = to; + const auto declared = crc_by_target.find(to); + if (declared != crc_by_target.end()) { + const flowjson::Value& crc = *declared->second; + if (!IsAbsent(crc)) { + if (!ReadCrc32(crc, ©.expected_crc)) { + return ManifestError("copiesCrc entry is not a CRC32: " + to); + } + copy.has_expected_crc = true; + const auto inserted = + crc_by_source.emplace(copy.from, copy.expected_crc); + if (!inserted.second && + inserted.first->second != copy.expected_crc) { + return ManifestError("conflicting copiesCrc for source " + copy.from); + } + } + } + manifest->copies.push_back(std::move(copy)); + } + return Status::Ok(); +} + +Status ParseDeletes(const flowjson::Value& deletes, PatchManifest* manifest) { + if (IsAbsent(deletes)) { + return Status::Ok(); + } + if (deletes.IsObject()) { + manifest->deletes.reserve(deletes.members().size()); + for (const auto& member : deletes.members()) { + manifest->deletes.push_back(member.first); + } + return Status::Ok(); + } + if (deletes.IsArray()) { + manifest->deletes.reserve(deletes.Size()); + for (const flowjson::Value& path : deletes.elements()) { + if (!path.IsString()) { + return ManifestError("deletes entry is not a string"); + } + manifest->deletes.push_back(path.AsString()); + } + return Status::Ok(); + } + return ManifestError("deletes is not an object or array"); +} + +Status ParseHbcTransform( + const flowjson::Value& hbc_transform, + const PatchManifestOptions& options, + PatchManifest* manifest) { + if (IsAbsent(hbc_transform)) { + return Status::Ok(); + } + if (!hbc_transform.IsObject()) { + return ManifestError("hbcTransform is not an object"); + } + const flowjson::Value& meta = + hbc_transform.Get(options.bundle_patch_entry_name); + if (IsAbsent(meta)) { + return Status::Ok(); + } + if (!meta.IsObject()) { + return ManifestError( + "hbcTransform entry is not an object: " + + options.bundle_patch_entry_name); + } + manifest->bundle_hbc_transform_meta = flowjson::Stringify(meta); + return Status::Ok(); +} + +} // namespace + +Status ParsePatchManifest( + const std::string& json_text, + const PatchManifestOptions& options, + PatchManifest* out) { + if (out == nullptr) { + return Status::Error("Manifest output is required"); + } + flowjson::ParseLimits limits; + limits.max_input_bytes = + static_cast(archive_limits::kMaxManifestBytes); + limits.max_nodes = kMaxManifestNodes; + bool ok = false; + const flowjson::Value root = flowjson::Parse(json_text, limits, &ok); + if (!ok) { + return ManifestError("invalid JSON or over the size limits"); + } + if (!root.IsObject()) { + return ManifestError("not a JSON object"); + } + + PatchManifest manifest; + Status status = + ParseCopies(root.Get("copies"), root.Get("copiesCrc"), options, &manifest); + if (!status) { + return status; + } + status = ParseDeletes(root.Get("deletes"), &manifest); + if (!status) { + return status; + } + status = ParseHbcTransform(root.Get("hbcTransform"), options, &manifest); + if (!status) { + return status; + } + status = ValidateManifest(manifest); + if (!status) { + return status; + } + *out = std::move(manifest); + return Status::Ok(); +} + +} // namespace delta +} // namespace pushy diff --git a/cpp/patch_core/patch_manifest.h b/cpp/patch_core/patch_manifest.h new file mode 100644 index 00000000..012d1f4d --- /dev/null +++ b/cpp/patch_core/patch_manifest.h @@ -0,0 +1,74 @@ +#pragma once + +#include +#include + +#include "archive_limits.h" +#include "patch_core.h" + +// Keep the core internal to whichever binary links it (see podspec Core). +#pragma GCC visibility push(hidden) + +namespace pushy { +namespace delta { + +// __diff.json, the manifest inside every patch archive, as the generators +// emit it (pushy-go internal/taskexecutor/bundle_diff.go and +// react-native-update-cli src/diff.ts): +// +// {"copies": {"": "" | "", ...}, +// "copiesCrc": {"": , ...}, package diffs only +// "deletes": {"": 1, ...}, ppk diffs only +// "hbcTransform": {"": {...}}} hdiffv2 track only +// +// The one parser of that file on Android, iOS and HarmonyOS. The rules are +// the most permissive form that is still safe; anything else fails the +// patch, and the JS strategy chain falls back to the full package: +// - the text is a JSON object (at most archive_limits::kMaxManifestBytes, +// at most kMaxManifestNodes values); unknown members are ignored, and a +// member that is null counts as absent; +// - copies: an object of target -> source strings. An empty (or null) +// source means "same path as the target". Order follows the document; +// - copiesCrc: an object keyed by copy *target* (not source). Each value is +// an integer in [0, 2^32) or null; any other value is an error (a CRC the +// client cannot honour must not silently skip the content check). Keys +// naming no copy are ignored. Two copies of one source that declare +// different CRCs are a self-contradictory manifest and an error; +// - deletes: an object (its keys) or an array of strings; +// - hbcTransform: an object; its member named after the bundle patch entry, +// when present, must be an object and is handed on re-serialized +// (PatchManifest::bundle_hbc_transform_meta). Non-object metadata is an +// error, never ignored: applying a transform-domain patch without the +// transform produces a corrupt bundle; +// - every path passes ValidateManifest. +// Values of the wrong type (a numeric source, a non-string delete, copies as +// an array, ...) are errors on every platform. +struct PatchManifestOptions { + // The archive entry whose hbcTransform metadata is extracted + // (archive_patch::DefaultBundleDeltaEntryName() unless the platform names + // its bundle differently, e.g. HarmonyOS). + std::string bundle_patch_entry_name; + // Stripped once from the start of every copy source that begins with it, + // before the empty-source rule applies. HarmonyOS package diffs name the + // origin entries by their HAP path ("resources/rawfile/assets/a.png"), + // which the resource manager reads back as "assets/a.png". + std::string copy_source_prefix; +}; + +// A package holds at most archive_limits::kMaxEntries entries, and the +// generators emit at most one copy source, one CRC and one delete per entry +// (plus a few hundred values of hbcTransform metadata). The cap bounds the +// parsed tree of a hostile 16 MB manifest to about ten MB of heap. +constexpr size_t kMaxManifestNodes = + 4 * static_cast(archive_limits::kMaxEntries); + +// Fails without touching *out. +Status ParsePatchManifest( + const std::string& json_text, + const PatchManifestOptions& options, + PatchManifest* out); + +} // namespace delta +} // namespace pushy + +#pragma GCC visibility pop diff --git a/cpp/patch_core/tests/fuzz_patch_manifest.cpp b/cpp/patch_core/tests/fuzz_patch_manifest.cpp new file mode 100644 index 00000000..b1d7cd5e --- /dev/null +++ b/cpp/patch_core/tests/fuzz_patch_manifest.cpp @@ -0,0 +1,58 @@ +// libFuzzer harness for the __diff.json parser (patch_manifest.h): the whole +// manifest is untrusted archive content. Build and run with +// FUZZ=1 ./scripts/test-patch-core.sh +// (clang -fsanitize=fuzzer,address,undefined). Any crash, sanitizer report or +// abort is a finding. The first input byte picks the options so both the +// plain and the HarmonyOS (rawfile prefix) forms are covered. +#include +#include +#include +#include +#include +#include + +#include "../archive_patch_core.h" +#include "../hbc_transform_wire.h" +#include "../patch_manifest.h" + +extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { + if (size == 0) { + return 0; + } + pushy::delta::PatchManifestOptions options; + options.bundle_patch_entry_name = + (data[0] & 1) ? "bundle.harmony.js.patch" : "index.bundlejs.patch"; + if (data[0] & 2) { + options.copy_source_prefix = "resources/rawfile/"; + } + const std::string json(reinterpret_cast(data + 1), size - 1); + + pushy::delta::PatchManifest manifest; + if (!pushy::delta::ParsePatchManifest(json, options, &manifest).ok) { + return 0; + } + // Whatever parses is safe to apply and self-consistent. + if (!pushy::delta::ValidateManifest(manifest).ok) { + std::abort(); + } + std::unordered_map crc_by_source; + for (const pushy::delta::CopyOperation& copy : manifest.copies) { + if (!copy.has_expected_crc) { + continue; + } + const auto inserted = crc_by_source.emplace(copy.from, copy.expected_crc); + if (!inserted.second && inserted.first->second != copy.expected_crc) { + std::abort(); + } + } + std::vector groups; + if (!pushy::archive_patch::BuildCopyGroups(manifest, &groups).ok) { + std::abort(); + } + // The re-serialized metadata goes on to the strict wire parser. + if (!manifest.bundle_hbc_transform_meta.empty()) { + pushy::hbc::HbcTransformMeta meta; + pushy::hbc::ParseHbcTransformMeta(manifest.bundle_hbc_transform_meta, &meta); + } + return 0; +} diff --git a/cpp/patch_core/tests/patch-manifest-vectors.json b/cpp/patch_core/tests/patch-manifest-vectors.json new file mode 100644 index 00000000..6694cfea --- /dev/null +++ b/cpp/patch_core/tests/patch-manifest-vectors.json @@ -0,0 +1,327 @@ +{ + "description": "__diff.json parsing (cpp/patch_core/patch_manifest.h ParsePatchManifest), shared by Android, iOS and HarmonyOS. Each vector feeds `manifest` (serialized) or the raw `text`, with `options` (bundlePatchEntryName defaults to index.bundlejs.patch, copySourcePrefix to none). A vector expects either `error` (a substring of the message) or the parsed `copies` ([from, to, crc-or-null] in document order), `deletes` and `hbcTransformMeta` (all three default to empty). `was` records how the platforms disagreed before the parser was shared.", + "vectors": [ + { + "name": "server ppk diff: moved copies and deletes object", + "manifest": { + "copies": { "assets/b.png": "assets/a.png", "assets/c/d.png": "assets/a.png" }, + "deletes": { "assets/old.png": 1, "assets/gone/": 1 } + }, + "copies": [ + ["assets/a.png", "assets/b.png", null], + ["assets/a.png", "assets/c/d.png", null] + ], + "deletes": ["assets/old.png", "assets/gone/"] + }, + { + "name": "server package diff: same-path and moved copies with per-target CRCs", + "manifest": { + "copies": { "assets/a.png": "", "assets/b.png": "assets/old/b.png", "assets/c.png": "" }, + "copiesCrc": { "assets/a.png": 3421780262, "assets/b.png": 0, "assets/c.png": 4294967295 } + }, + "copies": [ + ["assets/a.png", "assets/a.png", 3421780262], + ["assets/old/b.png", "assets/b.png", 0], + ["assets/c.png", "assets/c.png", 4294967295] + ] + }, + { + "name": "empty object", + "manifest": {} + }, + { + "name": "null members count as absent", + "manifest": { "copies": null, "copiesCrc": null, "deletes": null, "hbcTransform": null } + }, + { + "name": "unknown members are ignored", + "manifest": { "copies": { "a.png": "b.png" }, "version": 3, "extra": [1, { "x": null }] }, + "copies": [["b.png", "a.png", null]] + }, + { + "name": "deletes as an array of paths", + "was": "Android ignored an array (deleted nothing); iOS and HarmonyOS accepted it", + "manifest": { "deletes": ["assets/old.png", "assets/removed.txt"] }, + "deletes": ["assets/old.png", "assets/removed.txt"] + }, + { + "name": "deletes array with a non-string entry", + "was": "HarmonyOS coerced it with String(); iOS rejected it", + "manifest": { "deletes": ["assets/old.png", 7] }, + "error": "deletes entry is not a string" + }, + { + "name": "deletes of another type", + "was": "Android and HarmonyOS ignored it; iOS rejected it", + "manifest": { "deletes": "assets/old.png" }, + "error": "deletes is not an object or array" + }, + { + "name": "copies as an array", + "was": "HarmonyOS read the indices as targets; Android ignored it; iOS rejected it", + "manifest": { "copies": ["assets/a.png"] }, + "error": "copies is not an object" + }, + { + "name": "numeric copy source", + "was": "HarmonyOS coerced it with String(); Android threw JSONException; iOS rejected it", + "manifest": { "copies": { "assets/a.png": 5 } }, + "error": "copies entry is not a string: assets/a.png" + }, + { + "name": "null copy source means the same path", + "was": "HarmonyOS kept an empty source (then failed path validation); Android read the string \"null\"; iOS rejected it", + "manifest": { "copies": { "assets/a.png": null } }, + "copies": [["assets/a.png", "assets/a.png", null]] + }, + { + "name": "empty copy source means the same path without a prefix option too", + "was": "HarmonyOS ppk patches kept the empty source", + "manifest": { "copies": { "assets/a.png": "" } }, + "copies": [["assets/a.png", "assets/a.png", null]] + }, + { + "name": "copiesCrc is keyed by target: two targets of one source share its CRC", + "was": "Android keyed copiesCrc by source, last target winning", + "manifest": { + "copies": { "assets/x.png": "assets/a.png", "assets/y.png": "assets/a.png" }, + "copiesCrc": { "assets/x.png": 123, "assets/y.png": 123 } + }, + "copies": [ + ["assets/a.png", "assets/x.png", 123], + ["assets/a.png", "assets/y.png", 123] + ] + }, + { + "name": "copiesCrc on only some targets of one source", + "manifest": { + "copies": { "assets/x.png": "assets/a.png", "assets/y.png": "assets/a.png" }, + "copiesCrc": { "assets/y.png": 9 } + }, + "copies": [ + ["assets/a.png", "assets/x.png", null], + ["assets/a.png", "assets/y.png", 9] + ] + }, + { + "name": "copiesCrc conflicting for one source", + "was": "Android kept the last CRC per source; iOS and HarmonyOS checked each target", + "manifest": { + "copies": { "assets/x.png": "assets/a.png", "assets/y.png": "assets/a.png" }, + "copiesCrc": { "assets/x.png": 1, "assets/y.png": 2 } + }, + "error": "conflicting copiesCrc for source assets/a.png" + }, + { + "name": "copiesCrc null skips the check, unknown targets are ignored", + "manifest": { + "copies": { "a": "x", "b": "y" }, + "copiesCrc": { "a": null, "zzz": 77 } + }, + "copies": [["x", "a", null], ["y", "b", null]] + }, + { + "name": "copiesCrc given as a string", + "was": "Android parsed numeric strings; HarmonyOS and iOS skipped the check", + "manifest": { "copies": { "a": "x" }, "copiesCrc": { "a": "123" } }, + "error": "copiesCrc entry is not a CRC32: a" + }, + { + "name": "copiesCrc negative", + "manifest": { "copies": { "a": "x" }, "copiesCrc": { "a": -1 } }, + "error": "copiesCrc entry is not a CRC32: a" + }, + { + "name": "copiesCrc fractional", + "was": "iOS truncated it", + "manifest": { "copies": { "a": "x" }, "copiesCrc": { "a": 1.5 } }, + "error": "copiesCrc entry is not a CRC32: a" + }, + { + "name": "copiesCrc beyond 32 bits", + "was": "iOS truncated it to 32 bits", + "manifest": { "copies": { "a": "x" }, "copiesCrc": { "a": 4294967296 } }, + "error": "copiesCrc entry is not a CRC32: a" + }, + { + "name": "copiesCrc boolean", + "was": "iOS read true as CRC 1", + "manifest": { "copies": { "a": "x" }, "copiesCrc": { "a": true } }, + "error": "copiesCrc entry is not a CRC32: a" + }, + { + "name": "copiesCrc of another type", + "was": "every platform ignored it", + "manifest": { "copies": { "a": "x" }, "copiesCrc": [1] }, + "error": "copiesCrc is not an object" + }, + { + "name": "HarmonyOS rawfile prefix is stripped from copy sources", + "manifest": { + "copies": { + "assets/icon.png": "resources/rawfile/assets/icon.png", + "assets/media.png": "resources/base/media/media.png" + }, + "copiesCrc": { "assets/icon.png": 5 } + }, + "options": { "copySourcePrefix": "resources/rawfile/", "bundlePatchEntryName": "bundle.harmony.js.patch" }, + "copies": [ + ["assets/icon.png", "assets/icon.png", 5], + ["resources/base/media/media.png", "assets/media.png", null] + ] + }, + { + "name": "without the option the prefix stays", + "manifest": { "copies": { "assets/icon.png": "resources/rawfile/assets/icon.png" } }, + "copies": [["resources/rawfile/assets/icon.png", "assets/icon.png", null]] + }, + { + "name": "the prefix is stripped only at the start", + "was": "HarmonyOS replaced its first occurrence anywhere in the path", + "manifest": { "copies": { "a.png": "assets/resources/rawfile/a.png" } }, + "options": { "copySourcePrefix": "resources/rawfile/" }, + "copies": [["assets/resources/rawfile/a.png", "a.png", null]] + }, + { + "name": "a source that is only the prefix means the same path", + "manifest": { "copies": { "assets/icon.png": "resources/rawfile/" } }, + "options": { "copySourcePrefix": "resources/rawfile/" }, + "copies": [["assets/icon.png", "assets/icon.png", null]] + }, + { + "name": "hbcTransform metadata of the bundle patch entry, re-serialized", + "text": "{\"hbcTransform\": {\"other.patch\": {\"v\": 9}, \"index.bundlejs.patch\": {\"v\": 1, \"hbcVersion\": 96, \"layout\": {\"counts\": 19, \"sections\": [[2, 16, [[0, 0, 25]]]]}}}}", + "hbcTransformMeta": "{\"v\":1,\"hbcVersion\":96,\"layout\":{\"counts\":19,\"sections\":[[2,16,[[0,0,25]]]]}}" + }, + { + "name": "hbcTransform under a platform-specific bundle patch name", + "manifest": { "hbcTransform": { "index.bundlejs.patch": { "v": 2 }, "bundle.harmony.js.patch": { "v": 1 } } }, + "options": { "bundlePatchEntryName": "bundle.harmony.js.patch" }, + "hbcTransformMeta": "{\"v\":1}" + }, + { + "name": "hbcTransform without the bundle patch entry", + "manifest": { "hbcTransform": { "other.patch": {} } } + }, + { + "name": "hbcTransform entry null", + "manifest": { "hbcTransform": { "index.bundlejs.patch": null } } + }, + { + "name": "hbcTransform entry that is not an object", + "was": "every platform ignored it and applied the patch untransformed", + "manifest": { "hbcTransform": { "index.bundlejs.patch": "{\"v\":1}" } }, + "error": "hbcTransform entry is not an object: index.bundlejs.patch" + }, + { + "name": "hbcTransform that is not an object", + "was": "every platform ignored it", + "manifest": { "hbcTransform": [{ "v": 1 }] }, + "error": "hbcTransform is not an object" + }, + { + "name": "duplicate copy targets: the last source wins", + "text": "{\"copies\":{\"a.png\":\"x.png\",\"b.png\":\"y.png\",\"a.png\":\"z.png\"}}", + "copies": [["z.png", "a.png", null], ["y.png", "b.png", null]] + }, + { + "name": "non-ASCII paths, escaped or raw", + "text": "{\"copies\":{\"assets/\\u56fe.png\":\"assets/图片.png\",\"assets/\\ud83d\\ude00.png\":\"\"}}", + "copies": [ + ["assets/图片.png", "assets/图.png", null], + ["assets/😀.png", "assets/😀.png", null] + ] + }, + { + "name": "root array", + "text": "[]", + "error": "not a JSON object" + }, + { + "name": "root string", + "text": "\"copies\"", + "error": "not a JSON object" + }, + { + "name": "empty text", + "text": "", + "error": "invalid JSON" + }, + { + "name": "malformed JSON", + "text": "{\"copies\":{\"a\":\"b\",}}", + "error": "invalid JSON" + }, + { + "name": "trailing garbage", + "text": "{} {}", + "error": "invalid JSON" + }, + { + "name": "malicious: copy target escapes the version directory", + "manifest": { "copies": { "../escape.txt": "assets/a.png" } }, + "error": "Unsafe copy target path: ../escape.txt" + }, + { + "name": "malicious: nested parent segment in a copy target", + "manifest": { "copies": { "assets/../../escape.txt": "assets/a.png" } }, + "error": "Unsafe copy target path" + }, + { + "name": "malicious: absolute copy source", + "manifest": { "copies": { "assets/a.png": "/etc/passwd" } }, + "error": "Unsafe copy source path: /etc/passwd" + }, + { + "name": "malicious: absolute copy target", + "manifest": { "copies": { "/data/local/tmp/x": "" } }, + "error": "Unsafe copy" + }, + { + "name": "malicious: backslash traversal", + "manifest": { "copies": { "assets\\..\\..\\x": "assets/a.png" } }, + "error": "Unsafe copy target path" + }, + { + "name": "malicious: empty and dot segments", + "manifest": { "copies": { "assets//a.png": "assets/a.png" } }, + "error": "Unsafe copy target path" + }, + { + "name": "malicious: current-directory segment", + "manifest": { "copies": { "./a.png": "assets/a.png" } }, + "error": "Unsafe copy target path" + }, + { + "name": "malicious: NUL truncating to a parent segment", + "manifest": { "copies": { "..\u0000x": "assets/a.png" } }, + "error": "Unsafe copy target path" + }, + { + "name": "malicious: control byte in a source", + "manifest": { "copies": { "a.png": "assets/a\u0001.png" } }, + "error": "Unsafe copy source path" + }, + { + "name": "malicious: prefix stripping cannot smuggle a parent segment", + "manifest": { "copies": { "a.png": "resources/rawfile/../../x" } }, + "options": { "copySourcePrefix": "resources/rawfile/" }, + "error": "Unsafe copy source path: ../../x" + }, + { + "name": "malicious: delete escapes the version directory", + "manifest": { "deletes": { "../../Documents": 1 } }, + "error": "Unsafe deleted path: ../../Documents" + }, + { + "name": "malicious: absolute delete in an array", + "manifest": { "deletes": ["/"] }, + "error": "Unsafe deleted path: /" + }, + { + "name": "malicious: empty delete", + "manifest": { "deletes": { "": 1 } }, + "error": "Unsafe deleted path" + } + ] +} diff --git a/cpp/patch_core/tests/patch_core_test.cpp b/cpp/patch_core/tests/patch_core_test.cpp index 1038a8a3..727fb561 100644 --- a/cpp/patch_core/tests/patch_core_test.cpp +++ b/cpp/patch_core/tests/patch_core_test.cpp @@ -1,7 +1,9 @@ #include "../archive_patch_core.h" #include "../digest.h" #include "../patch_core.h" +#include "../patch_manifest.h" #include "../state_core.h" +#include "../../update_flow_core/flow_json.h" #include #include @@ -163,7 +165,7 @@ void TestApplyPatchWithHbcTransform() { options.bundle_patch_path = patch; options.bundle_output_path = JoinPath(temp.path, "out/index.bundlejs"); options.enable_merge = false; - options.bundle_hbc_transform_meta = meta; + options.manifest.bundle_hbc_transform_meta = meta; Status status = ApplyPatchFromFileSource(options); Expect(status.ok, "hbc transform patch should succeed: " + status.message); @@ -195,7 +197,7 @@ void TestApplyPatchWithHbcTransformRejectsBadMeta() { options.enable_merge = false; // 不可解析的元数据 → 失败(绝不能忽略元数据直接 hpatch) - options.bundle_hbc_transform_meta = "not json"; + options.manifest.bundle_hbc_transform_meta = "not json"; Expect( !ApplyPatchFromFileSource(options).ok, "malformed hbcTransform metadata must fail"); @@ -207,7 +209,7 @@ void TestApplyPatchWithHbcTransformRejectsBadMeta() { const size_t pos = meta.find(needle); Expect(pos != std::string::npos, "meta fixture must contain v:1"); meta.replace(pos, needle.size(), "\"v\":9"); - options.bundle_hbc_transform_meta = meta; + options.manifest.bundle_hbc_transform_meta = meta; Status status = ApplyPatchFromFileSource(options); Expect(!status.ok, "unsupported hbcTransform version must fail"); Expect( @@ -246,7 +248,7 @@ void TestApplyStreamFormatWithHbcTransform() { options.bundle_patch_path = JoinPath(g_fixtures_dir, "v96.tstreampatch.bin"); options.bundle_output_path = JoinPath(temp.path, "out/index.bundlejs"); options.enable_merge = false; - options.bundle_hbc_transform_meta = + options.manifest.bundle_hbc_transform_meta = ReadFile(JoinPath(g_fixtures_dir, "v96.meta.json")); Status status = ApplyPatchFromFileSource(options); @@ -599,7 +601,7 @@ void TestHpatchRejectsOversizedLzmaDictionary() { options.bundle_patch_path = tampered; options.bundle_output_path = JoinPath(temp.path, "out/index.bundlejs"); options.enable_merge = false; - options.bundle_hbc_transform_meta = ReadFile(JoinPath(g_fixtures_dir, "v96.meta.json")); + options.manifest.bundle_hbc_transform_meta = ReadFile(JoinPath(g_fixtures_dir, "v96.meta.json")); Status status = ApplyPatchFromFileSource(options); Expect(!status.ok, "a patch declaring a 4 GB LZMA dictionary must be refused"); @@ -804,6 +806,16 @@ void TestArchivePatchCoreBuildPlanAndCopyGroups() { Expect(groups.size() == 1, "copy groups should merge identical sources"); ExpectEq(groups[0].from, "assets/a.png", "copy group source mismatch"); Expect(groups[0].to_paths.size() == 2, "copy group target count mismatch"); + Expect(!groups[0].has_expected_crc, "no CRC declared, none grouped"); + + // A CRC declared on a later copy of the source still lands on its group. + manifest.copies[1].has_expected_crc = true; + manifest.copies[1].expected_crc = 7; + status = pushy::archive_patch::BuildCopyGroups(manifest, &groups); + Expect(status.ok, status.message); + Expect(groups.size() == 1 && groups[0].has_expected_crc && + groups[0].expected_crc == 7, + "group CRC comes from whichever copy declared it"); FileSourcePatchOptions options; status = pushy::archive_patch::BuildFileSourcePatchOptions( @@ -1145,6 +1157,320 @@ void TestApplyPatchCopiesVerifyExpectedCrc() { "drifted content must not be copied"); } +// ---- __diff.json (patch_manifest.h) ---- + +std::string DescribeManifest(const PatchManifest& manifest) { + std::string out = "copies="; + for (const CopyOperation& copy : manifest.copies) { + out += "[" + copy.from + "," + copy.to + "," + + (copy.has_expected_crc ? std::to_string(copy.expected_crc) + : std::string("null")) + + "]"; + } + out += " deletes="; + for (const std::string& path : manifest.deletes) { + out += "[" + path + "]"; + } + out += " meta=" + manifest.bundle_hbc_transform_meta; + return out; +} + +// The shared vectors (tests/patch-manifest-vectors.json): server shapes, the +// cases the platforms used to disagree on, and hostile paths. +void TestPatchManifestVectors() { + const std::string path = + JoinPath(g_fixtures_dir, "../patch-manifest-vectors.json"); + bool ok = false; + const flowjson::Value doc = flowjson::Parse(ReadFile(path), &ok); + Expect(ok && doc.Get("vectors").IsArray(), "vectors file must parse: " + path); + Expect(doc.Get("vectors").Size() >= 40, "vectors file lost its cases"); + for (const flowjson::Value& vector : doc.Get("vectors").elements()) { + const std::string name = vector.Get("name").AsString(); + const std::string text = vector.Get("text").IsString() + ? vector.Get("text").AsString() + : flowjson::Stringify(vector.Get("manifest")); + pushy::delta::PatchManifestOptions options; + options.bundle_patch_entry_name = "index.bundlejs.patch"; + const flowjson::Value& given = vector.Get("options"); + if (given.Get("bundlePatchEntryName").IsString()) { + options.bundle_patch_entry_name = + given.Get("bundlePatchEntryName").AsString(); + } + if (given.Get("copySourcePrefix").IsString()) { + options.copy_source_prefix = given.Get("copySourcePrefix").AsString(); + } + + PatchManifest manifest; + manifest.deletes.push_back("sentinel"); + const Status status = + pushy::delta::ParsePatchManifest(text, options, &manifest); + if (vector.Get("error").IsString()) { + const std::string expected = vector.Get("error").AsString(); + Expect(!status.ok, name + ": expected an error, got " + + DescribeManifest(manifest)); + Expect(status.message.find(expected) != std::string::npos, + name + ": error [" + status.message + "] lacks [" + expected + "]"); + Expect(manifest.deletes.size() == 1 && manifest.copies.empty(), + name + ": a failed parse must leave the output untouched"); + continue; + } + Expect(status.ok, name + ": " + status.message); + + PatchManifest expected; + for (const flowjson::Value& copy : vector.Get("copies").elements()) { + CopyOperation operation; + operation.from = copy.At(0).AsString(); + operation.to = copy.At(1).AsString(); + if (copy.At(2).IsNumber()) { + operation.has_expected_crc = true; + operation.expected_crc = static_cast(copy.At(2).AsNumber()); + } + expected.copies.push_back(operation); + } + for (const flowjson::Value& deleted : vector.Get("deletes").elements()) { + expected.deletes.push_back(deleted.AsString()); + } + if (vector.Get("hbcTransformMeta").IsString()) { + expected.bundle_hbc_transform_meta = + vector.Get("hbcTransformMeta").AsString(); + } + ExpectEq(DescribeManifest(manifest), DescribeManifest(expected), name); + } +} + +void TestPatchManifestSizeCaps() { + pushy::delta::PatchManifestOptions options; + options.bundle_patch_entry_name = "index.bundlejs.patch"; + PatchManifest manifest; + + // Over kMaxManifestBytes: rejected before parsing. + std::string big = "{\"pad\":\""; + big.append(static_cast(pushy::archive_limits::kMaxManifestBytes), 'x'); + big += "\"}"; + Status status = pushy::delta::ParsePatchManifest(big, options, &manifest); + Expect(!status.ok, "a manifest over kMaxManifestBytes must be rejected"); + + // A real-sized manifest well over flowjson's 1 MB response cap still parses. + std::string large = "{\"copies\":{"; + std::string large_crc = "\"copiesCrc\":{"; + const int copies = 20000; + for (int i = 0; i < copies; ++i) { + const std::string to = "assets/node_modules/some-package/images/icon_" + + std::to_string(i) + "@3x.png"; + large += (i ? ",\"" : "\"") + to + "\":\"\""; + large_crc += (i ? ",\"" : "\"") + to + "\":" + std::to_string(i); + } + large += "}," + large_crc + "}}"; + Expect(large.size() > 2 * 1024 * 1024, "the large manifest must exceed 2 MB"); + status = pushy::delta::ParsePatchManifest(large, options, &manifest); + Expect(status.ok, "a 20000-copy manifest must parse: " + status.message); + Expect(manifest.copies.size() == static_cast(copies) && + manifest.copies.back().has_expected_crc && + manifest.copies.back().expected_crc == copies - 1, + "every copy keeps its CRC"); + + // Over kMaxManifestNodes values: rejected. + std::string nodes = "{\"deletes\":["; + for (size_t i = 0; i < pushy::delta::kMaxManifestNodes; ++i) { + nodes += i ? ",\"a\"" : "\"a\""; + } + nodes += "]}"; + status = pushy::delta::ParsePatchManifest(nodes, options, &manifest); + Expect(!status.ok, "a manifest over kMaxManifestNodes values must be rejected"); + + Expect(!pushy::delta::ParsePatchManifest("{}", options, nullptr).ok, + "a null output must be rejected"); +} + +pushy::archive_patch::ArchivePatchInput PpkInput(const std::string& manifest_json) { + pushy::archive_patch::ArchivePatchInput input; + input.type = pushy::archive_patch::ArchivePatchType::kPatchFromPpk; + input.entry_names = {"__diff.json", "index.bundlejs.patch"}; + input.manifest_json = manifest_json; + return input; +} + +void TestPlanArchivePatch() { + pushy::archive_patch::ArchivePatchPlan plan; + + pushy::archive_patch::ArchivePatchInput input = PpkInput("not json"); + input.entry_names = {"index.bundlejs.patch"}; + Status status = pushy::archive_patch::PlanArchivePatch(input, &plan); + ExpectEq(status.message, "diff.json not found", + "a missing manifest entry is reported before the text is parsed"); + input.entry_names = {"__diff.json"}; + status = pushy::archive_patch::PlanArchivePatch(input, &plan); + ExpectEq(status.message, "bundle delta not found", "missing bundle patch"); + + input = PpkInput("{\"copies\":{\"../x\":\"\"}}"); + status = pushy::archive_patch::PlanArchivePatch(input, &plan); + Expect(!status.ok && status.message.find("Unsafe copy") != std::string::npos, + "the plan validates the parsed paths: " + status.message); + + input = PpkInput( + "{\"copies\":{\"assets/b.png\":\"assets/a.png\"}," + "\"deletes\":{\"assets/old.png\":1}," + "\"hbcTransform\":{\"index.bundlejs.patch\":{\"v\":1}}}"); + status = pushy::archive_patch::PlanArchivePatch(input, &plan); + Expect(status.ok, status.message); + Expect(plan.enable_merge && plan.merge_source_subdir.empty(), "ppk merge plan"); + Expect(plan.manifest.copies.size() == 1 && + plan.manifest.copies[0].from == "assets/a.png" && + plan.manifest.deletes.size() == 1, + "the plan carries the parsed manifest"); + ExpectEq(plan.manifest.bundle_hbc_transform_meta, "{\"v\":1}", + "the plan carries the hbcTransform metadata"); + + // HarmonyOS: its own bundle patch name keys both the entry check and the + // metadata; the rawfile prefix is stripped. + input.type = pushy::archive_patch::ArchivePatchType::kPatchFromPackage; + input.entry_names = {"__diff.json", "bundle.harmony.js.patch"}; + input.bundle_patch_entry_name = "bundle.harmony.js.patch"; + input.copy_source_prefix = "resources/rawfile/"; + input.manifest_json = + "{\"copies\":{\"assets/a.png\":\"resources/rawfile/assets/a.png\"}," + "\"hbcTransform\":{\"bundle.harmony.js.patch\":{\"v\":1}}}"; + status = pushy::archive_patch::PlanArchivePatch(input, &plan); + Expect(status.ok, status.message); + ExpectEq(plan.merge_source_subdir, "assets", "package merge subdir"); + ExpectEq(plan.manifest.copies[0].from, "assets/a.png", "prefix stripped"); + ExpectEq(plan.manifest.bundle_hbc_transform_meta, "{\"v\":1}", + "metadata under the platform's bundle patch name"); + + // A full package plans nothing and never looks at the text. + input = PpkInput("not json"); + input.type = pushy::archive_patch::ArchivePatchType::kFull; + input.entry_names.clear(); + status = pushy::archive_patch::PlanArchivePatch(input, &plan); + Expect(status.ok && !plan.enable_merge && plan.manifest.copies.empty(), + "full package plan: " + status.message); + Expect(!pushy::archive_patch::PlanArchivePatch(input, nullptr).ok, + "a null plan output must be rejected"); +} + +void TestApplyArchivePatchFromPpk() { + TempDir temp; + const std::string source = JoinPath(temp.path, "origin"); + const std::string target = JoinPath(temp.path, "target"); + + WriteFile(JoinPath(source, "index.bundlejs"), "old bundle"); + WriteFile(JoinPath(source, "assets/keep.txt"), "keep"); + WriteFile(JoinPath(source, "assets/delete.txt"), "delete"); + WriteFile(JoinPath(source, "assets/from.txt"), "123456789"); + WriteFile(JoinPath(target, "index.bundlejs.patch"), "unused patch"); + + pushy::archive_patch::ArchivePatchRequest request; + request.archive = PpkInput( + "{\"copies\":{\"assets/renamed.txt\":\"assets/from.txt\"}," + "\"copiesCrc\":{\"assets/renamed.txt\":3421780262}," + "\"deletes\":{\"assets/delete.txt\":1}}"); + request.source_root = source; + request.target_root = target; + request.origin_bundle_path = JoinPath(source, "index.bundlejs"); + request.bundle_patch_path = JoinPath(target, "index.bundlejs.patch"); + request.bundle_output_path = JoinPath(target, "index.bundlejs"); + + FakeBundlePatcher patcher("patched bundle"); + std::vector host_copies(1); + Status status = + pushy::archive_patch::ApplyArchivePatch(request, &host_copies, patcher); + Expect(status.ok, status.message); + Expect(host_copies.empty(), "nothing is left to the host"); + ExpectEq(ReadFile(JoinPath(target, "index.bundlejs")), "patched bundle", "bundle"); + ExpectEq(ReadFile(JoinPath(target, "assets/renamed.txt")), "123456789", "copy"); + ExpectEq(ReadFile(JoinPath(target, "assets/keep.txt")), "keep", "merge"); + Expect(!Exists(JoinPath(target, "assets/delete.txt")), "delete honoured"); + + // The declared CRC is checked against the source: drift fails the patch. + WriteFile(JoinPath(source, "assets/from.txt"), "drifted"); + const std::string target2 = JoinPath(temp.path, "target2"); + request.target_root = target2; + request.bundle_output_path = JoinPath(target2, "index.bundlejs"); + status = pushy::archive_patch::ApplyArchivePatch(request, nullptr, patcher); + Expect(!status.ok && status.message.find("crc32") != std::string::npos, + "a drifted copy source must fail: " + status.message); + + // A manifest error stops before the bundle is touched. + FakeBundlePatcher untouched("x"); + request.archive.manifest_json = "{\"deletes\":[\"../../x\"]}"; + status = pushy::archive_patch::ApplyArchivePatch(request, nullptr, untouched); + Expect(!status.ok && untouched.calls == 0, + "an unsafe manifest must fail before patching"); + + request.archive.type = pushy::archive_patch::ArchivePatchType::kFull; + Expect(!pushy::archive_patch::ApplyArchivePatch(request, nullptr, untouched).ok, + "a full package has no patch to apply"); +} + +// Android APK / HarmonyOS rawfile packages: only the bundle is rebuilt; the +// copies come back grouped by source, with the CRC each group must match. +void TestApplyArchivePatchHostCopies() { + TempDir temp; + const std::string work = JoinPath(temp.path, "work"); + WriteFile(JoinPath(work, ".origin.bundle"), "old bundle"); + WriteFile(JoinPath(work, "index.bundlejs.patch"), "unused patch"); + WriteFile(JoinPath(work, "assets/new.png"), "new"); + + pushy::archive_patch::ArchivePatchRequest request; + request.archive = PpkInput( + "{\"copies\":{\"res/a.png\":\"\",\"res/b.png\":\"res/a.png\"," + "\"res/c.png\":\"res/c_old.png\"}," + "\"copiesCrc\":{\"res/b.png\":42}}"); + request.archive.type = pushy::archive_patch::ArchivePatchType::kPatchFromPackage; + request.source_root = work; + request.target_root = work; + request.origin_bundle_path = JoinPath(work, ".origin.bundle"); + request.bundle_patch_path = JoinPath(work, "index.bundlejs.patch"); + request.bundle_output_path = JoinPath(work, "index.bundlejs"); + request.host_copies_resources = true; + + FakeBundlePatcher patcher("patched bundle"); + Expect(!pushy::archive_patch::ApplyArchivePatch(request, nullptr, patcher).ok, + "host copies need an output"); + Expect(patcher.calls == 0, "nothing runs without the output"); + + std::vector groups; + Status status = pushy::archive_patch::ApplyArchivePatch(request, &groups, patcher); + Expect(status.ok, status.message); + ExpectEq(ReadFile(JoinPath(work, "index.bundlejs")), "patched bundle", "bundle"); + Expect(!Exists(JoinPath(work, "res")), "the core copies nothing itself"); + ExpectEq(ReadFile(JoinPath(work, "assets/new.png")), "new", "no merge into work"); + Expect(groups.size() == 2, "two sources"); + ExpectEq(groups[0].from, "res/a.png", "first group source"); + Expect(groups[0].to_paths.size() == 2 && groups[0].to_paths[1] == "res/b.png", + "targets of one source are grouped in order"); + Expect(groups[0].has_expected_crc && groups[0].expected_crc == 42, + "a CRC declared for any target covers the group"); + Expect(!groups[1].has_expected_crc, "no CRC declared for the second source"); +} + +void TestApplyArchivePatchWithHbcTransform() { + TempDir temp; + const std::string meta = ReadFile(JoinPath(g_fixtures_dir, "v96.meta.json")); + pushy::archive_patch::ArchivePatchRequest request; + request.archive = PpkInput( + "{\"copies\":{},\"hbcTransform\":{\"index.bundlejs.patch\":" + meta + "}}"); + request.source_root = JoinPath(temp.path, "src"); + request.target_root = JoinPath(temp.path, "dst"); + request.origin_bundle_path = JoinPath(g_fixtures_dir, "v96.hbc"); + request.bundle_patch_path = JoinPath(g_fixtures_dir, "v96.tpatch.bin"); + request.bundle_output_path = JoinPath(temp.path, "dst/index.bundlejs"); + Status status = pushy::archive_patch::ApplyArchivePatch(request, nullptr); + Expect(status.ok, "transform-domain patch through the manifest: " + status.message); + Expect(ReadFile(request.bundle_output_path) == + ReadFile(JoinPath(g_fixtures_dir, "v96b.hbc")), + "restored bundle must equal the new bundle byte-for-byte"); + + // Without the metadata the same patch must not silently produce a bundle + // that matches: the transform is part of the manifest contract. + request.archive.manifest_json = "{}"; + request.bundle_output_path = JoinPath(temp.path, "dst2/index.bundlejs"); + status = pushy::archive_patch::ApplyArchivePatch(request, nullptr); + Expect(!status.ok || ReadFile(request.bundle_output_path) != + ReadFile(JoinPath(g_fixtures_dir, "v96b.hbc")), + "the metadata is what makes the transform-domain patch apply"); +} + } // namespace int main(int argc, char** argv) { @@ -1189,6 +1515,12 @@ int main(int argc, char** argv) { {"Crc32KnownVectors", TestCrc32KnownVectors}, {"Crc32File", TestCrc32File}, {"ApplyPatchCopiesVerifyExpectedCrc", TestApplyPatchCopiesVerifyExpectedCrc}, + {"PatchManifestVectors", TestPatchManifestVectors}, + {"PatchManifestSizeCaps", TestPatchManifestSizeCaps}, + {"PlanArchivePatch", TestPlanArchivePatch}, + {"ApplyArchivePatchFromPpk", TestApplyArchivePatchFromPpk}, + {"ApplyArchivePatchHostCopies", TestApplyArchivePatchHostCopies}, + {"ApplyArchivePatchWithHbcTransform", TestApplyArchivePatchWithHbcTransform}, }; int failures = 0; diff --git a/harmony/pushy/src/main/cpp/pushy.cpp b/harmony/pushy/src/main/cpp/pushy.cpp index aa5487a7..ce1561f6 100644 --- a/harmony/pushy/src/main/cpp/pushy.cpp +++ b/harmony/pushy/src/main/cpp/pushy.cpp @@ -875,7 +875,7 @@ napi_value ApplyPatchFromFileSource(napi_env env, napi_callback_info info) { work.options.bundle_patch_path = bundle_patch_path; work.options.bundle_output_path = bundle_output_path; work.options.merge_source_subdir = merge_source_subdir; - work.options.bundle_hbc_transform_meta = bundle_hbc_transform_meta; + work.options.manifest.bundle_hbc_transform_meta = bundle_hbc_transform_meta; work.options.enable_merge = enable_merge; return QueueWork( diff --git a/ios/RCTPushy/RCTPushyInstaller.mm b/ios/RCTPushy/RCTPushyInstaller.mm index bd6f12ed..fbbd89c1 100644 --- a/ios/RCTPushy/RCTPushyInstaller.mm +++ b/ios/RCTPushy/RCTPushyInstaller.mm @@ -623,7 +623,7 @@ + (NSError *)applyDeltaForHash:(NSString *)hash if ([meta isKindOfClass:[NSDictionary class]]) { NSString *metaString = PushyJsonString(meta); if (metaString != nil) { - options.bundle_hbc_transform_meta = PushyToStdString(metaString); + options.manifest.bundle_hbc_transform_meta = PushyToStdString(metaString); } } } diff --git a/scripts/test-patch-core.sh b/scripts/test-patch-core.sh index eabacaba..ef008964 100755 --- a/scripts/test-patch-core.sh +++ b/scripts/test-patch-core.sh @@ -31,7 +31,8 @@ cc -Wall -Wextra $SANITIZE_FLAGS $COMMON_INCLUDES -c "$ROOT_DIR/android/jni/lzma cc -Wall -Wextra $SANITIZE_FLAGS $COMMON_INCLUDES -c "$ROOT_DIR/android/jni/lzma/C/Lzma2Dec.c" -o "$BUILD_DIR/Lzma2Dec.o" # Every patch core source, picked up by directory like the platform builds -# (tests/ is a subdirectory and stays out). +# (tests/ is a subdirectory and stays out), plus the flow core's JSON parser +# the manifest parser reads __diff.json with (every platform links both). c++ \ -std=c++17 \ $CORE_WARNINGS \ @@ -39,6 +40,7 @@ c++ \ $COMMON_INCLUDES \ "$ROOT_DIR/cpp/patch_core/tests/patch_core_test.cpp" \ "$ROOT_DIR"/cpp/patch_core/*.cpp \ + "$ROOT_DIR/cpp/update_flow_core/flow_json.cpp" \ "$BUILD_DIR/hpatch.o" \ "$BUILD_DIR/patch.o" \ "$BUILD_DIR/file_for_patch.o" \ @@ -58,8 +60,8 @@ c++ \ "$BUILD_DIR/patch_core_test" "$ROOT_DIR/cpp/patch_core/tests/fixtures" "$BUILD_DIR/hbc_transform_test" "$ROOT_DIR/cpp/patch_core/tests/fixtures" -# Opt-in libFuzzer run of the hbcTransform wire parser (untrusted __diff.json -# metadata): FUZZ=1 bun run test:patch-core. +# Opt-in libFuzzer runs of the hbcTransform wire parser (untrusted __diff.json +# metadata) and of the whole __diff.json parser: FUZZ=1 bun run test:patch-core. if cpp_test_fuzz_compiler; then echo "Fuzzing hbcTransform wire parser for ${FUZZ_SECONDS:-20}s ($FUZZ_CXX)" "$FUZZ_CXX" \ @@ -79,4 +81,35 @@ if cpp_test_fuzz_compiler; then -max_len=8192 \ -print_final_stats=1 \ "$CORPUS_DIR" + + # The __diff.json parser (patch_manifest.h) links the whole core, so the + # hpatch/HDiffPatch/lzma objects built above (uninstrumented) come along. + echo "Fuzzing __diff.json manifest parser for ${FUZZ_SECONDS:-20}s ($FUZZ_CXX)" + "$FUZZ_CXX" \ + -std=c++17 \ + $CORE_WARNINGS \ + -g -O1 -fno-omit-frame-pointer \ + -fsanitize=fuzzer,address,undefined \ + "$ROOT_DIR/cpp/patch_core/tests/fuzz_patch_manifest.cpp" \ + "$ROOT_DIR"/cpp/patch_core/*.cpp \ + "$ROOT_DIR/cpp/update_flow_core/flow_json.cpp" \ + "$BUILD_DIR/hpatch.o" \ + "$BUILD_DIR/patch.o" \ + "$BUILD_DIR/file_for_patch.o" \ + "$BUILD_DIR/LzmaDec.o" \ + "$BUILD_DIR/Lzma2Dec.o" \ + -o "$BUILD_DIR/fuzz_patch_manifest" + CORPUS_DIR="$BUILD_DIR/fuzz-corpus-patch-manifest" + mkdir -p "$CORPUS_DIR" + # Seeds: option byte + manifest text (server ppk and package shapes, the + # HarmonyOS form, and a transform-domain manifest). + printf '\000{"copies":{"assets/b.png":"assets/a.png"},"deletes":{"assets/old.png":1,"assets/gone/":1}}' > "$CORPUS_DIR/seed-ppk.json" + printf '\000{"copies":{"assets/a.png":"","assets/b.png":"assets/a.png"},"copiesCrc":{"assets/a.png":3421780262,"assets/b.png":3421780262}}' > "$CORPUS_DIR/seed-package.json" + printf '\003{"copies":{"assets/i.png":"resources/rawfile/assets/i.png"},"deletes":["x"],"hbcTransform":{"bundle.harmony.js.patch":{"v":1}}}' > "$CORPUS_DIR/seed-harmony.json" + { printf '\000{"hbcTransform":{"index.bundlejs.patch":'; cat "$ROOT_DIR/cpp/patch_core/tests/fixtures/v96.meta.json"; printf '}}'; } > "$CORPUS_DIR/seed-hbc.json" + "$BUILD_DIR/fuzz_patch_manifest" \ + -max_total_time="${FUZZ_SECONDS:-20}" \ + -max_len=8192 \ + -print_final_stats=1 \ + "$CORPUS_DIR" fi From 02e383d1d8bf20776ca3d5887ac0b4e1d8ec31e2 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 16:34:12 +0800 Subject: [PATCH 081/104] refactor(android): hand __diff.json to the shared core as bytes DownloadTask no longer parses the manifest: extractPatchArchive keeps the raw __diff.json bytes and both patch tasks call one native, applyArchiveDelta (JNI over archive_patch::ApplyArchivePatch), which checks the entries, parses and validates the manifest, rebuilds the bundle and, for ppk patches, copies/deletes/merges. The APK task passes hostCopiesResources and gets back the copy groups, now with the CRC each source must match, for BundledResourceCopier. The manifest crosses JNI as byte[] rather than a jstring, so paths with supplementary characters are no longer re-encoded as modified UTF-8. appendManifestEntries, the buildArchivePlan/buildCopyGroups/ applyDeltaFromSource natives, ArchivePatchPlanResult and buildCopyList's canonical-path check are gone: ValidateManifest rejects every lexical escape (absolute, '..', '.', empty segment, backslash, control bytes), and the staging directory holds only what SafeZipFile extracted, which never creates symlinks. SafeZipFile's own entry checks stay, since zip entry names never reach the C++ validation. Behaviour changes, from the shared rules: copiesCrc is read per target (conflicting CRCs for one source fail the patch instead of the last one winning), a deletes array is honoured, and type errors are patch failures. Co-Authored-By: Claude Opus 5.5 --- android/proguard.pro | 1 - .../update/ArchivePatchPlanResult.java | 6 - .../modules/update/BundledResourceCopier.java | 25 +- .../modules/update/CopyGroupResult.java | 3 + .../modules/update/DownloadTask.java | 213 ++++-------------- cpp/android_jni/jni_natives.h | 4 +- cpp/android_jni/jni_registration.cpp | 8 +- cpp/android_jni/jni_util.h | 28 +-- cpp/android_jni/update_core_android.cpp | 154 +++---------- src/__tests__/jniRegistration.test.ts | 3 +- 10 files changed, 116 insertions(+), 329 deletions(-) delete mode 100644 android/src/main/java/cn/reactnative/modules/update/ArchivePatchPlanResult.java diff --git a/android/proguard.pro b/android/proguard.pro index b7db737e..e06f4414 100644 --- a/android/proguard.pro +++ b/android/proguard.pro @@ -3,7 +3,6 @@ # no-arg constructor and every field must survive shrinking with their names # (a -keepnames rule alone lets R8 strip the unreferenced constructor/fields). -keep class cn.reactnative.modules.update.StateCoreResult { (); ; } --keep class cn.reactnative.modules.update.ArchivePatchPlanResult { (); ; } -keep class cn.reactnative.modules.update.CopyGroupResult { (); ; } # Native methods are bound from JNI_OnLoad by class/method name (the encoded diff --git a/android/src/main/java/cn/reactnative/modules/update/ArchivePatchPlanResult.java b/android/src/main/java/cn/reactnative/modules/update/ArchivePatchPlanResult.java deleted file mode 100644 index a4477cf8..00000000 --- a/android/src/main/java/cn/reactnative/modules/update/ArchivePatchPlanResult.java +++ /dev/null @@ -1,6 +0,0 @@ -package cn.reactnative.modules.update; - -class ArchivePatchPlanResult { - String mergeSourceSubdir; - boolean enableMerge; -} diff --git a/android/src/main/java/cn/reactnative/modules/update/BundledResourceCopier.java b/android/src/main/java/cn/reactnative/modules/update/BundledResourceCopier.java index 76f372ee..5eb656ba 100644 --- a/android/src/main/java/cn/reactnative/modules/update/BundledResourceCopier.java +++ b/android/src/main/java/cn/reactnative/modules/update/BundledResourceCopier.java @@ -47,10 +47,25 @@ private static final class ZipSource { this.context = context.getApplicationContext(); } - void copyFromResource( - HashMap> resToCopy, - HashMap crcByFrom - ) throws IOException { + /** + * Performs the manifest copies of an APK-based patch (the host share of + * the native applyArchiveDelta): each group's targets, relative to + * targetRoot, receive its source entry. The native manifest parser has + * already validated every path and the copiesCrc rules. + */ + void copyFromResource(File targetRoot, CopyGroupResult[] groups) throws IOException { + HashMap> resToCopy = new HashMap>(); + HashMap crcByFrom = new HashMap(); + for (CopyGroupResult group : groups) { + ArrayList targets = new ArrayList(); + for (String to : group.toPaths) { + targets.add(new File(targetRoot, to)); + } + resToCopy.put(group.from, targets); + if (group.expectedCrc >= 0) { + crcByFrom.put(group.from, group.expectedCrc); + } + } ArrayList apkPaths = collectApkPaths(); ArrayList zipFiles = new ArrayList(); // Entry name -> first archive (base APK first) that carries it. @@ -111,7 +126,7 @@ void copyFromResource( ZipSource entry = availableEntries.get(fromPath); String actualSourcePath = fromPath; ResolvedResourceSource resolvedResource = null; - Long wantedCrc = crcByFrom != null ? crcByFrom.get(fromPath) : null; + Long wantedCrc = crcByFrom.get(fromPath); if (entry == null) { String normalizedFrom = normalizeResPath(fromPath); diff --git a/android/src/main/java/cn/reactnative/modules/update/CopyGroupResult.java b/android/src/main/java/cn/reactnative/modules/update/CopyGroupResult.java index d732d301..f28de988 100644 --- a/android/src/main/java/cn/reactnative/modules/update/CopyGroupResult.java +++ b/android/src/main/java/cn/reactnative/modules/update/CopyGroupResult.java @@ -1,6 +1,9 @@ package cn.reactnative.modules.update; +/** Built by the native applyArchiveDelta: one copy source and its targets. */ class CopyGroupResult { String from; String[] toPaths; + // CRC32 the source content must match (__diff.json copiesCrc), or -1. + long expectedCrc = -1; } 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 945f3452..fc0d27bd 100644 --- a/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +++ b/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java @@ -4,7 +4,6 @@ import android.os.Handler; import android.os.Looper; import android.util.Log; -import androidx.annotation.Nullable; import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.WritableMap; import java.io.File; @@ -16,8 +15,6 @@ import java.security.MessageDigest; import java.util.ArrayList; import java.util.Enumeration; -import java.util.HashMap; -import java.util.Iterator; import java.util.zip.ZipEntry; import okhttp3.Call; import okhttp3.OkHttpClient; @@ -72,46 +69,16 @@ private File stagingDirectory() { // null when the bundle was produced some other way (patch tasks). private String stagedBundleSha256; - /** What a staged patch archive declared, as the native planner consumes it. */ + /** What a staged patch archive declared, as the native core consumes it. */ private static final class PatchArchiveContents { final String[] entryNames; - final String[] copyFroms; - final String[] copyTos; - final String[] deletes; - // Maps a copy source path ("from") to the CRC32 of the file content, - // when provided by the manifest ("copiesCrc"). Lets the resource - // copier locate the file by content if the path is not present on - // device (APK baseline -> AAB install path shortening). - final HashMap copyCrcs; - // __diff.json 的 hbcTransform 元数据(按 patch 条目名索引); - // 为空对象/缺失时返回 ""(native 走现状路径) - @Nullable - final JSONObject hbcTransform; - // Set by stagePatchArchive once the native planner accepted the archive. - ArchivePatchPlanResult plan; - - PatchArchiveContents( - ArrayList entryNames, - ArrayList copyFroms, - ArrayList copyTos, - ArrayList deletes, - HashMap copyCrcs, - @Nullable JSONObject hbcTransform - ) { - this.entryNames = entryNames.toArray(new String[0]); - this.copyFroms = copyFroms.toArray(new String[0]); - this.copyTos = copyTos.toArray(new String[0]); - this.deletes = deletes.toArray(new String[0]); - this.copyCrcs = copyCrcs; - this.hbcTransform = hbcTransform; - } + // The raw __diff.json bytes (UTF-8): parsed only by the native core + // (cpp/patch_core/patch_manifest.h), never here. + final byte[] manifestJson; - String hbcTransformMetaFor(String patchEntryName) { - if (hbcTransform == null) { - return ""; - } - JSONObject meta = hbcTransform.optJSONObject(patchEntryName); - return meta != null ? meta.toString() : ""; + PatchArchiveContents(ArrayList entryNames, byte[] manifestJson) { + this.entryNames = entryNames.toArray(new String[0]); + this.manifestJson = manifestJson; } } @@ -444,88 +411,19 @@ private boolean transferArchive( return true; } - private void appendManifestEntries( - JSONObject manifest, - ArrayList copyFroms, - ArrayList copyTos, - ArrayList deletes, - HashMap copyCrcs - ) throws JSONException { - JSONObject copiesCrc = manifest.optJSONObject("copiesCrc"); - - JSONObject copies = manifest.optJSONObject("copies"); - if (copies != null) { - Iterator keys = copies.keys(); - while (keys.hasNext()) { - String to = (String) keys.next(); - String from = copies.getString(to); - if (from.isEmpty()) { - from = to; - } - copyFroms.add(from); - copyTos.add(to); - if (copiesCrc != null && copiesCrc.has(to)) { - // Same content => same crc, so grouping multiple "to" under - // one "from" stays consistent. - copyCrcs.put(from, copiesCrc.getLong(to)); - } - } - } - - JSONObject deleteMap = manifest.optJSONObject("deletes"); - if (deleteMap != null) { - Iterator deleteKeys = deleteMap.keys(); - while (deleteKeys.hasNext()) { - deletes.add((String) deleteKeys.next()); - } - } - } - private void copyBundledAssetToFile(String assetName, File destination) throws IOException { try (InputStream in = context.getAssets().open(assetName)) { UpdateFileUtils.copyInputStreamToFile(in, destination); } } - private HashMap> buildCopyList( - File unzipDirectory, - CopyGroupResult[] groups - ) throws IOException { - HashMap> copyList = new HashMap>(); - if (groups == null) { - return copyList; - } - - String rootPath = unzipDirectory.getCanonicalPath() + File.separator; - for (CopyGroupResult group : groups) { - ArrayList targets = new ArrayList(); - if (group.toPaths != null) { - for (String to : group.toPaths) { - File toFile = new File(unzipDirectory, to); - String canonicalPath = toFile.getCanonicalPath(); - if (!canonicalPath.startsWith(rootPath)) { - throw new SecurityException("Illegal name: " + to); - } - targets.add(toFile); - } - } - copyList.put(group.from, targets); - } - - return copyList; - } - private PatchArchiveContents extractPatchArchive(File archiveFile, File unzipDirectory) - throws IOException, JSONException { + throws IOException { UpdateFileUtils.removeDirectory(unzipDirectory); UpdateFileUtils.ensureDirectory(unzipDirectory); ArrayList entryNames = new ArrayList(); - ArrayList copyFroms = new ArrayList(); - ArrayList copyTos = new ArrayList(); - ArrayList deletes = new ArrayList(); - HashMap copyCrcs = new HashMap(); - JSONObject hbcTransform = null; + byte[] manifestJson = new byte[0]; try (SafeZipFile zipFile = new SafeZipFile(archiveFile)) { ensureArchiveWithinLimits(archiveFile, zipFile, unzipDirectory); Enumeration entries = zipFile.entries(); @@ -538,34 +436,26 @@ private PatchArchiveContents extractPatchArchive(File archiveFile, File unzipDir if (entry.getSize() > ArchiveLimits.MAX_MANIFEST_BYTES) { throw new IOException("delta manifest too large: " + entry.getSize()); } - byte[] bytes = UpdateFileUtils.readBytes( + manifestJson = UpdateFileUtils.readBytes( zipFile.getInputStream(entry), ArchiveLimits.MAX_MANIFEST_BYTES); - String json = new String(bytes, StandardCharsets.UTF_8); - JSONObject manifest = (JSONObject) new JSONTokener(json).nextValue(); - appendManifestEntries(manifest, copyFroms, copyTos, deletes, copyCrcs); - hbcTransform = manifest.optJSONObject("hbcTransform"); continue; } zipFile.unzipToPath(entry, unzipDirectory); } } - return new PatchArchiveContents( - entryNames, copyFroms, copyTos, deletes, copyCrcs, hbcTransform); + return new PatchArchiveContents(entryNames, manifestJson); } /** - * Shared first half of the two patch tasks: download the archive, unpack - * it into the staging directory and have the native planner validate it. + * Shared first half of the two patch tasks: download the archive and + * unpack it into the staging directory. The native applyArchiveDelta + * validates what it declared. */ - private PatchArchiveContents stagePatchArchive(int patchType) - throws IOException, JSONException { + private PatchArchiveContents stagePatchArchive() throws IOException { downloadFile(); artifactSha256 = archiveSha256(); - PatchArchiveContents contents = extractPatchArchive(params.targetFile, stagingDirectory()); - contents.plan = buildArchivePlan( - patchType, contents.entryNames, contents.copyFroms, contents.copyTos, contents.deletes); - return contents; + return extractPatchArchive(params.targetFile, stagingDirectory()); } private void doFullPatch() throws IOException { @@ -670,53 +560,48 @@ private void deleteConsumedArchive() { deleteResumeSidecar(params.targetFile); } - private void doPatchFromApk() throws IOException, JSONException { - PatchArchiveContents contents = - stagePatchArchive(DownloadTaskParams.TASK_TYPE_PATCH_FROM_APK); + private void doPatchFromApk() throws IOException { + PatchArchiveContents contents = stagePatchArchive(); File work = stagingDirectory(); - HashMap> copyList = buildCopyList( - work, buildCopyGroups(contents.copyFroms, contents.copyTos)); File originBundleFile = new File(work, ".origin.bundle"); copyBundledAssetToFile("index.android.bundle", originBundleFile); + CopyGroupResult[] copies; try { - applyDeltaFromSource( + // The APK's resources are zip entries, not files: the core + // rebuilds the bundle and hands the manifest copies back. + copies = applyArchiveDelta( + DownloadTaskParams.TASK_TYPE_PATCH_FROM_APK, + contents.entryNames, + contents.manifestJson, work.getAbsolutePath(), work.getAbsolutePath(), originBundleFile.getAbsolutePath(), new File(work, BUNDLE_DELTA_ENTRY).getAbsolutePath(), InstallRecord.bundleFile(work).getAbsolutePath(), - "", - false, - new String[0], - new String[0], - new String[0], - contents.hbcTransformMetaFor(BUNDLE_DELTA_ENTRY) + true ); } finally { originBundleFile.delete(); } - bundledResourceCopier.copyFromResource(copyList, contents.copyCrcs); + bundledResourceCopier.copyFromResource(work, copies); deleteConsumedArchive(); } - private void doPatchFromPpk() throws IOException, JSONException { - PatchArchiveContents contents = - stagePatchArchive(DownloadTaskParams.TASK_TYPE_PATCH_FROM_PPK); + private void doPatchFromPpk() throws IOException { + PatchArchiveContents contents = stagePatchArchive(); File work = stagingDirectory(); - applyDeltaFromSource( + applyArchiveDelta( + DownloadTaskParams.TASK_TYPE_PATCH_FROM_PPK, + contents.entryNames, + contents.manifestJson, params.originDirectory.getAbsolutePath(), work.getAbsolutePath(), InstallRecord.bundleFile(params.originDirectory).getAbsolutePath(), new File(work, BUNDLE_DELTA_ENTRY).getAbsolutePath(), InstallRecord.bundleFile(work).getAbsolutePath(), - contents.plan.mergeSourceSubdir, - contents.plan.enableMerge, - contents.copyFroms, - contents.copyTos, - contents.deletes, - contents.hbcTransformMetaFor(BUNDLE_DELTA_ENTRY) + false ); deleteConsumedArchive(); } @@ -903,18 +788,21 @@ && isPatchTask(taskType) } } - private static native void applyDeltaFromSource( + // The native half of a patch install (cpp/patch_core/archive_patch_core.h + // ApplyArchivePatch): checks the archive's entries, parses and validates + // manifestJson (__diff.json), rebuilds the bundle and, unless + // hostCopiesResources, performs the copies, deletes and merge. Returns + // the copies the caller still owes (empty unless hostCopiesResources). + private static native CopyGroupResult[] applyArchiveDelta( + int patchType, + String[] entryNames, + byte[] manifestJson, String sourceRoot, String targetRoot, String originBundlePath, String bundlePatchPath, String bundleOutputPath, - String mergeSourceSubdir, - boolean enableMerge, - String[] copyFroms, - String[] copyTos, - String[] deletes, - String hbcTransformMeta + boolean hostCopiesResources ); // trashDir non-empty: move the selected entries there instead of @@ -925,17 +813,4 @@ private static native void cleanupOldEntries( int maxAgeDays, String trashDir ); - - private static native ArchivePatchPlanResult buildArchivePlan( - int patchType, - String[] entryNames, - String[] copyFroms, - String[] copyTos, - String[] deletes - ); - - private static native CopyGroupResult[] buildCopyGroups( - String[] copyFroms, - String[] copyTos - ); } diff --git a/cpp/android_jni/jni_natives.h b/cpp/android_jni/jni_natives.h index 6efb5867..5b19c779 100644 --- a/cpp/android_jni/jni_natives.h +++ b/cpp/android_jni/jni_natives.h @@ -18,9 +18,7 @@ bool InitResultClassCache(JNIEnv* env); jint SupportedDiffVersion(JNIEnv*, jclass); jobject SyncStateWithBinaryVersion(JNIEnv* env, jclass, jstring package_version, jstring build_time, jobject state_result); jobject RunStateCore(JNIEnv* env, jclass, jint operation, jobject state_result, jstring string_arg, jboolean flag_a, jboolean flag_b); -jobject BuildArchivePlan(JNIEnv* env, jclass, jint patch_type, jobjectArray entry_names, jobjectArray copy_froms, jobjectArray copy_tos, jobjectArray deletes); -jobjectArray BuildCopyGroups(JNIEnv* env, jclass, jobjectArray copy_froms, jobjectArray copy_tos); -void ApplyDeltaFromSource(JNIEnv* env, jclass, jstring source_root, jstring target_root, jstring origin_bundle_path, jstring bundle_patch_path, jstring bundle_output_path, jstring merge_source_subdir, jboolean enable_merge, jobjectArray copy_froms, jobjectArray copy_tos, jobjectArray deletes, jstring hbc_transform_meta); +jobjectArray ApplyArchiveDelta(JNIEnv* env, jclass, jint patch_type, jobjectArray entry_names, jbyteArray manifest_json, jstring source_root, jstring target_root, jstring origin_bundle_path, jstring bundle_patch_path, jstring bundle_output_path, jboolean host_copies_resources); void CleanupOldEntries(JNIEnv* env, jclass, jstring root_dir, jobjectArray keep_names, jint max_age_days, jstring trash_dir); jstring FlowBuildRequestBody(JNIEnv* env, jclass, jstring inputJson); jstring FlowOrderEndpointCandidates(JNIEnv* env, jclass, jstring endpointsJson, jdouble randomSample); diff --git a/cpp/android_jni/jni_registration.cpp b/cpp/android_jni/jni_registration.cpp index e4a592bc..ed74b42b 100644 --- a/cpp/android_jni/jni_registration.cpp +++ b/cpp/android_jni/jni_registration.cpp @@ -42,12 +42,8 @@ const std::vector& NativeClasses() { }}, {"3919bbc3ab8a6b512c3e08f0c0b6df60452311edfbc8f780624b2d1de38c84b28d79583e0feffca49194", // DownloadTask { - {"3802fdddaaaa7a462a360afce6bf9163", "723ecffda48a7e446d331df7d1fca379582e0ae6a5e0949f73592d46eac2aebad544402307e5cffeb9b376582012bfc1ab89630e6d2f0afcdca8d7526a29010bfb98b890604c6736f6edd5b79128190109e98bb3bb9a7b413c0ef8c0b0862f70553301fdcbb8c770525b3d0df39c919f694f2d171bcbd9a191677c2507edf2d8a982784575", - reinterpret_cast(&pushy::jni_natives::BuildArchivePlan)}, - {"3802fdddaaa867553b180ef6c3a383", "722cd8dbaf9d690a2e3e12fe9980847f432903bac5f7b294644e6305e7cda7f2a963463800ec93ecb9b37f577901f5cca9936a404a320ef09da2836d532f050eb5c2a4956f5f2d4ac1f0cca0b1615f381ad5c1b2ab976c0e", - reinterpret_cast(&pushy::jni_natives::BuildCopyGroups)}, - {"3b07e4ddb7af6d49363e3aebd9bea3625f3507e4", "723bfed0b88a274923311bb6e5a7826444205fcdf4daae943d432d07e18c93a9887e5a3655c7c2a4949e3355371df78299937648503c43d9d8ae9a68092f0113fd9887857c422602b9d3d6b880721f210be9c3ee8d8f6a5c3c08b7e5ac82767c153b15ffc9e4bb715056321eade98ba16046320051f7d9bb95207f3d14eacedae1ac585b2f1de98aaebe927e190004ffc3a9833a45773214e4cee385674d277229e3c6b8806c1309081eead8f99f71432d48d7d5ccb2967209663a", - reinterpret_cast(&pushy::jni_natives::ApplyDeltaFromSource)}, + {"3b07e4ddb7aa7a462a360afcf2b69c794b", "723ecffda48a7e446d331df7d1fca379582e0ae6a5e09ab9784e3a08a9cfa1b39d3867251ce2c6a2d9b376582012bfc1ab89630e6d2f0afcdca8d7454c22161cb5dbb59f69041b11f0f6d2becd5f5a2c1ce68badbf957f1a011bfec0a8843b51503602f081a7896b45100f0de4dabe8a317d6d3a32f8d6fa806a4d2a12edc1c9b381711e2304ecd0aeba8f36432314ecdea2cb42714b2132e0c0b9995446332816e38f", + reinterpret_cast(&pushy::jni_natives::ApplyArchiveDelta)}, {"391bf1d0a09e786a2e3b39f7c2a1996859", "723bfed0b88a274923311bb6e5a7826444205fdad2d1b98373002008e8c4ef8e8e655d3f09b0e189889e6a58791ff1c3adc857554c3216f289e6ba", reinterpret_cast(&pushy::jni_natives::CleanupOldEntries)}, }}, diff --git a/cpp/android_jni/jni_util.h b/cpp/android_jni/jni_util.h index 233d1d00..0065f865 100644 --- a/cpp/android_jni/jni_util.h +++ b/cpp/android_jni/jni_util.h @@ -9,8 +9,6 @@ #include #include -#include "../patch_core/patch_core.h" - // Small JNI helpers shared by every Android glue translation unit // (update_core_android.cpp and update_flow_jni.cpp). Header-only with inline // linkage so each can include it without a separate compilation unit. @@ -125,22 +123,18 @@ inline void ThrowRuntimeException(JNIEnv* env, const std::string& message) { ThrowRuntimeException(env, message.c_str()); } -// Reads the parallel copy_froms/copy_tos arrays (+ deletes, may be null) into -// a PatchManifest via delta::BuildPatchManifest. On failure (length mismatch) -// a RuntimeException is pending and false is returned. -inline bool ReadPatchManifest( - JNIEnv* env, - jobjectArray copy_froms, - jobjectArray copy_tos, - jobjectArray deletes, - delta::PatchManifest* out) { - const delta::Status status = delta::BuildPatchManifest( - JArrayToVector(env, copy_froms), JArrayToVector(env, copy_tos), - JArrayToVector(env, deletes), out); - if (!status.ok) { - ThrowRuntimeException(env, status.message); +// The bytes of a Java byte[] as-is (an empty string for null). +inline std::string JByteArrayToString(JNIEnv* env, jbyteArray values) { + if (values == nullptr) { + return std::string(); } - return status.ok; + const jsize size = env->GetArrayLength(values); + std::string result(static_cast(size), '\0'); + if (size > 0) { + env->GetByteArrayRegion(values, 0, size, + reinterpret_cast(&result[0])); + } + return result; } // Runs a native method body so no C++ exception (bad_alloc/length_error from diff --git a/cpp/android_jni/update_core_android.cpp b/cpp/android_jni/update_core_android.cpp index b8e5cc28..540813d2 100644 --- a/cpp/android_jni/update_core_android.cpp +++ b/cpp/android_jni/update_core_android.cpp @@ -18,8 +18,8 @@ namespace { using pushy::jni_util::GuardJni; using pushy::jni_util::JArrayToVector; +using pushy::jni_util::JByteArrayToString; using pushy::jni_util::JStringToString; -using pushy::jni_util::ReadPatchManifest; using pushy::jni_util::ThrowRuntimeException; using pushy::jni_util::ToJString; using pushy::state_ops::StateOperation; @@ -47,14 +47,6 @@ struct StateResultIds { jfieldID consumed_first_time = nullptr; }; -struct PlanResultIds { - bool ready = false; - jclass clazz = nullptr; - jmethodID constructor = nullptr; - jfieldID merge_source_subdir = nullptr; - jfieldID enable_merge = nullptr; -}; - struct CopyGroupIds { bool ready = false; jclass clazz = nullptr; @@ -62,10 +54,10 @@ struct CopyGroupIds { jmethodID constructor = nullptr; jfieldID from = nullptr; jfieldID to_paths = nullptr; + jfieldID expected_crc = nullptr; }; StateResultIds g_state; -PlanResultIds g_plan; CopyGroupIds g_copy_group; // Resolves a class to a global ref. Logs and clears the pending exception on @@ -138,22 +130,6 @@ bool InitStateResultIds(JNIEnv* env) { return ok; } -bool InitPlanResultIds(JNIEnv* env) { - const std::string name = pushy::text::Reveal( - "3919bbc3ab8a6b512c3e08f0c0b6df60452311edfbc8f780624b2d1de38c81af997f5d270bdbc9b181974c55371dc2c8b9926855"); - PlanResultIds ids; - ids.clazz = FindGlobalClass(env, name); - if (ids.clazz == nullptr) { - return false; - } - bool ok = LookupConstructor(env, ids.clazz, name, &ids.constructor); - ok &= LookupField(env, ids.clazz, name, "mergeSourceSubdir", kStringSig, &ids.merge_source_subdir); - ok &= LookupField(env, ids.clazz, name, "enableMerge", "Z", &ids.enable_merge); - ids.ready = ok; - g_plan = ids; - return ok; -} - bool InitCopyGroupIds(JNIEnv* env) { const std::string name = pushy::text::Reveal( "3919bbc3ab8a6b512c3e08f0c0b6df60452311edfbc8f780624b2d1de38c83b28a6e732301fed897878c695522"); @@ -166,6 +142,7 @@ bool InitCopyGroupIds(JNIEnv* env) { bool ok = LookupConstructor(env, ids.clazz, name, &ids.constructor); ok &= LookupField(env, ids.clazz, name, "from", kStringSig, &ids.from); ok &= LookupField(env, ids.clazz, name, "toPaths", "[Ljava/lang/String;", &ids.to_paths); + ok &= LookupField(env, ids.clazz, name, "expectedCrc", "J", &ids.expected_crc); ids.ready = ok; g_copy_group = ids; return ok; @@ -263,22 +240,6 @@ jobject NewStateCoreResult( return result; } -jobject NewArchivePatchPlanResult( - JNIEnv* env, - const pushy::archive_patch::ArchivePatchPlan& plan) { - if (!RequireReady(env, g_plan.ready, "archive plan result")) { - return nullptr; - } - jobject result = env->NewObject(g_plan.clazz, g_plan.constructor); - if (result == nullptr) { - return nullptr; - } - - SetStringField(env, result, g_plan.merge_source_subdir, plan.merge_source_subdir); - SetBooleanField(env, result, g_plan.enable_merge, plan.enable_merge); - return result; -} - // Caller has checked g_copy_group.ready. jobject NewCopyGroupResult( JNIEnv* env, @@ -302,6 +263,9 @@ jobject NewCopyGroupResult( env->SetObjectField(result, g_copy_group.to_paths, to_paths); env->DeleteLocalRef(to_paths); } + env->SetLongField( + result, g_copy_group.expected_crc, + group.has_expected_crc ? static_cast(group.expected_crc) : -1); return result; } @@ -311,7 +275,6 @@ jobject NewCopyGroupResult( bool pushy::jni_natives::InitResultClassCache(JNIEnv* env) { // Every class is attempted so the log names all missing members at once. bool ok = InitStateResultIds(env); - ok &= InitPlanResultIds(env); ok &= InitCopyGroupIds(env); return ok; } @@ -402,71 +365,56 @@ jobject pushy::jni_natives::RunStateCore( }); } -jobject pushy::jni_natives::BuildArchivePlan( +jobjectArray pushy::jni_natives::ApplyArchiveDelta( JNIEnv* env, jclass, jint patch_type, jobjectArray entry_names, - jobjectArray copy_froms, - jobjectArray copy_tos, - jobjectArray deletes) { + jbyteArray manifest_json, + jstring source_root, + jstring target_root, + jstring origin_bundle_path, + jstring bundle_patch_path, + jstring bundle_output_path, + jboolean host_copies_resources) { return GuardJni( - env, "Unexpected native exception while planning archive", - [&]() -> jobject { - pushy::delta::PatchManifest manifest; - if (!ReadPatchManifest(env, copy_froms, copy_tos, deletes, &manifest)) { + env, "Unexpected native exception while applying archive delta", + [&]() -> jobjectArray { + if (!RequireReady(env, g_copy_group.ready, "copy group result")) { return nullptr; } - // The shared parser keeps Android and HarmonyOS in agreement on which - // integer values are valid; neither silently falls back to kFull. - pushy::archive_patch::ArchivePatchType archive_type; + pushy::archive_patch::ArchivePatchRequest request; + // Unknown values never fall back to kFull (which would skip the + // manifest and entry checks). if (!pushy::archive_patch::TryParseArchivePatchType( - static_cast(patch_type), &archive_type)) { + static_cast(patch_type), &request.archive.type)) { ThrowRuntimeException(env, "Unknown archive delta type"); return nullptr; } - pushy::archive_patch::ArchivePatchPlan plan; - pushy::delta::Status status = - pushy::archive_patch::BuildArchivePatchPlan( - archive_type, manifest, JArrayToVector(env, entry_names), &plan); - if (!status.ok) { - ThrowRuntimeException(env, status.message); - return nullptr; - } - - return NewArchivePatchPlanResult(env, plan); - }); -} + request.archive.entry_names = JArrayToVector(env, entry_names); + // The manifest's own UTF-8 bytes: a jstring would come through as + // modified UTF-8 and mangle supplementary characters in paths. + request.archive.manifest_json = JByteArrayToString(env, manifest_json); + request.source_root = JStringToString(env, source_root); + request.target_root = JStringToString(env, target_root); + request.origin_bundle_path = JStringToString(env, origin_bundle_path); + request.bundle_patch_path = JStringToString(env, bundle_patch_path); + request.bundle_output_path = JStringToString(env, bundle_output_path); + request.host_copies_resources = host_copies_resources == JNI_TRUE; -jobjectArray pushy::jni_natives::BuildCopyGroups( - JNIEnv* env, - jclass, - jobjectArray copy_froms, - jobjectArray copy_tos) { - return GuardJni( - env, "Unexpected native exception in buildCopyGroups", - [&]() -> jobjectArray { - pushy::delta::PatchManifest manifest; - if (!ReadPatchManifest(env, copy_froms, copy_tos, nullptr, &manifest)) { - return nullptr; - } std::vector groups; - pushy::delta::Status status = - pushy::archive_patch::BuildCopyGroups(manifest, &groups); + const pushy::delta::Status status = + pushy::archive_patch::ApplyArchivePatch(request, &groups); if (!status.ok) { ThrowRuntimeException(env, status.message); return nullptr; } - if (!RequireReady(env, g_copy_group.ready, "copy group result")) { - return nullptr; - } jobjectArray result = env->NewObjectArray( static_cast(groups.size()), g_copy_group.clazz, nullptr); if (result == nullptr) { return nullptr; } - for (jsize index = 0; index < static_cast(groups.size()); ++index) { jobject group = NewCopyGroupResult(env, groups[index]); @@ -477,42 +425,6 @@ jobjectArray pushy::jni_natives::BuildCopyGroups( }); } -void pushy::jni_natives::ApplyDeltaFromSource( - JNIEnv* env, - jclass, - jstring source_root, - jstring target_root, - jstring origin_bundle_path, - jstring bundle_patch_path, - jstring bundle_output_path, - jstring merge_source_subdir, - jboolean enable_merge, - jobjectArray copy_froms, - jobjectArray copy_tos, - jobjectArray deletes, - jstring hbc_transform_meta) { - GuardJni(env, "Unexpected native exception while applying delta", [&] { - pushy::delta::FileSourcePatchOptions options; - if (!ReadPatchManifest(env, copy_froms, copy_tos, deletes, &options.manifest)) { - return; - } - options.source_root = JStringToString(env, source_root); - options.target_root = JStringToString(env, target_root); - options.origin_bundle_path = JStringToString(env, origin_bundle_path); - options.bundle_patch_path = JStringToString(env, bundle_patch_path); - options.bundle_output_path = JStringToString(env, bundle_output_path); - options.merge_source_subdir = JStringToString(env, merge_source_subdir); - options.enable_merge = enable_merge == JNI_TRUE; - options.manifest.bundle_hbc_transform_meta = JStringToString(env, hbc_transform_meta); - - const pushy::delta::Status status = - pushy::delta::ApplyPatchFromFileSource(options); - if (!status.ok) { - ThrowRuntimeException(env, status.message); - } - }); -} - void pushy::jni_natives::CleanupOldEntries( JNIEnv* env, jclass, diff --git a/src/__tests__/jniRegistration.test.ts b/src/__tests__/jniRegistration.test.ts index 71fcdbe7..34797fef 100644 --- a/src/__tests__/jniRegistration.test.ts +++ b/src/__tests__/jniRegistration.test.ts @@ -16,6 +16,7 @@ const descriptor = (type: string): string => { const base = array ? type.slice(0, -2) : type; const primitives: Record = { boolean: 'Z', + byte: 'B', int: 'I', long: 'J', double: 'D', @@ -72,7 +73,7 @@ const registered = () => { describe('JNI registration table', () => { test('matches every Java native declaration', () => { const natives = javaNatives(); - expect(natives.length).toBe(14); + expect(natives.length).toBe(12); expect(registered()).toEqual(natives); }); From 20acb8e008ac017e01e421a988079d0bd0fca657 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 16:34:19 +0800 Subject: [PATCH 082/104] refactor(ios): hand __diff.json to the shared core as text applyDeltaForHash reads the manifest (size-capped as before) and calls archive_patch::ApplyArchivePatch with it, so PushyPatchManifestFromJson, the NSJSONSerialization round trip and the hbcTransform extraction are gone. The rules are the shared ones: a CRC must be an integer in [0, 2^32) (true, 1.5 or 2^32 are no longer read as 1, 1 or 0) and non-object hbcTransform metadata fails the patch instead of being skipped. A missing manifest is now reported as "diff.json not found". Co-Authored-By: Claude Opus 5.5 --- ios/RCTPushy/RCTPushyInstaller.mm | 173 +++++------------------------- 1 file changed, 29 insertions(+), 144 deletions(-) diff --git a/ios/RCTPushy/RCTPushyInstaller.mm b/ios/RCTPushy/RCTPushyInstaller.mm index fbbd89c1..7417dd92 100644 --- a/ios/RCTPushy/RCTPushyInstaller.mm +++ b/ios/RCTPushy/RCTPushyInstaller.mm @@ -264,73 +264,6 @@ static void PushyFinishDownload(NSString *hash, NSError *error) { } } -static BOOL PushyJsonIsAbsent(id value) { - return value == nil || [value isKindOfClass:[NSNull class]]; -} - -// Builds the patch-core manifest from a parsed __diff.json. Every field is -// type-checked: this runs inside a GCD block on the file queue, where the -// unrecognized-selector exception a `copies` array or a numeric `from` would -// raise takes the whole app down (and trips the crash rescue) instead of -// failing the patch. NO with `reason` set for a malformed manifest. -static BOOL PushyPatchManifestFromJson(NSDictionary *json, - pushy::delta::PatchManifest *manifest, - NSString **reason) { - id copies = json[@"copies"]; - if (!PushyJsonIsAbsent(copies)) { - if (![copies isKindOfClass:[NSDictionary class]]) { - *reason = @"delta manifest: copies is not an object"; - return NO; - } - // Content checksum per copy target ("copiesCrc", pdiff manifests - // from CLI >= 2.21.2). The patch core verifies the copy source - // against it and fails the patch on mismatch, so the JS strategy - // chain falls back to the full package instead of copying drifted - // bytes from a rebuilt binary. - NSDictionary *copiesCrc = json[@"copiesCrc"]; - if (![copiesCrc isKindOfClass:[NSDictionary class]]) { - copiesCrc = nil; - } - for (id to in (NSDictionary *)copies) { - id from = ((NSDictionary *)copies)[to]; - if (![to isKindOfClass:[NSString class]] || ![from isKindOfClass:[NSString class]]) { - *reason = @"delta manifest: copies entry is not a string"; - return NO; - } - if ([from length] == 0) { - from = to; - } - pushy::delta::CopyOperation operation; - operation.from = PushyToStdString(from); - operation.to = PushyToStdString(to); - NSNumber *expectedCrc = copiesCrc[to]; - if ([expectedCrc isKindOfClass:[NSNumber class]]) { - operation.has_expected_crc = true; - operation.expected_crc = (uint32_t)[expectedCrc unsignedLongLongValue]; - } - manifest->copies.push_back(operation); - } - } - - // The CLI emits `deletes` as an object keyed by path (Android reads its - // keys); a plain array of paths is accepted as well, like Harmony. - id deletes = json[@"deletes"]; - if (!PushyJsonIsAbsent(deletes)) { - if (![deletes isKindOfClass:[NSDictionary class]] && ![deletes isKindOfClass:[NSArray class]]) { - *reason = @"delta manifest: deletes is not an object or array"; - return NO; - } - for (id path in deletes) { - if (![path isKindOfClass:[NSString class]]) { - *reason = @"delta manifest: deletes entry is not a string"; - return NO; - } - manifest->deletes.push_back(PushyToStdString(path)); - } - } - return YES; -} - // Everything under rctpushy is re-downloadable, and Application Support is // backed up to iCloud by default — Apple requires such content to be // excluded from backups. @@ -541,7 +474,8 @@ + (void)finishDownloadedPackage:(NSString *)hash } // Applies the unpacked patch in the staging directory (two-phase install); -// nil on success, a PATCH_FAILED error otherwise. +// nil on success, a PATCH_FAILED error otherwise. __diff.json is handed to +// the patch core as text (cpp/patch_core/patch_manifest.h parses it). + (NSError *)applyDeltaForHash:(NSString *)hash type:(PushyType)type fromBundle:(NSString *)bundleOrigin @@ -550,87 +484,38 @@ + (NSError *)applyDeltaForHash:(NSString *)hash NSString *unzipDir = PushyStagingDir(hash); NSString *sourcePatch = [unzipDir stringByAppendingPathComponent:SOURCE_PATCH_NAME]; NSString *bundlePatch = [unzipDir stringByAppendingPathComponent:BUNDLE_PATCH_NAME()]; + NSFileManager *fileManager = [NSFileManager defaultManager]; - NSString *destination = [unzipDir stringByAppendingPathComponent:BUNDLE_FILE_NAME]; - long long manifestBytes = RCTPushyFileSize(sourcePatch); - if (manifestBytes > pushy::archive_limits::kMaxManifestBytes) { - return PushyErrorWithCode(pushy::error_codes::kDeltaFailed, - [NSString stringWithFormat:@"delta manifest too large: %lld bytes", manifestBytes]); - } - NSData *data = [NSData dataWithContentsOfFile:sourcePatch]; - if (data == nil) { - return PushyErrorWithCode(pushy::error_codes::kDeltaFailed, @"missing delta manifest"); - } - - // A parse failure is a patch failure like the sibling manifest branches; - // unclassified errors would otherwise be tagged DOWNLOAD_FAILED by the - // fetchPackage fallback even though the download itself succeeded. - NSError *error = nil; - NSDictionary *json = PushyJsonDictionary(data, &error); - if (json == nil) { - return PushyErrorWithCode(pushy::error_codes::kDeltaFailed, - error != nil ? error.localizedDescription : @"invalid delta manifest"); - } - pushy::delta::PatchManifest manifest; - NSString *manifestReason = nil; - if (!PushyPatchManifestFromJson(json, &manifest, &manifestReason)) { - return PushyErrorWithCode(pushy::error_codes::kDeltaFailed, manifestReason); - } - - std::vector entryNames; - if ([[NSFileManager defaultManager] fileExistsAtPath:sourcePatch isDirectory:NULL]) { - entryNames.push_back(PushyToStdString(SOURCE_PATCH_NAME)); - } - if ([[NSFileManager defaultManager] fileExistsAtPath:bundlePatch isDirectory:NULL]) { - entryNames.push_back(PushyToStdString(BUNDLE_PATCH_NAME())); - } - - NSError *(^statusError)(const pushy::delta::Status &) = ^NSError *(const pushy::delta::Status &status) { - return PushyErrorWithCode(pushy::error_codes::kDeltaFailed, @(status.message.c_str())); - }; - pushy::archive_patch::ArchivePatchPlan plan; - pushy::delta::Status planStatus = pushy::archive_patch::BuildArchivePatchPlan( - type == PushyTypePatchFromPackage - ? pushy::archive_patch::ArchivePatchType::kPatchFromPackage - : pushy::archive_patch::ArchivePatchType::kPatchFromPpk, - manifest, - entryNames, - &plan - ); - if (!planStatus.ok) { - return statusError(planStatus); - } - - pushy::delta::FileSourcePatchOptions options; - pushy::delta::Status optionStatus = pushy::archive_patch::BuildFileSourcePatchOptions( - plan, - PushyToStdString(sourceOrigin), - PushyToStdString(unzipDir), - PushyToStdString(bundleOrigin), - PushyToStdString(bundlePatch), - PushyToStdString(destination), - &options - ); - if (!optionStatus.ok) { - return statusError(optionStatus); - } - - // __diff.json 的 hbcTransform 元数据(HBC 变换域 patch,hdiffv2 轨道): - // 存在时透传给 patch 内核执行 T(origin) → hpatch → T⁻¹;缺失走现状路径。 - NSDictionary *hbcTransform = json[@"hbcTransform"]; - if ([hbcTransform isKindOfClass:[NSDictionary class]]) { - NSDictionary *meta = hbcTransform[BUNDLE_PATCH_NAME()]; - if ([meta isKindOfClass:[NSDictionary class]]) { - NSString *metaString = PushyJsonString(meta); - if (metaString != nil) { - options.manifest.bundle_hbc_transform_meta = PushyToStdString(metaString); - } + pushy::archive_patch::ArchivePatchRequest request; + request.archive.type = type == PushyTypePatchFromPackage + ? pushy::archive_patch::ArchivePatchType::kPatchFromPackage + : pushy::archive_patch::ArchivePatchType::kPatchFromPpk; + if ([fileManager fileExistsAtPath:sourcePatch isDirectory:NULL]) { + long long manifestBytes = RCTPushyFileSize(sourcePatch); + if (manifestBytes > pushy::archive_limits::kMaxManifestBytes) { + return PushyErrorWithCode(pushy::error_codes::kDeltaFailed, + [NSString stringWithFormat:@"delta manifest too large: %lld bytes", manifestBytes]); + } + NSData *data = [NSData dataWithContentsOfFile:sourcePatch]; + if (data == nil) { + return PushyErrorWithCode(pushy::error_codes::kDeltaFailed, @"unreadable delta manifest"); } + request.archive.entry_names.push_back(PushyToStdString(SOURCE_PATCH_NAME)); + request.archive.manifest_json.assign(static_cast(data.bytes), data.length); } + if ([fileManager fileExistsAtPath:bundlePatch isDirectory:NULL]) { + request.archive.entry_names.push_back(PushyToStdString(BUNDLE_PATCH_NAME())); + } + request.source_root = PushyToStdString(sourceOrigin); + request.target_root = PushyToStdString(unzipDir); + request.origin_bundle_path = PushyToStdString(bundleOrigin); + request.bundle_patch_path = PushyToStdString(bundlePatch); + request.bundle_output_path = PushyToStdString( + [unzipDir stringByAppendingPathComponent:BUNDLE_FILE_NAME]); - pushy::delta::Status status = pushy::delta::ApplyPatchFromFileSource(options); + pushy::delta::Status status = pushy::archive_patch::ApplyArchivePatch(request, nullptr); if (!status.ok) { - return statusError(status); + return PushyErrorWithCode(pushy::error_codes::kDeltaFailed, @(status.message.c_str())); } return nil; } From 4fc51ca0d9215b9861d77ca39a7f7debeef4778d Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 16:34:27 +0800 Subject: [PATCH 083/104] refactor(harmony): hand __diff.json to the shared core as text pushy.cpp exports applyArchiveDelta (async, over archive_patch::ApplyArchivePatch) in place of buildArchivePlan, buildCopyGroups and applyDeltaFromSource; the manifest is parsed on the worker thread with the rest of the patch. DownloadTask reads the manifest text and passes it with the entry names: the ppk task lets the core copy, delete and merge, the package task passes hostCopiesResources and the "resources/rawfile/" prefix and copies the returned groups, each with the CRC its source must match. parseManifestToArrays and its bun tests are gone; the cases moved to cpp/patch_core/tests/patch-manifest-vectors.json. Behaviour changes, from the shared rules: the rawfile prefix is stripped only at the start of a source, an empty source means the target's path in ppk patches too, and values of the wrong type fail the patch instead of being coerced with String() or ignored. Co-Authored-By: Claude Opus 5.5 --- harmony/pushy/src/main/cpp/pushy.cpp | 238 +++++------------- harmony/pushy/src/main/ets/DownloadTask.ts | 212 ++++------------ harmony/pushy/src/main/ets/NativePatchCore.ts | 43 ++-- src/__tests__/harmonyDownloadTask.test.ts | 130 +--------- 4 files changed, 128 insertions(+), 495 deletions(-) diff --git a/harmony/pushy/src/main/cpp/pushy.cpp b/harmony/pushy/src/main/cpp/pushy.cpp index ce1561f6..61461a6b 100644 --- a/harmony/pushy/src/main/cpp/pushy.cpp +++ b/harmony/pushy/src/main/cpp/pushy.cpp @@ -375,16 +375,6 @@ napi_value NewStateResult( return result; } -napi_value NewArchivePatchPlanResult( - napi_env env, - const pushy::archive_patch::ArchivePatchPlan& plan) { - napi_value result = nullptr; - napi_create_object(env, &result); - SetStringProperty(env, result, "mergeSourceSubdir", plan.merge_source_subdir); - SetBoolProperty(env, result, "enableMerge", plan.enable_merge); - return result; -} - napi_value NewCopyGroupArray( napi_env env, const std::vector& groups) { @@ -406,29 +396,18 @@ napi_value NewCopyGroupArray( NewString(env, groups[index].to_paths[target_index])); } napi_set_named_property(env, group, "toPaths", to_paths); + if (groups[index].has_expected_crc) { + // A CRC32 fits a double exactly, like the manifest's JSON number. + napi_value crc = nullptr; + napi_create_double( + env, static_cast(groups[index].expected_crc), &crc); + napi_set_named_property(env, group, "expectedCrc", crc); + } napi_set_element(env, result, index, group); } return result; } -// The parallel copy lists (+ deletes) as a PatchManifest via the shared -// delta::BuildPatchManifest. On failure (length mismatch) a JS error is -// pending and false is returned. -bool ReadPatchManifest( - napi_env env, - std::vector copy_froms, - std::vector copy_tos, - std::vector deletes, - pushy::delta::PatchManifest* out) { - const pushy::delta::Status status = pushy::delta::BuildPatchManifest( - std::move(copy_froms), std::move(copy_tos), std::move(deletes), out); - if (!status.ok) { - ThrowError(env, status.message); - return false; - } - return true; -} - napi_value SyncStateWithBinaryVersion(napi_env env, napi_callback_info info) { napi_value args[3] = {nullptr, nullptr, nullptr}; size_t argc = 3; @@ -529,107 +508,10 @@ napi_value RunStateCore(napi_env env, napi_callback_info info) { return nullptr; } -napi_value BuildArchivePatchPlan(napi_env env, napi_callback_info info) { - napi_value args[6] = {nullptr, nullptr, nullptr, nullptr, nullptr, nullptr}; - size_t argc = 6; - if (!GetArgCount(env, info, &argc, args) || argc < 5) { - ThrowError(env, "Wrong number of arguments"); - return nullptr; - } - - int32_t patch_type = 0; - if (!GetInt32(env, args[0], &patch_type)) { - return nullptr; - } - - std::vector entry_names; - std::vector copy_froms; - std::vector copy_tos; - std::vector deletes; - if (!GetStringArray(env, args[1], &entry_names) || - !GetStringArray(env, args[2], ©_froms) || - !GetStringArray(env, args[3], ©_tos) || - !GetStringArray(env, args[4], &deletes)) { - return nullptr; - } - pushy::delta::PatchManifest manifest; - if (!ReadPatchManifest( - env, - std::move(copy_froms), - std::move(copy_tos), - std::move(deletes), - &manifest)) { - return nullptr; - } - - std::string bundle_patch_entry_name = - pushy::archive_patch::DefaultBundleDeltaEntryName(); - if (argc >= 6) { - bool ok = false; - const std::string candidate = GetString(env, args[5], &ok); - if (!ok) { - return nullptr; - } - if (!candidate.empty()) { - bundle_patch_entry_name = candidate; - } - } - - pushy::archive_patch::ArchivePatchType archive_type; - if (!pushy::archive_patch::TryParseArchivePatchType(patch_type, &archive_type)) { - ThrowError(env, "Unknown archive delta type"); - return nullptr; - } - pushy::archive_patch::ArchivePatchPlan plan; - const pushy::delta::Status status = pushy::archive_patch::BuildArchivePatchPlan( - archive_type, - manifest, - entry_names, - &plan, - bundle_patch_entry_name); - if (!status.ok) { - ThrowError(env, status.message); - return nullptr; - } - - return NewArchivePatchPlanResult(env, plan); -} - -napi_value BuildCopyGroups(napi_env env, napi_callback_info info) { - napi_value args[2] = {nullptr, nullptr}; - size_t argc = 2; - if (!GetArgCount(env, info, &argc, args) || argc < 2) { - ThrowError(env, "Wrong number of arguments"); - return nullptr; - } - - std::vector copy_froms; - std::vector copy_tos; - if (!GetStringArray(env, args[0], ©_froms) || - !GetStringArray(env, args[1], ©_tos)) { - return nullptr; - } - pushy::delta::PatchManifest manifest; - if (!ReadPatchManifest( - env, std::move(copy_froms), std::move(copy_tos), {}, &manifest)) { - return nullptr; - } - - std::vector groups; - const pushy::delta::Status status = - pushy::archive_patch::BuildCopyGroups(manifest, &groups); - if (!status.ok) { - ThrowError(env, status.message); - return nullptr; - } - - return NewCopyGroupArray(env, groups); -} - // --------------------------------------------------------------------------- // Async work plumbing for the heavy patch operations. // -// applyDeltaFromSource and cleanupOldEntries run hdiff / recursive file IO +// applyArchiveDelta and cleanupOldEntries run hdiff / recursive file IO // that can take hundreds of ms to seconds. The Pushy TurboModule executes on // the UI thread, so running these synchronously froze the UI. These are now // wrapped in napi_create_async_work: arguments are parsed on the JS thread, the @@ -738,8 +620,9 @@ napi_value QueueWork( return promise; } -struct ApplyPatchWork { - pushy::delta::FileSourcePatchOptions options; +struct ApplyArchiveDeltaWork { + pushy::archive_patch::ArchivePatchRequest request; + std::vector host_copies; pushy::delta::Status status{false, ""}; }; @@ -822,7 +705,11 @@ bool GetNonNegativeInt64( return true; } -napi_value ApplyPatchFromFileSource(napi_env env, napi_callback_info info) { +// applyArchiveDelta(request) -> Promise. The manifest +// text is parsed (with the rest of the patch) on the worker thread; the +// promise resolves with the copies the caller still owes (empty unless +// hostCopiesResources) and rejects with the core's message. +napi_value ApplyArchiveDelta(napi_env env, napi_callback_info info) { napi_value args[1] = {nullptr}; size_t argc = 1; if (!GetArgCount(env, info, &argc, args) || argc < 1) { @@ -833,60 +720,59 @@ napi_value ApplyPatchFromFileSource(napi_env env, napi_callback_info info) { return nullptr; } - std::vector copy_froms; - std::vector copy_tos; - std::vector deletes; - std::string source_root; - std::string target_root; - std::string origin_bundle_path; - std::string bundle_patch_path; - std::string bundle_output_path; - std::string merge_source_subdir; - std::string bundle_hbc_transform_meta; - bool enable_merge = true; - - if (!GetOptionalStringArrayProperty(env, args[0], "copyFroms", ©_froms) || - !GetOptionalStringArrayProperty(env, args[0], "copyTos", ©_tos) || - !GetOptionalStringArrayProperty(env, args[0], "deletes", &deletes) || - !GetOptionalStringProperty(env, args[0], "sourceRoot", &source_root) || - !GetOptionalStringProperty(env, args[0], "targetRoot", &target_root) || - !GetOptionalStringProperty(env, args[0], "originBundlePath", &origin_bundle_path) || - !GetOptionalStringProperty(env, args[0], "bundleDeltaPath", &bundle_patch_path) || - !GetOptionalStringProperty(env, args[0], "bundleOutputPath", &bundle_output_path) || - !GetOptionalStringProperty(env, args[0], "mergeSourceSubdir", &merge_source_subdir) || + ApplyArchiveDeltaWork work; + pushy::archive_patch::ArchivePatchRequest& request = work.request; + napi_value patch_type_value = nullptr; + int32_t patch_type = 0; + std::string bundle_patch_entry_name; + if (!GetNamedProperty(env, args[0], "archiveType", &patch_type_value) || + !GetInt32(env, patch_type_value, &patch_type) || + !GetOptionalStringArrayProperty( + env, args[0], "entryNames", &request.archive.entry_names) || + !GetOptionalStringProperty( + env, args[0], "manifestJson", &request.archive.manifest_json) || + !GetOptionalStringProperty( + env, args[0], "bundleDeltaEntry", &bundle_patch_entry_name) || + !GetOptionalStringProperty( + env, args[0], "copySourcePrefix", &request.archive.copy_source_prefix) || + !GetOptionalStringProperty(env, args[0], "sourceRoot", &request.source_root) || + !GetOptionalStringProperty(env, args[0], "targetRoot", &request.target_root) || !GetOptionalStringProperty( - env, args[0], "bundleHbcTransformMeta", &bundle_hbc_transform_meta) || - !GetOptionalBoolProperty(env, args[0], "enableMerge", true, &enable_merge)) { + env, args[0], "originBundlePath", &request.origin_bundle_path) || + !GetOptionalStringProperty( + env, args[0], "bundleDeltaPath", &request.bundle_patch_path) || + !GetOptionalStringProperty( + env, args[0], "bundleOutputPath", &request.bundle_output_path) || + !GetOptionalBoolProperty( + env, args[0], "hostCopiesResources", false, + &request.host_copies_resources)) { return nullptr; } - - ApplyPatchWork work; - if (!ReadPatchManifest( - env, - std::move(copy_froms), - std::move(copy_tos), - std::move(deletes), - &work.options.manifest)) { + // The shared parser keeps Android and HarmonyOS in agreement on which + // integer values are valid; neither silently falls back to kFull. + if (!pushy::archive_patch::TryParseArchivePatchType( + patch_type, &request.archive.type)) { + ThrowError(env, "Unknown archive delta type"); return nullptr; } - work.options.source_root = source_root; - work.options.target_root = target_root; - work.options.origin_bundle_path = origin_bundle_path; - work.options.bundle_patch_path = bundle_patch_path; - work.options.bundle_output_path = bundle_output_path; - work.options.merge_source_subdir = merge_source_subdir; - work.options.manifest.bundle_hbc_transform_meta = bundle_hbc_transform_meta; - work.options.enable_merge = enable_merge; + if (!bundle_patch_entry_name.empty()) { + request.archive.bundle_patch_entry_name = bundle_patch_entry_name; + } - return QueueWork( + return QueueWork( env, - "applyDeltaFromSource", + "applyArchiveDelta", std::move(work), - [](ApplyPatchWork& w) { - w.status = pushy::delta::ApplyPatchFromFileSource(w.options); + [](ApplyArchiveDeltaWork& w) { + w.status = + pushy::archive_patch::ApplyArchivePatch(w.request, &w.host_copies); }, - [](napi_env cb_env, napi_deferred deferred, const ApplyPatchWork& w) { - SettleWithStatus(cb_env, deferred, w.status); + [](napi_env cb_env, napi_deferred deferred, const ApplyArchiveDeltaWork& w) { + if (!w.status.ok) { + RejectDeferredWithMessage(cb_env, deferred, w.status.message.c_str()); + return; + } + napi_resolve_deferred(cb_env, deferred, NewCopyGroupArray(cb_env, w.host_copies)); }); } @@ -1269,9 +1155,7 @@ static napi_value FlowNormalizeConfiguration(napi_env env, napi_value Init(napi_env env, napi_value exports) { if (!ExportFunction(env, exports, "syncStateWithBinaryVersion", SyncStateWithBinaryVersion) || !ExportFunction(env, exports, "runStateCore", RunStateCore) || - !ExportFunction(env, exports, "buildArchivePlan", BuildArchivePatchPlan) || - !ExportFunction(env, exports, "buildCopyGroups", BuildCopyGroups) || - !ExportFunction(env, exports, "applyDeltaFromSource", ApplyPatchFromFileSource) || + !ExportFunction(env, exports, "applyArchiveDelta", ApplyArchiveDelta) || !ExportFunction(env, exports, "cleanupOldEntries", CleanupOldEntries) || !ExportFunction(env, exports, "sha256HexFileAsync", Sha256HexFileAsync) || !ExportFunction(env, exports, "sha256HexRawFdAsync", Sha256HexRawFdAsync) || diff --git a/harmony/pushy/src/main/ets/DownloadTask.ts b/harmony/pushy/src/main/ets/DownloadTask.ts index 6d0c90c1..fac36325 100644 --- a/harmony/pushy/src/main/ets/DownloadTask.ts +++ b/harmony/pushy/src/main/ets/DownloadTask.ts @@ -46,80 +46,16 @@ import { BUNDLE_DELTA_ENTRY } from './Texts'; const TAG = 'DownloadTask'; -export interface PatchManifestArrays { - copyFroms: string[]; - copyTos: string[]; - // 与 copyFroms/copyTos 逐位对齐的 copiesCrc(CLI >= 2.21.2 的 pdiff - // manifest 携带,键是 to);无声明时为 null。拷贝前用它校验包内资源内容, - // 不符则整次 patch 失败落 full——重打包二进制不能静默拷出漂移的资源。 - copyCrcs: (number | null)[]; - deletes: string[]; - // __diff.json 中对应 bundle patch 条目的 hbcTransform 元数据(原始 JSON - // 字符串);为空时 native 走现状路径 - hbcTransformMeta: string; -} - -export function parseManifestToArrays( - manifest: Record, - normalizeResourceCopies: boolean, -): PatchManifestArrays { - const copyFroms: string[] = []; - const copyTos: string[] = []; - const copyCrcs: (number | null)[] = []; - const deletesValue = manifest.deletes; - const deletes = Array.isArray(deletesValue) - ? deletesValue.map(item => String(item)) - : deletesValue && typeof deletesValue === 'object' - ? Object.keys(deletesValue) - : []; - - const copies = (manifest.copies || {}) as Record; - const copiesCrcValue = manifest.copiesCrc; - const copiesCrc = - copiesCrcValue && typeof copiesCrcValue === 'object' - ? (copiesCrcValue as Record) - : ({} as Record); - for (const to of Object.keys(copies)) { - const rawFrom = copies[to]; - let from = String(rawFrom || ''); - if (normalizeResourceCopies) { - from = from.replace('resources/rawfile/', ''); - if (!from) { - from = to; - } - } - copyFroms.push(from); - copyTos.push(to); - const crc = copiesCrc[to]; - copyCrcs.push(typeof crc === 'number' && Number.isFinite(crc) ? crc : null); - } - - const hbcTransform = manifest.hbcTransform as - | Record - | undefined; - const hbcTransformEntry = - hbcTransform && typeof hbcTransform === 'object' - ? hbcTransform[BUNDLE_DELTA_ENTRY] - : undefined; - const hbcTransformMeta = - hbcTransformEntry && typeof hbcTransformEntry === 'object' - ? JSON.stringify(hbcTransformEntry) - : ''; - - return { - copyFroms, - copyTos, - copyCrcs, - deletes, - hbcTransformMeta, - }; -} - +// staging 目录里的条目名 + __diff.json 原文:由原生内核统一校验与解析 +// (cpp/patch_core/patch_manifest.h),这里不解析 manifest。 interface DeltaInputs { entryNames: string[]; - manifestArrays: PatchManifestArrays; + manifestJson: string; } +// 包内 rawfile 在 HAP 中的路径前缀:pdiff 的拷贝源按 HAP 路径命名, +// resourceManager 按去掉前缀后的路径读取。原生解析时只剥开头一次。 +const RAWFILE_COPY_SOURCE_PREFIX = 'resources/rawfile/'; const DIFF_MANIFEST_ENTRY = '__diff.json'; const TEMP_ORIGIN_BUNDLE_ENTRY = `.origin.${HARMONY_BUNDLE_FILE_NAME}`; const DOWNLOAD_CALL_TIMEOUT_MS = 10 * 60 * 1000; @@ -434,19 +370,11 @@ export class DownloadTask { ) as Record; } - private async readManifestArrays( - directory: string, - normalizeResourceCopies: boolean, - ): Promise { + // __diff.json 原文;归档里没有时为空串(原生内核报 "diff.json not found")。 + private async readManifestJson(directory: string): Promise { const manifestPath = `${directory}/${DIFF_MANIFEST_ENTRY}`; if (!fileIo.accessSync(manifestPath)) { - return { - copyFroms: [], - copyTos: [], - copyCrcs: [], - deletes: [], - hbcTransformMeta: '', - }; + return ''; } const manifestStat = await fileIo.stat(manifestPath); @@ -456,42 +384,39 @@ export class DownloadTask { `delta manifest too large: ${manifestStat.size} bytes`, ); } - return parseManifestToArrays( - this.parseJsonEntry(await this.readFileContent(manifestPath)), - normalizeResourceCopies, + return new util.TextDecoder().decodeToString( + new Uint8Array(await this.readFileContent(manifestPath)), ); } - /** - * pdiff 的 bundle 补丁:hpatch 需要文件源,先把二进制内嵌的 bundle 从 - * rawfile descriptor 异步流式拷到 staging 里的临时文件(不整包读进内存、 - * 不在 UI 线程同步 IO),补丁完成后删除。 + * pdiff:hpatch 需要文件源,先把二进制内嵌的 bundle 从 rawfile descriptor + * 异步流式拷到 staging 里的临时文件(不整包读进内存、不在 UI 线程同步 + * IO),原生内核只重建 bundle,拷贝分组交回由 copyFromResource 完成。 */ - private async applyBundlePatchFromEmbeddedBundle( - workingDirectory: string, - bundleDeltaPath: string, - outputFile: string, - hbcTransformMeta = '', - ): Promise { - const originBundlePath = `${workingDirectory}/${TEMP_ORIGIN_BUNDLE_ENTRY}`; + private async applyPatchFromEmbeddedBundle( + work: string, + inputs: DeltaInputs, + ): Promise { + const originBundlePath = `${work}/${TEMP_ORIGIN_BUNDLE_ENTRY}`; try { await withRawFd( this.context.resourceManager, HARMONY_BUNDLE_FILE_NAME, descriptor => saveFileToSandbox(descriptor, originBundlePath), ); - await NativePatchCore.applyDeltaFromSource({ - copyFroms: [], - copyTos: [], - deletes: [], - sourceRoot: workingDirectory, - targetRoot: workingDirectory, + return await NativePatchCore.applyArchiveDelta({ + archiveType: ARCHIVE_PATCH_TYPE_FROM_PACKAGE, + entryNames: inputs.entryNames, + manifestJson: inputs.manifestJson, + bundleDeltaEntry: BUNDLE_DELTA_ENTRY, + copySourcePrefix: RAWFILE_COPY_SOURCE_PREFIX, + sourceRoot: work, + targetRoot: work, originBundlePath, - bundleDeltaPath, - bundleOutputPath: outputFile, - enableMerge: false, - bundleHbcTransformMeta: hbcTransformMeta, + bundleDeltaPath: `${work}/${BUNDLE_DELTA_ENTRY}`, + bundleOutputPath: `${work}/${HARMONY_BUNDLE_FILE_NAME}`, + hostCopiesResources: true, }); } catch (error) { throw createPushyError( @@ -1024,16 +949,12 @@ export class DownloadTask { return work; } - /** 解压目录里的条目名 + __diff.json 解析结果,供 patch plan 使用。 */ - private async readDeltaInputs( - work: string, - normalizeResourceCopies: boolean, - ): Promise { + private async readDeltaInputs(work: string): Promise { const results = await Promise.all([ this.listEntryNames(work), - this.readManifestArrays(work, normalizeResourceCopies), + this.readManifestJson(work), ]); - return { entryNames: results[0], manifestArrays: results[1] }; + return { entryNames: results[0], manifestJson: results[1] }; } private async doFullPatch(params: DownloadTaskParams): Promise { @@ -1043,73 +964,27 @@ export class DownloadTask { private async doPatchFromApp(params: DownloadTaskParams): Promise { const work = await this.downloadAndExtract(params); - const inputs = await this.readDeltaInputs(work, true); - const manifestArrays = inputs.manifestArrays; - - NativePatchCore.buildArchivePlan( - ARCHIVE_PATCH_TYPE_FROM_PACKAGE, - inputs.entryNames, - manifestArrays.copyFroms, - manifestArrays.copyTos, - manifestArrays.deletes, - BUNDLE_DELTA_ENTRY, - ); - - const bundleDeltaPath = `${work}/${BUNDLE_DELTA_ENTRY}`; - if (!fileIo.accessSync(bundleDeltaPath)) { - throw createPushyError(ERROR_DELTA_FAILED, 'bundle delta not found'); - } - await this.applyBundlePatchFromEmbeddedBundle( + const copyGroups = await this.applyPatchFromEmbeddedBundle( work, - bundleDeltaPath, - `${work}/${HARMONY_BUNDLE_FILE_NAME}`, - manifestArrays.hbcTransformMeta, - ); - // 组按 from 聚合;同一 from 的内容必然一致,所以每组一个 CRC 即可 - // (与 manifest 逐位对齐的 copyCrcs 里取第一个非空值)。 - const crcByFrom = new Map(); - manifestArrays.copyFroms.forEach((from, index) => { - const crc = manifestArrays.copyCrcs[index]; - if (crc !== null && !crcByFrom.has(from)) { - crcByFrom.set(from, crc); - } - }); - await this.copyFromResource( - NativePatchCore.buildCopyGroups( - manifestArrays.copyFroms, - manifestArrays.copyTos, - ), - work, - crcByFrom, + await this.readDeltaInputs(work), ); + await this.copyFromResource(copyGroups, work); await this.deleteArchiveAndSidecar(params.targetFile); } private async doPatchFromPpk(params: DownloadTaskParams): Promise { const work = await this.downloadAndExtract(params); - const inputs = await this.readDeltaInputs(work, false); - const manifestArrays = inputs.manifestArrays; - - const plan = NativePatchCore.buildArchivePlan( - ARCHIVE_PATCH_TYPE_FROM_PPK, - inputs.entryNames, - manifestArrays.copyFroms, - manifestArrays.copyTos, - manifestArrays.deletes, - BUNDLE_DELTA_ENTRY, - ); - await NativePatchCore.applyDeltaFromSource({ - copyFroms: manifestArrays.copyFroms, - copyTos: manifestArrays.copyTos, - deletes: manifestArrays.deletes, + const inputs = await this.readDeltaInputs(work); + await NativePatchCore.applyArchiveDelta({ + archiveType: ARCHIVE_PATCH_TYPE_FROM_PPK, + entryNames: inputs.entryNames, + manifestJson: inputs.manifestJson, + bundleDeltaEntry: BUNDLE_DELTA_ENTRY, sourceRoot: params.originDirectory, targetRoot: work, originBundlePath: `${params.originDirectory}/${HARMONY_BUNDLE_FILE_NAME}`, bundleDeltaPath: `${work}/${BUNDLE_DELTA_ENTRY}`, bundleOutputPath: `${work}/${HARMONY_BUNDLE_FILE_NAME}`, - mergeSourceSubdir: plan.mergeSourceSubdir, - enableMerge: plan.enableMerge, - bundleHbcTransformMeta: manifestArrays.hbcTransformMeta, }); logger.info(TAG, 'Delta from PPK completed'); await this.deleteArchiveAndSidecar(params.targetFile); @@ -1118,7 +993,6 @@ export class DownloadTask { private async copyFromResource( copyGroups: CopyGroupResult[], targetRoot: string, - crcByFrom?: Map, ): Promise { let currentFrom = ''; try { @@ -1130,7 +1004,7 @@ export class DownloadTask { if (targets.length === 0) { continue; } - const expectedCrc = crcByFrom?.get(currentFrom); + const expectedCrc = group.expectedCrc; if (currentFrom.startsWith('resources/base/media/')) { // Strip only the final extension: 'icon.round.png' -> 'icon.round', diff --git a/harmony/pushy/src/main/ets/NativePatchCore.ts b/harmony/pushy/src/main/ets/NativePatchCore.ts index 4e830f7b..7b7f751c 100644 --- a/harmony/pushy/src/main/ets/NativePatchCore.ts +++ b/harmony/pushy/src/main/ets/NativePatchCore.ts @@ -24,29 +24,34 @@ export interface StateCoreResult { consumedFirstTime?: boolean; } -export interface ArchivePatchPlanResult { - mergeSourceSubdir?: string; - enableMerge: boolean; -} - export interface CopyGroupResult { from: string; toPaths: string[]; + /** 源内容必须匹配的 CRC32(__diff.json copiesCrc);未声明时缺省 */ + expectedCrc?: number; } -export interface FileSourcePatchRequest { - copyFroms: string[]; - copyTos: string[]; - deletes: string[]; +/** + * 一次 patch 安装的原生部分(cpp/patch_core/archive_patch_core.h + * ApplyArchivePatch):校验条目、解析并校验 __diff.json、重建 bundle; + * hostCopiesResources 为 false 时同时完成拷贝/删除/合并。 + */ +export interface ArchivePatchRequest { + archiveType: number; + /** staging 目录里解压出的条目名 */ + entryNames: string[]; + /** __diff.json 原文;归档里没有时为空串 */ + manifestJson: string; + bundleDeltaEntry: string; + /** 从拷贝源开头剥掉的前缀(包内 rawfile 路径) */ + copySourcePrefix?: string; sourceRoot: string; targetRoot: string; originBundlePath: string; bundleDeltaPath: string; bundleOutputPath: string; - mergeSourceSubdir?: string; - enableMerge?: boolean; - /** __diff.json 中该 bundle patch 条目的 hbcTransform 元数据(原始 JSON) */ - bundleHbcTransformMeta?: string; + /** 资源不在 sourceRoot 下(rawfile):只重建 bundle,拷贝分组交回调用方 */ + hostCopiesResources?: boolean; } interface NativePatchCoreBindings { @@ -62,16 +67,8 @@ interface NativePatchCoreBindings { flagA?: boolean, flagB?: boolean, ): StateCoreResult; - buildArchivePlan( - patchType: number, - entryNames: string[], - copyFroms: string[], - copyTos: string[], - deletes: string[], - bundlePatchEntryName?: string, - ): ArchivePatchPlanResult; - buildCopyGroups(copyFroms: string[], copyTos: string[]): CopyGroupResult[]; - applyDeltaFromSource(options: FileSourcePatchRequest): Promise; + /** 工作线程执行;resolve 为调用方仍需完成的拷贝(hostCopiesResources 之外为空) */ + applyArchiveDelta(request: ArchivePatchRequest): Promise; /** * 删除 rootDir 下超过 maxAgeDays 且名字不在 keepNames 里的条目(native * 工作线程)。keepNames 必须包含所有可能仍在使用的版本:持久化的 diff --git a/src/__tests__/harmonyDownloadTask.test.ts b/src/__tests__/harmonyDownloadTask.test.ts index 8a6d51b8..20e59048 100644 --- a/src/__tests__/harmonyDownloadTask.test.ts +++ b/src/__tests__/harmonyDownloadTask.test.ts @@ -6,25 +6,16 @@ import { evalHarmonyModule } from './nativeSources'; // The pure parsers of Harmony DownloadTask.ts, evaluated from source with its // platform imports stripped (the download/patch class itself needs a device). // InstallRecord.ts imports @ohos modules, so its bundle name is inlined. +// __diff.json is parsed by the native core only +// (cpp/patch_core/tests/patch-manifest-vectors.json). const HARMONY_BUNDLE_FILE_NAME = 'bundle.harmony.js'; -interface PatchManifestArrays { - copyFroms: string[]; - copyTos: string[]; - copyCrcs: (number | null)[]; - deletes: string[]; - hbcTransformMeta: string; -} -const { parseContentRangeTotal, parseManifestToArrays } = evalHarmonyModule( +const { parseContentRangeTotal } = evalHarmonyModule( 'DownloadTask.ts', - '({ parseContentRangeTotal, parseManifestToArrays });', + '({ parseContentRangeTotal });', { HARMONY_BUNDLE_FILE_NAME, BUNDLE_DELTA_ENTRY } ) as { parseContentRangeTotal(header: string, expectedStart: number): number; - parseManifestToArrays( - manifest: Record, - normalizeResourceCopies: boolean - ): PatchManifestArrays; }; // A 206's Content-Range decides whether appended bytes can be trusted. @@ -77,119 +68,6 @@ describe('harmony parseContentRangeTotal', () => { }); }); -describe('harmony parseManifestToArrays', () => { - test('returns empty arrays for an empty manifest', () => { - // toEqual across the VM boundary compares plain values. - expect( - JSON.parse(JSON.stringify(parseManifestToArrays({}, false))) - ).toEqual({ - copyFroms: [], - copyTos: [], - copyCrcs: [], - deletes: [], - hbcTransformMeta: '', - }); - }); - - test('parses copies and a deletes array', () => { - const result = parseManifestToArrays( - { - copies: { - 'assets/icon.png': 'assets/original.png', - 'assets/logo.png': 'assets/brand.png', - }, - deletes: ['assets/old.png', 'assets/removed.txt'], - }, - false - ); - expect([...result.copyFroms]).toEqual([ - 'assets/original.png', - 'assets/brand.png', - ]); - expect([...result.copyTos]).toEqual(['assets/icon.png', 'assets/logo.png']); - expect([...result.deletes]).toEqual([ - 'assets/old.png', - 'assets/removed.txt', - ]); - }); - - test('parses deletes given as object keys', () => { - const result = parseManifestToArrays( - { deletes: { 'assets/old.png': true, 'assets/removed.txt': true } }, - false - ); - expect([...result.deletes]).toEqual([ - 'assets/old.png', - 'assets/removed.txt', - ]); - }); - - test('handles missing copies or deletes', () => { - const noCopies = parseManifestToArrays({ deletes: ['a.txt'] }, false); - expect(noCopies.copyFroms.length).toBe(0); - expect(noCopies.deletes.length).toBe(1); - const noDeletes = parseManifestToArrays( - { copies: { 'a.png': 'b.png' } }, - false - ); - expect(noDeletes.deletes.length).toBe(0); - expect(noDeletes.copyFroms.length).toBe(1); - }); - - test('normalizes rawfile resource copies only when enabled', () => { - const manifest = { - copies: { 'assets/icon.png': 'resources/rawfile/assets/icon.png' }, - }; - expect(parseManifestToArrays(manifest, true).copyFroms[0]).toBe( - 'assets/icon.png' - ); - expect(parseManifestToArrays(manifest, false).copyFroms[0]).toBe( - 'resources/rawfile/assets/icon.png' - ); - }); - - test('falls back to the key when the normalized source is empty', () => { - const result = parseManifestToArrays( - { copies: { 'assets/icon.png': 'resources/rawfile/' } }, - true - ); - expect(result.copyFroms[0]).toBe('assets/icon.png'); - }); - - test('treats a null source as empty', () => { - const result = parseManifestToArrays( - { copies: { 'assets/icon.png': null } }, - false - ); - expect(result.copyFroms[0]).toBe(''); - expect(result.copyTos[0]).toBe('assets/icon.png'); - }); - - test('aligns copiesCrc with the copies and keeps only finite numbers', () => { - const result = parseManifestToArrays( - { - copies: { a: 'x', b: 'y', c: 'z' }, - copiesCrc: { a: 123, c: 'nope' }, - }, - false - ); - expect([...result.copyCrcs]).toEqual([123, null, null]); - }); - - test('extracts the bundle entry hbcTransform metadata', () => { - const meta = { version: 1, base: 'abc' }; - const result = parseManifestToArrays( - { hbcTransform: { [BUNDLE_DELTA_ENTRY]: meta, other: { x: 1 } } }, - false - ); - expect(JSON.parse(result.hbcTransformMeta)).toEqual(meta); - expect( - parseManifestToArrays({ hbcTransform: { other: {} } }, false) - .hbcTransformMeta - ).toBe(''); - }); -}); - describe('harmony DownloadTaskParams', () => { test('defaults to a full-package task with empty paths', () => { const params = new DownloadTaskParams(); From c4ba7aca9abff5110fa32742925d9ebef947d684 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 16:34:33 +0800 Subject: [PATCH 084/104] refactor(patch-core): drop BuildPatchManifest No binding assembles a manifest from parallel copy lists any more; every platform hands __diff.json to ApplyArchivePatch. CODE_AUDIT 3.14 records the manifest parser as sunk into the shared core. Co-Authored-By: Claude Opus 5.5 --- CODE_AUDIT.md | 7 +++--- cpp/patch_core/patch_core.cpp | 20 --------------- cpp/patch_core/patch_core.h | 10 -------- cpp/patch_core/tests/patch_core_test.cpp | 31 ------------------------ 4 files changed, 4 insertions(+), 64 deletions(-) diff --git a/CODE_AUDIT.md b/CODE_AUDIT.md index baee851b..0af458d3 100644 --- a/CODE_AUDIT.md +++ b/CODE_AUDIT.md @@ -87,7 +87,7 @@ - **3.1** 缺 PrivacyInfo.xcprivacy — 已修 77b5488c(`ios/PrivacyInfo.xcprivacy` 经 `s.resource_bundles` 发布) - **3.2** `packageVersion/buildTime` 无前缀 defaults key — 已修 77b5488c(`REACTNATIVECN_PUSHY_*` + 旧 key 回退迁移;未改用 suite) - **3.3** 归档 symlink 条目被物化 — 已修 77b5488c(unzip guard 拒绝 `S_IFLNK` 与 `.pushy-` 名,`preserveAttributes:NO`) -- **3.4** 畸形 `__diff.json` 抛 ObjC 异常 — 已修 77b5488c(`PushyPatchManifestFromJson` 全量 `isKindOfClass:` 校验,返回原因;下沉 C++ 见 3.14) +- **3.4** 畸形 `__diff.json` 抛 ObjC 异常 — 已修 77b5488c(`PushyPatchManifestFromJson` 全量 `isKindOfClass:` 校验,返回原因);现由共享 C++ 解析器取代,见 3.14 - **3.5** 归档大小/剩余空间检查在落盘之后 — 已修 77b5488c(`didReceiveResponse` 前置检查 + `didReceiveData` 流式上限) - **3.6** `use_frameworks!` 下 `buildTime` 资源找不到 — 已修 77b5488c(mainBundle → `bundleForClass:` → Info.plist mtime 回退) - **3.7** 重载路径预消费首载状态、失败仍 resolve — 已修 77b5488c(去掉 `RCTReloadCommandSetBundleURL`,无可用 bridge 时 reject `RESTART_FAILED`) @@ -107,8 +107,9 @@ 已做:重复实现已合并(`.resume` 后缀、文件大小、`runCheckRequest` 单一 `queryEndpoints`);拆出 `RCTPushyOrchestrator.mm` 与共享的 `RCTPushyInternal.h`。 剩余:`RCTPushy.mm` 仍约 2150 行(目标约 900);`PushyInstallRecord` / `PushyArchiveGuard` / `PushyDownloadRegistry` / `PushyState` / `PushyCrashRescue` 未拆。 -**3.14(P3,可测试性)纯决策下沉 C++ + XCTest**(未做) -应下沉到 `cpp/` 的纯决策:`__diff.json` → `PatchManifest` 解析含类型校验(替代 `PushyPatchManifestFromJson` 与 Java `DownloadTask` 的解析);安装记录校验 `install_record::Validate(json, hash)` 三端共用(目前 `install_record.h` 只有常量);归档限额累加器;下载注册表 join/defer 规则与 `Content-Range` 解析(Java/ObjC 各一份);crash-rescue 门控与检查端点尝试策略;`PushyIsSafePathComponent` 放到 `IsSafeRelativePath` 旁边。另建 `ios/Tests` XCTest target:`RCTPushyDownloader` 对 `NSURLProtocol` stub(416/206/redirect/encoded)、`unzipFileAtPath` 对夹具 zip(symlink、保留名、比值炸弹)。 +**3.14(P3,可测试性)纯决策下沉 C++ + XCTest**(部分) +已做:`__diff.json` → `PatchManifest` 解析含类型校验下沉 `cpp/patch_core/patch_manifest.h`(`ParsePatchManifest`,向量 `cpp/patch_core/tests/patch-manifest-vectors.json` + fuzz),三端经 `archive_patch::ApplyArchivePatch` 直接传 manifest 原文,`PushyPatchManifestFromJson`、Java `appendManifestEntries`、Harmony `parseManifestToArrays` 已删。 +剩余应下沉到 `cpp/` 的纯决策:安装记录校验 `install_record::Validate(json, hash)` 三端共用(目前 `install_record.h` 只有常量);归档限额累加器;下载注册表 join/defer 规则与 `Content-Range` 解析(Java/ObjC 各一份);crash-rescue 门控与检查端点尝试策略;`PushyIsSafePathComponent` 放到 `IsSafeRelativePath` 旁边。另建 `ios/Tests` XCTest target:`RCTPushyDownloader` 对 `NSURLProtocol` stub(416/206/redirect/encoded)、`unzipFileAtPath` 对夹具 zip(symlink、保留名、比值炸弹)。 --- diff --git a/cpp/patch_core/patch_core.cpp b/cpp/patch_core/patch_core.cpp index 70b8ed76..7fcda9a6 100644 --- a/cpp/patch_core/patch_core.cpp +++ b/cpp/patch_core/patch_core.cpp @@ -15,7 +15,6 @@ #include #include -#include #include #include "hbc_transform.h" @@ -503,25 +502,6 @@ Status ValidateManifest(const PatchManifest& manifest) { return ValidateManifestImpl(manifest); } -Status BuildPatchManifest( - std::vector copy_froms, - std::vector copy_tos, - std::vector deletes, - PatchManifest* out) { - if (copy_froms.size() != copy_tos.size()) { - return Status::Error("copy_froms and copy_tos length mismatch"); - } - PatchManifest manifest; - manifest.copies.reserve(copy_froms.size()); - for (size_t index = 0; index < copy_froms.size(); ++index) { - manifest.copies.push_back(CopyOperation{ - std::move(copy_froms[index]), std::move(copy_tos[index])}); - } - manifest.deletes = std::move(deletes); - *out = std::move(manifest); - return Status::Ok(); -} - const BundleRebuilder& DefaultBundleRebuilder() { static const HdiffBundleRebuilder kPatcher; return kPatcher; diff --git a/cpp/patch_core/patch_core.h b/cpp/patch_core/patch_core.h index 6558cd8c..230bd003 100644 --- a/cpp/patch_core/patch_core.h +++ b/cpp/patch_core/patch_core.h @@ -69,16 +69,6 @@ const BundleRebuilder& DefaultBundleRebuilder(); Status ValidateManifest(const PatchManifest& manifest); -// Assembles a manifest from the parallel copy source/target lists the -// platform bridges receive (deletes are taken as-is). Fails without touching -// *out when the two copy lists differ in length; path safety is still -// ValidateManifest's job. -Status BuildPatchManifest( - std::vector copy_froms, - std::vector copy_tos, - std::vector deletes, - PatchManifest* out); - Status ApplyPatchFromFileSource( const FileSourcePatchOptions& options, const BundleRebuilder& bundle_patcher = DefaultBundleRebuilder()); diff --git a/cpp/patch_core/tests/patch_core_test.cpp b/cpp/patch_core/tests/patch_core_test.cpp index 727fb561..1e9aca33 100644 --- a/cpp/patch_core/tests/patch_core_test.cpp +++ b/cpp/patch_core/tests/patch_core_test.cpp @@ -482,35 +482,6 @@ void TestIsSafeRelativePathRejectsControlBytes() { "manifest with a NUL-bearing delete must be rejected"); } -void TestBuildPatchManifestPairsCopiesAndRejectsMismatch() { - PatchManifest manifest; - pushy::delta::Status status = pushy::delta::BuildPatchManifest( - {"assets/a.png", "assets/b.png"}, {"x/a.png", "x/b.png"}, {"old.png"}, - &manifest); - Expect(status.ok, "matching copy lists must build: " + status.message); - Expect(manifest.copies.size() == 2, "both copies kept"); - Expect( - manifest.copies[1].from == "assets/b.png" && - manifest.copies[1].to == "x/b.png" && - !manifest.copies[1].has_expected_crc, - "copies pair up by index"); - Expect( - manifest.deletes.size() == 1 && manifest.deletes[0] == "old.png", - "deletes kept as-is"); - - PatchManifest untouched; - untouched.deletes.push_back("keep"); - status = pushy::delta::BuildPatchManifest( - {"a", "b"}, {"a"}, {}, &untouched); - Expect(!status.ok, "copy list length mismatch must fail"); - Expect( - status.message == "copy_froms and copy_tos length mismatch", - "mismatch message is stable: " + status.message); - Expect( - untouched.copies.empty() && untouched.deletes.size() == 1, - "a failed build leaves the output untouched"); -} - // Builds `levels` nested directories under `base` and returns the deepest. std::string MakeDeepTree(const std::string& base, int levels) { std::string deep = base; @@ -1490,8 +1461,6 @@ int main(int argc, char** argv) { {"ApplyPatchFromFileSourceCanLimitMergeSubdir", TestApplyPatchFromFileSourceCanLimitMergeSubdir}, {"ApplyPatchFromFileSourceRejectsUnsafePaths", TestApplyPatchFromFileSourceRejectsUnsafePaths}, {"IsSafeRelativePathRejectsControlBytes", TestIsSafeRelativePathRejectsControlBytes}, - {"BuildPatchManifestPairsCopiesAndRejectsMismatch", - TestBuildPatchManifestPairsCopiesAndRejectsMismatch}, {"DirectoryWalkersBoundNestingDepth", TestDirectoryWalkersBoundNestingDepth}, {"HpatchRejectsOversizedLzmaDictionary", TestHpatchRejectsOversizedLzmaDictionary}, {"CleanupOldEntriesRemovesOnlyExpiredPaths", TestCleanupOldEntriesRemovesOnlyExpiredPaths}, From 555ffee6238ad417b30d8305c437873ff485b4ed Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 16:47:48 +0800 Subject: [PATCH 085/104] feat(patch-core): one completion record implementation in install_record.cpp cpp/patch_core/install_record.h was a header of constants with the logic kept three times (InstallRecord.java, RCTPushyInstallRecord.mm, InstallRecord.ts). It now carries the whole contract on POSIX IO and digest::Sha256File: - Parse / Read / Names / HasRecordFile / RecordedBundleSha256; - Build (refuses an empty bundleSha256) and Write: temp file + fsync, rename over the record, directory fsync (best-effort). Android wrote in place with fsyncs, iOS atomically without fsync, Harmony in place with a file fsync only; - Seal (hash the staged bundle unless the digest is known, build, write; tells a file-system failure from a bundle failure) and Promote (rename the staging directory, fsync the versions root, which only Android did); - IsComplete (bundle is a regular file + legacy marker or a schema-1 record naming the version) for launch/dedup; - CheckActivation, the switchVersion gate: bundle present; no record file is grandfathered only for the current/last version; the zero-byte legacy marker passes; otherwise schema 1, versionHash and a non-empty bundleSha256 matching the bundle, which must not change while hashed (iOS's stamp check, now everywhere). Unchanged re-checks cheaply that the directory still passes on the same basis (iOS's under-lock check). Where the platforms disagreed, the stricter reading wins: only a zero-byte file is the legacy marker (all three trusted "{}" too), "schema" must be the number 1 (Android took "1" and 1.5, iOS true and 1.5), versionHash and bundleSha256 must be strings (Android coerced numbers), the bundle must be a regular file, and a grandfathered version still needs its bundle (iOS skipped that check). tests/install-record-vectors.json holds 31 record shapes with what each means for IsComplete, CheckActivation and RecordedBundleSha256; the C++ test replays them and covers the size cap, Build, atomic Write, Seal, Promote and Unchanged. check-constant-parity.js learns `only`, so the mirrors are held to the constants they still use. Co-Authored-By: Claude Opus 5.5 --- cpp/patch_core/install_record.cpp | 430 ++++++++++++++++++ cpp/patch_core/install_record.h | 163 ++++++- .../tests/install-record-vectors.json | 222 +++++++++ cpp/patch_core/tests/patch_core_test.cpp | 300 ++++++++++++ scripts/check-constant-parity.js | 12 +- 5 files changed, 1117 insertions(+), 10 deletions(-) create mode 100644 cpp/patch_core/install_record.cpp create mode 100644 cpp/patch_core/tests/install-record-vectors.json diff --git a/cpp/patch_core/install_record.cpp b/cpp/patch_core/install_record.cpp new file mode 100644 index 00000000..25b02595 --- /dev/null +++ b/cpp/patch_core/install_record.cpp @@ -0,0 +1,430 @@ +#include "install_record.h" + +#include +#include +#include +#include +#include +#include + +#include "digest.h" +// Relative like every other cross-core include (see patch_manifest.cpp): +// use_frameworks! flattens the pod's headers. +#include "../update_flow_core/flow_json.h" + +namespace pushy { +namespace install_record { +namespace { + +// Written next to the record, then renamed over it. Its `.pushy-` prefix is +// reserved like the record's own name, so no archive can ship one. +constexpr const char* kTempSuffix = ".tmp"; +// A record has five members; the cap only bounds a hostile file's tree. +constexpr size_t kMaxRecordNodes = 1024; + +std::string JoinPath(const std::string& dir, const std::string& name) { + return dir + "/" + name; +} + +std::string RecordPath(const std::string& dir) { + return JoinPath(dir, kFileName); +} + +std::string ErrnoText() { + return std::strerror(errno); +} + +bool IsRegularFile(const std::string& path) { + struct stat info; + return stat(path.c_str(), &info) == 0 && S_ISREG(info.st_mode); +} + +// The record file's text and what it is. kPresent only when it parses as a +// JSON object; *text is filled for kPresent alone. +Record::State ReadRecordText(const std::string& dir, std::string* text) { + const std::string path = RecordPath(dir); + struct stat info; + if (stat(path.c_str(), &info) != 0) { + return errno == ENOENT || errno == ENOTDIR ? Record::State::kAbsent + : Record::State::kInvalid; + } + if (!S_ISREG(info.st_mode)) { + return Record::State::kInvalid; + } + if (info.st_size == 0) { + return Record::State::kLegacy; + } + if (info.st_size > kMaxRecordBytes) { + return Record::State::kInvalid; + } + // O_NONBLOCK: a FIFO swapped in after the stat must not hang the caller. + const int fd = open(path.c_str(), O_RDONLY | O_CLOEXEC | O_NONBLOCK); + if (fd < 0) { + return Record::State::kInvalid; + } + std::string content; + char buffer[4096]; + bool failed = false; + for (;;) { + const ssize_t count = read(fd, buffer, sizeof(buffer)); + if (count < 0) { + if (errno == EINTR) { + continue; + } + failed = true; + break; + } + if (count == 0) { + break; + } + content.append(buffer, static_cast(count)); + if (static_cast(content.size()) > kMaxRecordBytes) { + failed = true; + break; + } + } + close(fd); + if (failed) { + return Record::State::kInvalid; + } + // Truncated to zero between the stat and the read: still the legacy shape. + const Record record = Parse(content); + if (record.state == Record::State::kPresent) { + *text = std::move(content); + } + return record.state; +} + +std::string StringMember(const flowjson::Value& object, const char* key) { + const flowjson::Value& value = object.Get(key); + return value.IsString() ? value.AsString() : std::string(); +} + +bool EqualsIgnoringAsciiCase(const std::string& a, const std::string& b) { + if (a.size() != b.size()) { + return false; + } + for (size_t i = 0; i < a.size(); i++) { + char x = a[i]; + char y = b[i]; + if (x >= 'A' && x <= 'Z') { + x = static_cast(x - 'A' + 'a'); + } + if (y >= 'A' && y <= 'Z') { + y = static_cast(y - 'A' + 'a'); + } + if (x != y) { + return false; + } + } + return true; +} + +bool WriteAll(int fd, const std::string& data) { + size_t written = 0; + while (written < data.size()) { + const ssize_t count = write(fd, data.data() + written, data.size() - written); + if (count < 0) { + if (errno == EINTR) { + continue; + } + return false; + } + written += static_cast(count); + } + return true; +} + +// Makes a directory's entries durable (a rename or a new file only is once +// the directory itself is synced). Best-effort by design: some file systems +// refuse to open or fsync a directory, which must not fail an install whose +// files are already synced. +void SyncDirectory(const std::string& dir) { + const int fd = open(dir.c_str(), O_RDONLY | O_CLOEXEC | O_DIRECTORY); + if (fd < 0) { + return; + } + while (fsync(fd) != 0 && errno == EINTR) { + } + close(fd); +} + +std::string ParentDirectory(const std::string& path) { + const size_t slash = path.find_last_of('/'); + if (slash == std::string::npos) { + return "."; + } + return slash == 0 ? "/" : path.substr(0, slash); +} + +Status Rejected(const std::string& version_hash, const char* reason) { + return Status::Error("Bundle version " + version_hash + " " + reason); +} + +Status RecordMismatch(const std::string& version_hash) { + return Rejected(version_hash, "completion record mismatch."); +} + +} // namespace + +Record Parse(const std::string& text) { + Record record; + if (text.empty()) { + record.state = Record::State::kLegacy; + return record; + } + record.state = Record::State::kInvalid; + if (static_cast(text.size()) > kMaxRecordBytes) { + return record; + } + flowjson::ParseLimits limits; + limits.max_input_bytes = static_cast(kMaxRecordBytes); + limits.max_nodes = kMaxRecordNodes; + bool ok = false; + const flowjson::Value object = flowjson::Parse(text, limits, &ok); + if (!ok || !object.IsObject()) { + return record; + } + record.state = Record::State::kPresent; + const flowjson::Value& schema = object.Get("schema"); + record.current_schema = + schema.IsNumber() && schema.AsNumber() == static_cast(kSchema); + record.version_hash = StringMember(object, "versionHash"); + record.bundle_sha256 = StringMember(object, "bundleSha256"); + record.artifact_sha256 = StringMember(object, "artifactSha256"); + return record; +} + +bool Names(const Record& record, const std::string& version_hash) { + return record.state == Record::State::kPresent && record.current_schema && + !version_hash.empty() && record.version_hash == version_hash; +} + +Record Read(const std::string& version_dir) { + std::string text; + const Record::State state = ReadRecordText(version_dir, &text); + if (state == Record::State::kPresent) { + return Parse(text); + } + Record record; + record.state = state; + return record; +} + +bool HasRecordFile(const std::string& version_dir) { + struct stat info; + return stat(RecordPath(version_dir).c_str(), &info) == 0; +} + +Status Build(const std::string& version_hash, + const std::string& bundle_sha256, + const std::string& artifact_sha256, + std::string* out) { + if (bundle_sha256.empty()) { + return Rejected(version_hash, "has no bundle digest."); + } + flowjson::Value record = flowjson::Value::Object(); + record.Set("schema", flowjson::Value::Number(kSchema)); + record.Set("versionHash", flowjson::Value::String(version_hash)); + record.Set("bundleSha256", flowjson::Value::String(bundle_sha256)); + if (!artifact_sha256.empty()) { + record.Set("artifactSha256", flowjson::Value::String(artifact_sha256)); + } + *out = flowjson::Stringify(record); + return Status::Ok(); +} + +Status Write(const std::string& dir, const std::string& record_json) { + const std::string path = RecordPath(dir); + const std::string temp = path + kTempSuffix; + const int fd = open(temp.c_str(), O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0666); + if (fd < 0) { + return Status::Error("cannot create completion record " + temp + ": " + + ErrnoText()); + } + bool ok = WriteAll(fd, record_json); + while (ok && fsync(fd) != 0) { + if (errno != EINTR) { + ok = false; + } + } + std::string error = ok ? std::string() : ErrnoText(); + if (close(fd) != 0 && ok) { + ok = false; + error = ErrnoText(); + } + if (!ok) { + unlink(temp.c_str()); + return Status::Error("cannot write completion record " + temp + ": " + error); + } + if (rename(temp.c_str(), path.c_str()) != 0) { + error = ErrnoText(); + unlink(temp.c_str()); + return Status::Error("cannot place completion record " + path + ": " + error); + } + SyncDirectory(dir); + return Status::Ok(); +} + +SealResult Seal(const std::string& staging_dir, + const std::string& version_hash, + const std::string& bundle_file_name, + const std::string& artifact_sha256, + const std::string& known_bundle_sha256) { + SealResult result; + const std::string bundle = JoinPath(staging_dir, bundle_file_name); + if (!IsRegularFile(bundle)) { + result.status = Status::Error("bundle missing after install: " + bundle); + return result; + } + const std::string bundle_sha256 = known_bundle_sha256.empty() + ? digest::Sha256File(bundle) + : known_bundle_sha256; + std::string record; + result.status = Build(version_hash, bundle_sha256, artifact_sha256, &record); + if (!result.status.ok) { + return result; + } + result.status = Write(staging_dir, record); + result.file_failure = !result.status.ok; + return result; +} + +Status Promote(const std::string& staging_dir, const std::string& version_dir) { + if (rename(staging_dir.c_str(), version_dir.c_str()) != 0) { + return Status::Error("cannot move " + staging_dir + " to " + version_dir + + ": " + ErrnoText()); + } + SyncDirectory(ParentDirectory(version_dir)); + return Status::Ok(); +} + +bool IsComplete(const std::string& version_dir, + const std::string& version_hash, + const std::string& bundle_file_name) { + if (!IsRegularFile(JoinPath(version_dir, bundle_file_name))) { + return false; + } + const Record record = Read(version_dir); + return record.state == Record::State::kLegacy || Names(record, version_hash); +} + +std::string RecordedBundleSha256(const std::string& version_dir) { + return Read(version_dir).bundle_sha256; +} + +bool FileStamp::operator==(const FileStamp& other) const { + return regular == other.regular && device == other.device && + inode == other.inode && size == other.size && + modified_sec == other.modified_sec && + modified_nsec == other.modified_nsec && + changed_sec == other.changed_sec && changed_nsec == other.changed_nsec; +} + +FileStamp StampFile(const std::string& path) { + FileStamp stamp; + struct stat info; + if (stat(path.c_str(), &info) != 0 || !S_ISREG(info.st_mode)) { + return stamp; + } + stamp.regular = true; + stamp.device = static_cast(info.st_dev); + stamp.inode = static_cast(info.st_ino); + stamp.size = static_cast(info.st_size); +#if defined(__APPLE__) + stamp.modified_sec = info.st_mtimespec.tv_sec; + stamp.modified_nsec = info.st_mtimespec.tv_nsec; + stamp.changed_sec = info.st_ctimespec.tv_sec; + stamp.changed_nsec = info.st_ctimespec.tv_nsec; +#else + stamp.modified_sec = info.st_mtim.tv_sec; + stamp.modified_nsec = info.st_mtim.tv_nsec; + stamp.changed_sec = info.st_ctim.tv_sec; + stamp.changed_nsec = info.st_ctim.tv_nsec; +#endif + return stamp; +} + +Status CheckActivation(const std::string& version_dir, + const std::string& version_hash, + const std::string& bundle_file_name, + bool grandfathered, + Activation* out) { + const std::string bundle = JoinPath(version_dir, bundle_file_name); + const FileStamp before = StampFile(bundle); + if (!before.regular) { + return Rejected(version_hash, "not found."); + } + std::string text; + const Record::State state = ReadRecordText(version_dir, &text); + // Versions activated before records existed are grandfathered through + // current/last state, but only while their directory has NO record file: + // a present record is verified like any other. + if (grandfathered && state == Record::State::kAbsent) { + out->basis = Activation::Basis::kGrandfathered; + out->record_text.clear(); + out->bundle = before; + return Status::Ok(); + } + // Any other directory without a record may be a crash-left partial + // install or something else put there. + if (state == Record::State::kAbsent) { + return Rejected(version_hash, "is incomplete."); + } + if (state == Record::State::kInvalid) { + return Rejected(version_hash, "has no valid completion record."); + } + if (state == Record::State::kLegacy) { + out->basis = Activation::Basis::kLegacyMarker; + out->record_text.clear(); + out->bundle = before; + return Status::Ok(); + } + const Record record = Parse(text); + if (!Names(record, version_hash)) { + return RecordMismatch(version_hash); + } + // Mandatory for schema 1: a record without it is never trusted. + if (record.bundle_sha256.empty()) { + return RecordMismatch(version_hash); + } + const std::string actual = digest::Sha256File(bundle); + if (actual.empty()) { + return Rejected(version_hash, "bundle could not be read."); + } + if (StampFile(bundle) != before) { + return Rejected(version_hash, "changed while being verified."); + } + if (!EqualsIgnoringAsciiCase(record.bundle_sha256, actual)) { + return Rejected(version_hash, "bundle digest mismatch."); + } + out->basis = Activation::Basis::kDigest; + out->record_text = text; + out->bundle = before; + return Status::Ok(); +} + +bool Unchanged(const std::string& version_dir, + const std::string& bundle_file_name, + bool grandfathered, + const Activation& passed) { + const FileStamp bundle = StampFile(JoinPath(version_dir, bundle_file_name)); + if (!bundle.regular) { + return false; + } + std::string text; + const Record::State state = ReadRecordText(version_dir, &text); + switch (passed.basis) { + case Activation::Basis::kGrandfathered: + return state == Record::State::kAbsent && grandfathered; + case Activation::Basis::kLegacyMarker: + return state == Record::State::kLegacy && bundle == passed.bundle; + case Activation::Basis::kDigest: + return state == Record::State::kPresent && text == passed.record_text && + bundle == passed.bundle; + } + return false; +} + +} // namespace install_record +} // namespace pushy diff --git a/cpp/patch_core/install_record.h b/cpp/patch_core/install_record.h index 59b368b4..6289edc8 100644 --- a/cpp/patch_core/install_record.h +++ b/cpp/patch_core/install_record.h @@ -1,6 +1,11 @@ #ifndef PUSHY_PATCH_CORE_INSTALL_RECORD_H_ #define PUSHY_PATCH_CORE_INSTALL_RECORD_H_ +#include +#include + +#include "patch_core.h" + // The completion record every platform writes into a version directory once // an install fully succeeded, and reads back before trusting that directory. // @@ -9,24 +14,26 @@ // // - Written by the SDK only (archives may not ship a `.pushy-*` entry), as // the LAST step of a two-phase install: unpack/patch into -// /.staging, write the record there, then atomically rename +// /.staging, Seal() the record there, then Promote() renames // the staging directory to /. // - versionHash must equal the directory name; bundleSha256 is the digest of -// the final index.bundlejs (bundle.harmony.js on Harmony) and is +// the final bundle (index.bundlejs; bundle.harmony.js on Harmony) and is // re-verified at switchVersion time; artifactSha256 is the digest of the // downloaded archive (diagnostics / CDN corruption attribution, optional). // - bundleSha256 is MANDATORY for schema 1. A writer whose bundle digest is // empty or fails must fail the install (staging dropped, no record); a // verifier must reject a non-empty record without one (record mismatch), // never activate the version unverified. -// - Legacy: an EMPTY file is a completed install written by SDK < 10.53 -// (no digests); it stays trusted for presence, nothing to verify. +// - Legacy: an EMPTY (zero-byte) file is a completed install written by +// SDK < 10.53 (no digests); it stays trusted for presence, nothing to +// verify. Any other content must be a schema-1 record. // -// Mirrors that cannot include this header MUST stay in sync by hand -// (scripts/check-constant-parity.js, part of `bun lint`, asserts they do): -// - android/.../InstallRecord.java -// - harmony/pushy/src/main/ets/InstallRecord.ts -// iOS (RCTPushyInstallRecord.mm) includes this header directly. +// This is the only implementation: Android (InstallRecord.java over JNI, +// cpp/android_jni/install_record_jni.cpp), iOS (RCTPushyInstallRecord.mm) +// and Harmony (InstallRecord.ts over NAPI, pushy.cpp) call it. The bundle +// file name is the caller's, since it differs per platform. Constants a +// host still mirrors are listed in scripts/check-constant-parity.js +// (part of `bun lint`), which asserts they match. // Keep the core internal to whichever binary links it (see podspec Core). #pragma GCC visibility push(hidden) @@ -34,9 +41,147 @@ namespace pushy { namespace install_record { +using delta::Status; + constexpr int kSchema = 1; constexpr const char* kFileName = ".pushy-complete"; constexpr const char* kStagingSuffix = ".staging"; +// A record is a few hundred bytes; anything larger is not one. +constexpr long long kMaxRecordBytes = 64 * 1024; + +// A record file's contents. +struct Record { + enum class State { + kAbsent, // no such entry + kInvalid, // not a readable regular file of at most kMaxRecordBytes, or + // not a JSON object + kLegacy, // the zero-byte marker of SDK < 10.53 + kPresent, // a JSON object + }; + State state = State::kAbsent; + // kPresent only. A member that is missing or of another JSON type reads + // as false / empty: records are written by this code alone, so nothing is + // coerced. + bool current_schema = false; // "schema" is the number kSchema + std::string version_hash; + std::string bundle_sha256; + std::string artifact_sha256; +}; + +// The text of a record file: "" is kLegacy, a JSON object kPresent, +// anything else (or more than kMaxRecordBytes) kInvalid. +Record Parse(const std::string& text); + +// Whether a kPresent record is a schema-1 record naming this version. +bool Names(const Record& record, const std::string& version_hash); + +// /.pushy-complete, parsed. +Record Read(const std::string& version_dir); + +// Whether /.pushy-complete exists at all (of any type). Only a +// directory WITHOUT one may be grandfathered through current/last state. +bool HasRecordFile(const std::string& version_dir); + +// The schema-1 record text. Fails when bundle_sha256 is empty: a version +// whose bundle could not be hashed must never be recorded as installed. +// artifact_sha256 is optional and omitted when empty. +Status Build(const std::string& version_hash, + const std::string& bundle_sha256, + const std::string& artifact_sha256, + std::string* out); + +// Writes record_json as /.pushy-complete atomically and durably: a +// temporary sibling is written and fsynced, renamed over the record, and the +// directory is fsynced (best-effort: a file system that refuses a directory +// fsync does not fail the install). +Status Write(const std::string& dir, const std::string& record_json); + +struct SealResult { + Status status = Status::Ok(); + // The record could not be written (a file-system failure), as opposed to + // an install that produced no hashable bundle. + bool file_failure = false; +}; + +// The second phase's first half: hashes / +// (unless the caller already knows its digest, e.g. from extracting it), +// builds the record and writes it into the staging directory. +SealResult Seal(const std::string& staging_dir, + const std::string& version_hash, + const std::string& bundle_file_name, + const std::string& artifact_sha256, + const std::string& known_bundle_sha256); + +// The second phase's second half: renames the sealed staging directory to +// version_dir (which the caller has removed if it existed) and fsyncs the +// parent directory (best-effort, as in Write). +Status Promote(const std::string& staging_dir, const std::string& version_dir); + +// Launch / dedup presence check, no digest work: the bundle is a regular +// file and the record is the legacy marker or names this version (a +// digest-less schema-1 record is still "installed"; only activation refuses +// it). +bool IsComplete(const std::string& version_dir, + const std::string& version_hash, + const std::string& bundle_file_name); + +// bundleSha256 of the version's record (crash attribution); "" when the +// record is absent, legacy, unreadable or has none. +std::string RecordedBundleSha256(const std::string& version_dir); + +// Identity of a file's bytes on disk without reading them: a rewrite or an +// atomic replacement (new inode) changes it. +struct FileStamp { + bool regular = false; + uint64_t device = 0; + uint64_t inode = 0; + int64_t size = 0; + int64_t modified_sec = 0; + int64_t modified_nsec = 0; + int64_t changed_sec = 0; + int64_t changed_nsec = 0; + + bool operator==(const FileStamp& other) const; + bool operator!=(const FileStamp& other) const { return !(*this == other); } +}; + +FileStamp StampFile(const std::string& path); + +// What CheckActivation let through, for Unchanged. +struct Activation { + enum class Basis { + kGrandfathered, // no record file; the version is the current or last one + kLegacyMarker, // the zero-byte marker: nothing to verify + kDigest, // the record's bundleSha256 matched the bundle's bytes + }; + Basis basis = Basis::kDigest; + std::string record_text; // the verified record's exact text + FileStamp bundle; // the bundle as it was hashed +}; + +// The switchVersion gate (slow: re-hashes the bundle; never call it on a UI +// thread or under a lock the UI thread takes). Passes when the bundle is a +// regular file and either +// - the directory has no record file and `grandfathered` (the version is +// the persisted current or last one: activated before records existed), +// - the record is the legacy marker, or +// - the record is a schema-1 record naming this version whose bundleSha256 +// matches the bundle, which did not change while it was hashed. +// Otherwise fails with the reason ("Bundle version ..."), every +// platform's SWITCH_VERSION_FAILED message. *out is set only on success. +Status CheckActivation(const std::string& version_dir, + const std::string& version_hash, + const std::string& bundle_file_name, + bool grandfathered, + Activation* out); + +// Cheap re-check (no hashing; e.g. under the state lock right before the +// switch is committed) that the directory still passes on the same basis: +// same grandfathering, same record text, same bundle file. +bool Unchanged(const std::string& version_dir, + const std::string& bundle_file_name, + bool grandfathered, + const Activation& passed); } // namespace install_record } // namespace pushy diff --git a/cpp/patch_core/tests/install-record-vectors.json b/cpp/patch_core/tests/install-record-vectors.json new file mode 100644 index 00000000..3870ebee --- /dev/null +++ b/cpp/patch_core/tests/install-record-vectors.json @@ -0,0 +1,222 @@ +{ + "description": "Completion records (cpp/patch_core/install_record.h), shared by Android, iOS and HarmonyOS. Each vector writes `record` as the raw text of /.pushy-complete (null: no record file) next to a bundle whose content is `var a = 1;` (unless `bundle` is false), then checks IsComplete(dir, `hash` or \"abc\") against `complete`, CheckActivation(dir, hash, grandfathered = `grandfathered`) against `activation` (\"ok\" or a substring of the rejection) and RecordedBundleSha256 against `recorded` (default \"\"). In the record text, @SHA@ is the bundle's SHA-256 (lowercase hex), @SHA_UPPER@ the same in uppercase and @OTHER_SHA@ the digest of other content. `was` records how the platforms disagreed before the logic was shared.", + "vectors": [ + { + "name": "legacy empty marker: installed, activates unverified", + "record": "", + "complete": true, + "activation": "ok" + }, + { + "name": "schema-1 record with a matching digest", + "record": "{\"schema\":1,\"versionHash\":\"abc\",\"bundleSha256\":\"@SHA@\",\"artifactSha256\":\"ff\"}", + "complete": true, + "activation": "ok", + "recorded": "@SHA@" + }, + { + "name": "digest compare is case-insensitive", + "record": "{\"schema\":1,\"versionHash\":\"abc\",\"bundleSha256\":\"@SHA_UPPER@\"}", + "complete": true, + "activation": "ok", + "recorded": "@SHA_UPPER@" + }, + { + "name": "unknown members are ignored", + "record": "{\"schema\":1,\"versionHash\":\"abc\",\"bundleSha256\":\"@SHA@\",\"future\":{\"x\":[1,2]}}", + "complete": true, + "activation": "ok", + "recorded": "@SHA@" + }, + { + "name": "schema written as 1.0 is the number 1", + "record": "{\"schema\":1.0,\"versionHash\":\"abc\",\"bundleSha256\":\"@SHA@\"}", + "complete": true, + "activation": "ok", + "recorded": "@SHA@" + }, + { + "name": "tampered bundle", + "record": "{\"schema\":1,\"versionHash\":\"abc\",\"bundleSha256\":\"@OTHER_SHA@\"}", + "complete": true, + "activation": "bundle digest mismatch", + "recorded": "@OTHER_SHA@" + }, + { + "name": "no bundleSha256: installed for dedup, never activated", + "record": "{\"schema\":1,\"versionHash\":\"abc\"}", + "complete": true, + "activation": "completion record mismatch" + }, + { + "name": "empty bundleSha256", + "record": "{\"schema\":1,\"versionHash\":\"abc\",\"bundleSha256\":\"\"}", + "complete": true, + "activation": "completion record mismatch" + }, + { + "name": "null bundleSha256", + "record": "{\"schema\":1,\"versionHash\":\"abc\",\"bundleSha256\":null}", + "complete": true, + "activation": "completion record mismatch" + }, + { + "name": "numeric bundleSha256 is not a digest", + "record": "{\"schema\":1,\"versionHash\":\"abc\",\"bundleSha256\":123}", + "complete": true, + "activation": "completion record mismatch", + "was": "Android's crash-attribution read coerced it to \"123\"" + }, + { + "name": "only an artifact digest", + "record": "{\"schema\":1,\"versionHash\":\"abc\",\"artifactSha256\":\"@SHA@\"}", + "complete": true, + "activation": "completion record mismatch" + }, + { + "name": "record of another version", + "record": "{\"schema\":1,\"versionHash\":\"def\",\"bundleSha256\":\"@SHA@\"}", + "complete": false, + "activation": "completion record mismatch", + "recorded": "@SHA@", + "was": "iOS/Harmony switchVersion said \"is incomplete.\"" + }, + { + "name": "future schema", + "record": "{\"schema\":2,\"versionHash\":\"abc\",\"bundleSha256\":\"@SHA@\"}", + "complete": false, + "activation": "completion record mismatch", + "recorded": "@SHA@" + }, + { + "name": "schema as a string", + "record": "{\"schema\":\"1\",\"versionHash\":\"abc\",\"bundleSha256\":\"@SHA@\"}", + "complete": false, + "activation": "completion record mismatch", + "recorded": "@SHA@", + "was": "Android (optInt) accepted it" + }, + { + "name": "schema as a boolean", + "record": "{\"schema\":true,\"versionHash\":\"abc\",\"bundleSha256\":\"@SHA@\"}", + "complete": false, + "activation": "completion record mismatch", + "recorded": "@SHA@", + "was": "iOS (NSNumber intValue) accepted it" + }, + { + "name": "fractional schema", + "record": "{\"schema\":1.5,\"versionHash\":\"abc\",\"bundleSha256\":\"@SHA@\"}", + "complete": false, + "activation": "completion record mismatch", + "recorded": "@SHA@", + "was": "Android (optInt) and iOS (intValue) truncated it to 1" + }, + { + "name": "numeric versionHash", + "hash": "123", + "record": "{\"schema\":1,\"versionHash\":123,\"bundleSha256\":\"@SHA@\"}", + "complete": false, + "activation": "completion record mismatch", + "recorded": "@SHA@", + "was": "Android (optString) coerced it to \"123\"" + }, + { + "name": "an empty JSON object is not the legacy marker", + "record": "{}", + "complete": false, + "activation": "completion record mismatch", + "was": "every platform trusted it like the zero-byte marker" + }, + { + "name": "malformed JSON", + "record": "not json", + "complete": false, + "activation": "has no valid completion record" + }, + { + "name": "truncated record", + "record": "{\"schema\":1,\"versionHash\":\"abc\"", + "complete": false, + "activation": "has no valid completion record" + }, + { + "name": "an array", + "record": "[1,2]", + "complete": false, + "activation": "has no valid completion record" + }, + { + "name": "null", + "record": "null", + "complete": false, + "activation": "has no valid completion record" + }, + { + "name": "whitespace only", + "record": " \n", + "complete": false, + "activation": "has no valid completion record" + }, + { + "name": "no record file", + "record": null, + "complete": false, + "activation": "is incomplete", + "was": "Android said \"has no valid completion record.\"" + }, + { + "name": "no record file, current/last version: grandfathered", + "record": null, + "grandfathered": true, + "complete": false, + "activation": "ok" + }, + { + "name": "a recorded current/last version is verified", + "record": "{\"schema\":1,\"versionHash\":\"abc\"}", + "grandfathered": true, + "complete": true, + "activation": "completion record mismatch" + }, + { + "name": "a recorded current/last version with a bad digest is refused", + "record": "{\"schema\":1,\"versionHash\":\"abc\",\"bundleSha256\":\"@OTHER_SHA@\"}", + "grandfathered": true, + "complete": true, + "activation": "bundle digest mismatch", + "recorded": "@OTHER_SHA@" + }, + { + "name": "legacy marker of a current/last version", + "record": "", + "grandfathered": true, + "complete": true, + "activation": "ok" + }, + { + "name": "no bundle", + "record": "{\"schema\":1,\"versionHash\":\"abc\",\"bundleSha256\":\"@SHA@\"}", + "bundle": false, + "complete": false, + "activation": "not found", + "recorded": "@SHA@" + }, + { + "name": "no bundle, legacy marker", + "record": "", + "bundle": false, + "complete": false, + "activation": "not found" + }, + { + "name": "no bundle, grandfathered: still not found", + "record": null, + "bundle": false, + "grandfathered": true, + "complete": false, + "activation": "not found", + "was": "iOS let a grandfathered version without its bundle through" + } + ] +} diff --git a/cpp/patch_core/tests/patch_core_test.cpp b/cpp/patch_core/tests/patch_core_test.cpp index 1e9aca33..37e42bc2 100644 --- a/cpp/patch_core/tests/patch_core_test.cpp +++ b/cpp/patch_core/tests/patch_core_test.cpp @@ -1,5 +1,6 @@ #include "../archive_patch_core.h" #include "../digest.h" +#include "../install_record.h" #include "../patch_core.h" #include "../patch_manifest.h" #include "../state_core.h" @@ -8,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -1444,6 +1446,298 @@ void TestApplyArchivePatchWithHbcTransform() { } // namespace +// ---- Completion records (install_record.h) ---- + +namespace ir = pushy::install_record; + +const char kRecordBundle[] = "index.bundlejs"; + +std::string ReplaceAll(std::string text, const std::string& from, const std::string& to) { + for (size_t at = text.find(from); at != std::string::npos; + at = text.find(from, at + to.size())) { + text.replace(at, from.size(), to); + } + return text; +} + +std::string Upper(std::string text) { + for (char& c : text) { + if (c >= 'a' && c <= 'z') { + c = static_cast(c - 'a' + 'A'); + } + } + return text; +} + +std::vector ListDirectory(const std::string& path) { + std::vector names; + DIR* dir = opendir(path.c_str()); + if (dir == nullptr) { + return names; + } + while (struct dirent* entry = readdir(dir)) { + const std::string name = entry->d_name; + if (name != "." && name != "..") { + names.push_back(name); + } + } + closedir(dir); + return names; +} + +// The shared vectors (tests/install-record-vectors.json): what each record +// shape means for launch/dedup, activation and crash attribution, including +// the cases the platforms used to disagree on. +void TestInstallRecordVectors() { + const std::string path = + JoinPath(g_fixtures_dir, "../install-record-vectors.json"); + bool ok = false; + const flowjson::Value doc = flowjson::Parse(ReadFile(path), &ok); + Expect(ok && doc.Get("vectors").IsArray(), "vectors file must parse: " + path); + Expect(doc.Get("vectors").Size() >= 30, "vectors file lost its cases"); + const std::string bundle = "var a = 1;"; + pushy::digest::Sha256 hasher; + hasher.Update(reinterpret_cast(bundle.data()), bundle.size()); + const std::string bundle_sha = hasher.HexDigest(); + pushy::digest::Sha256 other; + other.Update(reinterpret_cast("var a = 2;"), 10); + const std::string other_sha = other.HexDigest(); + auto expand = [&](const std::string& text) { + return ReplaceAll(ReplaceAll(ReplaceAll(text, "@SHA_UPPER@", Upper(bundle_sha)), + "@OTHER_SHA@", other_sha), + "@SHA@", bundle_sha); + }; + + for (const flowjson::Value& vector : doc.Get("vectors").elements()) { + const std::string name = vector.Get("name").AsString(); + const std::string hash = + vector.Get("hash").IsString() ? vector.Get("hash").AsString() : "abc"; + TempDir temp; + const std::string dir = temp.path + "/" + hash; + EnsureDirectory(dir); + if (!vector.Get("bundle").IsBool() || vector.Get("bundle").AsBool()) { + WriteFile(dir + "/" + kRecordBundle, bundle); + } + const bool has_record = vector.Get("record").IsString(); + if (has_record) { + WriteFile(dir + "/" + ir::kFileName, expand(vector.Get("record").AsString())); + } + Expect(ir::HasRecordFile(dir) == has_record, name + ": HasRecordFile"); + Expect(ir::IsComplete(dir, hash, kRecordBundle) == + vector.Get("complete").AsBool(), + name + ": IsComplete"); + const std::string recorded = vector.Get("recorded").IsString() + ? expand(vector.Get("recorded").AsString()) + : ""; + ExpectEq(ir::RecordedBundleSha256(dir), recorded, name + ": RecordedBundleSha256"); + + const bool grandfathered = + vector.Get("grandfathered").IsBool() && vector.Get("grandfathered").AsBool(); + ir::Activation activation; + activation.record_text = "sentinel"; + const Status status = + ir::CheckActivation(dir, hash, kRecordBundle, grandfathered, &activation); + const std::string expected = vector.Get("activation").AsString(); + if (expected == "ok") { + Expect(status.ok, name + ": " + status.message); + Expect(ir::Unchanged(dir, kRecordBundle, grandfathered, activation), + name + ": an untouched directory is unchanged"); + Expect(!has_record || activation.basis != ir::Activation::Basis::kGrandfathered, + name + ": a recorded directory is never grandfathered"); + } else { + Expect(!status.ok, name + ": expected [" + expected + "], activated"); + Expect(status.message.find(expected) != std::string::npos, + name + ": rejection [" + status.message + "] lacks [" + expected + "]"); + Expect(status.message.find("Bundle version " + hash + " ") == 0, + name + ": rejection names the version: " + status.message); + ExpectEq(activation.record_text, "sentinel", + name + ": a rejection leaves the output untouched"); + } + } +} + +void TestInstallRecordParse() { + Expect(ir::Parse("").state == ir::Record::State::kLegacy, "empty text is legacy"); + const ir::Record record = ir::Parse( + "{\"schema\":1,\"versionHash\":\"abc\",\"bundleSha256\":\"b\",\"artifactSha256\":\"a\"}"); + Expect(record.state == ir::Record::State::kPresent && record.current_schema, + "schema-1 record parses"); + ExpectEq(record.version_hash, "abc", "versionHash"); + ExpectEq(record.bundle_sha256, "b", "bundleSha256"); + ExpectEq(record.artifact_sha256, "a", "artifactSha256"); + Expect(ir::Names(record, "abc") && !ir::Names(record, "def") && !ir::Names(record, ""), + "Names matches the version only"); + Expect(!ir::Names(ir::Parse(""), "abc"), "the legacy marker names no version"); + + // The size cap: a padded record of exactly kMaxRecordBytes still parses + // (on disk as well), one byte more does not. + const std::string body = "{\"schema\":1,\"versionHash\":\"abc\",\"bundleSha256\":\"b\"}"; + const std::string at_cap = + body + std::string(static_cast(ir::kMaxRecordBytes) - body.size(), ' '); + Expect(ir::Parse(at_cap).state == ir::Record::State::kPresent, "record at the cap"); + Expect(ir::Parse(at_cap + " ").state == ir::Record::State::kInvalid, + "record over the cap"); + TempDir temp; + WriteFile(temp.path + "/" + kRecordBundle, "x"); + WriteFile(temp.path + "/" + ir::kFileName, at_cap); + Expect(ir::IsComplete(temp.path, "abc", kRecordBundle), "on-disk record at the cap"); + WriteFile(temp.path + "/" + ir::kFileName, at_cap + " "); + Expect(ir::Read(temp.path).state == ir::Record::State::kInvalid, + "on-disk record over the cap"); + Expect(!ir::IsComplete(temp.path, "abc", kRecordBundle), "oversized record is not complete"); + + // A directory where the record belongs is a record file, but not a record. + unlink((temp.path + "/" + ir::kFileName).c_str()); + EnsureDirectory(temp.path + "/" + ir::kFileName); + Expect(ir::HasRecordFile(temp.path), "a directory counts as a record file"); + Expect(ir::Read(temp.path).state == ir::Record::State::kInvalid, + "a directory is not a record"); + ir::Activation activation; + const Status status = + ir::CheckActivation(temp.path, "abc", kRecordBundle, true, &activation); + Expect(!status.ok && status.message.find("no valid completion record") != std::string::npos, + "a directory record is never grandfathered: " + status.message); + + // A directory where the bundle belongs is no bundle. + TempDir other; + EnsureDirectory(other.path + "/" + kRecordBundle); + WriteFile(other.path + "/" + ir::kFileName, ""); + Expect(!ir::IsComplete(other.path, "abc", kRecordBundle), "bundle must be a file"); +} + +void TestInstallRecordBuild() { + std::string text; + Expect(ir::Build("abc", "bb", "aa", &text).ok, "build a full record"); + ExpectEq(text, + "{\"schema\":1,\"versionHash\":\"abc\",\"bundleSha256\":\"bb\",\"artifactSha256\":\"aa\"}", + "record text"); + Expect(ir::Build("abc", "bb", "", &text).ok, "artifactSha256 is optional"); + ExpectEq(text, "{\"schema\":1,\"versionHash\":\"abc\",\"bundleSha256\":\"bb\"}", + "record without artifact digest"); + text = "untouched"; + const Status refused = ir::Build("abc", "", "aa", &text); + Expect(!refused.ok && refused.message.find("no bundle digest") != std::string::npos, + "a record without bundleSha256 is never built: " + refused.message); + ExpectEq(text, "untouched", "a refused build leaves the output untouched"); +} + +void TestInstallRecordWriteIsAtomic() { + TempDir temp; + std::string text; + Expect(ir::Build("abc", "bb", "", &text).ok, "build"); + Expect(ir::Write(temp.path, text).ok, "write a record"); + ExpectEq(ReadFile(temp.path + "/" + ir::kFileName), text, "record content"); + Expect(ListDirectory(temp.path).size() == 1, "no temporary file is left behind"); + + // Rewriting replaces the record whole (rename over it). + Expect(ir::Write(temp.path, "{\"x\":1}").ok, "rewrite"); + ExpectEq(ReadFile(temp.path + "/" + ir::kFileName), "{\"x\":1}", "rewritten content"); + Expect(ListDirectory(temp.path).size() == 1, "still no temporary file"); + + // A stale temporary file (crash mid-write) is overwritten, not appended to. + WriteFile(temp.path + "/" + ir::kFileName + ".tmp", std::string(100, 'z')); + Expect(ir::Write(temp.path, text).ok, "write over a stale temporary file"); + ExpectEq(ReadFile(temp.path + "/" + ir::kFileName), text, "content after a stale temp"); + Expect(ListDirectory(temp.path).size() == 1, "the stale temporary file is consumed"); + + const Status missing = ir::Write(temp.path + "/missing", text); + Expect(!missing.ok && missing.message.find("cannot create completion record") != std::string::npos, + "a missing directory fails the write: " + missing.message); +} + +void TestInstallRecordSealAndPromote() { + TempDir temp; + const std::string staging = temp.path + "/abc" + ir::kStagingSuffix; + const std::string version = temp.path + "/abc"; + EnsureDirectory(staging); + + ir::SealResult sealed = ir::Seal(staging, "abc", kRecordBundle, "ff", ""); + Expect(!sealed.status.ok && !sealed.file_failure && + sealed.status.message.find("bundle missing after install") != std::string::npos, + "no bundle: a bundle failure: " + sealed.status.message); + Expect(!ir::HasRecordFile(staging), "no record without a bundle"); + + WriteFile(staging + "/" + kRecordBundle, "var a = 1;"); + sealed = ir::Seal(staging, "abc", kRecordBundle, "ff", ""); + Expect(sealed.status.ok, "seal hashes the bundle: " + sealed.status.message); + const ir::Record record = ir::Read(staging); + ExpectEq(record.bundle_sha256, pushy::digest::Sha256File(staging + "/" + kRecordBundle), + "sealed digest is the bundle's"); + ExpectEq(record.artifact_sha256, "ff", "sealed artifact digest"); + Expect(ir::IsComplete(staging, "abc", kRecordBundle), "sealed staging is complete"); + + // A digest computed while extracting is taken as-is. + sealed = ir::Seal(staging, "abc", kRecordBundle, "", "0123"); + Expect(sealed.status.ok, "seal with a known digest"); + ExpectEq(ir::Read(staging).bundle_sha256, "0123", "known digest recorded"); + ExpectEq(ir::Read(staging).artifact_sha256, "", "no artifact digest"); + + // The version directory must be gone before the rename. + EnsureDirectory(version + "/old"); + const Status blocked = ir::Promote(staging, version); + Expect(!blocked.ok && blocked.message.find("cannot move") != std::string::npos, + "promote never merges into an existing version directory: " + blocked.message); + std::system(("rm -rf \"" + version + "\"").c_str()); + Expect(ir::Promote(staging, version).ok, "promote"); + Expect(!Exists(staging) && ir::IsComplete(version, "abc", kRecordBundle), + "promoted: installed"); + ir::Activation activation; + const Status wrong = ir::CheckActivation(version, "abc", kRecordBundle, false, &activation); + Expect(!wrong.ok && wrong.message.find("bundle digest mismatch") != std::string::npos, + "a wrong known digest is caught at activation: " + wrong.message); + + // A record that cannot be written is a file failure. A directory where the + // temporary file goes makes the write fail on every platform and user. + TempDir blocked_dir; + WriteFile(blocked_dir.path + "/" + kRecordBundle, "x"); + EnsureDirectory(blocked_dir.path + "/" + ir::kFileName + ".tmp"); + sealed = ir::Seal(blocked_dir.path, "abc", kRecordBundle, "", ""); + Expect(!sealed.status.ok && sealed.file_failure, + "an unwritable record is a file failure: " + sealed.status.message); +} + +void TestInstallRecordUnchanged() { + TempDir temp; + const std::string dir = temp.path + "/abc"; + const std::string bundle = dir + "/" + kRecordBundle; + WriteFile(bundle, "var a = 1;"); + Expect(ir::Seal(dir, "abc", kRecordBundle, "", "").status.ok, "seal"); + + ir::Activation passed; + Expect(ir::CheckActivation(dir, "abc", kRecordBundle, false, &passed).ok, "activate"); + Expect(passed.basis == ir::Activation::Basis::kDigest, "verified by digest"); + Expect(ir::Unchanged(dir, kRecordBundle, false, passed), "untouched"); + + // Same bytes, new file: a different inode / ctime. + unlink(bundle.c_str()); + WriteFile(bundle, "var a = 1;"); + Expect(!ir::Unchanged(dir, kRecordBundle, false, passed), "a replaced bundle is a change"); + Expect(ir::CheckActivation(dir, "abc", kRecordBundle, false, &passed).ok, "re-verify"); + Expect(ir::Unchanged(dir, kRecordBundle, false, passed), "re-verified"); + + std::string text; + Expect(ir::Build("abc", "00", "", &text).ok, "build"); + Expect(ir::Write(dir, text).ok, "rewrite record"); + Expect(!ir::Unchanged(dir, kRecordBundle, false, passed), "a rewritten record is a change"); + + // Grandfathered: only while there is still no record and the version is + // still current/last. + unlink((dir + "/" + ir::kFileName).c_str()); + Expect(ir::CheckActivation(dir, "abc", kRecordBundle, true, &passed).ok, "grandfathered"); + Expect(passed.basis == ir::Activation::Basis::kGrandfathered, "basis"); + Expect(ir::Unchanged(dir, kRecordBundle, true, passed), "still grandfathered"); + Expect(!ir::Unchanged(dir, kRecordBundle, false, passed), "no longer current/last"); + WriteFile(dir + "/" + ir::kFileName, ""); + Expect(!ir::Unchanged(dir, kRecordBundle, true, passed), "a record appeared"); + + // Legacy marker: the marker and the bundle must stay put. + Expect(ir::CheckActivation(dir, "abc", kRecordBundle, false, &passed).ok, "legacy"); + Expect(passed.basis == ir::Activation::Basis::kLegacyMarker, "legacy basis"); + Expect(ir::Unchanged(dir, kRecordBundle, false, passed), "legacy untouched"); + unlink(bundle.c_str()); + Expect(!ir::Unchanged(dir, kRecordBundle, false, passed), "bundle gone"); +} + int main(int argc, char** argv) { if (argc > 1) { g_fixtures_dir = argv[1]; @@ -1490,6 +1784,12 @@ int main(int argc, char** argv) { {"ApplyArchivePatchFromPpk", TestApplyArchivePatchFromPpk}, {"ApplyArchivePatchHostCopies", TestApplyArchivePatchHostCopies}, {"ApplyArchivePatchWithHbcTransform", TestApplyArchivePatchWithHbcTransform}, + {"InstallRecordVectors", TestInstallRecordVectors}, + {"InstallRecordParse", TestInstallRecordParse}, + {"InstallRecordBuild", TestInstallRecordBuild}, + {"InstallRecordWriteIsAtomic", TestInstallRecordWriteIsAtomic}, + {"InstallRecordSealAndPromote", TestInstallRecordSealAndPromote}, + {"InstallRecordUnchanged", TestInstallRecordUnchanged}, }; int failures = 0; diff --git a/scripts/check-constant-parity.js b/scripts/check-constant-parity.js index 8bcb758a..cbcea110 100644 --- a/scripts/check-constant-parity.js +++ b/scripts/check-constant-parity.js @@ -8,6 +8,8 @@ // android/.../ArchiveLimits.java // cpp/patch_core/install_record.h <-> harmony/.../InstallRecord.ts // android/.../InstallRecord.java +// (only the constants a pair lists +// under `only`) // cpp/patch_core/error_codes.h <-> harmony/.../ErrorCodes.ts // android/.../ErrorCodes.java // src/error.ts (every native code must @@ -114,6 +116,9 @@ const javaDir = 'android/src/main/java/cn/reactnative/modules/update'; // regression would otherwise silently drop the pair's coverage). const archiveLimitsRequired = ['UnknownLengthFreeSpaceProbeBytes', 'MaxArchiveBytes']; const errorCodesRequired = ['DeltaFailed', 'InvalidOptions']; +// The record logic itself lives in install_record.cpp; the hosts only mirror +// the names they still build paths from. +const installRecordMirrored = ['Schema', 'FileName', 'StagingSuffix']; const pairs = [ { @@ -140,12 +145,14 @@ const pairs = [ FileName: 'INSTALL_RECORD_FILE_NAME', StagingSuffix: 'STAGING_SUFFIX', })[cppName] || screamingSnake(cppName), + only: installRecordMirrored, }, { header: 'cpp/patch_core/install_record.h', mirror: `${javaDir}/InstallRecord.java`, parse: parseJavaConstants, mirrorName: screamingSnake, + only: installRecordMirrored, }, { header: 'cpp/patch_core/error_codes.h', @@ -185,12 +192,15 @@ for (const pair of pairs) { problems.push(`${pair.header}: no constexpr constants parsed`); continue; } - for (const name of pair.required || []) { + for (const name of [...(pair.required || []), ...(pair.only || [])]) { if (!cpp.has(name)) { problems.push(`${pair.header}: k${name} not parsed (required)`); } } for (const [cppName, cppValue] of cpp) { + if (pair.only && !pair.only.includes(cppName)) { + continue; + } const mirrorName = pair.byValue ? cppValue : pair.mirrorName(cppName); checked += 1; if (!mirror.has(mirrorName)) { From 8966c5ec8d516b0624068f055542a1329212f809 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 16:50:56 +0800 Subject: [PATCH 086/104] refactor(android): use the shared completion record core InstallRecord.java keeps the paths (bundleFile, stagingDirectoryFor) and wraps five natives (JNI over cpp/patch_core/install_record.h, in cpp/android_jni/install_record_jni.cpp): isInstalled, verifyInstalled (CheckActivation, now taking the grandfathering decision), seal, promote and recordedBundleSha256. build/write/read/isComplete/verifyForActivation/ syncDirectory and the org.json parsing are gone. - DownloadTask.promoteStaging seals the staging directory (reusing the digest taken while extracting a full package) and promotes it; the record is now written atomically (temp + rename) and the directory fsync no longer needs API 26. - verifySwitchTarget hands current/last to the core, which grandfathers only a directory without a record, as before; a markerless directory outside current/last now fails with "is incomplete." like iOS/Harmony. - The bundle hash moves from MessageDigest to the core's SHA-256 on the same threads as before (state serial thread, sync coordinator, download tasks); nothing on the UI thread hashes. Launch-time callers only stat and read the record. InstallRecordTest keeps the path helpers; the record logic is covered by the C++ vectors (the JVM cannot load the library). Co-Authored-By: Claude Opus 5.5 --- .../modules/update/DownloadTask.java | 24 +- .../modules/update/InstallRecord.java | 182 +++++--------- .../modules/update/NativeCore.java | 8 +- .../modules/update/UpdateContext.java | 26 +- .../modules/update/InstallRecordTest.java | 234 +----------------- cpp/android_jni/install_record_jni.cpp | 89 +++++++ cpp/android_jni/jni_natives.h | 5 + cpp/android_jni/jni_registration.cpp | 13 + scripts/check-constant-parity.js | 2 +- src/__tests__/jniRegistration.test.ts | 2 +- 10 files changed, 192 insertions(+), 393 deletions(-) create mode 100644 cpp/android_jni/install_record_jni.cpp 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 fc0d27bd..d1cdd912 100644 --- a/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +++ b/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java @@ -24,7 +24,6 @@ import okio.BufferedSink; import okio.BufferedSource; import okio.Okio; -import org.json.JSONException; import org.json.JSONObject; import org.json.JSONTokener; @@ -501,7 +500,7 @@ private String archiveSha256() throws IOException { * final bundle's digest) goes into the staging directory, which is then * renamed over the version directory in one atomic step. */ - private void promoteStaging() throws IOException, JSONException { + private void promoteStaging() throws IOException { File work = stagingDirectory(); if (targetsRunningVersion()) { // The process switched to this very version while the task ran @@ -511,26 +510,11 @@ private void promoteStaging() throws IOException, JSONException { UpdateFileUtils.removeDirectory(work); return; } - File bundle = InstallRecord.bundleFile(work); - if (!bundle.isFile()) { - throw new IOException("bundle missing after install: " + bundle); - } - String bundleSha256 = stagedBundleSha256 != null - ? stagedBundleSha256 : UpdateFileUtils.sha256Hex(bundle); - InstallRecord.write( - work, InstallRecord.build(params.hash, bundleSha256, artifactSha256)); + InstallRecord.seal(work, params.hash, artifactSha256, stagedBundleSha256); if (params.unzipDirectory.exists()) { UpdateFileUtils.removeDirectory(params.unzipDirectory); } - if (!work.renameTo(params.unzipDirectory)) { - throw new IOException("failed to promote staging directory to " + params.unzipDirectory); - } - // The rename rewrote the versions root's entries; the record's own - // directory was synced by InstallRecord.write, the root was not. - File versionsRoot = params.unzipDirectory.getParentFile(); - if (versionsRoot != null) { - InstallRecord.syncDirectory(versionsRoot); - } + InstallRecord.promote(work, params.unzipDirectory); } /** @@ -691,7 +675,7 @@ private boolean targetsRunningVersion() { */ private void ensureNotReinstallingRunningVersion() throws IOException { try { - InstallRecord.verifyInstalled(params.unzipDirectory, params.hash); + InstallRecord.verifyInstalled(params.unzipDirectory, params.hash, false); } catch (IOException e) { throw new FileOperationException("Running version " + params.hash + " cannot be reinstalled in place: " + e.getMessage()); diff --git a/android/src/main/java/cn/reactnative/modules/update/InstallRecord.java b/android/src/main/java/cn/reactnative/modules/update/InstallRecord.java index 7dc7c129..2aa373d8 100644 --- a/android/src/main/java/cn/reactnative/modules/update/InstallRecord.java +++ b/android/src/main/java/cn/reactnative/modules/update/InstallRecord.java @@ -1,25 +1,20 @@ package cn.reactnative.modules.update; -import android.os.Build; -import android.util.Log; import java.io.File; -import java.io.FileOutputStream; import java.io.IOException; -import java.nio.charset.StandardCharsets; -import org.json.JSONException; -import org.json.JSONObject; /** - * Java mirror of cpp/patch_core/install_record.h — keep in sync by hand. * The completion record written as the last step of a two-phase install and - * read back before a version directory is trusted. + * read back before a version directory is trusted. The logic is the shared + * core's (cpp/patch_core/install_record.h, over JNI); only the paths are + * built here. Every call does file IO and checkActivation hashes the whole + * bundle: callers run on worker threads (download tasks, the state serial + * thread, the sync coordinator), except the launch-time reads of + * isInstalled / recordedBundleSha256, which only stat and read the record. */ final class InstallRecord { - static final int SCHEMA = 1; - static final String FILE_NAME = ".pushy-complete"; + // Mirrors install_record.h kStagingSuffix (scripts/check-constant-parity.js). static final String STAGING_SUFFIX = ".staging"; - // A record is a few hundred bytes; anything larger is not one. - private static final long MAX_RECORD_BYTES = 64 * 1024; /** The JS bundle inside a version directory. */ static final String BUNDLE_FILE_NAME = "index.bundlejs"; @@ -35,137 +30,72 @@ static File stagingDirectoryFor(File versionDir) { } /** - * bundleSha256 is mandatory for schema 1: a version whose bundle could - * not be hashed must fail its install, never be recorded unverifiable. + * Launch / dedup presence check: the bundle exists and the record names + * this version (or is the legacy empty marker). No digest work. */ - static String build(String versionHash, String bundleSha256, String artifactSha256) - throws IOException, JSONException { - if (bundleSha256 == null || bundleSha256.isEmpty()) { - throw new IOException("Bundle version " + versionHash + " has no bundle digest."); - } - JSONObject record = new JSONObject(); - record.put("schema", SCHEMA); - record.put("versionHash", versionHash); - record.put("bundleSha256", bundleSha256); - if (artifactSha256 != null && !artifactSha256.isEmpty()) { - record.put("artifactSha256", artifactSha256); - } - return record.toString(); + static boolean isInstalled(File versionDir, String versionHash) { + return isComplete(versionDir.getPath(), versionHash, BUNDLE_FILE_NAME); } /** - * Writes the record and fsyncs it, then the directory holding it: the - * rename that follows must find the record on disk, and the new - * directory entry is only durable once the directory itself is synced. + * Activation check: the bundle exists and its bytes match the record's + * mandatory digest (the legacy empty marker has none). A directory + * without a record passes only when {@code grandfathered} (it is the + * persisted current or last version). Throws with the reason otherwise. */ - static void write(File versionDir, String recordJson) throws IOException { - File file = new File(versionDir, FILE_NAME); - try (FileOutputStream out = new FileOutputStream(file)) { - out.write(recordJson.getBytes(StandardCharsets.UTF_8)); - out.flush(); - out.getFD().sync(); - } - syncDirectory(versionDir); - } - - // java.nio.file (the only way to open a directory for fsync from Java) - // is API 26+ on Android; below that the file fsync above is all there - // is. Best-effort by design: a file system that refuses to open a - // directory for reading must not fail the install. Also called by the - // installer on the versions root after the staging -> version rename: - // that rename changes the parent's entries, which only the parent's own - // sync makes durable. - static void syncDirectory(File dir) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - return; - } - try (java.nio.channels.FileChannel channel = java.nio.channels.FileChannel.open( - dir.toPath(), java.nio.file.StandardOpenOption.READ)) { - channel.force(true); - } catch (Throwable e) { - Log.w(Texts.LOG_TAG, "Directory fsync skipped for " + dir + ": " + e); + static void verifyInstalled(File versionDir, String versionHash, boolean grandfathered) + throws IOException { + String reason = checkActivation( + versionDir.getPath(), versionHash, BUNDLE_FILE_NAME, grandfathered); + if (reason != null) { + throw new IOException(reason); } } /** - * Parsed record, or null when the file is absent or malformed. An empty - * file (legacy marker) yields an empty JSONObject. + * Writes the record (with the staged bundle's digest: knownBundleSha256 + * when the bundle was hashed while extracted, else hashed now) into the + * staging directory, atomically and durably. */ - static JSONObject read(File versionDir) { - File file = new File(versionDir, FILE_NAME); - if (!file.isFile()) { - return null; - } - if (file.length() == 0) { - return new JSONObject(); - } - try { - return new JSONObject(UpdateFileUtils.readSmallUtf8(file, MAX_RECORD_BYTES)); - } catch (IOException | JSONException e) { - return null; + static void seal( + File stagingDir, String versionHash, String artifactSha256, String knownBundleSha256 + ) throws IOException { + String reason = sealStaging(stagingDir.getPath(), versionHash, BUNDLE_FILE_NAME, + artifactSha256, knownBundleSha256); + if (reason != null) { + throw new IOException(reason); } } - /** - * Presence check: the record exists and, unless legacy-empty, names this - * version. No digest work — this runs on launch / dedup paths. - */ - static boolean isComplete(File versionDir, String versionHash) { - JSONObject record = read(versionDir); - return record != null && (record.length() == 0 || names(record, versionHash)); + /** Renames the sealed staging directory to the (absent) version directory. */ + static void promote(File stagingDir, File versionDir) throws IOException { + String reason = promoteStaging(stagingDir.getPath(), versionDir.getPath()); + if (reason != null) { + throw new IOException(reason); + } } - /** A schema-1 record for this version (the legacy empty marker has no fields). */ - private static boolean names(JSONObject record, String versionHash) { - return record.optInt("schema", 0) == SCHEMA - && versionHash.equals(record.optString("versionHash", "")); + /** The record's bundleSha256 (crash attribution); "" when there is none. */ + static String recordedBundleSha256(File versionDir) { + String digest = bundleSha256Of(versionDir.getPath()); + return digest == null ? "" : digest; } - /** - * Launch / dedup presence check: the bundle exists and the record names - * this version. No digest work. - */ - static boolean isInstalled(File versionDir, String versionHash) { - return bundleFile(versionDir).isFile() && isComplete(versionDir, versionHash); - } + private static native boolean isComplete( + String versionDir, String versionHash, String bundleFileName); - /** - * Activation check of an installed version: the bundle exists and its - * bytes match the record's digest. Throws with the reason otherwise. - */ - static void verifyInstalled(File versionDir, String versionHash) throws IOException { - File bundle = bundleFile(versionDir); - if (!bundle.isFile()) { - throw new IOException("Bundle version " + versionHash + " not found."); - } - verifyForActivation(versionDir, versionHash, bundle); - } + // These return null on success, the reason otherwise. + private static native String checkActivation( + String versionDir, String versionHash, String bundleFileName, boolean grandfathered); - /** - * Activation check: re-hashes the bundle against the record's digest, - * which schema 1 makes mandatory (only the legacy empty marker has none). - * Throws with the reason when the directory must not be activated. - */ - static void verifyForActivation(File versionDir, String versionHash, File bundleFile) - throws IOException { - JSONObject record = read(versionDir); - if (record == null) { - throw new IOException("Bundle version " + versionHash + " has no valid completion record."); - } - if (record.length() == 0) { - return; - } - if (!names(record, versionHash)) { - throw new IOException("Bundle version " + versionHash + " completion record mismatch."); - } - Object digest = record.opt("bundleSha256"); - String expected = digest instanceof String ? (String) digest : ""; - if (expected.isEmpty()) { - throw new IOException("Bundle version " + versionHash + " completion record mismatch."); - } - String actual = UpdateFileUtils.sha256Hex(bundleFile); - if (!expected.equalsIgnoreCase(actual)) { - throw new IOException("Bundle version " + versionHash + " bundle digest mismatch."); - } - } + private static native String sealStaging( + String stagingDir, + String versionHash, + String bundleFileName, + String artifactSha256, + String knownBundleSha256); + + private static native String promoteStaging(String stagingDir, String versionDir); + + private static native String bundleSha256Of(String versionDir); } diff --git a/android/src/main/java/cn/reactnative/modules/update/NativeCore.java b/android/src/main/java/cn/reactnative/modules/update/NativeCore.java index 0fd482f0..749b0509 100644 --- a/android/src/main/java/cn/reactnative/modules/update/NativeCore.java +++ b/android/src/main/java/cn/reactnative/modules/update/NativeCore.java @@ -4,10 +4,10 @@ * Loads librnpushy.so. Its JNI_OnLoad registers the natives of every class * (cpp/android_jni/jni_registration.cpp), so the library only has to be * loaded before the first native call: UpdateContext's static initializer - * does that, and DownloadTask, FlowBridge and supportedDiffVersion are only - * ever reached through an UpdateContext instance — except - * PushyRuntime.configure, which validates options before touching - * UpdateContext and so loads the library itself. + * does that, and DownloadTask, FlowBridge, InstallRecord and + * supportedDiffVersion are only ever reached through an UpdateContext + * instance — except PushyRuntime.configure, which validates options before + * touching UpdateContext and so loads the library itself. */ final class NativeCore { private static boolean loaded = false; 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 d97f6eeb..e6b2812c 100644 --- a/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +++ b/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java @@ -8,7 +8,6 @@ import androidx.annotation.Nullable; import com.facebook.react.ReactInstanceManager; import java.io.File; -import org.json.JSONObject; import java.io.IOException; import java.io.InputStream; import java.util.concurrent.Executor; @@ -485,24 +484,16 @@ private void verifySwitchTarget(String hash) { if (!UpdateFileUtils.isSafePathComponent(hash)) { throw new IllegalArgumentException("Invalid hash: " + hash); } - File versionDir = new File(rootDir, hash); - if (!InstallRecord.bundleFile(versionDir).isFile()) { - throw new IllegalStateException("Bundle version " + hash + " not found."); - } StateCoreResult currentState = getStateSnapshot(); - boolean isLegacyActivatedVersion = hash.equals(currentState.currentVersion) + // Versions activated before completion records existed are + // grandfathered through current/last state, but only while their + // directory has no record; any other directory must carry a record + // whose bundle digest matches the bytes on disk before the next + // launch is pointed at them. + boolean grandfathered = hash.equals(currentState.currentVersion) || hash.equals(currentState.lastVersion); - if (isLegacyActivatedVersion && !new File(versionDir, InstallRecord.FILE_NAME).exists()) { - // Versions activated before completion markers were introduced are - // explicitly grandfathered through current/last state. A directory - // that does carry a record is verified like any other. - return; - } - // An arbitrary markerless directory may be a crash-left partial - // install, and the record's bundle digest must match the bytes on - // disk before the next launch is pointed at them. try { - InstallRecord.verifyInstalled(versionDir, hash); + InstallRecord.verifyInstalled(new File(rootDir, hash), hash, grandfathered); } catch (IOException e) { throw new IllegalStateException(e.getMessage(), e); } @@ -904,8 +895,7 @@ private String readBundleSha256(String hash) { if (hash == null || hash.isEmpty() || !UpdateFileUtils.isSafePathComponent(hash)) { return ""; } - JSONObject record = InstallRecord.read(new File(rootDir, hash)); - return record == null ? "" : record.optString("bundleSha256", ""); + return InstallRecord.recordedBundleSha256(new File(rootDir, hash)); } /** Raw native check response for JS (§10.3); null when absent. */ diff --git a/android/src/test/java/cn/reactnative/modules/update/InstallRecordTest.java b/android/src/test/java/cn/reactnative/modules/update/InstallRecordTest.java index 93e00744..4c263dfb 100644 --- a/android/src/test/java/cn/reactnative/modules/update/InstallRecordTest.java +++ b/android/src/test/java/cn/reactnative/modules/update/InstallRecordTest.java @@ -1,240 +1,28 @@ package cn.reactnative.modules.update; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.util.Arrays; -import org.json.JSONObject; -import org.junit.Rule; import org.junit.Test; -import org.junit.rules.TemporaryFolder; +/** + * The record logic itself is the shared core's (cpp/patch_core/install_record.h, + * replayed against tests/install-record-vectors.json by the C++ test); the JVM + * cannot load the Android library, so only the paths built here are tested. + */ public class InstallRecordTest { private static final String HASH = "0123456789abcdef"; - @Rule - public TemporaryFolder temp = new TemporaryFolder(); - - private static void writeFile(File file, byte[] bytes) throws IOException { - try (FileOutputStream out = new FileOutputStream(file)) { - out.write(bytes); - } - } - - private static String sha256Hex(byte[] bytes) throws Exception { - byte[] digest = MessageDigest.getInstance("SHA-256").digest(bytes); - StringBuilder hex = new StringBuilder(); - for (byte b : digest) { - hex.append(String.format("%02x", b)); - } - return hex.toString(); - } - - @Test - public void buildWritesSchemaAndDigests() throws Exception { - JSONObject full = new JSONObject(InstallRecord.build(HASH, "bb", "aa")); - assertEquals(InstallRecord.SCHEMA, full.getInt("schema")); - assertEquals(HASH, full.getString("versionHash")); - assertEquals("bb", full.getString("bundleSha256")); - assertEquals("aa", full.getString("artifactSha256")); - - // artifactSha256 is optional diagnostics. - JSONObject noArtifact = new JSONObject(InstallRecord.build(HASH, "bb", null)); - assertEquals("bb", noArtifact.getString("bundleSha256")); - assertFalse(noArtifact.has("artifactSha256")); - } - - @Test - public void buildRefusesARecordWithoutBundleDigest() throws Exception { - for (String missing : new String[] {"", null}) { - try { - InstallRecord.build(HASH, missing, "aa"); - fail("a record without bundleSha256 must not be built"); - } catch (IOException expected) { - assertTrue(expected.getMessage().contains("no bundle digest")); - } - } - } - - @Test - public void writeThenReadRoundTrips() throws Exception { - File dir = temp.newFolder(); - InstallRecord.write(dir, InstallRecord.build(HASH, "bb", "aa")); - assertTrue(new File(dir, InstallRecord.FILE_NAME).isFile()); - - JSONObject record = InstallRecord.read(dir); - assertNotNull(record); - assertEquals(HASH, record.getString("versionHash")); - assertTrue(InstallRecord.isComplete(dir, HASH)); - assertFalse(InstallRecord.isComplete(dir, "someotherhash")); - } - - @Test - public void readHandlesAbsentLegacyAndMalformedRecords() throws Exception { - File dir = temp.newFolder(); - assertNull(InstallRecord.read(dir)); - assertFalse(InstallRecord.isComplete(dir, HASH)); - - // Legacy marker: an empty file counts as complete for any hash. - File marker = new File(dir, InstallRecord.FILE_NAME); - writeFile(marker, new byte[0]); - JSONObject legacy = InstallRecord.read(dir); - assertNotNull(legacy); - assertEquals(0, legacy.length()); - assertTrue(InstallRecord.isComplete(dir, HASH)); - - writeFile(marker, "not json".getBytes(StandardCharsets.UTF_8)); - assertNull(InstallRecord.read(dir)); - assertFalse(InstallRecord.isComplete(dir, HASH)); - - byte[] oversized = new byte[64 * 1024 + 1]; - Arrays.fill(oversized, (byte) ' '); - writeFile(marker, oversized); - assertNull(InstallRecord.read(dir)); - } - - @Test - public void isCompleteRequiresMatchingSchemaAndHash() throws Exception { - File dir = temp.newFolder(); - File marker = new File(dir, InstallRecord.FILE_NAME); - writeFile(marker, ("{\"schema\":" + (InstallRecord.SCHEMA + 1) - + ",\"versionHash\":\"" + HASH + "\"}").getBytes(StandardCharsets.UTF_8)); - assertFalse(InstallRecord.isComplete(dir, HASH)); - - writeFile(marker, ("{\"schema\":" + InstallRecord.SCHEMA - + ",\"versionHash\":\"other\"}").getBytes(StandardCharsets.UTF_8)); - assertFalse(InstallRecord.isComplete(dir, HASH)); - } - - @Test - public void verifyForActivationChecksTheBundleDigest() throws Exception { - File dir = temp.newFolder(); - File bundle = new File(dir, "index.bundlejs"); - byte[] bundleBytes = "var a = 1;".getBytes(StandardCharsets.UTF_8); - writeFile(bundle, bundleBytes); - - InstallRecord.write(dir, InstallRecord.build(HASH, sha256Hex(bundleBytes), "")); - InstallRecord.verifyForActivation(dir, HASH, bundle); - - // Digest stored upper-case still matches. - InstallRecord.write(dir, InstallRecord.build(HASH, sha256Hex(bundleBytes).toUpperCase(), "")); - InstallRecord.verifyForActivation(dir, HASH, bundle); - - writeFile(bundle, "var a = 2;".getBytes(StandardCharsets.UTF_8)); - try { - InstallRecord.verifyForActivation(dir, HASH, bundle); - fail("tampered bundle must not activate"); - } catch (IOException expected) { - assertTrue(expected.getMessage().contains("digest mismatch")); - } - } - - @Test - public void verifyForActivationTrustsLegacyEmptyMarkers() throws Exception { - File dir = temp.newFolder(); - File bundle = new File(dir, "index.bundlejs"); - writeFile(bundle, "x".getBytes(StandardCharsets.UTF_8)); - - writeFile(new File(dir, InstallRecord.FILE_NAME), new byte[0]); - InstallRecord.verifyForActivation(dir, HASH, bundle); - } - - @Test - public void verifyForActivationRejectsSchemaOneRecordsWithoutDigest() throws Exception { - File dir = temp.newFolder(); - File bundle = new File(dir, "index.bundlejs"); - writeFile(bundle, "x".getBytes(StandardCharsets.UTF_8)); - File marker = new File(dir, InstallRecord.FILE_NAME); - String prefix = "{\"schema\":" + InstallRecord.SCHEMA + ",\"versionHash\":\"" + HASH + "\""; - String[] records = { - prefix + "}", - prefix + ",\"bundleSha256\":\"\"}", - prefix + ",\"bundleSha256\":null}", - prefix + ",\"bundleSha256\":123}", - prefix + ",\"artifactSha256\":\"aa\"}", - }; - for (String json : records) { - writeFile(marker, json.getBytes(StandardCharsets.UTF_8)); - // Still present for launch / dedup: only activation is refused. - assertTrue(InstallRecord.isComplete(dir, HASH)); - try { - InstallRecord.verifyForActivation(dir, HASH, bundle); - fail("digest-less record must not activate: " + json); - } catch (IOException expected) { - assertTrue(expected.getMessage().contains("record mismatch")); - } - } - } - - @Test - public void verifyForActivationRejectsMissingOrForeignRecords() throws Exception { - File dir = temp.newFolder(); - File bundle = new File(dir, "index.bundlejs"); - writeFile(bundle, "x".getBytes(StandardCharsets.UTF_8)); - try { - InstallRecord.verifyForActivation(dir, HASH, bundle); - fail("missing record must not activate"); - } catch (IOException expected) { - assertTrue(expected.getMessage().contains("no valid completion record")); - } - - InstallRecord.write(dir, InstallRecord.build("anotherhash", "bb", "")); - try { - InstallRecord.verifyForActivation(dir, HASH, bundle); - fail("record for another version must not activate"); - } catch (IOException expected) { - assertTrue(expected.getMessage().contains("record mismatch")); - } - } - - @Test - public void isInstalledNeedsBundleAndMatchingRecord() throws Exception { - File dir = temp.newFolder(); - InstallRecord.write(dir, InstallRecord.build(HASH, "bb", "")); - assertFalse(InstallRecord.isInstalled(dir, HASH)); - - writeFile(InstallRecord.bundleFile(dir), "x".getBytes(StandardCharsets.UTF_8)); - assertTrue(InstallRecord.isInstalled(dir, HASH)); - assertFalse(InstallRecord.isInstalled(dir, "someotherhash")); - } - - @Test - public void verifyInstalledChecksBundlePresenceThenDigest() throws Exception { - File dir = temp.newFolder(); - byte[] bundleBytes = "var a = 1;".getBytes(StandardCharsets.UTF_8); - InstallRecord.write(dir, InstallRecord.build(HASH, sha256Hex(bundleBytes), "")); - try { - InstallRecord.verifyInstalled(dir, HASH); - fail("a version without its bundle must not activate"); - } catch (IOException expected) { - assertTrue(expected.getMessage().contains("not found")); - } - - writeFile(InstallRecord.bundleFile(dir), bundleBytes); - InstallRecord.verifyInstalled(dir, HASH); - - writeFile(InstallRecord.bundleFile(dir), "var a = 2;".getBytes(StandardCharsets.UTF_8)); - try { - InstallRecord.verifyInstalled(dir, HASH); - fail("tampered bundle must not activate"); - } catch (IOException expected) { - assertTrue(expected.getMessage().contains("digest mismatch")); - } - } - @Test public void stagingDirectoryIsASibling() { File version = new File("/data/_update/" + HASH); assertEquals(new File("/data/_update/" + HASH + InstallRecord.STAGING_SUFFIX), InstallRecord.stagingDirectoryFor(version)); } + + @Test + public void bundleLivesInTheVersionDirectory() { + File version = new File("/data/_update/" + HASH); + assertEquals(new File(version, "index.bundlejs"), InstallRecord.bundleFile(version)); + } } diff --git a/cpp/android_jni/install_record_jni.cpp b/cpp/android_jni/install_record_jni.cpp new file mode 100644 index 00000000..a014159a --- /dev/null +++ b/cpp/android_jni/install_record_jni.cpp @@ -0,0 +1,89 @@ +// JNI glue of InstallRecord.java over the shared completion record core +// (cpp/patch_core/install_record.h). The jstring-returning natives return +// null on success and the reason otherwise; Java turns a reason into an +// IOException. +#include + +#include + +#include "jni_natives.h" +#include "jni_util.h" +#include "../patch_core/install_record.h" + +namespace { + +using pushy::jni_util::GuardJni; +using pushy::jni_util::JStringToString; +using pushy::jni_util::ToJString; + +jstring ReasonOrNull(JNIEnv* env, const pushy::delta::Status& status) { + return status.ok ? nullptr : ToJString(env, status.message); +} + +} // namespace + +jboolean pushy::jni_natives::InstallRecordIsComplete( + JNIEnv* env, jclass, jstring version_dir, jstring version_hash, + jstring bundle_file_name) { + return GuardJni( + env, "Unexpected native exception while reading a completion record", [&] { + return pushy::install_record::IsComplete( + JStringToString(env, version_dir), + JStringToString(env, version_hash), + JStringToString(env, bundle_file_name)) + ? JNI_TRUE + : JNI_FALSE; + }); +} + +// Hashes the whole bundle: called from worker threads only. +jstring pushy::jni_natives::InstallRecordCheckActivation( + JNIEnv* env, jclass, jstring version_dir, jstring version_hash, + jstring bundle_file_name, jboolean grandfathered) { + return GuardJni( + env, "Unexpected native exception while verifying a version", [&] { + pushy::install_record::Activation activation; + return ReasonOrNull( + env, pushy::install_record::CheckActivation( + JStringToString(env, version_dir), + JStringToString(env, version_hash), + JStringToString(env, bundle_file_name), + grandfathered == JNI_TRUE, &activation)); + }); +} + +jstring pushy::jni_natives::InstallRecordSeal( + JNIEnv* env, jclass, jstring staging_dir, jstring version_hash, + jstring bundle_file_name, jstring artifact_sha256, + jstring known_bundle_sha256) { + return GuardJni( + env, "Unexpected native exception while writing a completion record", [&] { + return ReasonOrNull( + env, pushy::install_record::Seal( + JStringToString(env, staging_dir), + JStringToString(env, version_hash), + JStringToString(env, bundle_file_name), + JStringToString(env, artifact_sha256), + JStringToString(env, known_bundle_sha256)) + .status); + }); +} + +jstring pushy::jni_natives::InstallRecordPromote( + JNIEnv* env, jclass, jstring staging_dir, jstring version_dir) { + return GuardJni( + env, "Unexpected native exception while moving a staged version", [&] { + return ReasonOrNull( + env, pushy::install_record::Promote(JStringToString(env, staging_dir), + JStringToString(env, version_dir))); + }); +} + +jstring pushy::jni_natives::InstallRecordBundleSha256( + JNIEnv* env, jclass, jstring version_dir) { + return GuardJni( + env, "Unexpected native exception while reading a completion record", [&] { + return ToJString(env, pushy::install_record::RecordedBundleSha256( + JStringToString(env, version_dir))); + }); +} diff --git a/cpp/android_jni/jni_natives.h b/cpp/android_jni/jni_natives.h index 5b19c779..1ab074f1 100644 --- a/cpp/android_jni/jni_natives.h +++ b/cpp/android_jni/jni_natives.h @@ -27,6 +27,11 @@ jstring FlowHandleResponse(JNIEnv* env, jclass, jstring responseText, jstring id jboolean FlowConfigsEquivalent(JNIEnv* env, jclass, jstring a, jstring b); jstring FlowRunnableConfig(JNIEnv* env, jclass, jstring configJson); jstring FlowNormalizeConfiguration(JNIEnv* env, jclass, jstring optionsJson); +jboolean InstallRecordIsComplete(JNIEnv* env, jclass, jstring version_dir, jstring version_hash, jstring bundle_file_name); +jstring InstallRecordCheckActivation(JNIEnv* env, jclass, jstring version_dir, jstring version_hash, jstring bundle_file_name, jboolean grandfathered); +jstring InstallRecordSeal(JNIEnv* env, jclass, jstring staging_dir, jstring version_hash, jstring bundle_file_name, jstring artifact_sha256, jstring known_bundle_sha256); +jstring InstallRecordPromote(JNIEnv* env, jclass, jstring staging_dir, jstring version_dir); +jstring InstallRecordBundleSha256(JNIEnv* env, jclass, jstring version_dir); } // namespace jni_natives } // namespace pushy diff --git a/cpp/android_jni/jni_registration.cpp b/cpp/android_jni/jni_registration.cpp index ed74b42b..881bc3dd 100644 --- a/cpp/android_jni/jni_registration.cpp +++ b/cpp/android_jni/jni_registration.cpp @@ -64,6 +64,19 @@ const std::vector& NativeClasses() { {"3418e6dcaf87615f271c13f7d0ba9778582610e8f1d5", "723bfed0b88a274923311bb6e5a7826444205fa8d2d1b98373002008e8c4ef8e8e655d3f09b0", reinterpret_cast(&pushy::jni_natives::FlowNormalizeConfiguration)}, }}, + {"3919bbc3ab8a6b512c3e08f0c0b6df60452311edfbc8f780624b2d1de38c89b38963553d02d9cda68d8d78", // InstallRecord + { + {"3304d7dea39b6440363a", "723bfed0b88a274923311bb6e5a7826444205fcdf4daae943d432d07e18c93a9887e5a3655c7c2a4949e3355371df78299937648503c43bce8", + reinterpret_cast(&pushy::jni_natives::InstallRecordIsComplete)}, + {"391ff1d2a5aa6b512b291deddfbc9e", "723bfed0b88a274923311bb6e5a7826444205fcdf4daae943d432d07e18c93a9887e5a3655c7c2a4949e3355371df78299937648503c43cf9b83866850224f11fbd9b3de5d5f3a0cecf887", + reinterpret_cast(&pushy::jni_natives::InstallRecordCheckActivation)}, + {"2912f5dd9d9f69422b311b", "723bfed0b88a274923311bb6e5a7826444205fcdf4daae943d432d07e18c93a9887e5a3655c7c2a4949e3355371df78299937648503c43d9d8ae9a68092f0113fd9887857c422602b9d3d6b880721f210be9c3ee8d8f6a5c3c08b7808a89616b5b7818f0c0acc756564d3517f188", + reinterpret_cast(&pushy::jni_natives::InstallRecordSeal)}, + {"2a05fbdca19f6d76363e1bf0d8b4", "723bfed0b88a274923311bb6e5a7826444205fcdf4daae943d432d07e18c93a9887e5a3655a2e4af83897d163a12fecae5b4705357351fae", + reinterpret_cast(&pushy::jni_natives::InstallRecordPromote)}, + {"3802fad5a28e5b4d236d49aff9b5", "723bfed0b88a274923311bb6e5a7826444205fa8d2d1b98373002008e8c4ef8e8e655d3f09b0", + reinterpret_cast(&pushy::jni_natives::InstallRecordBundleSha256)}, + }}, }; return classes; } diff --git a/scripts/check-constant-parity.js b/scripts/check-constant-parity.js index cbcea110..977d4732 100644 --- a/scripts/check-constant-parity.js +++ b/scripts/check-constant-parity.js @@ -152,7 +152,7 @@ const pairs = [ mirror: `${javaDir}/InstallRecord.java`, parse: parseJavaConstants, mirrorName: screamingSnake, - only: installRecordMirrored, + only: ['StagingSuffix'], }, { header: 'cpp/patch_core/error_codes.h', diff --git a/src/__tests__/jniRegistration.test.ts b/src/__tests__/jniRegistration.test.ts index 34797fef..5360cd13 100644 --- a/src/__tests__/jniRegistration.test.ts +++ b/src/__tests__/jniRegistration.test.ts @@ -73,7 +73,7 @@ const registered = () => { describe('JNI registration table', () => { test('matches every Java native declaration', () => { const natives = javaNatives(); - expect(natives.length).toBe(12); + expect(natives.length).toBe(17); expect(registered()).toEqual(natives); }); From bde5b2f508fa484620d2f6ca9c092412be2392a3 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 16:51:33 +0800 Subject: [PATCH 087/104] refactor(patch-core): define Status::Ok/Error inline install_record.cpp reports a delta::Status but needs nothing else of patch_core.cpp; with the two factories in the header it links on its own, which lets the iOS purge-restore harness compile the real record core without hpatch, HDiffPatch and lzma. Co-Authored-By: Claude Opus 5.5 --- cpp/patch_core/patch_core.cpp | 8 -------- cpp/patch_core/patch_core.h | 8 ++++++-- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/cpp/patch_core/patch_core.cpp b/cpp/patch_core/patch_core.cpp index 7fcda9a6..27b6c0b8 100644 --- a/cpp/patch_core/patch_core.cpp +++ b/cpp/patch_core/patch_core.cpp @@ -490,14 +490,6 @@ Status MergeDirectoryRecursively( } // namespace -Status Status::Ok() { - return Status{true, std::string()}; -} - -Status Status::Error(const std::string& message) { - return Status{false, message}; -} - Status ValidateManifest(const PatchManifest& manifest) { return ValidateManifestImpl(manifest); } diff --git a/cpp/patch_core/patch_core.h b/cpp/patch_core/patch_core.h index 230bd003..fc6e230d 100644 --- a/cpp/patch_core/patch_core.h +++ b/cpp/patch_core/patch_core.h @@ -17,8 +17,12 @@ struct Status { explicit operator bool() const { return ok; } - static Status Ok(); - static Status Error(const std::string& message); + // Inline so a core that only reports a Status (install_record.cpp) links + // without patch_core.cpp and its hpatch dependencies. + static Status Ok() { return Status{true, std::string()}; } + static Status Error(const std::string& message) { + return Status{false, message}; + } }; struct CopyOperation { From 6a7e52e2b3b313d767f0994b9486cbbdca40c251 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 16:55:19 +0800 Subject: [PATCH 088/104] refactor(ios): use the shared completion record core RCTPushyInstallRecord.mm keeps the paths and becomes a thin layer over cpp/patch_core/install_record.h: PushyHasCompletedVersionAtPath is IsComplete, PushyRecordedBundleSha256 (constants, crash attribution) is RecordedBundleSha256, and PushyPromoteStagingDir seals the staging directory (Seal: bundle failures stay PATCH_FAILED, a record that cannot be written is FILE_OPERATION_FAILED instead of a raw Cocoa error) and promotes it (Promote, which now also fsyncs the record and the versions root; NSDataWritingAtomic did neither). switchVersion:error: calls CheckActivation outside the state lock with the current/last decision taken under it, and re-checks with Unchanged under the lock (same grandfathering, record text and bundle stamp) before committing. PushyReadInstallRecord, PushyInstallRecordMatches, PushyHasInstallRecordFile, PushyStampFile, PushyFileStamp and PushyVerifyInstallForActivation are gone. A grandfathered version now needs its bundle like on Android/Harmony. The purge-restore harness compiles the extractor's copy of install_record.cpp next to the extracted switchVersion:error:, so install_record_activation and switch_version_records exercise the shared core; the trust-digestless and grandfather-recorded mutants now alter that copy and are still caught by their named assertions. Co-Authored-By: Claude Opus 5.5 --- ios/RCTPushy/RCTPushy.mm | 10 +- ios/RCTPushy/RCTPushyInstallRecord.mm | 165 ++++-------------- ios/RCTPushy/RCTPushyInstaller.mm | 87 +++------ ios/RCTPushy/RCTPushyInternal.h | 29 +-- scripts/test-ios-purge-restore.sh | 11 +- scripts/tests/ios-purge-restore/README.md | 28 +-- scripts/tests/ios-purge-restore/extract.py | 39 +++-- .../ios-purge-restore/purge_restore_test.mm | 31 ++-- .../tests/ios-purge-restore/test_extract.py | 7 +- 9 files changed, 127 insertions(+), 280 deletions(-) diff --git a/ios/RCTPushy/RCTPushy.mm b/ios/RCTPushy/RCTPushy.mm index cbd41dce..e89d47e8 100644 --- a/ios/RCTPushy/RCTPushy.mm +++ b/ios/RCTPushy/RCTPushy.mm @@ -177,14 +177,8 @@ - (NSDictionary *)constantsToExport if (currentVersion != nil) { ret[@"currentVersionInfo"] = [defaults objectForKey:PushyHashInfoKey(currentVersion)]; // bundleSha256 from the install record, for crash-report attribution. - NSString *bundleSha256 = @""; - if (PushyIsSafePathComponent(currentVersion)) { - NSDictionary *record = PushyReadInstallRecord(PushyVersionDir(currentVersion)); - if ([record[@"bundleSha256"] isKindOfClass:[NSString class]]) { - bundleSha256 = record[@"bundleSha256"]; - } - } - ret[@"currentBundleSha256"] = bundleSha256; + ret[@"currentBundleSha256"] = PushyIsSafePathComponent(currentVersion) + ? PushyRecordedBundleSha256(PushyVersionDir(currentVersion)) : @""; } if (ret[@"isFirstTime"]) { diff --git a/ios/RCTPushy/RCTPushyInstallRecord.mm b/ios/RCTPushy/RCTPushyInstallRecord.mm index dc96ee24..eacb8c2d 100644 --- a/ios/RCTPushy/RCTPushyInstallRecord.mm +++ b/ios/RCTPushy/RCTPushyInstallRecord.mm @@ -1,19 +1,14 @@ -// Version directories and the completion record -// (cpp/patch_core/install_record.h): where an install lands, how it is -// recorded as complete, and how a recorded version is verified before it -// may be activated. +// Version directories and the completion record: where an install lands and +// how it is recorded as complete. The record itself (format, atomic write, +// presence and activation checks) is the shared core's +// (cpp/patch_core/install_record.h); switchVersion:error: calls its +// activation check directly. #import "RCTPushyInternal.h" -#include "../../cpp/patch_core/digest.h" #include "../../cpp/patch_core/error_codes.h" #include "../../cpp/patch_core/install_record.h" PUSHY_HIDDEN NSString *const BUNDLE_FILE_NAME = @"index.bundlejs"; -static NSString *PushyInstallRecordFileName(void) { - static NSString *name = @(pushy::install_record::kFileName); - return name; -} - PUSHY_HIDDEN NSString *PushyVersionDir(NSString *hash) { return [[RCTPushy downloadDir] stringByAppendingPathComponent:hash]; } @@ -23,144 +18,44 @@ return [PushyVersionDir(hash) stringByAppendingString:@(pushy::install_record::kStagingSuffix)]; } -// Parsed completion record: nil when the file is absent or malformed; an -// empty dictionary for the legacy empty marker written by SDK < 10.53. -PUSHY_HIDDEN NSDictionary *PushyReadInstallRecord(NSString *versionDir) { - NSString *path = [versionDir stringByAppendingPathComponent:PushyInstallRecordFileName()]; - NSData *data = [NSData dataWithContentsOfFile:path]; - if (data == nil) { - return nil; - } - if (data.length == 0) { - return @{}; - } - if (data.length > 64 * 1024) { - return nil; - } - return PushyJsonDictionary(data, NULL); -} - -// Whether a non-empty record names this version under the current schema. -static BOOL PushyInstallRecordMatches(NSDictionary *record, NSString *hash) { - return [record[@"schema"] isKindOfClass:[NSNumber class]] - && [record[@"schema"] intValue] == pushy::install_record::kSchema - && [record[@"versionHash"] isKindOfClass:[NSString class]] - && [record[@"versionHash"] isEqualToString:hash]; -} - -// Whether the directory carries a completion record file at all (the legacy -// empty marker included). Only directories WITHOUT one are grandfathered by -// current/last state in switchVersion; a present record is always verified. -PUSHY_HIDDEN BOOL PushyHasInstallRecordFile(NSString *versionDir) { - return [[NSFileManager defaultManager] fileExistsAtPath: - [versionDir stringByAppendingPathComponent:PushyInstallRecordFileName()]]; -} - // Presence check (launch / dedup paths, no digest work): bundle present and -// the record exists and, unless legacy-empty, names this version. +// the record is the legacy marker or names this version. PUSHY_HIDDEN BOOL PushyHasCompletedVersionAtPath(NSString *versionDir, NSString *hash) { - NSString *bundlePath = [versionDir stringByAppendingPathComponent:BUNDLE_FILE_NAME]; - if (![[NSFileManager defaultManager] fileExistsAtPath:bundlePath]) { - return NO; - } - NSDictionary *record = PushyReadInstallRecord(versionDir); - if (record == nil) { - return NO; - } - return record.count == 0 || PushyInstallRecordMatches(record, hash); + return pushy::install_record::IsComplete(PushyToStdString(versionDir), PushyToStdString(hash), + PushyToStdString(BUNDLE_FILE_NAME)); } -PUSHY_HIDDEN PushyFileStamp PushyStampFile(NSString *path) { - PushyFileStamp stamp; - struct stat info; - if (path != nil && stat(path.fileSystemRepresentation, &info) == 0) { - stamp.exists = true; - stamp.device = info.st_dev; - stamp.inode = info.st_ino; - stamp.size = info.st_size; - stamp.modified = info.st_mtimespec; - } - return stamp; +// The record's bundleSha256 (crash attribution); @"" when there is none. +PUSHY_HIDDEN NSString *PushyRecordedBundleSha256(NSString *versionDir) { + return PushyFromStdString( + pushy::install_record::RecordedBundleSha256(PushyToStdString(versionDir))) ?: @""; } -// Activation check: re-hashes the bundle against the record's digest, which -// schema 1 makes mandatory (only the legacy empty marker has none). -// Returns nil when the directory may be activated, the reason otherwise. -// Slow (a full read of the bundle): never call it under the state lock. On -// success *recordOut and *stampOut describe exactly what was verified, so the -// caller can confirm under the lock that nothing changed since. -PUSHY_HIDDEN NSString *PushyVerifyInstallForActivation(NSString *versionDir, NSString *hash, - NSDictionary **recordOut, - PushyFileStamp *stampOut) { - NSString *bundlePath = [versionDir stringByAppendingPathComponent:BUNDLE_FILE_NAME]; - PushyFileStamp before = PushyStampFile(bundlePath); - NSDictionary *record = PushyReadInstallRecord(versionDir); - if (record == nil) { - return [NSString stringWithFormat:@"Bundle version %@ has no valid completion record.", hash]; - } - *recordOut = record; - *stampOut = before; - if (record.count == 0) { - return nil; - } - if (!PushyInstallRecordMatches(record, hash)) { - return [NSString stringWithFormat:@"Bundle version %@ completion record mismatch.", hash]; - } - NSString *expected = record[@"bundleSha256"]; - if (![expected isKindOfClass:[NSString class]] || expected.length == 0) { - return [NSString stringWithFormat:@"Bundle version %@ completion record mismatch.", hash]; - } - std::string actual = pushy::digest::Sha256File(PushyToStdString(bundlePath)); - if (PushyStampFile(bundlePath) != before) { - return [NSString stringWithFormat:@"Bundle version %@ changed while being verified.", hash]; - } - if ([[expected lowercaseString] isEqualToString:[NSString stringWithUTF8String:actual.c_str()]]) { - return nil; - } - return [NSString stringWithFormat:@"Bundle version %@ bundle digest mismatch.", hash]; -} - -// Second phase of an install: writes the completion record, with the final -// bundle's digest, into .staging and renames the staging directory over +// Second phase of an install: seals .staging with the completion +// record (the final bundle's digest is mandatory: a version whose bundle +// could not be hashed is never recorded as installed) and renames it over // in one atomic step. nil on success. Runs on the file queue. The -// digest is mandatory: a version whose bundle could not be hashed is never -// recorded as installed. The caller drops the staging directory on failure. +// caller drops the staging directory on failure. PUSHY_HIDDEN NSError *PushyPromoteStagingDir(NSString *hash, NSString *artifactSha256) { - NSFileManager *fileManager = [NSFileManager defaultManager]; - NSString *staging = PushyStagingDir(hash); - NSString *bundlePath = [staging stringByAppendingPathComponent:BUNDLE_FILE_NAME]; - if (![fileManager fileExistsAtPath:bundlePath]) { - return PushyErrorWithCode(pushy::error_codes::kDeltaFailed, @"bundle missing after install"); - } - std::string bundleSha256 = pushy::digest::Sha256File(PushyToStdString(bundlePath)); - if (bundleSha256.empty()) { - return PushyErrorWithCode(pushy::error_codes::kDeltaFailed, - @"bundle digest failed after install"); - } - NSMutableDictionary *record = [NSMutableDictionary dictionary]; - record[@"schema"] = @(pushy::install_record::kSchema); - record[@"versionHash"] = hash; - record[@"bundleSha256"] = @(bundleSha256.c_str()); - if (artifactSha256.length > 0) { - record[@"artifactSha256"] = artifactSha256; - } - NSError *recordError = nil; - NSData *recordData = [NSJSONSerialization dataWithJSONObject:record options:0 error:&recordError]; - NSString *marker = [staging stringByAppendingPathComponent:PushyInstallRecordFileName()]; - if (recordData == nil || ![recordData writeToFile:marker - options:NSDataWritingAtomic - error:&recordError]) { - return recordError ?: PushyErrorWithCode(pushy::error_codes::kFileOperationFailed, - @"failed to write completion record"); + std::string staging = PushyToStdString(PushyStagingDir(hash)); + pushy::install_record::SealResult sealed = pushy::install_record::Seal( + staging, PushyToStdString(hash), PushyToStdString(BUNDLE_FILE_NAME), + PushyToStdString(artifactSha256), std::string()); + if (!sealed.status.ok) { + return PushyErrorWithCode(sealed.file_failure ? pushy::error_codes::kFileOperationFailed + : pushy::error_codes::kDeltaFailed, + PushyFromStdString(sealed.status.message)); } NSString *versionDir = PushyVersionDir(hash); + NSFileManager *fileManager = [NSFileManager defaultManager]; if ([fileManager fileExistsAtPath:versionDir]) { [fileManager removeItemAtPath:versionDir error:nil]; } - NSError *moveError = nil; - if (![fileManager moveItemAtPath:staging toPath:versionDir error:&moveError]) { - return moveError ?: PushyErrorWithCode(pushy::error_codes::kFileOperationFailed, - @"failed to promote staging directory"); + pushy::install_record::Status promoted = + pushy::install_record::Promote(staging, PushyToStdString(versionDir)); + if (!promoted.ok) { + return PushyErrorWithCode(pushy::error_codes::kFileOperationFailed, + PushyFromStdString(promoted.message)); } return nil; } diff --git a/ios/RCTPushy/RCTPushyInstaller.mm b/ios/RCTPushy/RCTPushyInstaller.mm index 7417dd92..b8c6f951 100644 --- a/ios/RCTPushy/RCTPushyInstaller.mm +++ b/ios/RCTPushy/RCTPushyInstaller.mm @@ -8,6 +8,7 @@ #include "../../cpp/patch_core/archive_patch_core.h" #include "../../cpp/patch_core/digest.h" #include "../../cpp/patch_core/error_codes.h" +#include "../../cpp/patch_core/install_record.h" #include "../../cpp/patch_core/patch_core.h" // file def @@ -595,77 +596,43 @@ + (BOOL)switchVersion:(NSString *)hash error:(NSError **)error return NO; } - // Same rule as Android: only a version this SDK recorded as completely - // installed (bundle + marker) may be activated. Markerless versions - // activated before markers existed are grandfathered through current/last - // state; any other markerless directory may be a crash-left partial - // install or a directory something else put there. A directory that does - // carry a record is verified even when it is current/last. - NSString *versionDir = PushyVersionDir(hash); + // Same gate as Android and Harmony (install_record::CheckActivation): + // only a version this SDK recorded as completely installed, whose + // record's bundle digest matches the bytes on disk, may be activated. + // Versions activated before records existed are grandfathered through + // current/last state, but only while their directory has no record file. + std::string versionDir = PushyToStdString(PushyVersionDir(hash)); std::string hashStd = PushyToStdString(hash); - BOOL (^isLegacyActivated)(void) = ^BOOL { - if (PushyHasInstallRecordFile(versionDir)) { - return NO; - } + std::string bundleName = PushyToStdString(BUNDLE_FILE_NAME); + BOOL (^isCurrentOrLast)(void) = ^BOOL { pushy::state::State state = PushyStateFromDefaults(PushyDefaults()); return state.current_version == hashStd || state.last_version == hashStd; }; - NSError *(^incompleteError)(void) = ^NSError * { - BOOL hasBundle = [[NSFileManager defaultManager] fileExistsAtPath: - [versionDir stringByAppendingPathComponent:BUNDLE_FILE_NAME]]; - return PushyErrorWithCode( - pushy::error_codes::kSwitchVersionFailed, - [NSString stringWithFormat:@"Bundle version %@ %@", hash, - hasBundle ? @"is incomplete." : @"not found."]); - }; // The digest check reads the whole bundle, so it runs OUTSIDE the state // lock: +bundleURL takes that lock on the main thread at launch. - __block BOOL legacyActivated = NO; + __block BOOL grandfathered = NO; PushyWithStateLock(^{ - legacyActivated = isLegacyActivated(); + grandfathered = isCurrentOrLast(); }); - NSError *switchError = nil; - NSDictionary *verifiedRecord = nil; - PushyFileStamp verifiedStamp; - BOOL verified = NO; - if (!legacyActivated) { - if (!PushyHasCompletedVersionAtPath(versionDir, hash)) { - switchError = incompleteError(); - } else { - // The record's bundle digest must match the bytes on disk before - // the next launch is pointed at them. - NSString *reason = PushyVerifyInstallForActivation( - versionDir, hash, &verifiedRecord, &verifiedStamp); - if (reason != nil) { - switchError = PushyErrorWithCode(pushy::error_codes::kSwitchVersionFailed, reason); - } else { - verified = YES; - } - } - } - - __block NSError *lockedError = switchError; + pushy::install_record::Activation activation; + pushy::install_record::Status verified = pushy::install_record::CheckActivation( + versionDir, hashStd, bundleName, grandfathered, &activation); + + __block NSError *lockedError = verified.ok + ? nil + : PushyErrorWithCode(pushy::error_codes::kSwitchVersionFailed, + PushyFromStdString(verified.message)); if (lockedError == nil) { - NSDictionary *record = verifiedRecord; - PushyFileStamp stamp = verifiedStamp; PushyWithStateLock(^{ - if (!isLegacyActivated()) { - // Re-check cheaply that the directory is still the one that - // was verified: same record, same bundle file (size, mtime, - // inode). - if (!PushyHasCompletedVersionAtPath(versionDir, hash)) { - lockedError = incompleteError(); - return; - } - NSString *bundlePath = [versionDir stringByAppendingPathComponent:BUNDLE_FILE_NAME]; - if (!verified || ![PushyReadInstallRecord(versionDir) isEqual:record] - || PushyStampFile(bundlePath) != stamp) { - lockedError = PushyErrorWithCode( - pushy::error_codes::kSwitchVersionFailed, - [NSString stringWithFormat:@"Bundle version %@ changed while switching.", hash]); - return; - } + // Re-check cheaply that the directory is still the one that was + // verified: same grandfathering, same record, same bundle file. + if (!pushy::install_record::Unchanged(versionDir, bundleName, isCurrentOrLast(), + activation)) { + lockedError = PushyErrorWithCode( + pushy::error_codes::kSwitchVersionFailed, + [NSString stringWithFormat:@"Bundle version %@ changed while switching.", hash]); + return; } PushySwitchVersionLocked(hash); }); diff --git a/ios/RCTPushy/RCTPushyInternal.h b/ios/RCTPushy/RCTPushyInternal.h index 9585045b..21835857 100644 --- a/ios/RCTPushy/RCTPushyInternal.h +++ b/ios/RCTPushy/RCTPushyInternal.h @@ -6,8 +6,8 @@ // RCTPushy+Launch.mm launch bundle resolution, paths, binary identity // RCTPushyState.mm persisted keys, state lock, State <-> defaults, // string/error/JSON helpers, bundle hash -// RCTPushyInstallRecord.mm version directories, completion record, -// activation verification +// RCTPushyInstallRecord.mm version directories, completion record +// (over cpp/patch_core/install_record.h) // RCTPushyInstaller.mm download/unzip/patch pipeline, switchVersion, // cleanup (owns the process-wide file queue) // RCTPushyOrchestrator.mm the native round @@ -20,7 +20,6 @@ #include #include -#include #include "../../cpp/patch_core/state_core.h" @@ -82,32 +81,10 @@ PUSHY_HIDDEN NSString *PushyBundleHashSync(BOOL computeIfMissing); extern PUSHY_HIDDEN NSString *const BUNDLE_FILE_NAME; -// Identity of a file's bytes on disk without reading them: a rewrite or an -// atomic replacement (new inode) changes it. -struct PushyFileStamp { - bool exists = false; - dev_t device = 0; - ino_t inode = 0; - off_t size = 0; - struct timespec modified = {0, 0}; - - bool operator==(const PushyFileStamp &other) const { - return exists == other.exists && device == other.device && inode == other.inode - && size == other.size && modified.tv_sec == other.modified.tv_sec - && modified.tv_nsec == other.modified.tv_nsec; - } - bool operator!=(const PushyFileStamp &other) const { return !(*this == other); } -}; - PUSHY_HIDDEN NSString *PushyVersionDir(NSString *hash); PUSHY_HIDDEN NSString *PushyStagingDir(NSString *hash); -PUSHY_HIDDEN NSDictionary *PushyReadInstallRecord(NSString *versionDir); -PUSHY_HIDDEN BOOL PushyHasInstallRecordFile(NSString *versionDir); PUSHY_HIDDEN BOOL PushyHasCompletedVersionAtPath(NSString *versionDir, NSString *hash); -PUSHY_HIDDEN PushyFileStamp PushyStampFile(NSString *path); -PUSHY_HIDDEN NSString *PushyVerifyInstallForActivation(NSString *versionDir, NSString *hash, - NSDictionary **recordOut, - PushyFileStamp *stampOut); +PUSHY_HIDDEN NSString *PushyRecordedBundleSha256(NSString *versionDir); PUSHY_HIDDEN NSError *PushyPromoteStagingDir(NSString *hash, NSString *artifactSha256); #pragma mark - RCTPushyInstaller.mm diff --git a/scripts/test-ios-purge-restore.sh b/scripts/test-ios-purge-restore.sh index d799270b..70fc487b 100755 --- a/scripts/test-ios-purge-restore.sh +++ b/scripts/test-ios-purge-restore.sh @@ -30,12 +30,15 @@ build() { else python3 "$TEST_DIR/extract.py" "$ROOT_DIR/ios/RCTPushy" "$destination" --mutation "$variant" fi - # Compile the real production bodies and state_core; only their external I/O - # collaborators live in the test host. No RN, CocoaPods, simulator or network. + # Compile the real production bodies, state_core and the completion record + # core (the extractor's copy of install_record.cpp, which the record mutants + # alter; -I cpp/patch_core resolves its file-relative includes); only their + # external I/O collaborators live in the test host. No RN, CocoaPods, + # simulator or network. xcrun clang++ -std=c++17 -fobjc-arc -fblocks $WARNING_FLAGS $SANITIZE_FLAGS \ - -I"$ROOT_DIR" -I"$destination" \ + -I"$ROOT_DIR" -I"$destination" -I"$ROOT_DIR/cpp/patch_core" \ "$TEST_DIR/purge_restore_test.mm" "$ROOT_DIR/cpp/patch_core/state_core.cpp" \ - "$ROOT_DIR/cpp/patch_core/digest.cpp" \ + "$ROOT_DIR/cpp/patch_core/digest.cpp" "$destination/install_record.cpp" \ "$ROOT_DIR"/cpp/update_flow_core/*.cpp \ "$ROOT_DIR/ios/RCTPushy/RCTPushyConfiguration.mm" \ -framework Foundation -o "$destination/purge_restore_test" diff --git a/scripts/tests/ios-purge-restore/README.md b/scripts/tests/ios-purge-restore/README.md index 7950bf58..18acff13 100644 --- a/scripts/tests/ios-purge-restore/README.md +++ b/scripts/tests/ios-purge-restore/README.md @@ -27,17 +27,17 @@ python3 -m unittest discover -s scripts/tests/ios-purge-restore -p test_extract. ## Production code, not a second state machine `extract.py` reads the checkout's `ios/RCTPushy` sources listed in its -`SOURCES` (the module's `.mm` files plus `RCTPushyInternal.h`, which holds the -`PushyFileStamp` struct) on every build (each definition must exist exactly -once across them; pod-internal `PUSHY_HIDDEN` definitions count like `static` -ones, the header's `extern` declarations never match). It copies the exact definitions (with `#line` locations) of `bundleURL`, -`resolveLaunchBundleURL`, `restorePurgedLaunch`, `commitRoundWithGeneration`, -`resetToPackagedBundle`, their state/defaults helpers, the install record -activation check (`PushyVerifyInstallForActivation`) with -`+[RCTPushyInstaller switchVersion:error:]`, -and the relevant globals -into build-only `.inc` files. These are compiled unchanged with the actual -`cpp/patch_core/state_core.cpp`. +`SOURCES` (the module's `.mm` files plus `RCTPushyInternal.h`) on every build +(each definition must exist exactly once across them; pod-internal +`PUSHY_HIDDEN` definitions count like `static` ones, the header's `extern` +declarations never match). It copies the exact definitions (with `#line` +locations) of `bundleURL`, `resolveLaunchBundleURL`, `restorePurgedLaunch`, +`commitRoundWithGeneration`, `resetToPackagedBundle`, their state/defaults +helpers, `+[RCTPushyInstaller switchVersion:error:]` and the relevant globals +into build-only `.inc` files, and copies the shared completion record core +(`cpp/patch_core/install_record.cpp`, whose `CheckActivation`/`Unchanged` +switchVersion calls) into the build directory. These are compiled unchanged +with the actual `cpp/patch_core/state_core.cpp` and `digest.cpp`. Extraction ignores braces in comments and literals, skips forward declarations, and rejects missing or duplicate definitions. No generated implementation is @@ -111,9 +111,9 @@ since process start. - Remove the closed-window activation veto: `late_commit` must fail. - Return `NO` after timeout instead of re-resolving: `commit_before_signal` must fail. - Remove the reset-generation guard: `reset_wins` must fail. -- Trust a schema-1 install record without `bundleSha256`: - `install_record_activation` must fail. -- Grandfather current/last versions that do carry a record: +- Trust a schema-1 install record without `bundleSha256` (in the copy of + `install_record.cpp`): `install_record_activation` must fail. +- Grandfather current/last versions that do carry a record (same copy): `switch_version_records` must fail. Each mutant must compile and fail its specific state assertion; unrelated diff --git a/scripts/tests/ios-purge-restore/extract.py b/scripts/tests/ios-purge-restore/extract.py index 3de0c089..9e906ab0 100644 --- a/scripts/tests/ios-purge-restore/extract.py +++ b/scripts/tests/ios-purge-restore/extract.py @@ -99,16 +99,18 @@ def declaration(source: str, name: str) -> tuple[int, str]: # Canonical config equality (contract C1): the binding over # flowcore::ConfigsEquivalent, tested directly. 'PushyConfigsEquivalent', - # Install record activation check (cpp/patch_core/install_record.h) and - # what switchVersion:error: needs around it. - 'PushyInstallRecordFileName', 'PushyVersionDir', 'PushyReadInstallRecord', 'PushyInstallRecordMatches', 'PushyHasInstallRecordFile', - 'PushyHasCompletedVersionAtPath', 'PushyStampFile', 'PushyVerifyInstallForActivation', - 'PushyStringIsBlank', 'PushyIsSafePathComponent', + # What switchVersion:error: needs around the shared record core + # (cpp/patch_core/install_record.cpp, compiled from CORE_SOURCE). + 'PushyVersionDir', 'PushyStringIsBlank', 'PushyIsSafePathComponent', ] # Emitted ahead of HELPERS, which use them. -STRUCTS = ['PushyFileStamp'] -MUTATIONS = ('late-activation', 'skip-reresolve', 'ignore-reset', 'trust-digestless', - 'grandfather-recorded') +STRUCTS: list[str] = [] +# The completion record core switchVersion:error: calls. Copied (with #line) +# into the build directory so the record mutants below can alter it; the +# test script compiles the copy instead of the checkout's file. +CORE_SOURCE = Path('cpp/patch_core/install_record.cpp') +CORE_MUTATIONS = ('trust-digestless', 'grandfather-recorded') +MUTATIONS = ('late-activation', 'skip-reresolve', 'ignore-reset') + CORE_MUTATIONS def mutate(text: str, mutation: str) -> str: @@ -119,11 +121,13 @@ def mutate(text: str, mutation: str) -> str: 'ignore-reset': ( r'if \(pushyResetGeneration\.load\(\) != generation\)\s*\{\s*return;\s*\}', '(void)generation;'), + # Trust a schema-1 record without bundleSha256. 'trust-digestless': ( - r'(expected\.length == 0\) \{\s*)return \[NSString[^;]*record mismatch[^;]*;', - r'\1return nil;'), + r'(if \(record\.bundle_sha256\.empty\(\)\) \{\s*)return RecordMismatch\(version_hash\);', + r'\1return Status::Ok();'), + # Grandfather current/last versions even when they carry a record. 'grandfather-recorded': ( - r'if \(PushyHasInstallRecordFile\(versionDir\)\)\s*\{\s*return NO;\s*\}', ''), + r'if \(grandfathered && state == Record::State::kAbsent\)', 'if (grandfathered)'), } pattern, replacement = patterns[mutation] mutated, count = re.subn(pattern, replacement, text) @@ -161,15 +165,12 @@ def generate(source_dir: Path, output: Path, mutation: str | None = None) -> Non commit = locate(sources, method, 'commitRoundWithGeneration') helpers = [locate(sources, function, name) for name in HELPERS] switch_version = locate(sources, method, 'switchVersion') - if mutation == 'grandfather-recorded': - switch_version = (switch_version[0], switch_version[1], - mutate(switch_version[2], mutation)) + core_path = (source_dir / '..' / '..' / CORE_SOURCE).resolve() + core = core_path.read_text(encoding='utf-8') + if mutation in CORE_MUTATIONS: + core = mutate(core, mutation) elif mutation == 'skip-reresolve': restore = (restore[0], restore[1], mutate(restore[2], mutation)) - elif mutation == 'trust-digestless': - index = HELPERS.index('PushyVerifyInstallForActivation') - path, line, text = helpers[index] - helpers[index] = (path, line, mutate(text, mutation)) elif mutation: commit = (commit[0], commit[1], mutate(commit[2], mutation)) sections = { @@ -189,6 +190,8 @@ def generate(source_dir: Path, output: Path, mutation: str | None = None) -> Non f'#line {line} {json.dumps(str(path.resolve()))}\n{text}\n\n' for path, line, text in snippets ), encoding='utf-8') + output.joinpath(CORE_SOURCE.name).write_text( + f'#line 1 {json.dumps(str(core_path))}\n{core}', encoding='utf-8') def main() -> None: diff --git a/scripts/tests/ios-purge-restore/purge_restore_test.mm b/scripts/tests/ios-purge-restore/purge_restore_test.mm index 269a8e66..35ed28c7 100644 --- a/scripts/tests/ios-purge-restore/purge_restore_test.mm +++ b/scripts/tests/ios-purge-restore/purge_restore_test.mm @@ -544,9 +544,10 @@ static void ConfigEquivalence(void) { std::printf("[PASS] config_equivalence: %lu shared vectors\n", (unsigned long)cases.count); } -// Two-phase install record (cpp/patch_core/install_record.h): the production -// activation check against real files. Schema 1 makes bundleSha256 mandatory; -// only the legacy EMPTY marker (SDK < 10.53) activates without a digest. +// Two-phase install record (cpp/patch_core/install_record.h): the shared +// activation check switchVersion:error: calls, against real files. Schema 1 +// makes bundleSha256 mandatory; only the legacy EMPTY marker (SDK < 10.53) +// activates without a digest. static NSString *Sha256Hex(NSData *data) { unsigned char digest[CC_SHA256_DIGEST_LENGTH]; CC_SHA256(data.bytes, (CC_LONG)data.length, digest); @@ -564,14 +565,19 @@ static void InstallRecordActivation(void) { NSData *bundle = [@"var a = 1;" dataUsingEncoding:NSUTF8StringEncoding]; Expect([bundle writeToFile:[dir stringByAppendingPathComponent:BUNDLE_FILE_NAME] atomically:YES], "write bundle"); - NSString *marker = [dir stringByAppendingPathComponent:PushyInstallRecordFileName()]; + NSString *marker = [dir stringByAppendingPathComponent:@(pushy::install_record::kFileName)]; + auto check = ^NSString *(void) { + pushy::install_record::Activation activation; + pushy::install_record::Status status = pushy::install_record::CheckActivation( + PushyToStdString(dir), PushyToStdString(hash), PushyToStdString(BUNDLE_FILE_NAME), + false, &activation); + return status.ok ? nil : PushyFromStdString(status.message); + }; auto verify = ^NSString *(id record) { NSData *data = [record isKindOfClass:NSData.class] ? record : [NSJSONSerialization dataWithJSONObject:record options:0 error:nil]; Expect([data writeToFile:marker atomically:YES], "write record"); - NSDictionary *verified = nil; - PushyFileStamp stamp; - return PushyVerifyInstallForActivation(dir, hash, &verified, &stamp); + return check(); }; auto rejected = ^bool(NSString *reason, NSString *what) { return reason != nil && [reason containsString:what]; @@ -601,12 +607,11 @@ static void InstallRecordActivation(void) { Expect(rejected(verify(@{@"schema": schema, @"versionHash": @"other", @"bundleSha256": Sha256Hex(bundle)}), @"completion record mismatch"), "record for another version must not activate"); - [NSFileManager.defaultManager removeItemAtPath:marker error:nil]; - NSDictionary *verified = nil; - PushyFileStamp stamp; - Expect(rejected(PushyVerifyInstallForActivation(dir, hash, &verified, &stamp), + Expect(rejected(verify([@"{oops" dataUsingEncoding:NSUTF8StringEncoding]), @"no valid completion record"), - "missing record must not activate"); + "malformed record must not activate"); + [NSFileManager.defaultManager removeItemAtPath:marker error:nil]; + Expect(rejected(check(), @"is incomplete"), "missing record must not activate"); [NSFileManager.defaultManager removeItemAtPath:dir error:nil]; } @@ -623,7 +628,7 @@ static void SwitchVersionRecords(void) { NSData *bundle = [@"C" dataUsingEncoding:NSUTF8StringEncoding]; Expect([bundle writeToFile:[dir stringByAppendingPathComponent:BUNDLE_FILE_NAME] atomically:YES], "write C bundle"); - NSString *marker = [dir stringByAppendingPathComponent:PushyInstallRecordFileName()]; + NSString *marker = [dir stringByAppendingPathComponent:@(pushy::install_record::kFileName)]; NSNumber *schema = @(pushy::install_record::kSchema); auto setRecord = ^(NSDictionary *record) { [NSFileManager.defaultManager removeItemAtPath:marker error:nil]; diff --git a/scripts/tests/ios-purge-restore/test_extract.py b/scripts/tests/ios-purge-restore/test_extract.py index f4f562d3..0bc2a6d8 100644 --- a/scripts/tests/ios-purge-restore/test_extract.py +++ b/scripts/tests/ios-purge-restore/test_extract.py @@ -66,9 +66,12 @@ def test_mutations_fail_on_source_drift(self): self.assertEqual(mutate('activation = nil;', 'late-activation'), '(void)activation;') self.assertEqual(mutate('return YES;', 'skip-reresolve'), 'return !timedOut;') self.assertEqual( - mutate('expected.length == 0) {\n return [NSString stringWithFormat:@"%@ record mismatch.", h];', + mutate('if (record.bundle_sha256.empty()) {\n return RecordMismatch(version_hash);', 'trust-digestless'), - 'expected.length == 0) {\n return nil;') + 'if (record.bundle_sha256.empty()) {\n return Status::Ok();') + self.assertEqual( + mutate('if (grandfathered && state == Record::State::kAbsent) {', 'grandfather-recorded'), + 'if (grandfathered) {') for source in ('', 'activation = nil; activation = nil;'): with self.assertRaises(ValueError): mutate(source, 'late-activation') From 7a96ce535ea7c9d86a22a2ac0a77b3afbe2792b0 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 16:58:01 +0800 Subject: [PATCH 089/104] test(ios-purge-restore): drop the stale PushyFileStamp note The struct moved into the shared record core (install_record::FileStamp). Co-Authored-By: Claude Opus 5.5 --- scripts/tests/ios-purge-restore/extract.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/tests/ios-purge-restore/extract.py b/scripts/tests/ios-purge-restore/extract.py index 9e906ab0..b0dd51fc 100644 --- a/scripts/tests/ios-purge-restore/extract.py +++ b/scripts/tests/ios-purge-restore/extract.py @@ -136,8 +136,7 @@ def mutate(text: str, mutation: str) -> str: return mutated -# The sources the module is split across (see RCTPushyInternal.h, which -# also holds the PushyFileStamp struct). +# The sources the module is split across (see RCTPushyInternal.h). SOURCES = ('RCTPushyInternal.h', 'RCTPushy.mm', 'RCTPushy+Launch.mm', 'RCTPushyState.mm', 'RCTPushyInstallRecord.mm', 'RCTPushyInstaller.mm', 'RCTPushyOrchestrator.mm') From 46d6d86c608842f11a496bc57706580eef488b75 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 16:58:08 +0800 Subject: [PATCH 090/104] refactor(harmony): use the shared completion record core pushy.cpp exports the record core (cpp/patch_core/install_record.h): isInstallComplete and recordedBundleSha256 synchronously (a stat and a read of the record, as before), sealInstallRecord, promoteStaging and checkActivation as promises on libuv workers, so neither the bundle hash nor the fsyncs run on the JS thread. InstallRecord.ts keeps the paths and the reserved-entry rule and maps rejections to the stable codes (install steps PATCH_FAILED, activation SWITCH_VERSION_FAILED). parse/read/build/write, the record matching and assertActivatable are gone; UpdateContext.verifyActivation hands current/last to the core, DownloadTask seals and promotes. The record is now written atomically with a directory fsync (it was unlinked and rewritten in place) and the versions root is fsynced after the rename. The bun tests now cover only the binding: paths, the arguments handed to the NAPI module, the error-code mapping, and that NativePatchCore.ts declares exactly the functions pushy.cpp exports. The record logic is the C++ vectors'. check-constant-parity holds both mirrors to kStagingSuffix alone. Co-Authored-By: Claude Opus 5.5 --- harmony/pushy/src/main/cpp/pushy.cpp | 138 ++++++++ harmony/pushy/src/main/ets/DownloadTask.ts | 21 +- harmony/pushy/src/main/ets/InstallRecord.ts | 202 +++--------- harmony/pushy/src/main/ets/NativePatchCore.ts | 28 ++ harmony/pushy/src/main/ets/UpdateContext.ts | 56 +--- scripts/check-constant-parity.js | 13 +- src/__tests__/harmonyDownloadTask.test.ts | 2 +- src/__tests__/harmonyInstallRecord.test.ts | 297 +++++++++--------- .../harmonyInstallRecordLogic.test.ts | 179 ----------- 9 files changed, 379 insertions(+), 557 deletions(-) delete mode 100644 src/__tests__/harmonyInstallRecordLogic.test.ts diff --git a/harmony/pushy/src/main/cpp/pushy.cpp b/harmony/pushy/src/main/cpp/pushy.cpp index 61461a6b..ac740ba7 100644 --- a/harmony/pushy/src/main/cpp/pushy.cpp +++ b/harmony/pushy/src/main/cpp/pushy.cpp @@ -15,6 +15,7 @@ #include "archive_patch_core.h" #include "digest.h" +#include "install_record.h" #include "hbc_transform_wire.h" #include "patch_core.h" #include "state_core.h" @@ -903,6 +904,138 @@ napi_value Sha256HexRawFdAsync(napi_env env, napi_callback_info info) { }); } +// ---- Completion records (cpp/patch_core/install_record.h) ---- + +// Reads the first `count` arguments as strings, of which the first +// `non_empty` must not be empty; throws and returns false otherwise. +bool GetStringArgs( + napi_env env, + napi_callback_info info, + const char* name, + size_t count, + size_t non_empty, + std::string* out) { + napi_value args[5] = {nullptr, nullptr, nullptr, nullptr, nullptr}; + size_t argc = count; + if (count > 5 || !GetArgCount(env, info, &argc, args) || argc < count) { + ThrowError(env, std::string(name) + ": wrong number of arguments"); + return false; + } + for (size_t i = 0; i < count; i++) { + bool ok = false; + out[i] = GetString(env, args[i], &ok); + if (!ok) { + return false; + } + if (i < non_empty && out[i].empty()) { + ThrowError(env, std::string(name) + ": argument " + std::to_string(i) + + " must be a non-empty string"); + return false; + } + } + return true; +} + +// isInstallComplete(versionDir, versionHash, bundleFileName) -> boolean. +// Synchronous: a stat and a read of the (at most 64 KB) record, no digest. +napi_value InstallRecordIsComplete(napi_env env, napi_callback_info info) { + std::string args[3]; + if (!GetStringArgs(env, info, "isInstallComplete", 3, 3, args)) { + return nullptr; + } + return NewBoolean(env, pushy::install_record::IsComplete(args[0], args[1], args[2])); +} + +// recordedBundleSha256(versionDir) -> string; "" when the record has none. +// Synchronous like isInstallComplete. +napi_value InstallRecordBundleSha256(napi_env env, napi_callback_info info) { + std::string args[1]; + if (!GetStringArgs(env, info, "recordedBundleSha256", 1, 1, args)) { + return nullptr; + } + return NewString(env, pushy::install_record::RecordedBundleSha256(args[0])); +} + +// One install_record call on a libuv worker thread: hashing a bundle (tens +// of MB) or fsyncing must not block the JS thread. +struct InstallRecordWork { + std::string args[4]; + bool grandfathered = false; + pushy::delta::Status status{false, ""}; +}; + +void SettleInstallRecordWork( + napi_env env, + napi_deferred deferred, + const InstallRecordWork& w) { + SettleWithStatus(env, deferred, w.status); +} + +// sealInstallRecord(stagingDir, versionHash, bundleFileName, artifactSha256) +// -> Promise: hashes the staged bundle and writes the record. +napi_value SealInstallRecord(napi_env env, napi_callback_info info) { + InstallRecordWork work; + if (!GetStringArgs(env, info, "sealInstallRecord", 4, 3, work.args)) { + return nullptr; + } + return QueueWork( + env, + "sealInstallRecord", + std::move(work), + [](InstallRecordWork& w) { + w.status = pushy::install_record::Seal( + w.args[0], w.args[1], w.args[2], w.args[3], std::string()) + .status; + }, + SettleInstallRecordWork); +} + +// promoteStaging(stagingDir, versionDir) -> Promise: renames the sealed +// staging directory to the (removed) version directory. +napi_value PromoteStaging(napi_env env, napi_callback_info info) { + InstallRecordWork work; + if (!GetStringArgs(env, info, "promoteStaging", 2, 2, work.args)) { + return nullptr; + } + return QueueWork( + env, + "promoteStaging", + std::move(work), + [](InstallRecordWork& w) { + w.status = pushy::install_record::Promote(w.args[0], w.args[1]); + }, + SettleInstallRecordWork); +} + +// checkActivation(versionDir, versionHash, bundleFileName, grandfathered) +// -> Promise, rejected with the reason the version must not be +// activated. +napi_value CheckActivation(napi_env env, napi_callback_info info) { + InstallRecordWork work; + if (!GetStringArgs(env, info, "checkActivation", 3, 3, work.args)) { + return nullptr; + } + napi_value args[4] = {nullptr, nullptr, nullptr, nullptr}; + size_t argc = 4; + if (!GetArgCount(env, info, &argc, args) || argc < 4) { + ThrowError(env, "checkActivation: wrong number of arguments"); + return nullptr; + } + if (!GetBoolean(env, args[3], &work.grandfathered)) { + return nullptr; + } + return QueueWork( + env, + "checkActivation", + std::move(work), + [](InstallRecordWork& w) { + pushy::install_record::Activation activation; + w.status = pushy::install_record::CheckActivation( + w.args[0], w.args[1], w.args[2], w.grandfathered, &activation); + }, + SettleInstallRecordWork); +} + bool ExportFunction( napi_env env, napi_value exports, @@ -1159,6 +1292,11 @@ napi_value Init(napi_env env, napi_value exports) { !ExportFunction(env, exports, "cleanupOldEntries", CleanupOldEntries) || !ExportFunction(env, exports, "sha256HexFileAsync", Sha256HexFileAsync) || !ExportFunction(env, exports, "sha256HexRawFdAsync", Sha256HexRawFdAsync) || + !ExportFunction(env, exports, "isInstallComplete", InstallRecordIsComplete) || + !ExportFunction(env, exports, "recordedBundleSha256", InstallRecordBundleSha256) || + !ExportFunction(env, exports, "sealInstallRecord", SealInstallRecord) || + !ExportFunction(env, exports, "promoteStaging", PromoteStaging) || + !ExportFunction(env, exports, "checkActivation", CheckActivation) || !ExportFunction(env, exports, "crc32", Crc32) || !ExportFunction(env, exports, "getSupportedDiffVersion", GetSupportedDiffVersion) || !ExportFunction(env, exports, "buildRequestBody", FlowBuildRequestBody) || diff --git a/harmony/pushy/src/main/ets/DownloadTask.ts b/harmony/pushy/src/main/ets/DownloadTask.ts index fac36325..5b30cec6 100644 --- a/harmony/pushy/src/main/ets/DownloadTask.ts +++ b/harmony/pushy/src/main/ets/DownloadTask.ts @@ -29,9 +29,9 @@ import { } from './ArchiveLimits'; import { HARMONY_BUNDLE_FILE_NAME, - buildInstallRecord, + promoteStaging, + sealInstallRecord, stagingDirectoryFor, - writeInstallRecord, } from './InstallRecord'; import { ERROR_DOWNLOAD_FAILED, @@ -124,23 +124,12 @@ export class DownloadTask { private async promoteStaging(params: DownloadTaskParams): Promise { const work = this.stagingDirectory(params); - const bundlePath = `${work}/${HARMONY_BUNDLE_FILE_NAME}`; - if (!fileIo.accessSync(bundlePath)) { - throw createPushyError( - ERROR_DELTA_FAILED, - `bundle missing after install: ${bundlePath}`, - ); - } - // 整 bundle 几十 MB:摘要在 native 工作线程算,不冻结 UI 线程。 - const bundleSha256 = await NativePatchCore.sha256HexFileAsync(bundlePath); - await writeInstallRecord( - work, - buildInstallRecord(params.hash, bundleSha256, this.artifactSha256), - ); + // 整 bundle 几十 MB:摘要与记录写入在 native 工作线程完成,不冻结 UI 线程。 + await sealInstallRecord(work, params.hash, this.artifactSha256); if (fileIo.accessSync(params.unzipDirectory)) { await this.removeDirectory(params.unzipDirectory); } - await fileIo.rename(work, params.unzipDirectory); + await promoteStaging(work, params.unzipDirectory); } constructor(context: common.Context) { diff --git a/harmony/pushy/src/main/ets/InstallRecord.ts b/harmony/pushy/src/main/ets/InstallRecord.ts index 791afcf0..fa34c189 100644 --- a/harmony/pushy/src/main/ets/InstallRecord.ts +++ b/harmony/pushy/src/main/ets/InstallRecord.ts @@ -1,33 +1,22 @@ -import fileIo from '@ohos.file.fs'; -import { util } from '@kit.ArkTS'; import NativePatchCore from './NativePatchCore'; -import { unlinkIfExists } from './SaveFile'; import { ERROR_DELTA_FAILED, ERROR_SWITCH_VERSION_FAILED, createPushyError, + getErrorMessage, } from './ErrorCodes'; -// ArkTS mirror of cpp/patch_core/install_record.h — keep in sync by hand -// (scripts/check-constant-parity.js asserts the values match). -// 两阶段安装的完成记录:作为安装最后一步写进 staging 目录,随后 staging -// 原子 rename 成版本目录;激活前重新校验 bundle 摘要。空文件 = 旧版 SDK -// 写的历史标记,仍视为完整安装(无摘要可验)。 -export const INSTALL_RECORD_SCHEMA = 1; -export const INSTALL_RECORD_FILE_NAME = '.pushy-complete'; +// 两阶段安装的完成记录(cpp/patch_core/install_record.h,三端共用的唯一 +// 实现):解压/打补丁全在 staging 目录里进行,最后写入记录,再一次 rename 成 +// 版本目录;激活前重新校验 bundle 摘要。这里只拼路径、把原生结果映射成 +// 稳定错误码。 +// STAGING_SUFFIX 镜像 kStagingSuffix(scripts/check-constant-parity.js 断言)。 export const STAGING_SUFFIX = '.staging'; // 归档不得携带任何 `.pushy-` 前缀的条目(任意深度):这些名字保留给 SDK 自己 // 写的记录,与 Android 的规则一致。 export const RESERVED_ENTRY_PREFIX = '.pushy-'; export const HARMONY_BUNDLE_FILE_NAME = 'bundle.harmony.js'; -export interface InstallRecordData { - schema?: number; - versionHash?: string; - bundleSha256?: string; - artifactSha256?: string; -} - export function stagingDirectoryFor(versionDir: string): string { return `${versionDir}${STAGING_SUFFIX}`; } @@ -36,168 +25,71 @@ export function isReservedEntryName(name: string): boolean { return name.startsWith(RESERVED_ENTRY_PREFIX); } -export function buildInstallRecord( +/** + * 在 native 工作线程计算 staging 里 bundle 的摘要并原子写入完成记录 + * (schema 1 的 bundleSha256 必填:算不出摘要就安装失败)。失败 = PATCH_FAILED。 + */ +export async function sealInstallRecord( + stagingDir: string, versionHash: string, - bundleSha256: string, artifactSha256: string, -): string { - // schema 1 的 bundleSha256 是必填:算不出摘要的版本必须安装失败,绝不能 - // 写成一条无法复核的记录。 - if (!bundleSha256) { - throw createPushyError( - ERROR_DELTA_FAILED, - `Bundle version ${versionHash} has no bundle digest.`, - ); - } - const record: InstallRecordData = { - schema: INSTALL_RECORD_SCHEMA, - versionHash, - bundleSha256, - }; - if (artifactSha256) { - record.artifactSha256 = artifactSha256; - } - return JSON.stringify(record); -} - -/** 写入并 fsync:紧随其后的 rename 必须能在盘上看到它。 */ -export async function writeInstallRecord( - versionDir: string, - recordJson: string, ): Promise { - const path = `${versionDir}/${INSTALL_RECORD_FILE_NAME}`; - await unlinkIfExists(path); - const file = await fileIo.open( - path, - fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY, - ); try { - const bytes = new util.TextEncoder().encodeInto(recordJson); - await fileIo.write(file.fd, bytes.buffer as ArrayBuffer); - await fileIo.fsync(file.fd); - } finally { - await fileIo.close(file); - } -} - -/** - * 解析记录文本:空串(历史标记)返回 {};畸形/非对象返回 null。纯函数, - * 供 readInstallRecord 与单测共用。 - */ -export function parseInstallRecord(text: string): InstallRecordData | null { - if (text.length === 0) { - return {}; - } - try { - const parsed = JSON.parse(text) as InstallRecordData | null; - return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) - ? parsed - : null; + await NativePatchCore.sealInstallRecord( + stagingDir, + versionHash, + HARMONY_BUNDLE_FILE_NAME, + artifactSha256, + ); } catch (e) { - return null; + throw createPushyError(ERROR_DELTA_FAILED, getErrorMessage(e)); } } -/** - * 读取记录:文件缺失/畸形返回 null;空文件(历史标记)返回 {}。 - */ -export function readInstallRecord(versionDir: string): InstallRecordData | null { - const path = `${versionDir}/${INSTALL_RECORD_FILE_NAME}`; - if (!fileIo.accessSync(path)) { - return null; - } +/** staging rename 成版本目录(调用方先删掉已有的版本目录)。失败 = PATCH_FAILED。 */ +export async function promoteStaging( + stagingDir: string, + versionDir: string, +): Promise { try { - const stat = fileIo.statSync(path); - if (stat.size === 0) { - return {}; - } - if (stat.size > 64 * 1024) { - return null; - } - return parseInstallRecord(fileIo.readTextSync(path)); + await NativePatchCore.promoteStaging(stagingDir, versionDir); } catch (e) { - return null; + throw createPushyError(ERROR_DELTA_FAILED, getErrorMessage(e)); } } -/** 历史空记录(SDK < 10.53 写的空文件):视为完整安装,无摘要可验。 */ -function isLegacyRecord(record: InstallRecordData): boolean { - return Object.keys(record).length === 0; -} - -/** 记录是否宣告本版本完整安装(历史空记录视为是)。纯函数。 */ -export function isRecordForVersion( - record: InstallRecordData | null, - versionHash: string, -): boolean { - if (record === null) { - return false; - } - return ( - isLegacyRecord(record) || - (record.schema === INSTALL_RECORD_SCHEMA && - record.versionHash === versionHash) +/** 存在性判定(启动/去重路径,不算摘要)。 */ +export function isInstallComplete(versionDir: string, versionHash: string): boolean { + return NativePatchCore.isInstallComplete( + versionDir, + versionHash, + HARMONY_BUNDLE_FILE_NAME, ); } -/** 存在性判定(启动/去重路径,不算摘要):记录存在且(非历史空文件时)指向本版本。 */ -export function isInstallComplete(versionDir: string, versionHash: string): boolean { - return isRecordForVersion(readInstallRecord(versionDir), versionHash); +/** 记录里的 bundleSha256(崩溃归因);没有时为空串。 */ +export function recordedBundleSha256(versionDir: string): string { + return NativePatchCore.recordedBundleSha256(versionDir); } /** - * 激活前的记录校验(不含摘要):返回需要复核的 bundle 摘要,仅历史空记录 - * 返回空串;记录缺失/不符或缺摘要(schema 1 必填)抛 SWITCH_VERSION_FAILED。 - * 纯函数。 - */ -export function expectedBundleSha256ForActivation( - record: InstallRecordData | null, - versionHash: string, -): string { - if (record === null) { - throw createPushyError( - ERROR_SWITCH_VERSION_FAILED, - `Bundle version ${versionHash} has no valid completion record.`, - ); - } - if (isLegacyRecord(record)) { - return ''; - } - const expected = record.bundleSha256; - if ( - !isRecordForVersion(record, versionHash) || - typeof expected !== 'string' || - expected.length === 0 - ) { - throw createPushyError( - ERROR_SWITCH_VERSION_FAILED, - `Bundle version ${versionHash} completion record mismatch.`, - ); - } - return expected; -} - -/** - * 激活前校验:非历史空记录时在 native 工作线程重新计算 bundle 摘要(整包 - * 几十 MB,不能在 UI 线程同步算);不通过则抛错说明原因。 + * 激活前校验:bundle 在盘上,且记录的摘要与盘上字节一致(整包几十 MB,在 + * native 工作线程算)。无记录文件的目录只有是 current/last(grandfathered, + * 标记机制之前就激活过)时放行。不通过抛 SWITCH_VERSION_FAILED。 */ export async function verifyInstallForActivation( versionDir: string, versionHash: string, - bundlePath: string, + grandfathered: boolean, ): Promise { - const expected = expectedBundleSha256ForActivation( - readInstallRecord(versionDir), - versionHash, - ); - if (!expected) { - return; - } - const actual = await NativePatchCore.sha256HexFileAsync(bundlePath); - if (actual.toLowerCase() !== expected.toLowerCase()) { - throw createPushyError( - ERROR_SWITCH_VERSION_FAILED, - `Bundle version ${versionHash} bundle digest mismatch.`, + try { + await NativePatchCore.checkActivation( + versionDir, + versionHash, + HARMONY_BUNDLE_FILE_NAME, + grandfathered, ); + } catch (e) { + throw createPushyError(ERROR_SWITCH_VERSION_FAILED, getErrorMessage(e)); } } diff --git a/harmony/pushy/src/main/ets/NativePatchCore.ts b/harmony/pushy/src/main/ets/NativePatchCore.ts index 7b7f751c..f649ca61 100644 --- a/harmony/pushy/src/main/ets/NativePatchCore.ts +++ b/harmony/pushy/src/main/ets/NativePatchCore.ts @@ -85,6 +85,34 @@ interface NativePatchCoreBindings { // pread 流式计算(内嵌 bundle 走 resourceManager.getRawFd)。fd 由调用方 // 持有,promise 落定前不得关闭;读失败/提前 EOF 时 reject。 sha256HexRawFdAsync(fd: number, offset: number, length: number): Promise; + // 完成记录(cpp/patch_core/install_record.h,与 Android/iOS 共用)。 + /** 存在性判定(启动/去重路径,不算摘要):bundle 在盘上且记录指向本版本 */ + isInstallComplete( + versionDir: string, + versionHash: string, + bundleFileName: string, + ): boolean; + /** 记录里的 bundleSha256(崩溃归因);没有时为空串 */ + recordedBundleSha256(versionDir: string): string; + /** 工作线程上计算 staging 里 bundle 的摘要并原子写入完成记录 */ + sealInstallRecord( + stagingDir: string, + versionHash: string, + bundleFileName: string, + artifactSha256: string, + ): Promise; + /** 把已写完记录的 staging 目录 rename 成版本目录(调用方先删掉旧目录) */ + promoteStaging(stagingDir: string, versionDir: string): Promise; + /** + * 激活前校验(工作线程重算 bundle 摘要);不可激活时以原因 reject。 + * grandfathered = 该版本是持久化的 current/last。 + */ + checkActivation( + versionDir: string, + versionHash: string, + bundleFileName: string, + grandfathered: boolean, + ): Promise; /** CRC32(zip/zlib 多项式)。pdiff 拷贝前与 copiesCrc 比对用 */ crc32(data: Uint8Array | ArrayBuffer): number; /** 原生 patch 内核可消费的 diff 轨道版本(2 = hdiffv2 轨道) */ diff --git a/harmony/pushy/src/main/ets/UpdateContext.ts b/harmony/pushy/src/main/ets/UpdateContext.ts index 4654befb..6bd8aa0d 100644 --- a/harmony/pushy/src/main/ets/UpdateContext.ts +++ b/harmony/pushy/src/main/ets/UpdateContext.ts @@ -2,9 +2,8 @@ import preferences from '@ohos.data.preferences'; import fileIo from '@ohos.file.fs'; import { HARMONY_BUNDLE_FILE_NAME, - INSTALL_RECORD_FILE_NAME, isInstallComplete, - readInstallRecord, + recordedBundleSha256, verifyInstallForActivation, } from './InstallRecord'; import { DownloadTask } from './DownloadTask'; @@ -31,7 +30,6 @@ import { assertSafePathComponent } from './PathUtils'; import { withRawFd } from './SaveFile'; import { ERROR_SWITCH_VERSION_FAILED, - createPushyError, getErrorMessage, toPushyError, } from './ErrorCodes'; @@ -712,10 +710,9 @@ export class UpdateContext { return ''; } try { - const record = readInstallRecord( + return recordedBundleSha256( `${this.rootDir}/${assertSafePathComponent(hash)}`, ); - return record?.bundleSha256 ?? ''; } catch (e) { return ''; } @@ -726,53 +723,26 @@ export class UpdateContext { public hasDownloadedVersion(hash: string): boolean { try { const safeHash = assertSafePathComponent(hash); - return fileIo.accessSync(this.getBundlePath(safeHash)) - && isInstallComplete(`${this.rootDir}/${safeHash}`, safeHash); + return isInstallComplete(`${this.rootDir}/${safeHash}`, safeHash); } catch (e) { return false; } } /** - * 激活前置校验(同步,不写任何东西):bundle 在盘上,且要么是本 SDK 记录为 - * 完整安装的版本(bundle + 记录),要么是标记机制之前就激活过、目录里没有 - * 记录文件的历史版本(current/last 里有它)。其他无记录的目录可能是崩溃 - * 留下的半成品;有记录文件的目录即便是 current/last 也照常复核。 - * 返回是否还需复核安装记录(无记录的历史版本无可核)。 - */ - private assertActivatable(safeHash: string): boolean { - if (!fileIo.accessSync(this.getBundlePath(safeHash))) { - throw createPushyError( - ERROR_SWITCH_VERSION_FAILED, - `Bundle version ${safeHash} not found.`, - ); - } - const legacyActivated = - !fileIo.accessSync(`${this.rootDir}/${safeHash}/${INSTALL_RECORD_FILE_NAME}`) && - (this.readString('currentVersion') === safeHash || - this.readString('lastVersion') === safeHash); - if (!this.hasDownloadedVersion(safeHash) && !legacyActivated) { - throw createPushyError( - ERROR_SWITCH_VERSION_FAILED, - `Bundle version ${safeHash} is incomplete.`, - ); - } - return !legacyActivated; - } - - /** - * 完整的激活校验(含 native 工作线程上的 bundle 摘要复核),不写状态。 - * 记录里的 bundle 摘要必须与盘上字节一致,才能把下次启动指向它。 + * 完整的激活校验(含 native 工作线程上的 bundle 摘要复核),不写状态: + * bundle 在盘上,且记录里的 bundle 摘要与盘上字节一致,才能把下次启动指向 + * 它。标记机制之前就激活过、目录里没有记录文件的历史版本(current/last + * 里有它)免检;有记录文件的目录即便是 current/last 也照常复核。 */ public async verifyActivation(hash: string): Promise { const safeHash = assertSafePathComponent(hash); - if (this.assertActivatable(safeHash)) { - await verifyInstallForActivation( - `${this.rootDir}/${safeHash}`, - safeHash, - this.getBundlePath(safeHash), - ); - } + await verifyInstallForActivation( + `${this.rootDir}/${safeHash}`, + safeHash, + this.readString('currentVersion') === safeHash || + this.readString('lastVersion') === safeHash, + ); } /** 内存里切换状态(不落盘);调用方负责 flush。 */ diff --git a/scripts/check-constant-parity.js b/scripts/check-constant-parity.js index 977d4732..6e1e6b49 100644 --- a/scripts/check-constant-parity.js +++ b/scripts/check-constant-parity.js @@ -117,8 +117,8 @@ const javaDir = 'android/src/main/java/cn/reactnative/modules/update'; const archiveLimitsRequired = ['UnknownLengthFreeSpaceProbeBytes', 'MaxArchiveBytes']; const errorCodesRequired = ['DeltaFailed', 'InvalidOptions']; // The record logic itself lives in install_record.cpp; the hosts only mirror -// the names they still build paths from. -const installRecordMirrored = ['Schema', 'FileName', 'StagingSuffix']; +// the staging suffix they still build their unpack directory from. +const installRecordMirrored = ['StagingSuffix']; const pairs = [ { @@ -139,12 +139,7 @@ const pairs = [ header: 'cpp/patch_core/install_record.h', mirror: 'harmony/pushy/src/main/ets/InstallRecord.ts', parse: parseArkTsConstants, - mirrorName: (cppName) => - ({ - Schema: 'INSTALL_RECORD_SCHEMA', - FileName: 'INSTALL_RECORD_FILE_NAME', - StagingSuffix: 'STAGING_SUFFIX', - })[cppName] || screamingSnake(cppName), + mirrorName: screamingSnake, only: installRecordMirrored, }, { @@ -152,7 +147,7 @@ const pairs = [ mirror: `${javaDir}/InstallRecord.java`, parse: parseJavaConstants, mirrorName: screamingSnake, - only: ['StagingSuffix'], + only: installRecordMirrored, }, { header: 'cpp/patch_core/error_codes.h', diff --git a/src/__tests__/harmonyDownloadTask.test.ts b/src/__tests__/harmonyDownloadTask.test.ts index 20e59048..23890390 100644 --- a/src/__tests__/harmonyDownloadTask.test.ts +++ b/src/__tests__/harmonyDownloadTask.test.ts @@ -5,7 +5,7 @@ import { evalHarmonyModule } from './nativeSources'; // The pure parsers of Harmony DownloadTask.ts, evaluated from source with its // platform imports stripped (the download/patch class itself needs a device). -// InstallRecord.ts imports @ohos modules, so its bundle name is inlined. +// InstallRecord.ts imports the NAPI module, so its bundle name is inlined. // __diff.json is parsed by the native core only // (cpp/patch_core/tests/patch-manifest-vectors.json). const HARMONY_BUNDLE_FILE_NAME = 'bundle.harmony.js'; diff --git a/src/__tests__/harmonyInstallRecord.test.ts b/src/__tests__/harmonyInstallRecord.test.ts index f7f3cfb1..10d72ab0 100644 --- a/src/__tests__/harmonyInstallRecord.test.ts +++ b/src/__tests__/harmonyInstallRecord.test.ts @@ -1,196 +1,185 @@ import { describe, expect, test } from 'bun:test'; -import { evalHarmonyModule } from './nativeSources'; +import { + createPushyError, + ERROR_DELTA_FAILED, + ERROR_SWITCH_VERSION_FAILED, + getErrorMessage, + type PushyError, +} from '../../harmony/pushy/src/main/ets/ErrorCodes'; +import { evalHarmonyModule, source, stripComments } from './nativeSources'; -// Harmony InstallRecord.ts (ArkTS mirror of cpp/patch_core/install_record.h) -// imports @ohos modules, so it is evaluated with its imports stripped and its -// collaborators (file system, native digest, error factory) supplied as -// in-memory stubs. This covers the disk-backed activation path; the pure -// record logic is in harmonyInstallRecordLogic.test.ts. - -class PushyError extends Error { - code: string; - constructor(code: string, message: string) { - super(`[${code}] ${message}`); - this.code = code; - } -} +// Harmony InstallRecord.ts is a thin binding over the shared completion +// record core (cpp/patch_core/install_record.h, replayed against +// tests/install-record-vectors.json by the C++ test). What is left to test +// here: the paths it builds, what it hands the NAPI module, the stable error +// codes it maps rejections to, and that every NAPI binding it relies on is +// exported by pushy.cpp. interface InstallRecordModule { - buildInstallRecord( - versionHash: string, - bundleSha256: string, - artifactSha256: string - ): string; - expectedBundleSha256ForActivation( - record: Record | null, - versionHash: string - ): string; + STAGING_SUFFIX: string; + HARMONY_BUNDLE_FILE_NAME: string; + stagingDirectoryFor(versionDir: string): string; + isReservedEntryName(name: string): boolean; + sealInstallRecord(dir: string, hash: string, artifact: string): Promise; + promoteStaging(staging: string, version: string): Promise; + isInstallComplete(dir: string, hash: string): boolean; + recordedBundleSha256(dir: string): string; verifyInstallForActivation( - versionDir: string, - versionHash: string, - bundlePath: string + dir: string, + hash: string, + grandfathered: boolean ): Promise; } -function loadInstallRecord( - files: Map, - digests: Map -): InstallRecordModule { - const fileIo = { - accessSync: (path: string) => files.has(path), - statSync: (path: string) => ({ size: files.get(path)?.length ?? 0 }), - readTextSync: (path: string) => files.get(path) ?? '', - }; - const NativePatchCore = { - sha256HexFileAsync: async (path: string) => { - const digest = digests.get(path); - if (digest === undefined) { - throw new Error('sha256HexFileAsync: cannot read file'); +function load(rejection?: string) { + const calls: Array<[string, unknown[]]> = []; + const call = + (name: string, result: unknown) => + (...args: unknown[]) => { + calls.push([name, args]); + return result; + }; + const settle = + (name: string) => + async (...args: unknown[]) => { + calls.push([name, args]); + if (rejection !== undefined) { + throw new Error(rejection); } - return digest; - }, + }; + const NativePatchCore = { + sealInstallRecord: settle('sealInstallRecord'), + promoteStaging: settle('promoteStaging'), + checkActivation: settle('checkActivation'), + isInstallComplete: call('isInstallComplete', true), + recordedBundleSha256: call('recordedBundleSha256', 'ab'), }; - return evalHarmonyModule( + const module = evalHarmonyModule( 'InstallRecord.ts', - '({ buildInstallRecord, expectedBundleSha256ForActivation, verifyInstallForActivation });', + `({ STAGING_SUFFIX, HARMONY_BUNDLE_FILE_NAME, stagingDirectoryFor, + isReservedEntryName, sealInstallRecord, promoteStaging, + isInstallComplete, recordedBundleSha256, verifyInstallForActivation });`, { - fileIo, NativePatchCore, - ERROR_DELTA_FAILED: 'PATCH_FAILED', - ERROR_SWITCH_VERSION_FAILED: 'SWITCH_VERSION_FAILED', - createPushyError: (code: string, message: string) => - new PushyError(code, message), - util: {}, + ERROR_DELTA_FAILED, + ERROR_SWITCH_VERSION_FAILED, + createPushyError, + getErrorMessage, } - ); + ) as InstallRecordModule; + return { module, calls }; } -const HASH = 'abc'; -const DIR = `/data/_update/${HASH}`; -const RECORD = `${DIR}/.pushy-complete`; -const BUNDLE = `${DIR}/bundle.harmony.js`; -const DIGEST = 'ab'.repeat(32); +const DIR = '/data/_update/abc'; +const BUNDLE = 'bundle.harmony.js'; -function captureError(fn: () => unknown): PushyError { +async function rejectionOf(promise: Promise): Promise { try { - fn(); + await promise; } catch (e) { return e as PushyError; } - throw new Error('expected a throw'); + throw new Error('expected a rejection'); } -describe('harmony install record writer', () => { - const { buildInstallRecord } = loadInstallRecord(new Map(), new Map()); +describe('harmony install record paths', () => { + const { module } = load(); - test('writes the mandatory bundle digest', () => { - expect(JSON.parse(buildInstallRecord(HASH, DIGEST, 'ff'))).toEqual({ - schema: 1, - versionHash: HASH, - bundleSha256: DIGEST, - artifactSha256: 'ff', - }); - expect(JSON.parse(buildInstallRecord(HASH, DIGEST, ''))).toEqual({ - schema: 1, - versionHash: HASH, - bundleSha256: DIGEST, - }); + test('the staging directory is a sibling of the version directory', () => { + expect(module.stagingDirectoryFor(DIR)).toBe( + `${DIR}${module.STAGING_SUFFIX}` + ); + expect(module.HARMONY_BUNDLE_FILE_NAME).toBe(BUNDLE); }); - test('refuses a record without a bundle digest (install fails)', () => { - const error = captureError(() => buildInstallRecord(HASH, '', 'ff')); - expect(error.code).toBe('PATCH_FAILED'); - expect(error.message).toContain('no bundle digest'); + test('every .pushy- prefixed archive entry is reserved', () => { + for (const name of ['.pushy-complete', '.pushy-complete.tmp', '.pushy-x']) { + expect(module.isReservedEntryName(name)).toBe(true); + } + for (const name of [BUNDLE, '.pushyx', 'pushy-complete']) { + expect(module.isReservedEntryName(name)).toBe(false); + } }); }); -describe('harmony install record activation', () => { - test('a legacy empty record stays trusted without hashing', async () => { - const files = new Map([[RECORD, '']]); - const { verifyInstallForActivation } = loadInstallRecord(files, new Map()); - await verifyInstallForActivation(DIR, HASH, BUNDLE); - }); - - test('a matching digest activates, case-insensitively', async () => { - const files = new Map([ - [ - RECORD, - JSON.stringify({ - schema: 1, - versionHash: HASH, - bundleSha256: DIGEST.toUpperCase(), - }), - ], +describe('harmony install record bindings', () => { + test('presence and crash attribution are synchronous pass-throughs', () => { + const { module, calls } = load(); + expect(module.isInstallComplete(DIR, 'abc')).toBe(true); + expect(module.recordedBundleSha256(DIR)).toBe('ab'); + expect(calls).toEqual([ + ['isInstallComplete', [DIR, 'abc', BUNDLE]], + ['recordedBundleSha256', [DIR]], ]); - const { verifyInstallForActivation } = loadInstallRecord( - files, - new Map([[BUNDLE, DIGEST]]) - ); - await verifyInstallForActivation(DIR, HASH, BUNDLE); }); - test('a tampered bundle is rejected', async () => { - const files = new Map([ - [ - RECORD, - JSON.stringify({ schema: 1, versionHash: HASH, bundleSha256: DIGEST }), - ], + test('seal, promote and activation hand the core the Harmony bundle name', async () => { + const { module, calls } = load(); + await module.sealInstallRecord(`${DIR}.staging`, 'abc', 'ff'); + await module.promoteStaging(`${DIR}.staging`, DIR); + await module.verifyInstallForActivation(DIR, 'abc', true); + expect(calls).toEqual([ + ['sealInstallRecord', [`${DIR}.staging`, 'abc', BUNDLE, 'ff']], + ['promoteStaging', [`${DIR}.staging`, DIR]], + ['checkActivation', [DIR, 'abc', BUNDLE, true]], ]); - const { verifyInstallForActivation } = loadInstallRecord( - files, - new Map([[BUNDLE, 'cd'.repeat(32)]]) - ); - await expect(verifyInstallForActivation(DIR, HASH, BUNDLE)).rejects.toThrow( - 'bundle digest mismatch' - ); }); - for (const [name, record] of [ - ['no', { schema: 1, versionHash: HASH }], - ['an empty', { schema: 1, versionHash: HASH, bundleSha256: '' }], - ['a null', { schema: 1, versionHash: HASH, bundleSha256: null }], - ['a non-string', { schema: 1, versionHash: HASH, bundleSha256: 123 }], - [ - 'only an artifact', - { schema: 1, versionHash: HASH, artifactSha256: DIGEST }, - ], - ] as const) { - test(`a schema 1 record with ${name} bundle digest is rejected`, async () => { - const files = new Map([[RECORD, JSON.stringify(record)]]); - // The bundle hashes fine: rejection must come from the record alone. - const { verifyInstallForActivation, expectedBundleSha256ForActivation } = - loadInstallRecord(files, new Map([[BUNDLE, DIGEST]])); - const error = captureError(() => - expectedBundleSha256ForActivation(record, HASH) + test('install failures are PATCH_FAILED with the core message', async () => { + const { module } = load('Bundle version abc has no bundle digest.'); + for (const promise of [ + module.sealInstallRecord(`${DIR}.staging`, 'abc', ''), + module.promoteStaging(`${DIR}.staging`, DIR), + ]) { + const error = await rejectionOf(promise); + expect(error.code).toBe(ERROR_DELTA_FAILED); + expect(error.message).toBe( + `[${ERROR_DELTA_FAILED}] Bundle version abc has no bundle digest.` ); - expect(error.code).toBe('SWITCH_VERSION_FAILED'); - expect(error.message).toContain('completion record mismatch'); - await expect( - verifyInstallForActivation(DIR, HASH, BUNDLE) - ).rejects.toThrow('completion record mismatch'); - }); - } + } + }); - test('a missing or foreign record is rejected', async () => { - const { verifyInstallForActivation } = loadInstallRecord( - new Map(), - new Map() + test('activation failures are SWITCH_VERSION_FAILED with the core reason', async () => { + const { module } = load('Bundle version abc completion record mismatch.'); + const error = await rejectionOf( + module.verifyInstallForActivation(DIR, 'abc', false) ); - await expect(verifyInstallForActivation(DIR, HASH, BUNDLE)).rejects.toThrow( - 'no valid completion record' + expect(error.code).toBe(ERROR_SWITCH_VERSION_FAILED); + expect(error.message).toBe( + `[${ERROR_SWITCH_VERSION_FAILED}] Bundle version abc completion record mismatch.` ); - const foreign = new Map([ - [ - RECORD, - JSON.stringify({ schema: 1, versionHash: 'def', bundleSha256: DIGEST }), - ], - ]); - await expect( - loadInstallRecord(foreign, new Map()).verifyInstallForActivation( - DIR, - HASH, - BUNDLE + }); + + test('NativePatchCore declares exactly what pushy.cpp exports', () => { + const typings = stripComments( + source('harmony/pushy/src/main/ets/NativePatchCore.ts') + ); + const body = typings.slice( + typings.indexOf('interface NativePatchCoreBindings {'), + typings.indexOf( + '\n}\n', + typings.indexOf('interface NativePatchCoreBindings {') ) - ).rejects.toThrow('completion record mismatch'); + ); + const declared = [...body.matchAll(/^ {2}(\w+)\(/gm)] + .map((m) => m[1]) + .sort(); + const exported = [ + ...source('harmony/pushy/src/main/cpp/pushy.cpp').matchAll( + /ExportFunction\(env, exports, "(\w+)"/g + ), + ] + .map((m) => m[1]) + .sort(); + expect(declared).toEqual(exported); + for (const name of [ + 'isInstallComplete', + 'recordedBundleSha256', + 'sealInstallRecord', + 'promoteStaging', + 'checkActivation', + ]) { + expect(exported).toContain(name); + } }); }); diff --git a/src/__tests__/harmonyInstallRecordLogic.test.ts b/src/__tests__/harmonyInstallRecordLogic.test.ts deleted file mode 100644 index 59744844..00000000 --- a/src/__tests__/harmonyInstallRecordLogic.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { - createPushyError, - ERROR_DELTA_FAILED, - ERROR_SWITCH_VERSION_FAILED, - type PushyError, -} from '../../harmony/pushy/src/main/ets/ErrorCodes'; -import { evalHarmonyModule } from './nativeSources'; - -// The pure record logic of Harmony InstallRecord.ts (ArkTS mirror of -// cpp/patch_core/install_record.h): parsing, version matching and the -// activation digest rule. The disk-backed activation path is covered by -// harmonyInstallRecord.test.ts. -interface InstallRecordData { - schema?: number; - versionHash?: string; - bundleSha256?: unknown; - artifactSha256?: string; -} - -interface InstallRecordLogic { - INSTALL_RECORD_SCHEMA: number; - INSTALL_RECORD_FILE_NAME: string; - STAGING_SUFFIX: string; - buildInstallRecord(hash: string, bundle: string, artifact: string): string; - parseInstallRecord(text: string): InstallRecordData | null; - isRecordForVersion(record: InstallRecordData | null, hash: string): boolean; - expectedBundleSha256ForActivation( - record: InstallRecordData | null, - hash: string - ): string; - stagingDirectoryFor(versionDir: string): string; - isReservedEntryName(name: string): boolean; -} - -const record = evalHarmonyModule( - 'InstallRecord.ts', - `({ INSTALL_RECORD_SCHEMA, INSTALL_RECORD_FILE_NAME, STAGING_SUFFIX, - buildInstallRecord, parseInstallRecord, isRecordForVersion, - expectedBundleSha256ForActivation, stagingDirectoryFor, - isReservedEntryName });`, - { ERROR_DELTA_FAILED, ERROR_SWITCH_VERSION_FAILED, createPushyError } -) as InstallRecordLogic; - -function thrown(fn: () => unknown): PushyError { - try { - fn(); - } catch (e) { - return e as PushyError; - } - throw new Error('expected a throw'); -} - -describe('harmony install record writer', () => { - test('serializes schema, version and digests', () => { - const parsed = record.parseInstallRecord( - record.buildInstallRecord('abc', 'BUNDLE', 'ARTIFACT') - ); - expect({ ...parsed }).toEqual({ - schema: record.INSTALL_RECORD_SCHEMA, - versionHash: 'abc', - bundleSha256: 'BUNDLE', - artifactSha256: 'ARTIFACT', - }); - }); - - test('omits an empty artifact digest', () => { - const parsed = record.parseInstallRecord( - record.buildInstallRecord('abc', 'BUNDLE', '') - ); - expect(parsed?.artifactSha256).toBeUndefined(); - }); - - test('refuses a record without a bundle digest', () => { - expect(thrown(() => record.buildInstallRecord('abc', '', 'A')).code).toBe( - ERROR_DELTA_FAILED - ); - }); - - test('derives the staging directory from the version directory', () => { - expect(record.stagingDirectoryFor('/data/_update/abc')).toBe( - `/data/_update/abc${record.STAGING_SUFFIX}` - ); - }); -}); - -describe('harmony install record parsing', () => { - test('an empty file is a legacy record', () => { - expect(Object.keys(record.parseInstallRecord('') ?? { x: 1 })).toEqual([]); - }); - - test('parses a JSON object', () => { - expect({ - ...record.parseInstallRecord('{"schema":1,"versionHash":"abc"}'), - }).toEqual({ schema: 1, versionHash: 'abc' }); - }); - - test('rejects malformed or non-object content', () => { - for (const text of ['{oops', '[1,2]', '"text"', 'null']) { - expect(record.parseInstallRecord(text)).toBeNull(); - } - }); -}); - -describe('harmony install record matching', () => { - test('presence: no record, legacy record, schema and version', () => { - expect(record.isRecordForVersion(null, 'abc')).toBe(false); - expect(record.isRecordForVersion({}, 'abc')).toBe(true); - const current = record.parseInstallRecord( - record.buildInstallRecord('abc', 'DIGEST', '') - ); - expect(record.isRecordForVersion(current, 'abc')).toBe(true); - expect(record.isRecordForVersion(current, 'def')).toBe(false); - expect( - record.isRecordForVersion( - { schema: record.INSTALL_RECORD_SCHEMA + 1, versionHash: 'abc' }, - 'abc' - ) - ).toBe(false); - }); - - test('activation: no record is SWITCH_VERSION_FAILED with a code prefix', () => { - const error = thrown(() => - record.expectedBundleSha256ForActivation(null, 'abc') - ); - expect(error.code).toBe(ERROR_SWITCH_VERSION_FAILED); - expect(error.message.startsWith(`[${ERROR_SWITCH_VERSION_FAILED}] `)).toBe( - true - ); - expect(error.message).toContain('no valid completion record'); - }); - - test('activation: a legacy record has nothing to verify', () => { - expect(record.expectedBundleSha256ForActivation({}, 'abc')).toBe(''); - }); - - test('activation: returns the recorded digest of a matching record', () => { - expect( - record.expectedBundleSha256ForActivation( - record.parseInstallRecord(record.buildInstallRecord('abc', 'D', '')), - 'abc' - ) - ).toBe('D'); - }); - - test('activation: rejects digest-less, foreign and future records', () => { - const schema = record.INSTALL_RECORD_SCHEMA; - const records: InstallRecordData[] = [ - { schema, versionHash: 'abc' }, - { schema, versionHash: 'abc', bundleSha256: '' }, - { schema, versionHash: 'abc', bundleSha256: 7 }, - { schema, versionHash: 'abc', artifactSha256: 'A' }, - { schema, versionHash: 'def', bundleSha256: 'D' }, - { schema: schema + 1, versionHash: 'abc', bundleSha256: 'D' }, - ]; - for (const candidate of records) { - const error = thrown(() => - record.expectedBundleSha256ForActivation(candidate, 'abc') - ); - expect(error.code).toBe(ERROR_SWITCH_VERSION_FAILED); - expect(error.message).toContain('record mismatch'); - } - }); -}); - -describe('harmony reserved archive entries', () => { - test('every .pushy- prefixed name is reserved', () => { - expect(record.isReservedEntryName(record.INSTALL_RECORD_FILE_NAME)).toBe( - true - ); - expect(record.isReservedEntryName('.pushy-anything')).toBe(true); - }); - - test('ordinary entries are allowed', () => { - for (const name of ['bundle.harmony.js', '.pushyx', 'pushy-complete']) { - expect(record.isReservedEntryName(name)).toBe(false); - } - }); -}); From e13bc052594ff2e6d0c64b432f4d120bb898f106 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 17:15:05 +0800 Subject: [PATCH 091/104] feat(flow-core): one native round core for request, query walk and response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cpp/update_flow_core/native_round.{h,cpp} takes over the pure parts the Android, iOS and Harmony orchestrators each carried around the shared decision layer: - PrepareRound(config, env, sample): the checkUpdate request body (identity, cInfo and input assembled from the persisted config and the launch env, diffV from this core) plus the first endpoint-query state. - NextQueryStep(state, text): the sequential endpoint fallback — configured endpoints in sampled order, then the first queryUrls list that parses, https-only for remote bases, the 8-request budget and the response schema gate. The request path now lives here, encoded. - HandleRoundResponse(...): the decision with attempts planned for the device (no diff without a running version, no cleartext URL under an https-only config), the hashInfo to persist (incl. forceBootRescue) and the response-cache text. tests/native-round-vectors.json pins it and records, per case, where the three platforms disagreed: non-string packageVersion/rnu/rn overrides, absent rnu/rn on iOS, Android's optBoolean forceBoot marker and lenient remote-list JSON, non-string endpoint/queryUrls handling, and Harmony's failures on non-array endpoints or a non-string afterDownload. The core takes the JS-equivalent or strictest behaviour. The fuzz target walks the new entry points. Co-Authored-By: Claude Opus 5.5 --- cpp/update_flow_core/native_round.cpp | 326 +++ cpp/update_flow_core/native_round.h | 90 + cpp/update_flow_core/tests/fuzz_flow_json.cpp | 23 + .../tests/native-round-vectors.json | 1765 +++++++++++++++++ .../tests/update_flow_core_test.cpp | 107 +- cpp/update_flow_core/update_flow_core.h | 4 +- scripts/test-update-flow-core.sh | 5 +- 7 files changed, 2311 insertions(+), 9 deletions(-) create mode 100644 cpp/update_flow_core/native_round.cpp create mode 100644 cpp/update_flow_core/native_round.h create mode 100644 cpp/update_flow_core/tests/native-round-vectors.json diff --git a/cpp/update_flow_core/native_round.cpp b/cpp/update_flow_core/native_round.cpp new file mode 100644 index 00000000..b8597f31 --- /dev/null +++ b/cpp/update_flow_core/native_round.cpp @@ -0,0 +1,326 @@ +#include "native_round.h" + +#include +#include + +#include "../patch_core/hbc_transform_wire.h" +#include "../patch_core/obscured_text.h" +#include "native_config.h" +#include "update_flow_core.h" + +namespace flowcore { + +using flowjson::Value; + +namespace { + +// Stored encoded (see obscured_text.h). +const std::string& QueryPath() { + static const std::string value = pushy::text::Reveal("7514fcd4ad805d55263e08fc99"); + return value; +} +const std::string& InfoForceBoot() { + static const std::string value = + pushy::text::Reveal("3c18e6d2aba9674a360d19ead5a695"); + return value; +} + +// The lists NextQueryStep walks, in order; the state's "list" names the +// current one ("" once the query is over). +const char kEndpoints[] = "endpoints"; +const char kQueryUrls[] = "queryUrls"; +const char kRemote[] = "remote"; + +bool HasPrefixIgnoringAsciiCase(const std::string& text, const char* prefix) { + size_t i = 0; + for (; prefix[i] != '\0'; i++) { + if (i >= text.size()) { + return false; + } + char c = text[i]; + if (c >= 'A' && c <= 'Z') { + c = static_cast(c - 'A' + 'a'); + } + if (c != prefix[i]) { + return false; + } + } + return true; +} + +bool IsHttpsUrl(const std::string& url) { + return HasPrefixIgnoringAsciiCase(url, "https://"); +} + +bool IsCleartextUrl(const std::string& url) { + return HasPrefixIgnoringAsciiCase(url, "http://"); +} + +std::string StripTrailingSlashes(std::string url) { + while (!url.empty() && url.back() == '/') { + url.pop_back(); + } + return url; +} + +Value StringOr(const Value& value, const char* fallback) { + return value.IsString() ? value : Value::String(fallback); +} + +// A non-negative integral state counter; anything else reads as 0. +size_t Counter(const Value& value) { + const double number = value.AsNumber(); + if (!value.IsNumber() || !std::isfinite(number) || number < 0) { + return 0; + } + return number >= 1e15 ? static_cast(1e15) + : static_cast(number); +} + +// { packageVersion, currentVersion, uuid, rolledBackVersion } — what +// HandleResponse keys its decision on. +Value Identity(const Value& config, const Value& env) { + const Value& configured = config.Get("packageVersion"); + Value identity = Value::Object(); + identity.Set("packageVersion", + configured.IsString() && !configured.AsString().empty() + ? configured + : env.Get("packageVersion")); + identity.Set("currentVersion", env.Get("currentVersion")); + identity.Set("uuid", StringOr(env.Get("uuid"), "")); + identity.Set("rolledBackVersion", env.Get("rolledBackVersion")); + return identity; +} + +std::string RequestBody(const Value& config, const Value& env) { + const Value identity = Identity(config, env); + Value cInfo = Value::Object(); + cInfo.Set("rnu", StringOr(config.Get("rnu"), "")); + cInfo.Set("rn", StringOr(config.Get("rn"), "")); + cInfo.Set("os", env.Get("os")); + cInfo.Set("uuid", identity.Get("uuid")); + Value input = Value::Object(); + input.Set("packageVersion", identity.Get("packageVersion")); + input.Set("currentVersion", env.Get("currentVersion")); + input.Set("buildTime", env.Get("buildTime")); + input.Set("cInfo", std::move(cInfo)); + input.Set("supportedDiffVersion", + Value::Number(pushy::hbc::kSupportedDiffVersion)); + input.Set("bundleHash", env.Get("bundleHash")); + return flowjson::Stringify(BuildRequestBody(input)); +} + +bool Contains(const Value& list, const std::string& text) { + for (const Value& entry : list.elements()) { + if (entry.AsString() == text) { + return true; + } + } + return false; +} + +Value PlanAttempts(const Value& attempts, bool hasOrigin, bool httpsOnly) { + Value planned = Value::Array(); + for (const Value& attempt : attempts.elements()) { + // diff patches the running version; with none running it cannot apply. + if (attempt.Get("type").AsString() == "diff" && !hasOrigin) { + continue; + } + Value urls = Value::Array(); + for (const Value& url : attempt.Get("urls").elements()) { + if (url.IsString() && !url.AsString().empty() && + !(httpsOnly && IsCleartextUrl(url.AsString()))) { + urls.Push(url); + } + } + if (urls.Size() > 0) { + Value kept = Value::Object(); + kept.Set("type", attempt.Get("type")); + kept.Set("urls", std::move(urls)); + planned.Push(std::move(kept)); + } + } + return planned; +} + +Value VersionInfo(const Value& info) { + Value hashInfo = Value::Object(); + for (const char* key : {"name", "description", "metaInfo"}) { + if (info.Get(key).IsString()) { + hashInfo.Set(key, info.Get(key)); + } + } + // A forceBoot activation is the brick-rescue path: JS reports it once + // this version survives to markSuccess. Only the server's directive counts + // — a silent-strategy activation is ordinary delivery. + if (info.Get("config").Get("forceBoot").Truthy()) { + hashInfo.Set(InfoForceBoot(), Value::Bool(true)); + } + return hashInfo; +} + +} // namespace + +bool IsHttpsOnly(const Value& endpoints) { + if (endpoints.elements().empty()) { + return false; + } + for (const Value& endpoint : endpoints.elements()) { + if (!endpoint.IsString() || !IsHttpsUrl(endpoint.AsString())) { + return false; + } + } + return true; +} + +Value PrepareRound(const std::string& configJson, const Value& env, + double randomSample) { + RunnableConfig gate = ParseRunnableConfig(configJson); + if (!gate.runnable() || !env.IsObject()) { + return Value::Undefined(); + } + const Value& config = gate.config; + const Value& queryUrls = config.Get(kQueryUrls); + Value query = Value::Object(); + query.Set("appKey", Value::String(gate.appKey)); + query.Set("httpsOnly", Value::Bool(IsHttpsOnly(config.Get(kEndpoints)))); + query.Set(kEndpoints, + OrderEndpointCandidates(config.Get(kEndpoints), randomSample)); + query.Set(kQueryUrls, queryUrls.IsArray() ? queryUrls : Value::Array()); + query.Set("list", Value::String(kEndpoints)); + query.Set("next", Value::Number(0)); + query.Set("tried", Value::Array()); + query.Set("spent", Value::Number(0)); + + Value round = Value::Object(); + round.Set("body", Value::String(RequestBody(config, env))); + round.Set("query", std::move(query)); + return round; +} + +Value NextQueryStep(const Value& previous, const Value& responseText) { + if (!previous.IsObject()) { + return Value::Undefined(); + } + Value state = previous; + if (state.Get("url").IsString()) { + const bool post = state.Get("post").Truthy(); + state.Remove("url"); + state.Remove("post"); + if (responseText.IsString()) { + if (post && IsValidResponse(responseText.AsString())) { + state.Set("found", Value::Bool(true)); + } else if (!post) { + bool ok = false; + Value remote = flowjson::Parse(responseText.AsString(), &ok); + if (ok && remote.IsArray()) { + state.Set("list", Value::String(kRemote)); + state.Set(kRemote, std::move(remote)); + state.Set("next", Value::Number(0)); + } + } + } + } + if (state.Get("found").Truthy()) { + return state; + } + const bool httpsOnly = state.Get("httpsOnly").Truthy(); + for (;;) { + const std::string list = state.Get("list").AsString(); + if (list != kEndpoints && list != kQueryUrls && list != kRemote) { + state.Set("list", Value::String("")); + return state; + } + const bool isListOfLists = list == kQueryUrls; + const Value& entries = state.Get(list); + size_t next = Counter(state.Get("next")); + std::string target; + while (target.empty() && next < entries.Size()) { + const Value& entry = entries.At(next++); + if (!entry.IsString()) { + continue; + } + if (isListOfLists) { + target = entry.AsString(); + continue; + } + std::string base = StripTrailingSlashes(entry.AsString()); + if (base.empty() || Contains(state.Get("tried"), base)) { + continue; + } + // A remotely injected endpoint must not downgrade an https + // configuration to plaintext. + if (list == kRemote && httpsOnly && !IsHttpsUrl(base)) { + continue; + } + target = std::move(base); + } + state.Set("next", Value::Number(static_cast(next))); + if (target.empty()) { + // The configured endpoints are followed by the list discovery; a + // fetched remote list (or running out of lists) ends the query. + state.Set("list", Value::String(list == kEndpoints ? kQueryUrls : "")); + state.Set("next", Value::Number(0)); + continue; + } + const size_t spent = Counter(state.Get("spent")); + if (spent >= static_cast(kMaxQueryRequests)) { + state.Set("list", Value::String("")); + return state; + } + state.Set("spent", Value::Number(static_cast(spent + 1))); + if (isListOfLists) { + state.Set("url", Value::String(target)); + state.Set("post", Value::Bool(false)); + } else { + Value tried = state.Get("tried").IsArray() ? state.Get("tried") + : Value::Array(); + tried.Push(Value::String(target)); + state.Set("tried", std::move(tried)); + state.Set("url", Value::String(target + QueryPath() + + state.Get("appKey").AsString())); + state.Set("post", Value::Bool(true)); + } + return state; + } +} + +Value HandleRoundResponse(const std::string& responseText, + const std::string& configJson, const Value& env, + const std::string& requestBody, + double responseAtSeconds) { + bool ok = false; + const Value config = flowjson::Parse(configJson, &ok); + if (!ok || !config.IsObject() || !env.IsObject()) { + return Value::Undefined(); + } + const Value& afterDownload = config.Get("afterDownload"); + const Value decision = HandleResponse( + responseText, Identity(config, env), false, + afterDownload.IsString() ? afterDownload.AsString() : std::string()); + + Value round = Value::Object(); + round.Set("action", decision.Get("action")); + if (decision.Get("action").AsString() == "download") { + round.Set("hash", decision.Get("hash")); + round.Set("attempts", + PlanAttempts(decision.Get("attempts"), + env.Get("currentVersion").Truthy(), + IsHttpsOnly(config.Get(kEndpoints)))); + round.Set("activate", decision.Get("activate")); + round.Set("hashInfo", VersionInfo(decision.Get("info"))); + } else { + round.Set("reason", decision.Get("reason")); + } + // Scoped to the exact request and config that produced the response (§10.3); + // freshness is anchored to the response's arrival. + Value cache = Value::Object(); + cache.Set("ts", Value::Number(responseAtSeconds)); + cache.Set("body", Value::String(responseText)); + cache.Set("request", Value::String(requestBody)); + cache.Set("config", Value::String(configJson)); + round.Set("cache", Value::String(flowjson::Stringify(cache))); + return round; +} + +} // namespace flowcore diff --git a/cpp/update_flow_core/native_round.h b/cpp/update_flow_core/native_round.h new file mode 100644 index 00000000..a5827d89 --- /dev/null +++ b/cpp/update_flow_core/native_round.h @@ -0,0 +1,90 @@ +#pragma once + +#include + +#include "flow_json.h" + +// The pure steps of the native cold-start round (NATIVE_CHECKUPDATE_DESIGN +// §10), one implementation for the Android, iOS and Harmony orchestrators: +// what to send, which endpoint to ask next, and everything a commit needs +// from the answer. HTTP, timeouts, locks, downloads and persistence stay +// native. JS has no counterpart (its client builds its own request through +// src/updateFlowCore.ts buildCheckRequestBody, which BuildRequestBody +// mirrors); tests/native-round-vectors.json pins this layer. +// +// `config` is the persisted config text (already through +// ParseRunnableConfig). `env` is what the platform knows about this launch, +// as a JSON object: +// packageVersion the binary's package version +// currentVersion the running version exactly as the platform's JS +// constant reports it (Android null, iOS absent, +// Harmony "" when none), so the request matches the one +// JS builds and JS can reuse the response cache +// uuid missing -> "" +// rolledBackVersion the launch-path rollback snapshot +// buildTime passed through +// os " ", as JS cInfo.os +// bundleHash omitted from the request when empty +// Keep the core internal to whichever binary links it (see podspec Core). +#pragma GCC visibility push(hidden) + +namespace flowcore { + +// Total HTTP requests one query may issue: endpoint queries and remote list +// fetches alike. +constexpr int kMaxQueryRequests = 8; + +// {"body": "", "query": }. The body is BuildRequestBody over the config and env: a non-empty +// string config.packageVersion overrides env.packageVersion, cInfo.rnu/rn are +// the config's strings ("" otherwise), diffV is this core's +// kSupportedDiffVersion. randomSample ∈ [0, 1) picks the first endpoint +// (OrderEndpointCandidates). Undefined when the config is not runnable or +// env is not an object. +flowjson::Value PrepareRound(const std::string& configJson, + const flowjson::Value& env, double randomSample); + +// One step of the sequential endpoint fallback (§5.1). Pass the previous +// state and the text the request it named returned (Undefined when it failed +// or none was made). The result is the next state, with +// "url" (and "post"): issue that request — a POST of the round's body for +// an endpoint query, a GET for a remote endpoint list — then call again; +// "found": true: the text just passed in is the valid check response; +// neither: no endpoint answered; the query is over. +// Policy: the configured endpoints in sampled order, then the first +// queryUrls list that fetches and parses as a JSON array, whose entries are +// queried in turn (one list is enough). Bases are stripped of trailing '/' +// and asked once each; entries that are not strings are skipped; a remote +// entry must be https when every configured endpoint is (IsHttpsOnly), so the +// network cannot downgrade the round. A response counts only when +// IsValidResponse accepts it. At most kMaxQueryRequests requests. Undefined +// for a state that is not an object. +flowjson::Value NextQueryStep(const flowjson::Value& state, + const flowjson::Value& responseText); + +// Every configured endpoint is an https URL (ASCII case-insensitive); false +// for an empty or missing list. +bool IsHttpsOnly(const flowjson::Value& endpoints); + +// HandleResponse for the round, plus what each commit needs: +// {action: "none", reason, cache} +// {action: "download", hash, attempts, activate, hashInfo, cache} +// attempts are planned for this device: no diff attempt without a running +// version (env.currentVersion falsy), no cleartext http:// URL when the +// config IsHttpsOnly; strategies left without a URL are dropped. hashInfo is +// the version info to persist next to the download (the JS side's +// setLocalHashInfo): the info's string name/description/metaInfo, plus +// forceBootRescue when the server's config.forceBoot is truthy (the same +// truthiness that activates it). cache is the response-cache text JS reuses +// ({ts, body, request, config}). afterDownload is the config's string +// (otherwise ""). Undefined when the config is not a JSON object or env is +// not an object. +flowjson::Value HandleRoundResponse(const std::string& responseText, + const std::string& configJson, + const flowjson::Value& env, + const std::string& requestBody, + double responseAtSeconds); + +} // namespace flowcore + +#pragma GCC visibility pop diff --git a/cpp/update_flow_core/tests/fuzz_flow_json.cpp b/cpp/update_flow_core/tests/fuzz_flow_json.cpp index 386094f1..f6229928 100644 --- a/cpp/update_flow_core/tests/fuzz_flow_json.cpp +++ b/cpp/update_flow_core/tests/fuzz_flow_json.cpp @@ -10,6 +10,7 @@ #include "../flow_json.h" #include "../native_config.h" +#include "../native_round.h" #include "../update_flow_core.h" extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { @@ -43,6 +44,28 @@ extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { flowcore::HandleResponse(text, identity, false, "none")); flowcore::IsValidResponse(text); + // The round: the text as the persisted config, as a response and as a + // remote endpoint list. Every query walk must end within its request cap. + flowjson::Value env = flowjson::Value::Object(); + env.Set("packageVersion", flowjson::Value::String("2.3.4")); + env.Set("currentVersion", flowjson::Value::String("cur")); + env.Set("os", flowjson::Value::String("fuzz 1")); + const flowjson::Value round = flowcore::PrepareRound(text, env, 0.5); + flowjson::Value state = round.Get("query"); + for (int step = 0; state.IsObject(); step++) { + if (step > 2 * flowcore::kMaxQueryRequests + 2) { + std::abort(); + } + state = flowcore::NextQueryStep(state, flowjson::Value::String(text)); + if (state.Get("found").Truthy() || !state.Get("url").IsString()) { + break; + } + } + flowjson::Stringify(flowcore::HandleRoundResponse( + text, "{\"appKey\":\"k\",\"endpoints\":[\"https://a\"]}", env, "{}", 1)); + flowjson::Stringify( + flowcore::HandleRoundResponse("{\"upToDate\":true}", text, env, text, 1)); + // The persisted-config layer reads host/JS-written text: equivalence must // be reflexive for every object, and a normalized config must be runnable // (or disabled) and stable under its own normalization. diff --git a/cpp/update_flow_core/tests/native-round-vectors.json b/cpp/update_flow_core/tests/native-round-vectors.json new file mode 100644 index 00000000..b7d8c6af --- /dev/null +++ b/cpp/update_flow_core/tests/native-round-vectors.json @@ -0,0 +1,1765 @@ +{ + "description": "The native cold-start round (cpp/update_flow_core/native_round.h), shared by the Android, iOS and Harmony orchestrators and replayed by scripts/test-update-flow-core.sh. `prepare`: config text + env + sample -> PrepareRound ({body (compared as an object), query}; no `expected` = Undefined). `query`: the requests a NextQueryStep walk issues against the scripted `responses` (url -> text; a missing url is a failed request) and the response it settles on. `respond`: HandleRoundResponse. `before` records what each platform's own orchestrator did where they disagreed before this core replaced them; the expected value is the JS-equivalent or strictest of them. Hand-maintained.", + "prepare": [ + { + "name": "typical round", + "config": "{\"appKey\":\" key \",\"endpoints\":[\"https://a.example/\",\"HTTPS://b.example\"],\"queryUrls\":[\"https://l.example/list.json\"],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\",\"packageVersion\":\"2.0.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "expected": { + "body": { + "packageVersion": "2.0.0", + "hash": "cur", + "buildTime": "1700000000", + "cInfo": { + "rnu": "10.0.0", + "rn": "0.80.0", + "os": "android 34", + "uuid": "u-1" + }, + "diffV": 2, + "bundleHash": "bh" + }, + "query": { + "appKey": "key", + "httpsOnly": true, + "endpoints": [ + "https://a.example/", + "HTTPS://b.example" + ], + "queryUrls": [ + "https://l.example/list.json" + ], + "list": "endpoints", + "next": 0, + "tried": [], + "spent": 0 + } + } + }, + { + "name": "the sample picks the first endpoint", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\",\"https://b.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0.6, + "expected": { + "body": { + "packageVersion": "1.0.0", + "hash": "cur", + "buildTime": "1700000000", + "cInfo": { + "rnu": "10.0.0", + "rn": "0.80.0", + "os": "android 34", + "uuid": "u-1" + }, + "diffV": 2, + "bundleHash": "bh" + }, + "query": { + "appKey": "key", + "httpsOnly": true, + "endpoints": [ + "https://b.example", + "https://a.example" + ], + "queryUrls": [], + "list": "endpoints", + "next": 0, + "tried": [], + "spent": 0 + } + } + }, + { + "name": "no packageVersion override uses the binary's", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "expected": { + "body": { + "packageVersion": "1.0.0", + "hash": "cur", + "buildTime": "1700000000", + "cInfo": { + "rnu": "10.0.0", + "rn": "0.80.0", + "os": "android 34", + "uuid": "u-1" + }, + "diffV": 2, + "bundleHash": "bh" + }, + "query": { + "appKey": "key", + "httpsOnly": true, + "endpoints": [ + "https://a.example" + ], + "queryUrls": [], + "list": "endpoints", + "next": 0, + "tried": [], + "spent": 0 + } + } + }, + { + "name": "an empty packageVersion uses the binary's", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\",\"packageVersion\":\"\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "expected": { + "body": { + "packageVersion": "1.0.0", + "hash": "cur", + "buildTime": "1700000000", + "cInfo": { + "rnu": "10.0.0", + "rn": "0.80.0", + "os": "android 34", + "uuid": "u-1" + }, + "diffV": 2, + "bundleHash": "bh" + }, + "query": { + "appKey": "key", + "httpsOnly": true, + "endpoints": [ + "https://a.example" + ], + "queryUrls": [], + "list": "endpoints", + "next": 0, + "tried": [], + "spent": 0 + } + } + }, + { + "name": "a non-string packageVersion uses the binary's", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\",\"packageVersion\":2}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "before": { + "android": "\"2\" (optString coerces)", + "ios": "\"1.0.0\" (the binary's)", + "harmony": "2 (the number, as sent)" + }, + "expected": { + "body": { + "packageVersion": "1.0.0", + "hash": "cur", + "buildTime": "1700000000", + "cInfo": { + "rnu": "10.0.0", + "rn": "0.80.0", + "os": "android 34", + "uuid": "u-1" + }, + "diffV": 2, + "bundleHash": "bh" + }, + "query": { + "appKey": "key", + "httpsOnly": true, + "endpoints": [ + "https://a.example" + ], + "queryUrls": [], + "list": "endpoints", + "next": 0, + "tried": [], + "spent": 0 + } + } + }, + { + "name": "no running version, Android form (null) is sent as null", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": null, + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "expected": { + "body": { + "packageVersion": "1.0.0", + "hash": null, + "buildTime": "1700000000", + "cInfo": { + "rnu": "10.0.0", + "rn": "0.80.0", + "os": "android 34", + "uuid": "u-1" + }, + "diffV": 2, + "bundleHash": "bh" + }, + "query": { + "appKey": "key", + "httpsOnly": true, + "endpoints": [ + "https://a.example" + ], + "queryUrls": [], + "list": "endpoints", + "next": 0, + "tried": [], + "spent": 0 + } + } + }, + { + "name": "no running version, iOS form (absent) is left out", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "expected": { + "body": { + "packageVersion": "1.0.0", + "buildTime": "1700000000", + "cInfo": { + "rnu": "10.0.0", + "rn": "0.80.0", + "os": "android 34", + "uuid": "u-1" + }, + "diffV": 2, + "bundleHash": "bh" + }, + "query": { + "appKey": "key", + "httpsOnly": true, + "endpoints": [ + "https://a.example" + ], + "queryUrls": [], + "list": "endpoints", + "next": 0, + "tried": [], + "spent": 0 + } + } + }, + { + "name": "no running version, Harmony form (\"\") is sent as \"\"", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "expected": { + "body": { + "packageVersion": "1.0.0", + "hash": "", + "buildTime": "1700000000", + "cInfo": { + "rnu": "10.0.0", + "rn": "0.80.0", + "os": "android 34", + "uuid": "u-1" + }, + "diffV": 2, + "bundleHash": "bh" + }, + "query": { + "appKey": "key", + "httpsOnly": true, + "endpoints": [ + "https://a.example" + ], + "queryUrls": [], + "list": "endpoints", + "next": 0, + "tried": [], + "spent": 0 + } + } + }, + { + "name": "absent rnu/rn are sent as \"\"", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "before": { + "android": "\"\" and \"\"", + "ios": "both left out of cInfo", + "harmony": "\"\" and \"\"" + }, + "expected": { + "body": { + "packageVersion": "1.0.0", + "hash": "cur", + "buildTime": "1700000000", + "cInfo": { + "rnu": "", + "rn": "", + "os": "android 34", + "uuid": "u-1" + }, + "diffV": 2, + "bundleHash": "bh" + }, + "query": { + "appKey": "key", + "httpsOnly": true, + "endpoints": [ + "https://a.example" + ], + "queryUrls": [], + "list": "endpoints", + "next": 0, + "tried": [], + "spent": 0 + } + } + }, + { + "name": "non-string rnu/rn are sent as \"\"", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":null,\"rn\":1}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "before": { + "android": "\"null\" and \"1\" (optString coerces)", + "ios": "null and 1", + "harmony": "\"\" and 1" + }, + "expected": { + "body": { + "packageVersion": "1.0.0", + "hash": "cur", + "buildTime": "1700000000", + "cInfo": { + "rnu": "", + "rn": "", + "os": "android 34", + "uuid": "u-1" + }, + "diffV": 2, + "bundleHash": "bh" + }, + "query": { + "appKey": "key", + "httpsOnly": true, + "endpoints": [ + "https://a.example" + ], + "queryUrls": [], + "list": "endpoints", + "next": 0, + "tried": [], + "spent": 0 + } + } + }, + { + "name": "no uuid is sent as \"\"", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "expected": { + "body": { + "packageVersion": "1.0.0", + "hash": "cur", + "buildTime": "1700000000", + "cInfo": { + "rnu": "10.0.0", + "rn": "0.80.0", + "os": "android 34", + "uuid": "" + }, + "diffV": 2, + "bundleHash": "bh" + }, + "query": { + "appKey": "key", + "httpsOnly": true, + "endpoints": [ + "https://a.example" + ], + "queryUrls": [], + "list": "endpoints", + "next": 0, + "tried": [], + "spent": 0 + } + } + }, + { + "name": "no bundleHash is left out", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34" + }, + "sample": 0, + "expected": { + "body": { + "packageVersion": "1.0.0", + "hash": "cur", + "buildTime": "1700000000", + "cInfo": { + "rnu": "10.0.0", + "rn": "0.80.0", + "os": "android 34", + "uuid": "u-1" + }, + "diffV": 2 + }, + "query": { + "appKey": "key", + "httpsOnly": true, + "endpoints": [ + "https://a.example" + ], + "queryUrls": [], + "list": "endpoints", + "next": 0, + "tried": [], + "spent": 0 + } + } + }, + { + "name": "an empty bundleHash is left out", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "" + }, + "sample": 0, + "expected": { + "body": { + "packageVersion": "1.0.0", + "hash": "cur", + "buildTime": "1700000000", + "cInfo": { + "rnu": "10.0.0", + "rn": "0.80.0", + "os": "android 34", + "uuid": "u-1" + }, + "diffV": 2 + }, + "query": { + "appKey": "key", + "httpsOnly": true, + "endpoints": [ + "https://a.example" + ], + "queryUrls": [], + "list": "endpoints", + "next": 0, + "tried": [], + "spent": 0 + } + } + }, + { + "name": "no buildTime is left out", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "expected": { + "body": { + "packageVersion": "1.0.0", + "hash": "cur", + "cInfo": { + "rnu": "10.0.0", + "rn": "0.80.0", + "os": "android 34", + "uuid": "u-1" + }, + "diffV": 2, + "bundleHash": "bh" + }, + "query": { + "appKey": "key", + "httpsOnly": true, + "endpoints": [ + "https://a.example" + ], + "queryUrls": [], + "list": "endpoints", + "next": 0, + "tried": [], + "spent": 0 + } + } + }, + { + "name": "one http endpoint turns https-only off", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\",\"http://b.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "expected": { + "body": { + "packageVersion": "1.0.0", + "hash": "cur", + "buildTime": "1700000000", + "cInfo": { + "rnu": "10.0.0", + "rn": "0.80.0", + "os": "android 34", + "uuid": "u-1" + }, + "diffV": 2, + "bundleHash": "bh" + }, + "query": { + "appKey": "key", + "httpsOnly": false, + "endpoints": [ + "https://a.example", + "http://b.example" + ], + "queryUrls": [], + "list": "endpoints", + "next": 0, + "tried": [], + "spent": 0 + } + } + }, + { + "name": "a non-string endpoint turns https-only off and is kept for the walk", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\",42],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "expected": { + "body": { + "packageVersion": "1.0.0", + "hash": "cur", + "buildTime": "1700000000", + "cInfo": { + "rnu": "10.0.0", + "rn": "0.80.0", + "os": "android 34", + "uuid": "u-1" + }, + "diffV": 2, + "bundleHash": "bh" + }, + "query": { + "appKey": "key", + "httpsOnly": false, + "endpoints": [ + "https://a.example", + 42 + ], + "queryUrls": [], + "list": "endpoints", + "next": 0, + "tried": [], + "spent": 0 + } + } + }, + { + "name": "non-array endpoints and queryUrls read as empty", + "config": "{\"appKey\":\"key\",\"endpoints\":\"https://a.example\",\"queryUrls\":\"https://l.example\",\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "before": { + "android": "no endpoints, no list discovery", + "ios": "no endpoints, no list discovery", + "harmony": "no request at all (the ordering binding rejected a non-array); a string queryUrls was walked per character" + }, + "expected": { + "body": { + "packageVersion": "1.0.0", + "hash": "cur", + "buildTime": "1700000000", + "cInfo": { + "rnu": "10.0.0", + "rn": "0.80.0", + "os": "android 34", + "uuid": "u-1" + }, + "diffV": 2, + "bundleHash": "bh" + }, + "query": { + "appKey": "key", + "httpsOnly": false, + "endpoints": [], + "queryUrls": [], + "list": "endpoints", + "next": 0, + "tried": [], + "spent": 0 + } + } + }, + { + "name": "a disabled config prepares nothing", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\",\"disabled\":true}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0 + }, + { + "name": "an invalid config prepares nothing", + "config": "{", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0 + }, + { + "name": "an env that is not an object prepares nothing", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": [], + "sample": 0 + } + ], + "query": [ + { + "name": "falls back through configured endpoints, deduped by normalized base", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a/\",\"https://a\",\"\",\"https://b\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "responses": { + "https://b/checkUpdate/key": "{\"upToDate\":true}" + }, + "expected": { + "requests": [ + "POST https://a/checkUpdate/key", + "POST https://b/checkUpdate/key" + ], + "result": "{\"upToDate\":true}" + } + }, + { + "name": "an invalid response does not stop the fallback", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a\",\"https://b\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "responses": { + "https://a/checkUpdate/key": "{\"error\":\"x\"}", + "https://b/checkUpdate/key": "{\"upToDate\":true}" + }, + "expected": { + "requests": [ + "POST https://a/checkUpdate/key", + "POST https://b/checkUpdate/key" + ], + "result": "{\"upToDate\":true}" + } + }, + { + "name": "the appKey is trimmed in the URL", + "config": "{\"appKey\":\" key\\t\",\"endpoints\":[\"https://a\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "responses": { + "https://a/checkUpdate/key": "{\"upToDate\":true}" + }, + "expected": { + "requests": [ + "POST https://a/checkUpdate/key" + ], + "result": "{\"upToDate\":true}" + } + }, + { + "name": "the sample decides which endpoint goes first", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a\",\"https://b\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0.9, + "responses": { + "https://a/checkUpdate/key": "{\"upToDate\":true}" + }, + "expected": { + "requests": [ + "POST https://b/checkUpdate/key", + "POST https://a/checkUpdate/key" + ], + "result": "{\"upToDate\":true}" + } + }, + { + "name": "discovers remote endpoints, skipping tried, non-string and cleartext ones", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a\"],\"queryUrls\":[\"\",\"https://list\"],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "responses": { + "https://list": "[\"https://a/\",42,\"http://plain\",\"https://c\"]", + "https://c/checkUpdate/key": "{\"upToDate\":true}" + }, + "expected": { + "requests": [ + "POST https://a/checkUpdate/key", + "GET https://list", + "POST https://c/checkUpdate/key" + ], + "result": "{\"upToDate\":true}" + }, + "before": { + "android": "42 was coerced to \"42\" and dropped as non-https", + "ios": "same requests", + "harmony": "same requests" + } + }, + { + "name": "cleartext remote endpoints are allowed without an https-only config", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"http://a\"],\"queryUrls\":[\"https://list\"],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "responses": { + "https://list": "[\"http://plain\"]", + "http://plain/checkUpdate/key": "{\"upToDate\":true}" + }, + "expected": { + "requests": [ + "POST http://a/checkUpdate/key", + "GET https://list", + "POST http://plain/checkUpdate/key" + ], + "result": "{\"upToDate\":true}" + } + }, + { + "name": "only the first parsable remote list is used", + "config": "{\"appKey\":\"key\",\"endpoints\":[],\"queryUrls\":[\"https://bad\",\"https://l1\",\"https://l2\"],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "responses": { + "https://bad": "{\"not\":\"array\"}", + "https://l1": "[\"https://x\"]", + "https://l2": "[\"https://y\"]" + }, + "expected": { + "requests": [ + "GET https://bad", + "GET https://l1", + "POST https://x/checkUpdate/key" + ], + "result": null + } + }, + { + "name": "a failed list fetch moves on to the next list", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a\"],\"queryUrls\":[\"https://down\",\"https://l1\"],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "responses": { + "https://l1": "[\"https://x\"]", + "https://x/checkUpdate/key": "{\"upToDate\":true}" + }, + "expected": { + "requests": [ + "POST https://a/checkUpdate/key", + "GET https://down", + "GET https://l1", + "POST https://x/checkUpdate/key" + ], + "result": "{\"upToDate\":true}" + } + }, + { + "name": "a remote list must be strict JSON", + "config": "{\"appKey\":\"key\",\"endpoints\":[],\"queryUrls\":[\"https://lenient\",\"https://strict\"],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "responses": { + "https://lenient": "['https://x']", + "https://strict": "[\"https://y\"]", + "https://y/checkUpdate/key": "{\"upToDate\":true}" + }, + "expected": { + "requests": [ + "GET https://lenient", + "GET https://strict", + "POST https://y/checkUpdate/key" + ], + "result": "{\"upToDate\":true}" + }, + "before": { + "android": "org.json accepted the single-quoted list and queried https://x", + "ios": "same requests", + "harmony": "same requests" + } + }, + { + "name": "stops after 8 HTTP requests, list fetches included", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://e0\",\"https://e1\",\"https://e2\",\"https://e3\",\"https://e4\",\"https://e5\"],\"queryUrls\":[\"https://list\"],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "responses": { + "https://list": "[\"https://r0\",\"https://r1\",\"https://r2\"]", + "https://r2/checkUpdate/key": "{\"upToDate\":true}" + }, + "expected": { + "requests": [ + "POST https://e0/checkUpdate/key", + "POST https://e1/checkUpdate/key", + "POST https://e2/checkUpdate/key", + "POST https://e3/checkUpdate/key", + "POST https://e4/checkUpdate/key", + "POST https://e5/checkUpdate/key", + "GET https://list", + "POST https://r0/checkUpdate/key" + ], + "result": null + } + }, + { + "name": "non-string configured endpoints are skipped without spending a request", + "config": "{\"appKey\":\"key\",\"endpoints\":[42,\"https://a\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "responses": { + "https://a/checkUpdate/key": "{\"upToDate\":true}" + }, + "expected": { + "requests": [ + "POST https://a/checkUpdate/key" + ], + "result": "{\"upToDate\":true}" + }, + "before": { + "android": "requested \"42/checkUpdate/key\" first (optString coerces)", + "ios": "same requests", + "harmony": "the round threw (normalizeEndpointBase on a number) and failed internal_error" + } + }, + { + "name": "non-string and empty queryUrls entries are skipped without spending a request", + "config": "{\"appKey\":\"key\",\"endpoints\":[],\"queryUrls\":[7,\"\",\"https://l\"],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "responses": { + "https://l": "[\"https://x\"]", + "https://x/checkUpdate/key": "{\"upToDate\":true}" + }, + "expected": { + "requests": [ + "GET https://l", + "POST https://x/checkUpdate/key" + ], + "result": "{\"upToDate\":true}" + }, + "before": { + "android": "fetched \"7\" first (optString coerces), spending a request", + "ios": "same requests", + "harmony": "spent a request on 7 (the fetch failed), then the same" + } + }, + { + "name": "non-array endpoints still try the list discovery", + "config": "{\"appKey\":\"key\",\"endpoints\":\"https://a\",\"queryUrls\":[\"https://l\"],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "responses": { + "https://l": "[\"https://x\"]", + "https://x/checkUpdate/key": "{\"upToDate\":true}" + }, + "expected": { + "requests": [ + "GET https://l", + "POST https://x/checkUpdate/key" + ], + "result": "{\"upToDate\":true}" + }, + "before": { + "android": "same requests", + "ios": "same requests", + "harmony": "no request at all" + } + }, + { + "name": "nothing reachable", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a\",\"https://b\"],\"queryUrls\":[\"https://l\"],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "responses": {}, + "expected": { + "requests": [ + "POST https://a/checkUpdate/key", + "POST https://b/checkUpdate/key", + "GET https://l" + ], + "result": null + } + }, + { + "name": "no endpoints and no lists issue no request", + "config": "{\"appKey\":\"key\",\"endpoints\":[],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "sample": 0, + "responses": {}, + "expected": { + "requests": [], + "result": null + } + } + ], + "respond": [ + { + "name": "up to date", + "response": "{\"upToDate\":true}", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "request": "{\"r\":1}", + "ts": 1700000000, + "expected": { + "action": "none", + "reason": "noUpdate", + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"upToDate\\\":true}\",\"request\":\"{\\\"r\\\":1}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}" + } + }, + { + "name": "a download drops cleartext artifact URLs under an https-only config", + "response": "{\"update\":true,\"hash\":\"h2\",\"name\":\"v2\",\"description\":\"notes\",\"metaInfo\":\"{\\\"k\\\":1}\",\"diff\":\"d.hdiff\",\"pdiff\":\"p.hdiff\",\"full\":\"f.ppk\",\"paths\":[\"cdn.x.com\",\"http://plain.x.com\"]}", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "request": "{\"r\":1}", + "ts": 1700000000, + "expected": { + "action": "download", + "hash": "h2", + "attempts": [ + { + "type": "diff", + "urls": [ + "https://cdn.x.com/d.hdiff" + ] + }, + { + "type": "pdiff", + "urls": [ + "https://cdn.x.com/p.hdiff" + ] + }, + { + "type": "full", + "urls": [ + "https://cdn.x.com/f.ppk" + ] + } + ], + "activate": false, + "hashInfo": { + "name": "v2", + "description": "notes", + "metaInfo": "{\"k\":1}" + }, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"h2\\\",\\\"name\\\":\\\"v2\\\",\\\"description\\\":\\\"notes\\\",\\\"metaInfo\\\":\\\"{\\\\\\\"k\\\\\\\":1}\\\",\\\"diff\\\":\\\"d.hdiff\\\",\\\"pdiff\\\":\\\"p.hdiff\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"cdn.x.com\\\",\\\"http://plain.x.com\\\"]}\",\"request\":\"{\\\"r\\\":1}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}" + } + }, + { + "name": "cleartext artifact URLs are kept without an https-only config", + "response": "{\"update\":true,\"hash\":\"h2\",\"name\":\"v2\",\"description\":\"notes\",\"metaInfo\":\"{\\\"k\\\":1}\",\"diff\":\"d.hdiff\",\"pdiff\":\"p.hdiff\",\"full\":\"f.ppk\",\"paths\":[\"cdn.x.com\",\"http://plain.x.com\"]}", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"http://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "request": "{\"r\":1}", + "ts": 1700000000, + "expected": { + "action": "download", + "hash": "h2", + "attempts": [ + { + "type": "diff", + "urls": [ + "https://cdn.x.com/d.hdiff", + "http://plain.x.com/d.hdiff" + ] + }, + { + "type": "pdiff", + "urls": [ + "https://cdn.x.com/p.hdiff", + "http://plain.x.com/p.hdiff" + ] + }, + { + "type": "full", + "urls": [ + "https://cdn.x.com/f.ppk", + "http://plain.x.com/f.ppk" + ] + } + ], + "activate": false, + "hashInfo": { + "name": "v2", + "description": "notes", + "metaInfo": "{\"k\":1}" + }, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"h2\\\",\\\"name\\\":\\\"v2\\\",\\\"description\\\":\\\"notes\\\",\\\"metaInfo\\\":\\\"{\\\\\\\"k\\\\\\\":1}\\\",\\\"diff\\\":\\\"d.hdiff\\\",\\\"pdiff\\\":\\\"p.hdiff\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"cdn.x.com\\\",\\\"http://plain.x.com\\\"]}\",\"request\":\"{\\\"r\\\":1}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"http://a.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}" + } + }, + { + "name": "no running version (null) drops the diff attempt", + "response": "{\"update\":true,\"hash\":\"h2\",\"name\":\"v2\",\"description\":\"notes\",\"metaInfo\":\"{\\\"k\\\":1}\",\"diff\":\"d.hdiff\",\"pdiff\":\"p.hdiff\",\"full\":\"f.ppk\",\"paths\":[\"cdn.x.com\",\"http://plain.x.com\"]}", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": null, + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "request": "{\"r\":1}", + "ts": 1700000000, + "expected": { + "action": "download", + "hash": "h2", + "attempts": [ + { + "type": "pdiff", + "urls": [ + "https://cdn.x.com/p.hdiff" + ] + }, + { + "type": "full", + "urls": [ + "https://cdn.x.com/f.ppk" + ] + } + ], + "activate": false, + "hashInfo": { + "name": "v2", + "description": "notes", + "metaInfo": "{\"k\":1}" + }, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"h2\\\",\\\"name\\\":\\\"v2\\\",\\\"description\\\":\\\"notes\\\",\\\"metaInfo\\\":\\\"{\\\\\\\"k\\\\\\\":1}\\\",\\\"diff\\\":\\\"d.hdiff\\\",\\\"pdiff\\\":\\\"p.hdiff\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"cdn.x.com\\\",\\\"http://plain.x.com\\\"]}\",\"request\":\"{\\\"r\\\":1}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}" + } + }, + { + "name": "no running version (absent) drops the diff attempt", + "response": "{\"update\":true,\"hash\":\"h2\",\"name\":\"v2\",\"description\":\"notes\",\"metaInfo\":\"{\\\"k\\\":1}\",\"diff\":\"d.hdiff\",\"pdiff\":\"p.hdiff\",\"full\":\"f.ppk\",\"paths\":[\"cdn.x.com\",\"http://plain.x.com\"]}", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "request": "{\"r\":1}", + "ts": 1700000000, + "expected": { + "action": "download", + "hash": "h2", + "attempts": [ + { + "type": "pdiff", + "urls": [ + "https://cdn.x.com/p.hdiff" + ] + }, + { + "type": "full", + "urls": [ + "https://cdn.x.com/f.ppk" + ] + } + ], + "activate": false, + "hashInfo": { + "name": "v2", + "description": "notes", + "metaInfo": "{\"k\":1}" + }, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"h2\\\",\\\"name\\\":\\\"v2\\\",\\\"description\\\":\\\"notes\\\",\\\"metaInfo\\\":\\\"{\\\\\\\"k\\\\\\\":1}\\\",\\\"diff\\\":\\\"d.hdiff\\\",\\\"pdiff\\\":\\\"p.hdiff\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"cdn.x.com\\\",\\\"http://plain.x.com\\\"]}\",\"request\":\"{\\\"r\\\":1}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}" + } + }, + { + "name": "no running version (\"\") drops the diff attempt", + "response": "{\"update\":true,\"hash\":\"h2\",\"name\":\"v2\",\"description\":\"notes\",\"metaInfo\":\"{\\\"k\\\":1}\",\"diff\":\"d.hdiff\",\"pdiff\":\"p.hdiff\",\"full\":\"f.ppk\",\"paths\":[\"cdn.x.com\",\"http://plain.x.com\"]}", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "request": "{\"r\":1}", + "ts": 1700000000, + "expected": { + "action": "download", + "hash": "h2", + "attempts": [ + { + "type": "pdiff", + "urls": [ + "https://cdn.x.com/p.hdiff" + ] + }, + { + "type": "full", + "urls": [ + "https://cdn.x.com/f.ppk" + ] + } + ], + "activate": false, + "hashInfo": { + "name": "v2", + "description": "notes", + "metaInfo": "{\"k\":1}" + }, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"h2\\\",\\\"name\\\":\\\"v2\\\",\\\"description\\\":\\\"notes\\\",\\\"metaInfo\\\":\\\"{\\\\\\\"k\\\\\\\":1}\\\",\\\"diff\\\":\\\"d.hdiff\\\",\\\"pdiff\\\":\\\"p.hdiff\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"cdn.x.com\\\",\\\"http://plain.x.com\\\"]}\",\"request\":\"{\\\"r\\\":1}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}" + } + }, + { + "name": "only cleartext mirrors under https-only leave no attempt", + "response": "{\"update\":true,\"hash\":\"h2\",\"name\":\"v2\",\"description\":\"notes\",\"metaInfo\":\"{\\\"k\\\":1}\",\"diff\":\"d.hdiff\",\"pdiff\":\"p.hdiff\",\"full\":\"f.ppk\",\"paths\":[\"http://plain.x.com\"]}", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "request": "{\"r\":1}", + "ts": 1700000000, + "expected": { + "action": "download", + "hash": "h2", + "attempts": [], + "activate": false, + "hashInfo": { + "name": "v2", + "description": "notes", + "metaInfo": "{\"k\":1}" + }, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"h2\\\",\\\"name\\\":\\\"v2\\\",\\\"description\\\":\\\"notes\\\",\\\"metaInfo\\\":\\\"{\\\\\\\"k\\\\\\\":1}\\\",\\\"diff\\\":\\\"d.hdiff\\\",\\\"pdiff\\\":\\\"p.hdiff\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"http://plain.x.com\\\"]}\",\"request\":\"{\\\"r\\\":1}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}" + } + }, + { + "name": "forceBoot activates and marks the rescue", + "response": "{\"update\":true,\"hash\":\"h2\",\"name\":\"v2\",\"description\":\"notes\",\"metaInfo\":\"{\\\"k\\\":1}\",\"diff\":\"d.hdiff\",\"pdiff\":\"p.hdiff\",\"full\":\"f.ppk\",\"paths\":[\"cdn.x.com\",\"http://plain.x.com\"],\"config\":{\"forceBoot\":true}}", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "request": "{\"r\":1}", + "ts": 1700000000, + "expected": { + "action": "download", + "hash": "h2", + "attempts": [ + { + "type": "diff", + "urls": [ + "https://cdn.x.com/d.hdiff" + ] + }, + { + "type": "pdiff", + "urls": [ + "https://cdn.x.com/p.hdiff" + ] + }, + { + "type": "full", + "urls": [ + "https://cdn.x.com/f.ppk" + ] + } + ], + "activate": true, + "hashInfo": { + "name": "v2", + "description": "notes", + "metaInfo": "{\"k\":1}", + "forceBootRescue": true + }, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"h2\\\",\\\"name\\\":\\\"v2\\\",\\\"description\\\":\\\"notes\\\",\\\"metaInfo\\\":\\\"{\\\\\\\"k\\\\\\\":1}\\\",\\\"diff\\\":\\\"d.hdiff\\\",\\\"pdiff\\\":\\\"p.hdiff\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"cdn.x.com\\\",\\\"http://plain.x.com\\\"],\\\"config\\\":{\\\"forceBoot\\\":true}}\",\"request\":\"{\\\"r\\\":1}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}" + } + }, + { + "name": "a truthy forceBoot 1 marks the rescue it activates", + "response": "{\"update\":true,\"hash\":\"h2\",\"name\":\"v2\",\"description\":\"notes\",\"metaInfo\":\"{\\\"k\\\":1}\",\"full\":\"f.ppk\",\"paths\":[\"cdn.x.com\",\"http://plain.x.com\"],\"config\":{\"forceBoot\":1}}", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "request": "{\"r\":1}", + "ts": 1700000000, + "before": { + "android": "activated without the marker (optBoolean(1) is false)", + "ios": "marked", + "harmony": "marked" + }, + "expected": { + "action": "download", + "hash": "h2", + "attempts": [ + { + "type": "full", + "urls": [ + "https://cdn.x.com/f.ppk" + ] + } + ], + "activate": true, + "hashInfo": { + "name": "v2", + "description": "notes", + "metaInfo": "{\"k\":1}", + "forceBootRescue": true + }, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"h2\\\",\\\"name\\\":\\\"v2\\\",\\\"description\\\":\\\"notes\\\",\\\"metaInfo\\\":\\\"{\\\\\\\"k\\\\\\\":1}\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"cdn.x.com\\\",\\\"http://plain.x.com\\\"],\\\"config\\\":{\\\"forceBoot\\\":1}}\",\"request\":\"{\\\"r\\\":1}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}" + } + }, + { + "name": "a truthy forceBoot \"false\" marks the rescue it activates", + "response": "{\"update\":true,\"hash\":\"h2\",\"name\":\"v2\",\"description\":\"notes\",\"metaInfo\":\"{\\\"k\\\":1}\",\"full\":\"f.ppk\",\"paths\":[\"cdn.x.com\",\"http://plain.x.com\"],\"config\":{\"forceBoot\":\"false\"}}", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "request": "{\"r\":1}", + "ts": 1700000000, + "before": { + "android": "activated without the marker (optBoolean(\"false\") is false)", + "ios": "marked", + "harmony": "marked" + }, + "expected": { + "action": "download", + "hash": "h2", + "attempts": [ + { + "type": "full", + "urls": [ + "https://cdn.x.com/f.ppk" + ] + } + ], + "activate": true, + "hashInfo": { + "name": "v2", + "description": "notes", + "metaInfo": "{\"k\":1}", + "forceBootRescue": true + }, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"h2\\\",\\\"name\\\":\\\"v2\\\",\\\"description\\\":\\\"notes\\\",\\\"metaInfo\\\":\\\"{\\\\\\\"k\\\\\\\":1}\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"cdn.x.com\\\",\\\"http://plain.x.com\\\"],\\\"config\\\":{\\\"forceBoot\\\":\\\"false\\\"}}\",\"request\":\"{\\\"r\\\":1}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}" + } + }, + { + "name": "a falsy forceBoot neither activates nor marks", + "response": "{\"update\":true,\"hash\":\"h2\",\"name\":\"v2\",\"description\":\"notes\",\"metaInfo\":\"{\\\"k\\\":1}\",\"full\":\"f.ppk\",\"paths\":[\"cdn.x.com\",\"http://plain.x.com\"],\"config\":{\"forceBoot\":0}}", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "request": "{\"r\":1}", + "ts": 1700000000, + "expected": { + "action": "download", + "hash": "h2", + "attempts": [ + { + "type": "full", + "urls": [ + "https://cdn.x.com/f.ppk" + ] + } + ], + "activate": false, + "hashInfo": { + "name": "v2", + "description": "notes", + "metaInfo": "{\"k\":1}" + }, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"h2\\\",\\\"name\\\":\\\"v2\\\",\\\"description\\\":\\\"notes\\\",\\\"metaInfo\\\":\\\"{\\\\\\\"k\\\\\\\":1}\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"cdn.x.com\\\",\\\"http://plain.x.com\\\"],\\\"config\\\":{\\\"forceBoot\\\":0}}\",\"request\":\"{\\\"r\\\":1}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}" + } + }, + { + "name": "the silent strategy activates without marking a rescue", + "response": "{\"update\":true,\"hash\":\"h2\",\"name\":\"v2\",\"description\":\"notes\",\"metaInfo\":\"{\\\"k\\\":1}\",\"full\":\"f.ppk\",\"paths\":[\"cdn.x.com\",\"http://plain.x.com\"]}", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"setNeedUpdate\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "request": "{\"r\":1}", + "ts": 1700000000, + "expected": { + "action": "download", + "hash": "h2", + "attempts": [ + { + "type": "full", + "urls": [ + "https://cdn.x.com/f.ppk" + ] + } + ], + "activate": true, + "hashInfo": { + "name": "v2", + "description": "notes", + "metaInfo": "{\"k\":1}" + }, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"h2\\\",\\\"name\\\":\\\"v2\\\",\\\"description\\\":\\\"notes\\\",\\\"metaInfo\\\":\\\"{\\\\\\\"k\\\\\\\":1}\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"cdn.x.com\\\",\\\"http://plain.x.com\\\"]}\",\"request\":\"{\\\"r\\\":1}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"setNeedUpdate\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}" + } + }, + { + "name": "a non-string afterDownload never activates", + "response": "{\"update\":true,\"hash\":\"h2\",\"name\":\"v2\",\"description\":\"notes\",\"metaInfo\":\"{\\\"k\\\":1}\",\"full\":\"f.ppk\",\"paths\":[\"cdn.x.com\",\"http://plain.x.com\"]}", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":1,\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "request": "{\"r\":1}", + "ts": 1700000000, + "before": { + "android": "same decision", + "ios": "same decision", + "harmony": "the binding rejected the number: failed invalid_response, no cache" + }, + "expected": { + "action": "download", + "hash": "h2", + "attempts": [ + { + "type": "full", + "urls": [ + "https://cdn.x.com/f.ppk" + ] + } + ], + "activate": false, + "hashInfo": { + "name": "v2", + "description": "notes", + "metaInfo": "{\"k\":1}" + }, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"h2\\\",\\\"name\\\":\\\"v2\\\",\\\"description\\\":\\\"notes\\\",\\\"metaInfo\\\":\\\"{\\\\\\\"k\\\\\\\":1}\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"cdn.x.com\\\",\\\"http://plain.x.com\\\"]}\",\"request\":\"{\\\"r\\\":1}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":1,\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}" + } + }, + { + "name": "only string name/description/metaInfo are kept", + "response": "{\"update\":true,\"hash\":\"h2\",\"name\":2,\"description\":null,\"metaInfo\":\"m\",\"full\":\"f.ppk\",\"paths\":[\"cdn.x.com\",\"http://plain.x.com\"]}", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "request": "{\"r\":1}", + "ts": 1700000000, + "expected": { + "action": "download", + "hash": "h2", + "attempts": [ + { + "type": "full", + "urls": [ + "https://cdn.x.com/f.ppk" + ] + } + ], + "activate": false, + "hashInfo": { + "metaInfo": "m" + }, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"h2\\\",\\\"name\\\":2,\\\"description\\\":null,\\\"metaInfo\\\":\\\"m\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"cdn.x.com\\\",\\\"http://plain.x.com\\\"]}\",\"request\":\"{\\\"r\\\":1}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}" + } + }, + { + "name": "the launch's rolled-back version is never downloaded again", + "response": "{\"update\":true,\"hash\":\"bad\",\"name\":\"v2\",\"description\":\"notes\",\"metaInfo\":\"{\\\"k\\\":1}\",\"diff\":\"d.hdiff\",\"pdiff\":\"p.hdiff\",\"full\":\"f.ppk\",\"paths\":[\"cdn.x.com\",\"http://plain.x.com\"]}", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh", + "rolledBackVersion": "bad" + }, + "request": "{\"r\":1}", + "ts": 1700000000, + "expected": { + "action": "none", + "reason": "rolledBack", + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"bad\\\",\\\"name\\\":\\\"v2\\\",\\\"description\\\":\\\"notes\\\",\\\"metaInfo\\\":\\\"{\\\\\\\"k\\\\\\\":1}\\\",\\\"diff\\\":\\\"d.hdiff\\\",\\\"pdiff\\\":\\\"p.hdiff\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"cdn.x.com\\\",\\\"http://plain.x.com\\\"]}\",\"request\":\"{\\\"r\\\":1}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}" + } + }, + { + "name": "the running version is already current", + "response": "{\"update\":true,\"hash\":\"cur\",\"name\":\"v2\",\"description\":\"notes\",\"metaInfo\":\"{\\\"k\\\":1}\",\"diff\":\"d.hdiff\",\"pdiff\":\"p.hdiff\",\"full\":\"f.ppk\",\"paths\":[\"cdn.x.com\",\"http://plain.x.com\"]}", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "request": "{\"r\":1}", + "ts": 1700000000, + "expected": { + "action": "none", + "reason": "noUpdate", + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"update\\\":true,\\\"hash\\\":\\\"cur\\\",\\\"name\\\":\\\"v2\\\",\\\"description\\\":\\\"notes\\\",\\\"metaInfo\\\":\\\"{\\\\\\\"k\\\\\\\":1}\\\",\\\"diff\\\":\\\"d.hdiff\\\",\\\"pdiff\\\":\\\"p.hdiff\\\",\\\"full\\\":\\\"f.ppk\\\",\\\"paths\\\":[\\\"cdn.x.com\\\",\\\"http://plain.x.com\\\"]}\",\"request\":\"{\\\"r\\\":1}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\"}\"}" + } + }, + { + "name": "the packageVersion override keys the gray release", + "response": "{\"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}}}}", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\",\"packageVersion\":\"2.3.4\"}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "test1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "request": "{\"r\":1}", + "ts": 1700000000, + "expected": { + "action": "download", + "hash": "gray", + "attempts": [ + { + "type": "full", + "urls": [ + "https://cdn.x.com/gray.ppk" + ] + } + ], + "activate": false, + "hashInfo": { + "name": "g" + }, + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"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}}}}\",\"request\":\"{\\\"r\\\":1}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example\\\"],\\\"queryUrls\\\":[],\\\"afterDownload\\\":\\\"none\\\",\\\"rnu\\\":\\\"10.0.0\\\",\\\"rn\\\":\\\"0.80.0\\\",\\\"packageVersion\\\":\\\"2.3.4\\\"}\"}" + } + }, + { + "name": "the cache keeps every text verbatim", + "response": "{\"upToDate\":true,\"s\":\"a/b \\u00e9 \\\"q\\\"\"}", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example/\"]}", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "request": "{\"hash\":\"x/y\"}", + "ts": 1700000000, + "expected": { + "action": "none", + "reason": "noUpdate", + "cache": "{\"ts\":1700000000,\"body\":\"{\\\"upToDate\\\":true,\\\"s\\\":\\\"a/b \\\\u00e9 \\\\\\\"q\\\\\\\"\\\"}\",\"request\":\"{\\\"hash\\\":\\\"x/y\\\"}\",\"config\":\"{\\\"appKey\\\":\\\"key\\\",\\\"endpoints\\\":[\\\"https://a.example/\\\"]}\"}" + } + }, + { + "name": "an invalid config handles nothing", + "response": "{\"upToDate\":true}", + "config": "{", + "env": { + "packageVersion": "1.0.0", + "currentVersion": "cur", + "uuid": "u-1", + "buildTime": "1700000000", + "os": "android 34", + "bundleHash": "bh" + }, + "request": "{\"r\":1}", + "ts": 1700000000 + }, + { + "name": "an env that is not an object handles nothing", + "response": "{\"upToDate\":true}", + "config": "{\"appKey\":\"key\",\"endpoints\":[\"https://a.example\"],\"queryUrls\":[],\"afterDownload\":\"none\",\"rnu\":\"10.0.0\",\"rn\":\"0.80.0\"}", + "env": "env", + "request": "{\"r\":1}", + "ts": 1700000000 + } + ] +} diff --git a/cpp/update_flow_core/tests/update_flow_core_test.cpp b/cpp/update_flow_core/tests/update_flow_core_test.cpp index fefcc220..f1481cae 100644 --- a/cpp/update_flow_core/tests/update_flow_core_test.cpp +++ b/cpp/update_flow_core/tests/update_flow_core_test.cpp @@ -13,6 +13,7 @@ #include "../flow_json.h" #include "../native_config.h" +#include "../native_round.h" #include "../update_flow_core.h" using flowjson::Parse; @@ -615,6 +616,93 @@ int RunTrimAndDeepEquals() { return failures; } +// The native round (native_round.h): request preparation, the endpoint +// query walk (replayed against a scripted network) and the response +// handling, from tests/native-round-vectors.json. Cases whose `before` +// records per-platform outputs pin a deliberate alignment. +int RunNativeRound(const Value& doc) { + int failures = 0; + const Value& prepare = doc.Get("prepare"); + const Value& query = doc.Get("query"); + const Value& respond = doc.Get("respond"); + if (!prepare.IsArray() || prepare.Size() == 0 || !query.IsArray() || + query.Size() == 0 || !respond.IsArray() || respond.Size() == 0) { + std::fprintf(stderr, "native round vectors: missing cases\n"); + return 1; + } + const auto report = [&failures](const char* section, const Value& c, + const std::string& expected, + const std::string& actual) { + if (actual != expected) { + std::fprintf(stderr, + "%s \"%s\" MISMATCH\n expected: %s\n actual: %s\n", + section, c.Get("name").AsString().c_str(), expected.c_str(), + actual.c_str()); + failures++; + } + }; + for (const Value& c : prepare.elements()) { + const Value round = flowcore::PrepareRound( + c.Get("config").AsString(), c.Get("env"), c.Get("sample").AsNumber()); + Value actual = Value::Undefined(); + if (round.IsObject()) { + // The body is compared as an object so the vectors stay readable; it + // must also be exactly the canonical text. + bool ok = false; + Value body = Parse(round.Get("body").AsString(), &ok); + if (!ok || Stringify(body) != round.Get("body").AsString()) { + std::fprintf(stderr, "prepare \"%s\": body is not canonical JSON\n", + c.Get("name").AsString().c_str()); + failures++; + } + actual = Value::Object(); + actual.Set("body", std::move(body)); + actual.Set("query", round.Get("query")); + } + report("prepare", c, Stringify(c.Get("expected")), Stringify(actual)); + } + for (const Value& c : query.elements()) { + const Value round = flowcore::PrepareRound( + c.Get("config").AsString(), c.Get("env"), c.Get("sample").AsNumber()); + Value state = round.Get("query"); + Value requests = Value::Array(); + Value text = Value::Undefined(); + Value result = Value::Null(); + for (int step = 0; step < 64; step++) { + state = flowcore::NextQueryStep(state, text); + if (state.Get("found").Truthy()) { + result = text; + break; + } + const Value& url = state.Get("url"); + if (!url.IsString()) { + break; + } + requests.Push(Value::String( + (state.Get("post").Truthy() ? "POST " : "GET ") + url.AsString())); + const Value& scripted = c.Get("responses").Get(url.AsString()); + text = scripted.IsString() ? scripted : Value::Undefined(); + } + Value actual = Value::Object(); + actual.Set("requests", std::move(requests)); + actual.Set("result", std::move(result)); + report("query", c, Stringify(c.Get("expected")), Stringify(actual)); + } + for (const Value& c : respond.elements()) { + const Value actual = flowcore::HandleRoundResponse( + c.Get("response").AsString(), c.Get("config").AsString(), + c.Get("env"), c.Get("request").AsString(), c.Get("ts").AsNumber()); + report("respond", c, Stringify(c.Get("expected")), Stringify(actual)); + } + // Walking past the end, or a state that is not an object, stays inert. + if (!flowcore::NextQueryStep(Value::Array(), Value::Undefined()) + .IsUndefined()) { + std::fprintf(stderr, "NextQueryStep: a non-object state must be rejected\n"); + failures++; + } + return failures; +} + bool LoadJson(const char* path, Value* doc) { std::ifstream file(path); if (!file) { @@ -634,19 +722,22 @@ bool LoadJson(const char* path, Value* doc) { } // namespace int main(int argc, char* argv[]) { - if (argc < 4) { + if (argc < 5) { std::fprintf(stderr, "usage: %s " " " - "\n", + " " + "\n", argv[0]); return 2; } Value doc; Value equivalenceDoc; Value nativeConfigDoc; + Value nativeRoundDoc; if (!LoadJson(argv[1], &doc) || !LoadJson(argv[2], &equivalenceDoc) || - !LoadJson(argv[3], &nativeConfigDoc)) { + !LoadJson(argv[3], &nativeConfigDoc) || + !LoadJson(argv[4], &nativeRoundDoc)) { return 2; } @@ -658,7 +749,8 @@ int main(int argc, char* argv[]) { int failures = RunParserRobustness() + RunNumberConversion() + RunInputCaps() + RunHandleCheckResponse() + RunConfigEquivalence(equivalenceDoc) + - RunNativeConfig(nativeConfigDoc) + RunTrimAndDeepEquals(); + RunNativeConfig(nativeConfigDoc) + + RunNativeRound(nativeRoundDoc) + RunTrimAndDeepEquals(); for (size_t i = 0; i < cases.Size(); i++) { const Value& testCase = cases.At(i); const std::string& fn = testCase.Get("fn").AsString(); @@ -690,9 +782,12 @@ int main(int argc, char* argv[]) { } std::printf( "all %zu flow vectors, %zu config equivalence vectors, %zu+%zu native " - "config vectors + parser robustness passed\n", + "config vectors, %zu+%zu+%zu native round vectors + parser robustness " + "passed\n", cases.Size(), equivalenceDoc.Get("cases").Size(), nativeConfigDoc.Get("runnable").Size(), - nativeConfigDoc.Get("normalize").Size()); + nativeConfigDoc.Get("normalize").Size(), + nativeRoundDoc.Get("prepare").Size(), nativeRoundDoc.Get("query").Size(), + nativeRoundDoc.Get("respond").Size()); return 0; } diff --git a/cpp/update_flow_core/update_flow_core.h b/cpp/update_flow_core/update_flow_core.h index 55b5ac16..3f1a76dd 100644 --- a/cpp/update_flow_core/update_flow_core.h +++ b/cpp/update_flow_core/update_flow_core.h @@ -81,8 +81,8 @@ bool ShouldActivateAfterDownload(const flowjson::Value& info, const std::string& afterDownload); // Composes Parse → ResolveResult → 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 +// raw checkUpdate response text to a download decision (the native round +// reaches it through HandleRoundResponse, native_round.h). 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 diff --git a/scripts/test-update-flow-core.sh b/scripts/test-update-flow-core.sh index a1ab84d2..917f5883 100755 --- a/scripts/test-update-flow-core.sh +++ b/scripts/test-update-flow-core.sh @@ -27,7 +27,8 @@ c++ \ "$BUILD_DIR/update_flow_core_test" \ "$ROOT_DIR/cpp/update_flow_core/tests/flow_vectors.json" \ "$ROOT_DIR/cpp/update_flow_core/tests/config-equivalence-vectors.json" \ - "$ROOT_DIR/cpp/update_flow_core/tests/native-config-vectors.json" + "$ROOT_DIR/cpp/update_flow_core/tests/native-config-vectors.json" \ + "$ROOT_DIR/cpp/update_flow_core/tests/native-round-vectors.json" # Opt-in libFuzzer run of the JSON parser + decision pipeline (raw server # bytes): FUZZ=1 bun run test:flow-core. @@ -47,6 +48,8 @@ if cpp_test_fuzz_compiler; then printf '{"update":true,"hash":"root","expVersion":{"hash":"gray","full":"g.ppk","config":{"rollout":{"2.3.4":63}}},"paths":["cdn"]}' > "$CORPUS_DIR/seed-rollout.json" printf '{"upToDate":true,"s":"\\ud83d\\ude00 \\u00e9 \\n","n":[1,-0.5,1e2,null,false]}' > "$CORPUS_DIR/seed-scalars.json" printf '{"appKey":" k ","endpoints":["https://a.example/","https://a.example"],"disabled":false,"rn":""}' > "$CORPUS_DIR/seed-config.json" + printf '{"appKey":"k","endpoints":["https://a","http://b",7],"queryUrls":["https://l",""],"packageVersion":"2.3.4"}' > "$CORPUS_DIR/seed-round.json" + printf '["https://a","http://b/",1,"https://c//"]' > "$CORPUS_DIR/seed-list.json" "$BUILD_DIR/fuzz_flow_json" \ -max_total_time="${FUZZ_SECONDS:-20}" \ -max_len=4096 \ From 1138e36c447eeb4986183f9aacfec1b75ffd966f Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 17:18:20 +0800 Subject: [PATCH 092/104] refactor(android): run the cold-start round through the shared round core SyncCoordinator hands the config text and a launch env to FlowBridge.prepareRound / nextQueryStep / handleResponse (JNI over cpp/update_flow_core/native_round.h) instead of assembling identity, cInfo and the request input, walking the endpoints (QueryRun, isHttpsOnly), building hashInfo and the response-cache JSON, and filtering attempts itself. The buildRequestBody / orderEndpointCandidates / isValidResponse natives are gone; the config gate returns just the rejection (configRejection), since the core reads the appKey itself. HttpUtils loses QUERY_PATH, stripTrailingSlashes and isCleartextUrl; the request path lives encoded in the core. Behaviour aligned with iOS/Harmony/JS: a truthy non-boolean forceBoot (1, "false") now marks forceBootRescue like the activation it already caused; non-string packageVersion/rnu/rn are no longer coerced to strings; non-string endpoints and queryUrls entries no longer spend a request; a remote endpoint list must be strict JSON; a config with duplicate keys is no longer rejected by org.json after the shared gate accepted it. Co-Authored-By: Claude Opus 5.5 --- .../modules/update/FlowBridge.java | 17 +- .../reactnative/modules/update/HttpUtils.java | 20 - .../modules/update/SyncCoordinator.java | 364 ++++-------------- .../modules/update/HttpUtilsTest.java | 21 +- .../modules/update/SyncCoordinatorTest.java | 40 +- cpp/android_jni/jni_natives.h | 7 +- cpp/android_jni/jni_registration.cpp | 12 +- cpp/android_jni/update_flow_jni.cpp | 94 ++--- src/__tests__/jniRegistration.test.ts | 2 +- src/__tests__/nativeTextEncoding.test.ts | 13 +- 10 files changed, 159 insertions(+), 431 deletions(-) diff --git a/android/src/main/java/cn/reactnative/modules/update/FlowBridge.java b/android/src/main/java/cn/reactnative/modules/update/FlowBridge.java index b0db9e77..5871487e 100644 --- a/android/src/main/java/cn/reactnative/modules/update/FlowBridge.java +++ b/android/src/main/java/cn/reactnative/modules/update/FlowBridge.java @@ -10,14 +10,23 @@ final class FlowBridge { private FlowBridge() { } - static native String buildRequestBody(String inputJson); + // The cold-start round (cpp/update_flow_core/native_round.h), shared with + // iOS and Harmony. envJson describes this launch (see native_round.h). - static native String orderEndpointCandidates(String endpointsJson, double randomSample); + /** {"body": request text, "query": first query state}; null when not runnable. */ + static native String prepareRound(String configJson, String envJson, double randomSample); - static native boolean isValidResponse(String responseText); + /** + * The next endpoint-query state: "url" (+ "post") to request next, + * "found" when the text passed in is the response, neither when the + * query is over. responseText is null for a failed (or no) request. + */ + static native String nextQueryStep(String stateJson, String responseText); + /** The decision with planned attempts, hashInfo and the response-cache text. */ static native String handleResponse( - String responseText, String identityJson, String afterDownload); + String responseText, String configJson, String envJson, String requestBody, + long responseAtSeconds); // Persisted-config layer (cpp/update_flow_core/native_config.h), shared // with iOS and Harmony. diff --git a/android/src/main/java/cn/reactnative/modules/update/HttpUtils.java b/android/src/main/java/cn/reactnative/modules/update/HttpUtils.java index cdc3f9fe..1919eb5f 100644 --- a/android/src/main/java/cn/reactnative/modules/update/HttpUtils.java +++ b/android/src/main/java/cn/reactnative/modules/update/HttpUtils.java @@ -15,26 +15,6 @@ static boolean isHttpsUrl(String url) { return url != null && url.regionMatches(true, 0, "https://", 0, 8); } - /** True for a plaintext "http:" URL, case-insensitively; false for anything else (incl. null). */ - static boolean isCleartextUrl(String url) { - return url != null && url.regionMatches(true, 0, "http://", 0, 7); - } - - /** Request path appended to an endpoint base, kept out of the plain string table. */ - static final String QUERY_PATH = Texts.reveal("7514fcd4ad805d55263e08fc99"); - - /** - * Strips every trailing slash: endpoint bases (so " - * " never doubles one) and the canonical config URL lists. - */ - static String stripTrailingSlashes(String url) { - int end = url.length(); - while (end > 0 && url.charAt(end - 1) == '/') { - end--; - } - return url.substring(0, end); - } - // "bytes -/". Returns the total (0 when "*"), or -1 // when the header is missing/malformed, the start does not match the // local partial, or the range and total contradict each other (end diff --git a/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java b/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java index 1816df44..4f0f5fd5 100644 --- a/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java +++ b/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java @@ -6,7 +6,6 @@ import java.io.IOException; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; -import java.util.HashSet; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -32,9 +31,8 @@ * version blacklisting. */ final class SyncCoordinator { - // Version-info flags read by JS (metadata.ts), stored encoded. + // Version-info flag read by JS (metadata.ts), stored encoded. private static final String INFO_CRASH_HOLD = Texts.reveal("3905f5c2a6b96d56212a19"); - private static final String INFO_FORCE_BOOT = Texts.reveal("3c18e6d2aba9674a360d19ead5a695"); static final String KEY_CONFIG = "nativeConfig"; // Raw response cache for the JS side to reuse (§10.3), scoped to the @@ -45,7 +43,6 @@ final class SyncCoordinator { // next launch means the previous process died mid-round (a crash rescue // was truncated): that launch resumes immediately instead of waiting 5s. static final String KEY_ROUND_INCOMPLETE = "nativeCheckIncomplete"; - private static final int MAX_QUERY_HTTP_ATTEMPTS = 8; private static final long DOWNLOAD_PHASE_TIMEOUT_SECONDS = 600; // The check response (and a remote queryUrls list) is a small JSON // document; anything bigger is a broken or hijacked endpoint. @@ -93,11 +90,11 @@ static BundlePreparationResult prepareBundle(UpdateContext context) throws Inter if (!nativeReady || sContext != context || !context.getIsUsingBundleUrl()) { return BundlePreparationResult.of(BundlePreparationResult.SKIPPED, "not_initialized"); } - RunnableConfig parsed = parseRunnableConfig(context.getKv(KEY_CONFIG)); - if (parsed.rejection != null) { - return parsed.rejection; + String configJson = context.getKv(KEY_CONFIG); + BundlePreparationResult rejection = configRejection(configJson); + if (rejection != null) { + return rejection; } - String configJson = parsed.json; startRound(0); if (!roundStarted.get()) { return BundlePreparationResult.of(BundlePreparationResult.SKIPPED, "config_changed"); @@ -135,55 +132,27 @@ private static boolean hasJsRound(UpdateContext context) { } /** - * The persisted config, parsed and checked once for every entry point - * (prepareBundle, the automatic round's gate, the round itself), so they - * cannot disagree on what is runnable. Exactly one of {config, rejection} - * is set. - */ - static final class RunnableConfig { - @Nullable final String json; - @Nullable final JSONObject config; - // Trimmed, non-empty. - @Nullable final String appKey; - @Nullable final BundlePreparationResult rejection; - - private RunnableConfig(String json, JSONObject config, String appKey, - BundlePreparationResult rejection) { - this.json = json; - this.config = config; - this.appKey = appKey; - this.rejection = rejection; - } - - static RunnableConfig rejected(String status, String reason) { - return new RunnableConfig( - null, null, null, BundlePreparationResult.of(status, reason)); - } - } - - /** - * The shared gate (FlowBridge.runnableConfig, update_flow_core): absent is - * skipped/not_configured, a non-object failed/invalid_config, a truthy - * disabled skipped/disabled, a blank appKey failed/invalid_config. + * The shared gate (FlowBridge.runnableConfig, update_flow_core) every + * entry point (prepareBundle, the automatic round's gate, the round + * itself) applies to the persisted config, so they cannot disagree on + * what is runnable: null when a round may run, otherwise the result — + * absent is skipped/not_configured, a non-object failed/invalid_config, a + * truthy disabled skipped/disabled, a blank appKey failed/invalid_config. */ - static RunnableConfig parseRunnableConfig(@Nullable String json) { - JSONObject verdict; - JSONObject config; + @Nullable + static BundlePreparationResult configRejection(@Nullable String json) { try { String result = FlowBridge.runnableConfig(json); if (result == null) { - return RunnableConfig.rejected(BundlePreparationResult.FAILED, "internal_error"); + return BundlePreparationResult.of(BundlePreparationResult.FAILED, "internal_error"); } - verdict = new JSONObject(result); - if (verdict.has("status")) { - return RunnableConfig.rejected( - verdict.getString("status"), verdict.getString("reason")); - } - config = new JSONObject(json); + JSONObject verdict = new JSONObject(result); + return verdict.has("status") + ? BundlePreparationResult.of(verdict.getString("status"), verdict.getString("reason")) + : null; } catch (JSONException e) { - return RunnableConfig.rejected(BundlePreparationResult.FAILED, "invalid_config"); + return BundlePreparationResult.of(BundlePreparationResult.FAILED, "invalid_config"); } - return new RunnableConfig(json, config, verdict.optString("appKey"), null); } private SyncCoordinator() { @@ -232,8 +201,7 @@ static boolean isRoundInFlight() { } private static boolean hasRunnableConfig(UpdateContext context) { - return context != null - && parseRunnableConfig(context.getKv(KEY_CONFIG)).rejection == null; + return context != null && configRejection(context.getKv(KEY_CONFIG)) == null; } static void onConfigured(UpdateContext context) { @@ -340,13 +308,11 @@ private static void runOnce( roundResult = BundlePreparationResult.of(BundlePreparationResult.FAILED, "check_failed"); String configJson = context.getKv(KEY_CONFIG); roundConfigJson = configJson; - RunnableConfig parsed = parseRunnableConfig(configJson); - if (parsed.rejection != null) { - roundResult = parsed.rejection; + BundlePreparationResult rejection = configRejection(configJson); + if (rejection != null) { + roundResult = rejection; return; } - JSONObject config = parsed.config; - String appKey = parsed.appKey; // Keep the existing interrupted-round breadcrumb and reset generation. try { context.setKv(KEY_ROUND_INCOMPLETE, "1"); @@ -354,8 +320,7 @@ private static void runOnce( } try { runConfiguredRound( - context, launchRolledBackVersion, deadlineNanos, - resetGeneration, configJson, config, appKey); + context, launchRolledBackVersion, deadlineNanos, resetGeneration, configJson); } finally { // A committed round already cleared the breadcrumb in its own // commit (UpdateContext.commitSyncResult); only rounds that ended @@ -374,65 +339,37 @@ private static void runConfiguredRound( String launchRolledBackVersion, long deadlineNanos, long resetGeneration, - String configJson, - JSONObject config, - String appKey + String configJson ) throws JSONException { - String packageVersion = config.optString( - "packageVersion", context.getPackageVersion()); - if (packageVersion.isEmpty()) { - packageVersion = context.getPackageVersion(); - } - String currentVersion = context.getCurrentVersion(); + // This launch as the shared core sees it (native_round.h). Absent + // values (null) are left out. + JSONObject env = new JSONObject(); + env.put("packageVersion", context.getPackageVersion()); + // The JS constant's form, so JS can reuse the response cache. + env.put("currentVersion", currentVersion == null ? JSONObject.NULL : currentVersion); + env.put("uuid", context.getKv("uuid")); // 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", "")); + env.put("rolledBackVersion", launchRolledBackVersion); + env.put("buildTime", context.getBuildTime()); // 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); + env.put("os", "android " + Build.VERSION.SDK_INT); + env.put("bundleHash", context.computeBundleHash()); + String envJson = env.toString(); - 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", NativeCore.supportedDiffVersion()); - input.put("bundleHash", context.computeBundleHash()); - - String body = FlowBridge.buildRequestBody(input.toString()); - if (body == null) { + String preparedJson = FlowBridge.prepareRound(configJson, envJson, Math.random()); + if (preparedJson == null) { roundResult = BundlePreparationResult.of(BundlePreparationResult.FAILED, "invalid_request"); return; } - - boolean httpsOnly = isHttpsOnly(config.optJSONArray("endpoints")); - String responseText = runQueryRequest(config, appKey, body, httpsOnly, deadlineNanos); + JSONObject prepared = new JSONObject(preparedJson); + String body = prepared.getString("body"); + String responseText = + runQueryRequest(prepared.getJSONObject("query").toString(), body, deadlineNanos); if (responseText == null) { Log.i(Texts.LOG_TAG, "native sync: no endpoint reachable, giving up until next launch"); @@ -440,18 +377,15 @@ private static void runConfiguredRound( } // 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; - // Built once; every commit below caches it for JS. - final String responseCacheJson = - buildResponseCacheJson(configJson, body, responseText, responseAtSeconds); - String decisionJson = FlowBridge.handleResponse( - responseText, identity.toString(), config.optString("afterDownload", "")); + responseText, configJson, envJson, body, System.currentTimeMillis() / 1000); if (decisionJson == null) { roundResult = BundlePreparationResult.of(BundlePreparationResult.FAILED, "invalid_response"); return; } JSONObject decision = new JSONObject(decisionJson); + // Built once; every commit below caches it for JS. + final String responseCacheJson = decision.getString("cache"); if (!"download".equals(decision.optString("action"))) { boolean committed = context.commitSyncResult( resetGeneration, null, null, false, responseCacheJson); @@ -471,7 +405,7 @@ private static void runConfiguredRound( boolean downloaded = context.hasCompletedVersion(hash); if (!downloaded) { downloaded = performAttempts( - decision.optJSONArray("attempts"), currentVersion, httpsOnly, deadlineNanos, + decision.optJSONArray("attempts"), deadlineNanos, downloadStarter(context, hash, currentVersion)); } if (!downloaded) { @@ -485,32 +419,15 @@ private static void runConfiguredRound( return; } - // Version info (mirroring the JS side's setLocalHashInfo), the - // activation and the response cache all land in one atomic commit — - // see UpdateContext.commitSyncResult. - 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); - } - } - // A forceBoot activation is the brick-rescue path: mark it in the - // persisted info so JS can report force_boot_rescue when this - // version survives to markSuccess. Only the server-sent directive - // counts — a silent-strategy activation is ordinary delivery. - JSONObject infoConfig = info.optJSONObject("config"); - if (infoConfig != null && infoConfig.optBoolean("forceBoot", false)) { - hashInfo.put(INFO_FORCE_BOOT, true); - } - if (crashHoldActive) { - hashInfo.put(INFO_CRASH_HOLD, true); - } - hashInfoJson = hashInfo.toString(); + // Version info (mirroring the JS side's setLocalHashInfo, the + // forceBoot rescue marked by the core), the activation and the + // response cache all land in one atomic commit — see + // UpdateContext.commitSyncResult. + JSONObject hashInfo = decision.getJSONObject("hashInfo"); + if (crashHoldActive) { + hashInfo.put(INFO_CRASH_HOLD, true); } + String 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. Unless a crash is @@ -549,20 +466,6 @@ private static void runConfiguredRound( : BundlePreparationResult.of(BundlePreparationResult.CANCELLED, "reset"); } - 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 = HttpClients.base().newBuilder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(10, TimeUnit.SECONDS) @@ -621,120 +524,31 @@ private static String readBoundedBody(ResponseBody body) throws IOException { return source.readString(charset == null ? StandardCharsets.UTF_8 : charset); } - // Shared schema rule (update_flow_core::IsValidResponse): a 200 with - // `{"error": ...}` is a failed endpoint, not a verdict, and must not stop - // the endpoint fallback. - private static boolean isValidResponse(String responseText) { - return responseText != null && FlowBridge.isValidResponse(responseText); - } - /** - * 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 + * Sequential fallback over the endpoints (§5.1), one request at a time + * with its own timeout, as the shared core walks it + * (FlowBridge.nextQueryStep: order, list discovery, https-only remote + * bases, budget, response validity). No hedged race on purpose — this * path is latency-insensitive. */ - private static String runQueryRequest( - JSONObject config, String appKey, String body, boolean httpsOnly, long deadlineNanos - ) { - JSONArray endpoints = config.optJSONArray("endpoints"); - String orderedJson = FlowBridge.orderEndpointCandidates( - endpoints == null ? "[]" : endpoints.toString(), Math.random()); - JSONArray ordered; - try { - ordered = orderedJson == null ? new JSONArray() : new JSONArray(orderedJson); - } catch (JSONException e) { - return null; - } - QueryRun run = new QueryRun(appKey, body, deadlineNanos); - for (int i = 0; i < ordered.length(); i++) { - String response = run.tryEndpoint(ordered.optString(i, ""), false); - if (response != null || run.exhausted) { - 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 (!run.spendAttempt()) { - return null; - } - String listText = httpRequest(listUrl, null, deadlineNanos); - if (listText == null) { - continue; - } - JSONArray remote; - try { - remote = new JSONArray(listText); - } catch (JSONException e) { - continue; - } - for (int j = 0; j < remote.length(); j++) { - // A remotely injected endpoint must not downgrade an https - // configuration to plaintext. - String response = run.tryEndpoint(remote.optString(j, ""), httpsOnly); - if (response != null || run.exhausted) { - return response; - } - } - // One successfully fetched remote list is enough. - break; - } - return null; - } - - /** One query's shared state: the bases already tried and the HTTP budget. */ - private static final class QueryRun { - private final String appKey; - private final String body; - private final long deadlineNanos; - private final HashSet tried = new HashSet<>(); - private int httpAttempts; - // Set once MAX_QUERY_HTTP_ATTEMPTS is spent: the whole query gives up. - boolean exhausted; - - QueryRun(String appKey, String body, long deadlineNanos) { - this.appKey = appKey; - this.body = body; - this.deadlineNanos = deadlineNanos; - } - - boolean spendAttempt() { - if (httpAttempts++ >= MAX_QUERY_HTTP_ATTEMPTS) { - exhausted = true; - return false; - } - return true; - } - - /** - * Queries one endpoint base unless it is empty, already tried or (with - * httpsOnly) not https. Returns the valid response, or null to move on - * (check {@link #exhausted}). - */ - @Nullable - String tryEndpoint(String rawBase, boolean httpsOnly) { - String base = HttpUtils.stripTrailingSlashes(rawBase); - if (base.isEmpty() || tried.contains(base)) { + @Nullable + private static String runQueryRequest(String state, String body, long deadlineNanos) + throws JSONException { + String text = null; + while (true) { + state = FlowBridge.nextQueryStep(state, text); + if (state == null) { return null; } - if (httpsOnly && !HttpUtils.isHttpsUrl(base)) { - Log.w(Texts.LOG_TAG, "native sync: ignoring non-https remote endpoint " + base); - return null; + JSONObject step = new JSONObject(state); + if (step.optBoolean("found")) { + return text; } - if (!spendAttempt()) { + String url = step.optString("url", ""); + if (url.isEmpty()) { return null; } - tried.add(base); - String response = httpRequest(base + HttpUtils.QUERY_PATH + appKey, body, deadlineNanos); - return isValidResponse(response) ? response : null; + text = httpRequest(url, step.optBoolean("post") ? body : null, deadlineNanos); } } @@ -767,26 +581,13 @@ private static DownloadStarter downloadStarter( } /** - * Shared https rule (SyncCoordinator.ts isHttpsOnly): when every - * configured endpoint is https, server-provided endpoints and artifact - * URLs must not downgrade the round to plaintext. + * Strategies in the core's order (diff, pdiff, full), each over its + * mirrors, already planned for this device (FlowBridge.handleResponse: + * no diff without a running version, no cleartext URL under an + * https-only config). */ - static boolean isHttpsOnly(@Nullable JSONArray endpoints) { - if (endpoints == null || endpoints.length() == 0) { - return false; - } - for (int i = 0; i < endpoints.length(); i++) { - Object endpoint = endpoints.opt(i); - if (!(endpoint instanceof String) || !HttpUtils.isHttpsUrl((String) endpoint)) { - return false; - } - } - return true; - } - static boolean performAttempts( - @Nullable JSONArray attempts, @Nullable String originHash, boolean httpsOnly, - long holdDeadlineNanos, DownloadStarter starter + @Nullable JSONArray attempts, long holdDeadlineNanos, DownloadStarter starter ) { if (attempts == null) { return false; @@ -801,10 +602,6 @@ static boolean performAttempts( 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 @@ -822,13 +619,6 @@ static boolean performAttempts( } for (int j = 0; j < urls.length(); j++) { String url = urls.optString(j, ""); - if (url.isEmpty()) { - continue; - } - if (httpsOnly && HttpUtils.isCleartextUrl(url)) { - Log.w(Texts.LOG_TAG, "native sync: ignoring cleartext " + type + " url " + url); - 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(); diff --git a/android/src/test/java/cn/reactnative/modules/update/HttpUtilsTest.java b/android/src/test/java/cn/reactnative/modules/update/HttpUtilsTest.java index 40d83f51..de2870d3 100644 --- a/android/src/test/java/cn/reactnative/modules/update/HttpUtilsTest.java +++ b/android/src/test/java/cn/reactnative/modules/update/HttpUtilsTest.java @@ -53,15 +53,6 @@ public void parseContentRangeRejectsMalformedHeaders() { assertEquals(-1, HttpUtils.parseContentRange("items 0-9/10", 0)); } - @Test - public void stripTrailingSlashesStripsEveryTrailingSlash() { - assertEquals("https://update.example.com", HttpUtils.stripTrailingSlashes("https://update.example.com/")); - assertEquals("https://update.example.com", HttpUtils.stripTrailingSlashes("https://update.example.com///")); - assertEquals("https://update.example.com/api", HttpUtils.stripTrailingSlashes("https://update.example.com/api")); - assertEquals("", HttpUtils.stripTrailingSlashes("")); - assertEquals("", HttpUtils.stripTrailingSlashes("/")); - } - @Test public void isHttpsUrlIsCaseInsensitiveAndStrict() { assertTrue(HttpUtils.isHttpsUrl("https://example.com/app.apk")); @@ -73,20 +64,10 @@ public void isHttpsUrlIsCaseInsensitiveAndStrict() { assertFalse(HttpUtils.isHttpsUrl(null)); } - @Test - public void isCleartextUrlMatchesOnlyPlainHttp() { - assertTrue(HttpUtils.isCleartextUrl("http://example.com/a.ppk")); - assertTrue(HttpUtils.isCleartextUrl("HTTP://example.com/a.ppk")); - assertFalse(HttpUtils.isCleartextUrl("https://example.com/a.ppk")); - assertFalse(HttpUtils.isCleartextUrl("ftp://example.com")); - assertFalse(HttpUtils.isCleartextUrl("http:/example.com")); - assertFalse(HttpUtils.isCleartextUrl(null)); - } - @Test public void revealDecodesEncodedText() { // Encoded with scripts/encode-native-text.ts. - assertEquals("/checkUpdate/", HttpUtils.QUERY_PATH); + assertEquals("/checkUpdate/", Texts.reveal("7514fcd4ad805d55263e08fc99")); assertEquals("https://update.react-native.cn/api", Texts.reveal("3203e0c1bdd1270a372f18f8c2b6de7f4f2607f5b3d5b9817b592947e5cdefbc8a7e")); assertEquals("", Texts.reveal("")); diff --git a/android/src/test/java/cn/reactnative/modules/update/SyncCoordinatorTest.java b/android/src/test/java/cn/reactnative/modules/update/SyncCoordinatorTest.java index 69cefee6..413139a0 100644 --- a/android/src/test/java/cn/reactnative/modules/update/SyncCoordinatorTest.java +++ b/android/src/test/java/cn/reactnative/modules/update/SyncCoordinatorTest.java @@ -62,7 +62,7 @@ public void patchFailureSkipsTheStrategysRemainingMirrors() throws Exception { attempts( new Object[] {"diff", "https://a/diff", "https://b/diff"}, new Object[] {"full", "https://a/full", "https://b/full"}), - "origin", true, 0, starter); + 0, starter); assertTrue(downloaded); assertEquals( @@ -77,46 +77,16 @@ public void retryableFailuresTryEveryMirror() throws Exception { .fail("https://b/full", new FileOperationException("refused")); boolean downloaded = SyncCoordinator.performAttempts( attempts(new Object[] {"full", "https://a/full", "https://b/full"}), - "", true, 0, starter); + 0, starter); assertFalse(downloaded); assertEquals( Arrays.asList("full https://a/full", "full https://b/full"), starter.started); } - @Test - public void httpsOnlyRoundsSkipCleartextArtifactUrls() throws Exception { - ScriptedStarter starter = new ScriptedStarter(); - assertTrue(SyncCoordinator.performAttempts( - attempts(new Object[] {"full", "http://a/full", "HTTP://b/full", "https://c/full"}), - "", true, 0, starter)); - assertEquals(Arrays.asList("full https://c/full"), starter.started); - - ScriptedStarter plain = new ScriptedStarter(); - assertTrue(SyncCoordinator.performAttempts( - attempts(new Object[] {"full", "http://a/full"}), "", false, 0, plain)); - assertEquals(Arrays.asList("full http://a/full"), plain.started); - } - - @Test - public void diffNeedsARunningVersion() throws Exception { - ScriptedStarter starter = new ScriptedStarter(); - assertTrue(SyncCoordinator.performAttempts( - attempts( - new Object[] {"diff", "https://a/diff"}, - new Object[] {"pdiff", "https://a/pdiff"}), - "", true, 0, starter)); - assertEquals(Arrays.asList("pdiff https://a/pdiff"), starter.started); - } - - @Test - public void httpsOnlyRequiresEveryConfiguredEndpointToBeHttps() { - assertTrue(SyncCoordinator.isHttpsOnly(new JSONArray(Arrays.asList("https://a", "HTTPS://b")))); - assertFalse(SyncCoordinator.isHttpsOnly(new JSONArray(Arrays.asList("https://a", "http://b")))); - assertFalse(SyncCoordinator.isHttpsOnly(new JSONArray())); - assertFalse(SyncCoordinator.isHttpsOnly(null)); - assertFalse(SyncCoordinator.isHttpsOnly(new JSONArray(Arrays.asList(1)))); - } + // Attempt planning (diff needs a running version, https-only rounds drop + // cleartext URLs) and the https-only rule live in the shared core: + // cpp/update_flow_core/tests/native-round-vectors.json. @Test public void onlyPatchFailuresAreNotMirrorRetryable() { diff --git a/cpp/android_jni/jni_natives.h b/cpp/android_jni/jni_natives.h index 1ab074f1..3d667e49 100644 --- a/cpp/android_jni/jni_natives.h +++ b/cpp/android_jni/jni_natives.h @@ -20,10 +20,9 @@ jobject SyncStateWithBinaryVersion(JNIEnv* env, jclass, jstring package_version, jobject RunStateCore(JNIEnv* env, jclass, jint operation, jobject state_result, jstring string_arg, jboolean flag_a, jboolean flag_b); jobjectArray ApplyArchiveDelta(JNIEnv* env, jclass, jint patch_type, jobjectArray entry_names, jbyteArray manifest_json, jstring source_root, jstring target_root, jstring origin_bundle_path, jstring bundle_patch_path, jstring bundle_output_path, jboolean host_copies_resources); void CleanupOldEntries(JNIEnv* env, jclass, jstring root_dir, jobjectArray keep_names, jint max_age_days, jstring trash_dir); -jstring FlowBuildRequestBody(JNIEnv* env, jclass, jstring inputJson); -jstring FlowOrderEndpointCandidates(JNIEnv* env, jclass, jstring endpointsJson, jdouble randomSample); -jboolean FlowIsValidResponse(JNIEnv* env, jclass, jstring responseText); -jstring FlowHandleResponse(JNIEnv* env, jclass, jstring responseText, jstring identityJson, jstring afterDownload); +jstring FlowPrepareRound(JNIEnv* env, jclass, jstring configJson, jstring envJson, jdouble randomSample); +jstring FlowNextQueryStep(JNIEnv* env, jclass, jstring stateJson, jstring responseText); +jstring FlowHandleResponse(JNIEnv* env, jclass, jstring responseText, jstring configJson, jstring envJson, jstring requestBody, jlong responseAtSeconds); jboolean FlowConfigsEquivalent(JNIEnv* env, jclass, jstring a, jstring b); jstring FlowRunnableConfig(JNIEnv* env, jclass, jstring configJson); jstring FlowNormalizeConfiguration(JNIEnv* env, jclass, jstring optionsJson); diff --git a/cpp/android_jni/jni_registration.cpp b/cpp/android_jni/jni_registration.cpp index 881bc3dd..e0e6ea60 100644 --- a/cpp/android_jni/jni_registration.cpp +++ b/cpp/android_jni/jni_registration.cpp @@ -49,13 +49,11 @@ const std::vector& NativeClasses() { }}, {"3919bbc3ab8a6b512c3e08f0c0b6df60452311edfbc8f780624b2d1de38c86b19560762307efcfa0", // FlowBridge { - {"3802fdddaab96d54373a0fedf4bc9474", "723bfed0b88a274923311bb6e5a7826444205fa8d2d1b98373002008e8c4ef8e8e655d3f09b0", - reinterpret_cast(&pushy::jni_natives::FlowBuildRequestBody)}, - {"3505f0d4bcae6641323015f7c29091634e2e00e0eadeab", "723bfed0b88a274923311bb6e5a7826444205fc5b7f7b294644e6305e7cda7f2a963463800ec93", - reinterpret_cast(&pushy::jni_natives::FlowOrderEndpointCandidates)}, - {"3304c2d0a2826c77272c0cf6d8a095", "723bfed0b88a274923311bb6e5a7826444205fa8c4", - reinterpret_cast(&pushy::jni_natives::FlowIsValidResponse)}, - {"3216fad5a28e5a40312f13f7c5b6", "723bfed0b88a274923311bb6e5a7826444205fcdf4daae943d432d07e18c93a9887e5a3655c7c2a4949e3355371df78299937648503c43bcfea58d7f476c0c1cf4d0fba27a59210be5a4", + {"2a05f1c1af996d772d2a12fd", "723bfed0b88a274923311bb6e5a7826444205fcdf4daae943d432d07e18c93a9887e5a3655cf8189889e6a58791ff1c3adc857554c3216f289", + reinterpret_cast(&pushy::jni_natives::FlowPrepareRound)}, + {"3412ecc59f9e6d573b0c08fcc6", "723bfed0b88a274923311bb6e5a7826444205fcdf4daae943d432d07e18c93a9887e5a3655a2e4af83897d163a12fecae5b4705357351fae", + reinterpret_cast(&pushy::jni_natives::FlowNextQueryStep)}, + {"3216fad5a28e5a40312f13f7c5b6", "723bfed0b88a274923311bb6e5a7826444205fcdf4daae943d432d07e18c93a9887e5a3655c7c2a4949e3355371df78299937648503c43d9d8ae9a68092f0113fd9887857c422602b9d595959c72462c45ebc5afb9d44b412006e2cefd", reinterpret_cast(&pushy::jni_natives::FlowHandleResponse)}, {"3918fad7a78c7b60332a15efd7bf95635e", "723bfed0b88a274923311bb6e5a7826444205fcdf4daae943d432d07e18c93a9887e5a3655a2f2", reinterpret_cast(&pushy::jni_natives::FlowConfigsEquivalent)}, diff --git a/cpp/android_jni/update_flow_jni.cpp b/cpp/android_jni/update_flow_jni.cpp index b0ee7188..6592a1ed 100644 --- a/cpp/android_jni/update_flow_jni.cpp +++ b/cpp/android_jni/update_flow_jni.cpp @@ -13,71 +13,71 @@ #include "jni_util.h" #include "../update_flow_core/flow_json.h" #include "../update_flow_core/native_config.h" -#include "../update_flow_core/update_flow_core.h" +#include "../update_flow_core/native_round.h" using pushy::jni_util::ToJString; -jstring pushy::jni_natives::FlowBuildRequestBody( - JNIEnv* env, jclass, jstring inputJson) { - try { - 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(flowcore::BuildRequestBody(input))); - } catch (...) { - return nullptr; - } +namespace { + +// Parses a JSON object argument; Undefined (-> null result) otherwise. +flowjson::Value ParseObject(JNIEnv* env, jstring json) { + bool ok = false; + flowjson::Value value = + flowjson::Parse(pushy::jni_util::JStringToString(env, json), &ok); + return ok && value.IsObject() ? value : flowjson::Value::Undefined(); } -jstring pushy::jni_natives::FlowOrderEndpointCandidates( - JNIEnv* env, jclass, jstring endpointsJson, jdouble randomSample) { +// Stringify, or null for an Undefined result (input the core rejected). +jstring ToJsonOrNull(JNIEnv* env, const flowjson::Value& value) { + return value.IsUndefined() ? nullptr + : ToJString(env, flowjson::Stringify(value)); +} + +} // namespace + +// Native round (update_flow_core/native_round.h). +jstring pushy::jni_natives::FlowPrepareRound(JNIEnv* env, jclass, + jstring configJson, + jstring envJson, + jdouble randomSample) { try { - 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(flowcore::OrderEndpointCandidates( - endpoints, randomSample))); + return ToJsonOrNull( + env, flowcore::PrepareRound( + pushy::jni_util::JStringToString(env, configJson), + ParseObject(env, envJson), randomSample)); } catch (...) { return nullptr; } } -jboolean pushy::jni_natives::FlowIsValidResponse( - JNIEnv* env, jclass, jstring responseText) { - if (responseText == nullptr) { - return JNI_FALSE; - } +// A null responseText is a failed request (or none made yet). +jstring pushy::jni_natives::FlowNextQueryStep(JNIEnv* env, jclass, + jstring stateJson, + jstring responseText) { try { - return flowcore::IsValidResponse( - pushy::jni_util::JStringToString(env, responseText)) - ? JNI_TRUE - : JNI_FALSE; + return ToJsonOrNull( + env, flowcore::NextQueryStep( + ParseObject(env, stateJson), + responseText == nullptr + ? flowjson::Value::Undefined() + : flowjson::Value::String(pushy::jni_util::JStringToString( + env, responseText)))); } catch (...) { - return JNI_FALSE; + return nullptr; } } jstring pushy::jni_natives::FlowHandleResponse( - JNIEnv* env, jclass, jstring responseText, jstring identityJson, - jstring afterDownload) { + JNIEnv* env, jclass, jstring responseText, jstring configJson, + jstring envJson, jstring requestBody, jlong responseAtSeconds) { try { - 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(flowcore::HandleResponse( - pushy::jni_util::JStringToString(env, responseText), - identity, false, - pushy::jni_util::JStringToString(env, afterDownload)))); + return ToJsonOrNull( + env, flowcore::HandleRoundResponse( + pushy::jni_util::JStringToString(env, responseText), + pushy::jni_util::JStringToString(env, configJson), + ParseObject(env, envJson), + pushy::jni_util::JStringToString(env, requestBody), + static_cast(responseAtSeconds))); } catch (...) { return nullptr; } diff --git a/src/__tests__/jniRegistration.test.ts b/src/__tests__/jniRegistration.test.ts index 5360cd13..753e2789 100644 --- a/src/__tests__/jniRegistration.test.ts +++ b/src/__tests__/jniRegistration.test.ts @@ -73,7 +73,7 @@ const registered = () => { describe('JNI registration table', () => { test('matches every Java native declaration', () => { const natives = javaNatives(); - expect(natives.length).toBe(17); + expect(natives.length).toBe(16); expect(registered()).toEqual(natives); }); diff --git a/src/__tests__/nativeTextEncoding.test.ts b/src/__tests__/nativeTextEncoding.test.ts index 0f5d1eab..834b2b1f 100644 --- a/src/__tests__/nativeTextEncoding.test.ts +++ b/src/__tests__/nativeTextEncoding.test.ts @@ -38,16 +38,17 @@ describe('native text encoding', () => { } }); - // The request path is appended by each platform's HTTP glue; the default - // service addresses only live in the shared normalizer - // (cpp/update_flow_core/native_config.cpp). - test('the request path is encoded per platform, the addresses in cpp/', () => { + // The request path is appended by the shared round core + // (cpp/update_flow_core/native_round.cpp) and, until they move to it, by + // the platforms' own HTTP glue; the default service addresses only live in + // the shared normalizer (cpp/update_flow_core/native_config.cpp). + test('the request path and the addresses are encoded in cpp/', () => { const decodedIn = (prefix: string) => paths .filter((path) => path.startsWith(prefix)) .flatMap((path) => encodedIn(source(path))) .map(decodeNativeText); - for (const platform of ['android/', 'ios/', 'harmony/']) { + for (const platform of ['ios/', 'harmony/']) { expect({ platform, found: decodedIn(platform).includes(plainTexts[0]), @@ -57,7 +58,7 @@ describe('native text encoding', () => { }); } const shared = decodedIn('cpp/'); - for (const text of plainTexts.slice(1)) { + for (const text of plainTexts) { expect({ text, found: shared.includes(text) }).toEqual({ text, found: true, From c90ac4ce37846425780cf0d5208a07c047decc7c Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 17:21:43 +0800 Subject: [PATCH 093/104] refactor(ios): run the cold-start round through the shared round core RCTPushyOrchestrator passes the config text and a launch env to flowcore::PrepareRound / NextQueryStep / HandleRoundResponse (cpp/update_flow_core/native_round.h) instead of assembling identity, cInfo and the request input, walking the endpoints with its own tried-set and budget, projecting the version info and filtering attempts. commitRoundWithGeneration takes the core's response-cache text (cache:) in place of the four fields it serialized itself; nil still marks the crash handler's activation-only commit. PushyLoadRunnableConfig only hands back the config text, RCTPushyQueryPath and the https/endpoint helpers are gone. Behaviour aligned with Android/Harmony/JS: absent rnu/rn are sent as "" (cInfo used to omit them) and non-string ones as "", like the core's C1 defaults. Co-Authored-By: Claude Opus 5.5 --- ios/RCTPushy/RCTPushyCommon.h | 3 - ios/RCTPushy/RCTPushyConfiguration.mm | 5 - ios/RCTPushy/RCTPushyOrchestrator.mm | 355 +++++------------- .../ios-purge-restore/purge_restore_test.mm | 8 +- src/__tests__/nativeTextEncoding.test.ts | 2 +- 5 files changed, 91 insertions(+), 282 deletions(-) diff --git a/ios/RCTPushy/RCTPushyCommon.h b/ios/RCTPushy/RCTPushyCommon.h index ea702175..8084ee34 100644 --- a/ios/RCTPushy/RCTPushyCommon.h +++ b/ios/RCTPushy/RCTPushyCommon.h @@ -23,9 +23,6 @@ static NSString *const RCTPushyErrorCodeKey = @"PushyErrorCode"; // binary; not a secret. PUSHY_HIDDEN NSString *RCTPushyRevealText(const char *hex); -// Request path appended to an endpoint base. -PUSHY_HIDDEN NSString *RCTPushyQueryPath(void); - // Whether following a redirect from `from` to `to` would leave https for // plaintext http. Every https request (check and artifact) refuses that // downgrade before the redirected request is sent. diff --git a/ios/RCTPushy/RCTPushyConfiguration.mm b/ios/RCTPushy/RCTPushyConfiguration.mm index fdf00f17..652120fc 100644 --- a/ios/RCTPushy/RCTPushyConfiguration.mm +++ b/ios/RCTPushy/RCTPushyConfiguration.mm @@ -12,11 +12,6 @@ encoding:NSUTF8StringEncoding] ?: @""; } -NSString *RCTPushyQueryPath(void) { - static NSString *path = RCTPushyRevealText("7514fcd4ad805d55263e08fc99"); - return path; -} - // Validation and defaults are the shared core's (flowcore::NormalizeConfiguration, // also used by Android and Harmony); this only bridges the dictionary. NSString *RCTPushyNormalizeConfiguration(NSDictionary *options, NSError **error) { diff --git a/ios/RCTPushy/RCTPushyOrchestrator.mm b/ios/RCTPushy/RCTPushyOrchestrator.mm index 922956a8..dc1e1539 100644 --- a/ios/RCTPushy/RCTPushyOrchestrator.mm +++ b/ios/RCTPushy/RCTPushyOrchestrator.mm @@ -1,8 +1,8 @@ #import "RCTPushyInternal.h" #include "../../cpp/patch_core/error_codes.h" -#include "../../cpp/patch_core/hbc_transform_wire.h" #include "../../cpp/update_flow_core/flow_json.h" #include "../../cpp/update_flow_core/native_config.h" +#include "../../cpp/update_flow_core/native_round.h" #include "../../cpp/update_flow_core/update_flow_core.h" #if TARGET_OS_TV @@ -19,16 +19,12 @@ // rescue was truncated): that launch resumes immediately instead of waiting. static NSString *const keyNativeCheckIncomplete = @"REACTNATIVECN_PUSHY_NATIVE_CHECK_INCOMPLETE_KEY"; -// Version-info flags and the no-op status read by JS, stored encoded -// (RCTPushyRevealText) so they do not appear as plain binary strings. +// The crash-hold version-info flag and the no-op status read by JS, stored +// encoded (RCTPushyRevealText) so they do not appear as plain binary strings. static NSString *PushyInfoCrashHold(void) { static NSString *value = RCTPushyRevealText("3905f5c2a6b96d56212a19"); return value; } -static NSString *PushyInfoForceBoot(void) { - static NSString *value = RCTPushyRevealText("3c18e6d2aba9674a360d19ead5a695"); - return value; -} static NSString *PushyStatusNone(void) { static NSString *value = RCTPushyRevealText("3418c1c1aa8a7c40"); return value; @@ -312,47 +308,6 @@ - (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task return result; } -static NSString *PushyNormalizeEndpointBase(NSString *base) { - while ([base hasSuffix:@"/"]) { - base = [base substringToIndex:base.length - 1]; - } - return base; -} - -// Same rule as Harmony (SyncCoordinator.ts isHttpsOnly, SR-1): when every -// configured endpoint is https, endpoints from a fetched remote list must be -// https and artifact URLs from the decision may not be plaintext http, so the -// network cannot downgrade the round. Redirects are guarded separately -// (RCTPushyIsHttpsDowngrade). -static BOOL PushyIsHttpsUrl(NSString *url) { - return [[url lowercaseString] hasPrefix:@"https://"]; -} - -static BOOL PushyIsCleartextUrl(NSString *url) { - return [[url lowercaseString] hasPrefix:@"http://"]; -} - -static BOOL PushyIsHttpsOnly(const flowjson::Value &endpoints) { - if (endpoints.elements().empty()) { - return NO; - } - for (const auto &endpoint : endpoints.elements()) { - if (!endpoint.IsString() || !PushyIsHttpsUrl(PushyFromStdString(endpoint.AsString()))) { - return NO; - } - } - return YES; -} - -static BOOL PushyIsValidResponse(NSString *responseText) { - if (responseText == nil) { - return NO; - } - // Shared schema rule (update_flow_core::IsValidResponse): a 200 with - // `{"error": ...}` is a failed endpoint, not a verdict. - return flowcore::IsValidResponse(PushyToStdString(responseText)) ? YES : NO; -} - // Contract C1 (flowcore::ConfigsEquivalent, shared with Android and Harmony; // JS has its own copy): the same config in another serialization — key order, // trailing slashes, defaulted keys — is not a change. Lets a host-owned @@ -367,29 +322,20 @@ static BOOL PushyConfigsEquivalent(NSString *a, NSString *b) { // install, round start, prepareBundle, the round itself) reads it through // PushyLoadRunnableConfig, which applies the shared gate // (flowcore::ParseRunnableConfig), so they agree on what "configured" and -// "disabled" mean. -struct PushyRunnableConfig { - NSString *json; - flowjson::Value value; - NSString *appKey; -}; - -// YES when a round may run; otherwise *rejection is the host-facing result -// (skipped: not_configured/disabled, failed: invalid_config). -static BOOL PushyLoadRunnableConfig(PushyRunnableConfig *config, NSDictionary **rejection) { - NSString *json = [PushyDefaults() stringForKey:keyNativeConfig]; - config->json = [json copy]; - flowcore::RunnableConfig gate = flowcore::ParseRunnableConfig(PushyToStdString(json)); - if (!gate.runnable()) { - if (rejection != NULL) { - *rejection = PushyHostResult(PushyFromStdString(gate.status), - PushyFromStdString(gate.reason)); - } - return NO; +// "disabled" mean. YES when a round may run (*json is the config text); +// otherwise *rejection is the host-facing result (skipped: +// not_configured/disabled, failed: invalid_config). +static BOOL PushyLoadRunnableConfig(NSString **json, NSDictionary **rejection) { + NSString *text = [PushyDefaults() stringForKey:keyNativeConfig]; + if (json != NULL) { + *json = [text copy]; } - config->value = std::move(gate.config); - config->appKey = PushyFromStdString(gate.appKey); - return YES; + flowcore::RunnableConfig gate = flowcore::ParseRunnableConfig(PushyToStdString(text)); + if (!gate.runnable() && rejection != NULL) { + *rejection = PushyHostResult(PushyFromStdString(gate.status), + PushyFromStdString(gate.reason)); + } + return gate.runnable(); } @implementation RCTPushyOrchestrator @@ -421,8 +367,7 @@ + (void)persistConfiguration:(NSString *)config { } + (BOOL)hasRunnableConfig { - PushyRunnableConfig config; - return PushyLoadRunnableConfig(&config, NULL); + return PushyLoadRunnableConfig(NULL, NULL); } // Process-wide round state, set up by whichever launch path needs a round @@ -549,12 +494,11 @@ + (NSDictionary *)prepareBundleWithin:(NSTimeInterval)timeout { if (!pushySyncReady.load()) { return PushyHostResult(@"skipped", @"not_initialized"); } - PushyRunnableConfig runnable; + NSString *configJson = nil; NSDictionary *rejection = nil; - if (!PushyLoadRunnableConfig(&runnable, &rejection)) { + if (!PushyLoadRunnableConfig(&configJson, &rejection)) { return rejection; } - NSString *configJson = runnable.json; // Run the round on its own queue rather than this caller's thread, so the // wait below can be bounded. if ([self claimRound]) { @@ -693,10 +637,7 @@ + (void)activatePendingVersion { configGeneration:configGeneration hashInfo:hashInfoEntry activate:hash - responseText:nil - request:nil - config:nil - responseAt:0 + cache:nil activated:NULL]; if (committed) { @synchronized (self) { @@ -717,10 +658,10 @@ + (void)runOnce:(NSString *)launchRolledBackVersion deadline:(NSTimeInterval)dea pushyHostRoundConfigGeneration = configGeneration; pushyHostRoundResult = PushyHostResult(@"failed", @"check_failed"); NSUserDefaults *defaults = PushyDefaults(); - PushyRunnableConfig config; + NSString *configJson = nil; NSDictionary *rejection = nil; - BOOL runnable = PushyLoadRunnableConfig(&config, &rejection); - pushyHostRoundConfig = config.json; + BOOL runnable = PushyLoadRunnableConfig(&configJson, &rejection); + pushyHostRoundConfig = configJson; if (!runnable) { pushyHostRoundResult = rejection; return; @@ -728,10 +669,8 @@ + (void)runOnce:(NSString *)launchRolledBackVersion deadline:(NSTimeInterval)dea // Preserve the interrupted-round breadcrumb and reset-safe atomic commit. [defaults setObject:@YES forKey:keyNativeCheckIncomplete]; @try { - [self runConfiguredRound:config.value - configJson:config.json - appKey:config.appKey - launchRolledBackVersion:launchRolledBackVersion + [self runConfiguredRound:configJson + launchRolledBackVersion:launchRolledBackVersion resetGeneration:resetGeneration configGeneration:configGeneration deadline:deadlineUptime]; @@ -740,85 +679,60 @@ + (void)runOnce:(NSString *)launchRolledBackVersion deadline:(NSTimeInterval)dea } } -+ (void)runConfiguredRound:(const flowjson::Value &)config - configJson:(NSString *)configJson - appKey:(NSString *)appKey ++ (void)runConfiguredRound:(NSString *)configJson launchRolledBackVersion:(NSString *)launchRolledBackVersion resetGeneration:(uint64_t)resetGeneration configGeneration:(uint64_t)configGeneration deadline:(NSTimeInterval)deadlineUptime { - NSUserDefaults *defaults = PushyDefaults(); - 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:@"%@ %@", PushyOsName, PushyOsVersion()]))); - 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)); + // This launch as the shared core sees it (native_round.h); nil values are + // left out — a version-less launch has no currentVersion, as the JS + // constant does not. + flowjson::Value env = flowjson::Value::Object(); + const auto put = [&env](const char *key, NSString *value) { + if (value != nil) { + env.Set(key, flowjson::Value::String(PushyToStdString(value))); + } + }; + put("packageVersion", [RCTPushy packageVersion]); + put("currentVersion", currentVersion); + put("uuid", [PushyDefaults() stringForKey:keyUuid]); + put("rolledBackVersion", launchRolledBackVersion); + put("buildTime", [RCTPushy buildTime]); + put("os", [NSString stringWithFormat:@"%@ %@", PushyOsName, PushyOsVersion()]); // Cache-only while a crash is being held (§11.3 budget) or the launch is // blocked on the tvOS purged-version restore; computed (and shared with JS // getBundleHash) on the ordinary delayed round. - input.Set("bundleHash", - flowjson::Value::String(PushyToStdString(PushyBundleHashSync( - !(pushyCrashHoldActive.load() || pushyPurgeRestoreActive.load()))))); + put("bundleHash", PushyBundleHashSync( + !(pushyCrashHoldActive.load() || pushyPurgeRestoreActive.load()))); - std::string bodyJson = - flowjson::Stringify(flowcore::BuildRequestBody(input)); - NSString *body = [NSString stringWithUTF8String:bodyJson.c_str()]; + const std::string config = PushyToStdString(configJson); + const flowjson::Value round = + flowcore::PrepareRound(config, env, arc4random() / 4294967296.0); + NSString *body = PushyFromStdString(round.Get("body").AsString()); if (body == nil) { - RCTLogWarn(@"RCTPushy -- native sync: request body is not valid UTF-8"); + RCTLogWarn(@"RCTPushy -- native sync: no request to send"); pushyHostRoundResult = PushyHostResult(@"failed", @"invalid_request"); return; } - BOOL httpsOnly = PushyIsHttpsOnly(config.Get("endpoints")); - NSString *responseText = [self runQueryRequest:config - appKey:appKey + NSString *responseText = [self runQueryRequest:round.Get("query") body:body - httpsOnly:httpsOnly deadline:deadlineUptime]; if (responseText == nil) { RCTLogInfo(@"RCTPushy -- native sync: no endpoint reachable, giving up until next launch"); return; } - long long responseAtSeconds = (long long)[[NSDate date] timeIntervalSince1970]; + // Planned attempts, hashInfo and the response cache (anchored to the + // response's arrival) all come from the shared core. + flowjson::Value decision = flowcore::HandleRoundResponse( + PushyToStdString(responseText), config, env, PushyToStdString(body), + (long long)[[NSDate date] timeIntervalSince1970]); + NSString *cache = PushyFromStdString(decision.Get("cache").AsString()); NSDictionary *cancelledByReset = PushyHostResult(@"cancelled", @"reset"); // Commits only the response cache (nothing to install or activate) and // yields `result`, unless a reset or config change since the round @@ -828,17 +742,11 @@ + (void)runConfiguredRound:(const flowjson::Value &)config configGeneration:configGeneration hashInfo:nil activate:nil - responseText:responseText - request:body - config:configJson - responseAt:responseAtSeconds + cache:cache activated:NULL]; return committed ? result : cancelledByReset; }; - flowjson::Value decision = flowcore::HandleResponse( - PushyToStdString(responseText), identity, false, - config.Get("afterDownload").AsString()); if (decision.Get("action").AsString() != "download") { pushyHostRoundResult = commitResponseOnly(PushyHostResult( PushyStatusNone(), PushyFromStdString(decision.Get("reason").AsString()))); @@ -857,7 +765,6 @@ + (void)runConfiguredRound:(const flowjson::Value &)config downloaded = [self performAttempts:decision.Get("attempts") hash:hash originHash:currentVersion - httpsOnly:httpsOnly deadline:deadlineUptime]; } if (!downloaded) { @@ -865,25 +772,14 @@ + (void)runConfiguredRound:(const flowjson::Value &)config 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()) ?: @""; - } - } - // A forceBoot activation is the brick-rescue path: mark it in the - // persisted info so JS can report force_boot_rescue when this version - // survives to markSuccess. Only the server-sent directive counts — a - // silent-strategy activation is ordinary delivery. - if (info.Get("config").Get("forceBoot").Truthy()) { - versionInfo[PushyInfoForceBoot()] = @YES; - } + // The version info to persist alongside it (the JS side's + // setLocalHashInfo, the forceBoot rescue marked by the core). + flowjson::Value info = decision.Get("hashInfo"); if (pushyCrashHoldActive.load()) { - versionInfo[PushyInfoCrashHold()] = @YES; + info.Set(PushyToStdString(PushyInfoCrashHold()), flowjson::Value::Bool(true)); } + NSDictionary *versionInfo = + PushyJsonDictionary(PushyFromStdString(flowjson::Stringify(info)), NULL); // 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). Unless a crash @@ -898,10 +794,7 @@ + (void)runConfiguredRound:(const flowjson::Value &)config configGeneration:configGeneration hashInfo:@{@"hash": hash, @"info": versionInfo} activate:activate ? hash : nil - responseText:responseText - request:body - config:configJson - responseAt:responseAtSeconds + cache:cache activated:&activated]; activate = activated; if (!committed) { @@ -935,19 +828,10 @@ + (BOOL)commitRoundWithGeneration:(uint64_t)generation configGeneration:(uint64_t)configGeneration hashInfo:(NSDictionary *)hashInfoEntry activate:(NSString *)hashToActivate - responseText:(NSString *)responseText - request:(NSString *)requestBody - config:(NSString *)configJson - responseAt:(long long)responseAtSeconds + cache:(NSString *)cache activated:(BOOL *)activatedOut { - // responseText is nil for the crash handler's activation-only commit + // cache is nil for the crash handler's activation-only commit // (activatePendingVersion): no round ran, so there is no cache to write. - NSString *cache = responseText == nil ? nil : PushyJsonString(@{ - @"ts": @(responseAtSeconds), - @"body": responseText, - @"request": requestBody, - @"config": configJson, - }); __block BOOL committed = NO; __block BOOL activated = NO; PushyWithStateLock(^{ @@ -965,7 +849,7 @@ + (BOOL)commitRoundWithGeneration:(uint64_t)generation // crash handler's activation-only one): it may take over this launch // only while +bundleURL still waits for it; after that the version is // left to JS unless a crash is being held (JS is dead then). - if (responseText != nil && pushyPurgeRestoreActive.load()) { + if (cache != nil && pushyPurgeRestoreActive.load()) { if (pushyPurgeRestoreWindowOpen) { if (activation != nil && info != nil) { NSMutableDictionary *marked = [info mutableCopy]; @@ -1005,98 +889,42 @@ static NSTimeInterval PushyQueryRequestTimeout(NSTimeInterval deadlineUptime) { return MIN(10, deadlineUptime - PushyMonotonicNow()); } -// 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 *)runQueryRequest:(const flowjson::Value &)config - appKey:(NSString *)appKey +// Sequential fallback over the endpoints (§5.1), one request at a time with +// its own timeout, as the shared core walks it (flowcore::NextQueryStep: +// order, list discovery, https-only remote bases, the request budget, +// response validity). No hedged race on purpose — this path is +// latency-insensitive. ++ (NSString *)runQueryRequest:(const flowjson::Value &)query body:(NSString *)body - httpsOnly:(BOOL)httpsOnly deadline:(NSTimeInterval)deadlineUptime { - NSMutableSet *tried = [NSMutableSet set]; - const NSUInteger maxHttpAttempts = 8; - __block NSUInteger httpAttempts = 0; - // Spends one attempt of the shared budget. NO once the attempt cap or the - // deadline is exhausted (the round must stop issuing requests); the - // per-request timeout lands in *timeout otherwise. - BOOL (^takeAttempt)(NSTimeInterval *) = ^BOOL(NSTimeInterval *timeout) { - if (httpAttempts++ >= maxHttpAttempts) { - return NO; + flowjson::Value state = query; + NSString *text = nil; + while (true) { + state = flowcore::NextQueryStep(state, text == nil + ? flowjson::Value::Undefined() + : flowjson::Value::String(PushyToStdString(text))); + if (state.Get("found").Truthy()) { + return text; } - *timeout = PushyQueryRequestTimeout(deadlineUptime); - return *timeout > 0; - }; - // Tries each not-yet-tried endpoint base in order and returns the first - // valid response; *stop is set when the budget ran out. A remote list - // (fetched from queryUrls) may not downgrade an https-only config. - NSString *(^queryEndpoints)(const flowjson::Value &, BOOL, BOOL *) = - ^NSString *(const flowjson::Value &endpoints, BOOL remote, BOOL *stop) { - for (const auto &endpoint : endpoints.elements()) { - NSString *base = PushyNormalizeEndpointBase( - PushyFromStdString(endpoint.AsString())); - if (base.length == 0 || [tried containsObject:base]) { - continue; - } - if (remote && httpsOnly && !PushyIsHttpsUrl(base)) { - RCTLogWarn(@"RCTPushy -- native sync: ignoring non-https remote endpoint %@", base); - continue; - } - NSTimeInterval timeout = 0; - if (!takeAttempt(&timeout)) { - *stop = YES; - return nil; - } - [tried addObject:base]; - NSString *response = PushyHttpRequest( - [NSString stringWithFormat:@"%@%@%@", base, RCTPushyQueryPath(), appKey], - @"POST", body, timeout); - if (PushyIsValidResponse(response)) { - return response; - } - } - return nil; - }; - - BOOL stop = NO; - double sample = arc4random() / 4294967296.0; - NSString *response = queryEndpoints( - flowcore::OrderEndpointCandidates(config.Get("endpoints"), sample), NO, &stop); - if (response != nil || stop) { - return response; - } - for (const auto &queryUrl : config.Get("queryUrls").elements()) { - NSString *listUrl = PushyFromStdString(queryUrl.AsString()); - if (listUrl == nil) { - continue; - } - NSTimeInterval listTimeout = 0; - if (!takeAttempt(&listTimeout)) { + NSString *url = PushyFromStdString(state.Get("url").AsString()); + NSTimeInterval timeout = PushyQueryRequestTimeout(deadlineUptime); + if (url == nil || timeout <= 0) { return nil; } - NSString *listText = PushyHttpRequest(listUrl, @"GET", nil, listTimeout); - if (listText == nil) { - continue; - } - bool ok = false; - flowjson::Value remote = flowjson::Parse(PushyToStdString(listText), &ok); - if (!ok || !remote.IsArray()) { - continue; - } - // One successfully fetched remote list is enough. - return queryEndpoints(remote, YES, &stop); + BOOL post = state.Get("post").Truthy(); + text = PushyHttpRequest(url, post ? @"POST" : @"GET", post ? body : nil, timeout); } - return nil; } -// Strategies in order (diff, pdiff, full), each over its mirrors. A failure +// Strategies in order (diff, pdiff, full), each over its mirrors, already +// planned for this device by the core (no diff without a running version, no +// cleartext URL under an https-only config). A failure // that is not mirror-retryable (PATCH_FAILED: the bytes arrived but could not // be applied, and every mirror serves the same bytes) moves on to the next // strategy instead of downloading the same artifact again, like the JS side. + (BOOL)performAttempts:(const flowjson::Value &)attempts hash:(NSString *)hash originHash:(NSString *)originHash - httpsOnly:(BOOL)httpsOnly deadline:(NSTimeInterval)holdDeadline { // Crash-rescue budget caps every phase; 0 keeps the normal 10min windows. NSTimeInterval incrementalDeadline = PushyMonotonicNow() + 600; @@ -1109,9 +937,6 @@ + (BOOL)performAttempts:(const flowjson::Value &)attempts 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. @@ -1126,10 +951,6 @@ + (BOOL)performAttempts:(const flowjson::Value &)attempts if (url == nil) { continue; } - if (httpsOnly && PushyIsCleartextUrl(url)) { - RCTLogWarn(@"RCTPushy -- native sync: ignoring cleartext %s url %@", type.c_str(), url); - continue; - } NSTimeInterval remaining = deadline - PushyMonotonicNow(); if (remaining <= 0) { if (isFullAttempt) { diff --git a/scripts/tests/ios-purge-restore/purge_restore_test.mm b/scripts/tests/ios-purge-restore/purge_restore_test.mm index 35ed28c7..5496d7e8 100644 --- a/scripts/tests/ios-purge-restore/purge_restore_test.mm +++ b/scripts/tests/ios-purge-restore/purge_restore_test.mm @@ -188,10 +188,7 @@ + (BOOL)commitRoundWithGeneration:(uint64_t)generation configGeneration:(uint64_t)configGeneration hashInfo:(NSDictionary *)hashInfo activate:(NSString *)hash - responseText:(NSString *)response - request:(NSString *)request - config:(NSString *)config - responseAt:(long long)responseAt + cache:(NSString *)cache activated:(BOOL *)activated; @end @@ -301,8 +298,7 @@ + (void)startRoundWithDeadline:(NSTimeInterval)deadline { roundCommitted = [self commitRoundWithGeneration:requestGeneration configGeneration:pushyNativeConfigGeneration.load() hashInfo:@{@"hash": @"B", @"info": @{@"name": @"B", @"forceBootRescue": @YES}} - activate:@"B" responseText:@"{\"update\":true,\"hash\":\"B\"}" - request:@"request" config:@"config" responseAt:123 activated:&roundActivated]; + activate:@"B" cache:@"{\"ts\":123,\"body\":\"{}\"}" activated:&roundActivated]; pushyHostRoundResult = @{@"status": @"test", @"reason": @"", @"hash": @"B"}; Signal(commitFinished); actor = Actor::None; diff --git a/src/__tests__/nativeTextEncoding.test.ts b/src/__tests__/nativeTextEncoding.test.ts index 834b2b1f..ef3e97f4 100644 --- a/src/__tests__/nativeTextEncoding.test.ts +++ b/src/__tests__/nativeTextEncoding.test.ts @@ -48,7 +48,7 @@ describe('native text encoding', () => { .filter((path) => path.startsWith(prefix)) .flatMap((path) => encodedIn(source(path))) .map(decodeNativeText); - for (const platform of ['ios/', 'harmony/']) { + for (const platform of ['harmony/']) { expect({ platform, found: decodedIn(platform).includes(plainTexts[0]), From e80703ff5bf224fd5fc570296f4b951b5c186f5d Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 17:24:37 +0800 Subject: [PATCH 094/104] refactor(harmony): run the cold-start round through the shared round core SyncCoordinator passes the config text and a launch env to the new NAPI prepareRound / nextQueryStep / handleResponse (cpp/update_flow_core/ native_round.h) instead of assembling identity, cInfo and the request input, walking the endpoints itself, projecting hashInfo, building the response-cache JSON and filtering attempts. The buildRequestBody / orderEndpointCandidates / isValidResponse exports and Texts.QUERY_PATH are gone; the request path now lives only in the core. harmonySyncQuery checks the glue follows the core's steps (the walk's policy moved to native-round-vectors.json); harmonySyncAttempts and the host-API harness use the new signatures, and nativeTextEncoding expects the request path in cpp/ only. Behaviour aligned with Android/iOS: non-array endpoints still try the list discovery (the round used to stop), a non-string endpoint is skipped (it used to throw and fail the round), a non-string afterDownload never activates (it used to fail the round as invalid_response), non-string queryUrls entries no longer spend a request, and non-string packageVersion/rn overrides are ignored. Co-Authored-By: Claude Opus 5.5 --- harmony/pushy/src/main/cpp/pushy.cpp | 170 ++++----- harmony/pushy/src/main/ets/NativePatchCore.ts | 32 +- harmony/pushy/src/main/ets/SyncCoordinator.ts | 339 ++++-------------- harmony/pushy/src/main/ets/Texts.ts | 3 - src/__tests__/harmonySyncAttempts.test.ts | 5 +- src/__tests__/harmonySyncQuery.test.ts | 156 +++----- src/__tests__/nativeHostApi.test.ts | 3 +- src/__tests__/nativeTextEncoding.test.ts | 30 +- 8 files changed, 232 insertions(+), 506 deletions(-) diff --git a/harmony/pushy/src/main/cpp/pushy.cpp b/harmony/pushy/src/main/cpp/pushy.cpp index ac740ba7..9cf443ad 100644 --- a/harmony/pushy/src/main/cpp/pushy.cpp +++ b/harmony/pushy/src/main/cpp/pushy.cpp @@ -2,7 +2,7 @@ #include "flow_json.h" #include "native_config.h" -#include "update_flow_core.h" +#include "native_round.h" #include #include @@ -1114,111 +1114,98 @@ 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 FlowBuildRequestBody(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, "buildRequestBody: missing input argument"); - return nullptr; - } - bool ok = false; - std::string input_json = GetString(env, args[0], &ok); - if (!ok) { - return nullptr; +// ---- native round (update_flow_core/native_round.h) ---- +// JSON text in and out. Returning undefined (nullptr without a pending +// exception) means the core rejected the input; the ArkTS orchestrator fails +// the round. + +// Reads the first `count` arguments (at most 5) into `args`, and those of +// them flagged in `strings` (bit i = argument i) as strings into `out[i]` +// (null/undefined read as ""). False with an exception pending otherwise. +bool GetFlowArgs(napi_env env, napi_callback_info info, const char* name, + size_t count, unsigned strings, napi_value* args, + std::string* out) { + size_t argc = count; + if (!GetArgCount(env, info, &argc, args) || argc < count) { + ThrowError(env, std::string(name) + ": missing arguments"); + return false; } - bool parsed = false; - flowjson::Value input = flowjson::Parse(input_json, &parsed); - if (!parsed || !input.IsObject()) { - return nullptr; + for (size_t i = 0; i < count; i++) { + bool ok = true; + if ((strings >> i) & 1u) { + out[i] = GetString(env, args[i], &ok); + } + if (!ok) { + return false; + } } - return NewString( - env, flowjson::Stringify(flowcore::BuildRequestBody(input))); + return true; } -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; +bool GetFlowNumber(napi_env env, napi_value value, const char* name, + double* out) { + if (napi_get_value_double(env, value, out) != napi_ok) { + ThrowError(env, std::string(name) + ": expected number"); + return false; } - return NewString(env, - flowjson::Stringify(flowcore::OrderEndpointCandidates( - endpoints, sample))); + return true; } -static napi_value FlowIsValidResponse(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, "isValidResponse: missing arguments"); - return nullptr; - } +// The parsed object, or Undefined (which the core rejects). +flowjson::Value ParseFlowObject(const std::string& json) { bool ok = false; - std::string response_text = GetString(env, args[0], &ok); - if (!ok) { - // Exception pending (non-string argument): propagate it as a throw - // instead of a boolean that the caller would treat as a verdict. - return nullptr; - } - napi_value result = nullptr; - napi_get_boolean(env, flowcore::IsValidResponse(response_text), - &result); - return result; + flowjson::Value value = flowjson::Parse(json, &ok); + return ok && value.IsObject() ? value : flowjson::Value::Undefined(); } -static napi_value FlowHandleResponse(napi_env env, - napi_callback_info info) { - size_t argc = 3; +napi_value FlowJsonOrUndefined(napi_env env, const flowjson::Value& value) { + return value.IsUndefined() ? nullptr + : NewString(env, flowjson::Stringify(value)); +} + +// prepareRound(configJson, envJson, randomSample) +static napi_value FlowPrepareRound(napi_env env, napi_callback_info info) { napi_value args[3] = {nullptr, nullptr, nullptr}; - if (!GetArgCount(env, info, &argc, args) || argc < 3) { - ThrowError(env, "handleResponse: 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) { + std::string text[3]; + double sample = 0; + if (!GetFlowArgs(env, info, "prepareRound", 3, 0b011, args, text) || + !GetFlowNumber(env, args[2], "prepareRound", &sample)) { return nullptr; } - std::string after_download = GetString(env, args[2], &ok); - if (!ok) { + return FlowJsonOrUndefined( + env, flowcore::PrepareRound(text[0], ParseFlowObject(text[1]), sample)); +} + +// nextQueryStep(stateJson, responseText?) — responseText undefined/null for +// a failed (or no) request. +static napi_value FlowNextQueryStep(napi_env env, napi_callback_info info) { + napi_value args[2] = {nullptr, nullptr}; + std::string text[2]; + if (!GetFlowArgs(env, info, "nextQueryStep", 2, 0b11, args, text)) { return nullptr; } - bool parsed = false; - flowjson::Value identity = flowjson::Parse(identity_json, &parsed); - if (!parsed || !identity.IsObject()) { + return FlowJsonOrUndefined( + env, flowcore::NextQueryStep( + ParseFlowObject(text[0]), + IsNullOrUndefined(env, args[1]) + ? flowjson::Value::Undefined() + : flowjson::Value::String(std::move(text[1])))); +} + +// handleResponse(responseText, configJson, envJson, requestBody, +// responseAtSeconds) +static napi_value FlowHandleResponse(napi_env env, napi_callback_info info) { + napi_value args[5] = {nullptr, nullptr, nullptr, nullptr, nullptr}; + std::string text[5]; + double responseAt = 0; + if (!GetFlowArgs(env, info, "handleResponse", 5, 0b01111, args, text) || + !GetFlowNumber(env, args[4], "handleResponse", &responseAt)) { return nullptr; } - return NewString(env, - flowjson::Stringify(flowcore::HandleResponse( - response_text, identity, false, after_download))); + return FlowJsonOrUndefined( + env, flowcore::HandleRoundResponse(text[0], text[1], + ParseFlowObject(text[2]), text[3], + responseAt)); } // ---- persisted native config (update_flow_core/native_config.h) ---- @@ -1299,9 +1286,8 @@ napi_value Init(napi_env env, napi_value exports) { !ExportFunction(env, exports, "checkActivation", CheckActivation) || !ExportFunction(env, exports, "crc32", Crc32) || !ExportFunction(env, exports, "getSupportedDiffVersion", GetSupportedDiffVersion) || - !ExportFunction(env, exports, "buildRequestBody", FlowBuildRequestBody) || - !ExportFunction(env, exports, "orderEndpointCandidates", FlowOrderEndpointCandidates) || - !ExportFunction(env, exports, "isValidResponse", FlowIsValidResponse) || + !ExportFunction(env, exports, "prepareRound", FlowPrepareRound) || + !ExportFunction(env, exports, "nextQueryStep", FlowNextQueryStep) || !ExportFunction(env, exports, "handleResponse", FlowHandleResponse) || !ExportFunction(env, exports, "configsEquivalent", FlowConfigsEquivalent) || !ExportFunction(env, exports, "runnableConfig", FlowRunnableConfig) || diff --git a/harmony/pushy/src/main/ets/NativePatchCore.ts b/harmony/pushy/src/main/ets/NativePatchCore.ts index f649ca61..adbd43cd 100644 --- a/harmony/pushy/src/main/ets/NativePatchCore.ts +++ b/harmony/pushy/src/main/ets/NativePatchCore.ts @@ -118,22 +118,32 @@ interface NativePatchCoreBindings { /** 原生 patch 内核可消费的 diff 轨道版本(2 = hdiffv2 轨道) */ getSupportedDiffVersion(): number; - // 更新流程决策层(cpp/update_flow_core,NATIVE_CHECKUPDATE_DESIGN §10): - // JSON 字符串进出,与决策层自身的边界一致。返回 undefined = 输入未通过 - // 解析,编排器跳过本轮检测。 - buildRequestBody(inputJson: string): string | undefined; - orderEndpointCandidates( - endpointsJson: string, + // 原生冷启动轮次(cpp/update_flow_core/native_round.h,与 Android/iOS 共用): + // JSON 字符串进出。返回 undefined = 内核拒收了输入,本轮失败。 + // envJson 描述本次启动(字段见 native_round.h)。 + /** {"body": 请求体文本, "query": 首个查询状态};配置不可运行时 undefined */ + prepareRound( + configJson: string, + envJson: string, randomSample: number, ): string | undefined; + /** + * 下一步查询状态:带 "url"(及 "post")= 发这个请求后再调;"found" = 刚传入 + * 的文本就是有效响应;都没有 = 查询结束。responseText 为 undefined 表示请求 + * 失败或尚未发出。 + */ + nextQueryStep( + stateJson: string, + responseText: string | undefined, + ): string | undefined; + /** 决策(已按本机规划的 attempts)、要持久化的 hashInfo 与响应缓存文本 */ handleResponse( responseText: string, - identityJson: string, - afterDownload: string, + configJson: string, + envJson: string, + requestBody: string, + responseAtSeconds: number, ): string | undefined; - // 响应 schema 门槛(update_flow_core::IsValidResponse):200 但只带 - // `{"error":...}` 的节点算失败,不得阻止继续 fallback。 - isValidResponse(responseText: string): boolean; // 持久化的原生配置(cpp/update_flow_core/native_config.h,与 Android/iOS // 共用)。undefined 视同空串:缺省配置。 diff --git a/harmony/pushy/src/main/ets/SyncCoordinator.ts b/harmony/pushy/src/main/ets/SyncCoordinator.ts index a0678866..2034bf65 100644 --- a/harmony/pushy/src/main/ets/SyncCoordinator.ts +++ b/harmony/pushy/src/main/ets/SyncCoordinator.ts @@ -6,7 +6,7 @@ import type { UpdateContext } from './UpdateContext'; import { BundlePreparationRound, bundlePreparationResult } from './BundlePreparationResult'; import type { BundlePreparationResult } from './BundlePreparationResult'; import { isSafePathComponent } from './PathUtils'; -import { QUERY_PATH, STATUS_NONE } from './Texts'; +import { STATUS_NONE } from './Texts'; import { monotonicNowMs } from './MonotonicClock'; import { ERROR_DELTA_FAILED, @@ -40,38 +40,26 @@ 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 { +// 本次启动的事实,交给共享内核(cpp/update_flow_core/native_round.h)。 +interface RoundEnv { packageVersion: string; - currentVersion?: string; + currentVersion: string; uuid: string; - rolledBackVersion?: string; + rolledBackVersion: string; + buildTime: string; + os: string; + bundleHash: string; } -interface FlowCInfo { - rnu: string; - rn: string; - os: string; - uuid: string; +interface PreparedRound { + body: string; + query: Object; } -interface FlowInput { - packageVersion: string; - currentVersion?: string; - buildTime: string; - cInfo: FlowCInfo; - supportedDiffVersion: number; - bundleHash: string; +interface QueryStep { + url?: string; + post?: boolean; + found?: boolean; } interface DecisionAttempt { @@ -79,35 +67,16 @@ interface DecisionAttempt { urls?: string[]; } -interface DecisionInfoConfig { - forceBoot?: boolean; -} - -interface DecisionInfo { - name?: string; - description?: string; - metaInfo?: string; - // 服务端按版本下发的配置(forceBoot = 救砖指令)。 - config?: DecisionInfoConfig; - // 写进 hash 信息:这个版本是被救援通道送进来的,JS 在 markSuccess 时据此 - // 上报 forceBootRescue 回执(与 Android/iOS 一致)。 - forceBootRescue?: boolean; -} - interface Decision { action?: string; reason?: string; hash?: string; attempts?: DecisionAttempt[]; activate?: boolean; - info?: DecisionInfo; -} - -interface RespCacheEntry { - ts: number; - body: string; - request: string; - config: string; + // 要与版本一起持久化的元信息(JS 侧 setLocalHashInfo;forceBootRescue 由内核标记)。 + hashInfo?: Object; + // 供 JS 复用的响应缓存文本(§10.3)。 + cache?: string; } let scheduled = false; @@ -141,9 +110,6 @@ function startNativeRound( interface ConfigRead { json: string; - config: NativeConfig; - /** Trimmed; the same value gates the round and builds the request URL. */ - appKey: string; /** Set when the persisted configuration cannot start a round. */ rejection?: BundlePreparationResult; } @@ -153,7 +119,6 @@ interface ConfigRead { // The verdict is the shared core's (cpp/update_flow_core ParseRunnableConfig, // also used by Android and iOS). interface RunnableVerdict { - appKey?: string; status?: string; reason?: string; } @@ -164,12 +129,10 @@ function readNativeConfig(context: UpdateContext): ConfigRead { if (verdict.status !== undefined) { return { json, - config: {}, - appKey: '', rejection: bundlePreparationResult(verdict.status, verdict.reason ?? ''), }; } - return { json, config: JSON.parse(json) as NativeConfig, appKey: verdict.appKey ?? '' }; + return { json }; } // A preflight skip must not consume the process's only round: configuration @@ -261,8 +224,6 @@ async function runOnce( return; } const configJson = read.json; - const config = read.config; - const appKey = read.appKey; // 从这里起本轮开始做真实工作:留下面包屑,死于轮中时下次启动零延迟续传。 logger.info( TAG, @@ -280,8 +241,6 @@ async function runOnce( launchRolledBackVersion, resetGeneration, configJson, - config, - appKey, ); } finally { try { @@ -293,87 +252,56 @@ async function runOnce( } } -// 配置端点全为 https 时,网络下发的端点列表/制品 URL 一律拒绝明文 http: -// @ohos.net.http 内部跟随重定向且不暴露最终 URL,这是鸿蒙侧唯一能做的 -// https→http 降级防线(SR-1);其余依赖宿主 network_config.json 的 -// cleartextTrafficPermitted:false(见 harmony/pushy/src/README.md)。 -function isHttpsOnly(config: NativeConfig): boolean { - const endpoints = config.endpoints ?? []; - return ( - endpoints.length > 0 && - endpoints.every(endpoint => isHttpsUrl(endpoint)) - ); -} - -function isHttpsUrl(url: string): boolean { - return typeof url === 'string' && url.toLowerCase().startsWith('https://'); -} - -function isCleartextUrl(url: string): boolean { - return typeof url === 'string' && url.toLowerCase().startsWith('http://'); -} - async function runConfiguredRound( context: UpdateContext, launchRolledBackVersion: string, resetGeneration: number, configJson: string, - config: NativeConfig, - appKey: string, ): Promise { 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, + const env: RoundEnv = { + packageVersion: context.getPackageVersion(), + // 与 JS 常量同形(无版本时为 ''),JS 才能复用这次请求的响应缓存。 currentVersion, - uuid, - }; - if (rolledBackVersion) { - identity.rolledBackVersion = rolledBackVersion; - } - - const cInfo: FlowCInfo = { - rnu: config.rnu ?? '', - rn: config.rn ?? '', + uuid: context.getKv('uuid') ?? '', + // getConstants consumes the persisted rollback marker during startup; use + // the launch-path snapshot captured before that happens. + rolledBackVersion: launchRolledBackVersion, + buildTime: context.getBuildTime(), // 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: FlowInput = { - packageVersion: identity.packageVersion, - currentVersion, - buildTime: context.getBuildTime(), - cInfo, - supportedDiffVersion: NativePatchCore.getSupportedDiffVersion(), bundleHash: await context.getBundleHash(), }; - const body = NativePatchCore.buildRequestBody(JSON.stringify(input)); - if (!body) { + const envJson = JSON.stringify(env); + const preparedJson = NativePatchCore.prepareRound(configJson, envJson, Math.random()); + if (!preparedJson) { roundResult = bundlePreparationResult('failed', 'invalid_request'); return; } - - const httpsOnly = isHttpsOnly(config); - const responseText = await runQueryRequest(config, appKey, body, httpsOnly); + const prepared = JSON.parse(preparedJson) as PreparedRound; + const responseText = await runQueryRequest( + JSON.stringify(prepared.query), + prepared.body, + ); if (!responseText) { logger.warn(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 responseCacheJson = buildResponseCacheJson( - configJson, - body, + // 规划好的 attempts、hashInfo 与响应缓存(时间戳取响应到达时)都来自共享内核。 + const decisionJson = NativePatchCore.handleResponse( responseText, - responseAtSeconds, + configJson, + envJson, + prepared.body, + Math.floor(Date.now() / 1000), ); + if (!decisionJson) { + roundResult = bundlePreparationResult('failed', 'invalid_response'); + return; + } + const decision = JSON.parse(decisionJson) as Decision; + const responseCacheJson = decision.cache ?? ''; // Commits the round's persistent outcome (see commitSyncResult) and maps // it to the round result; a reset during the round wins over `result`. const commit = async ( @@ -395,16 +323,6 @@ async function runConfiguredRound( return committed; }; - const decisionJson = NativePatchCore.handleResponse( - responseText, - JSON.stringify(identity), - config.afterDownload ?? '', - ); - if (!decisionJson) { - roundResult = bundlePreparationResult('failed', 'invalid_response'); - return; - } - const decision = JSON.parse(decisionJson) as Decision; if (decision.action !== 'download') { await commit( '', @@ -432,7 +350,6 @@ async function runConfiguredRound( decision.attempts ?? [], hash, currentVersion, - httpsOnly, ); } if (!downloaded) { @@ -441,28 +358,7 @@ async function runConfiguredRound( 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; - } - // forceBoot 激活就是救砖路径:记进持久化元信息,这个版本活到 markSuccess - // 时 JS 上报 forceBootRescue。只认服务端下发的指令——静默策略的激活是 - // 普通投递(Android/iOS 同一规则)。 - if (info.config && info.config.forceBoot) { - hashInfo.forceBootRescue = true; - } - hashInfoJson = JSON.stringify(hashInfo); - } + const hashInfoJson = JSON.stringify(decision.hashInfo); // 版本元信息、激活与响应缓存一次性原子提交(见 commitSyncResult); // 缓存只在原生文件/状态工作结束后公开,避免 JS 观察到响应后并发下载。 @@ -490,32 +386,6 @@ async function runConfiguredRound( } } -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 isValidResponse(responseText: string | undefined): boolean { - if (responseText === undefined) { - return false; - } - try { - return NativePatchCore.isValidResponse(responseText); - } catch (e) { - return false; - } -} - async function httpRequest( url: string, postBody?: string, @@ -561,98 +431,32 @@ async function httpRequest( } } -// 顺序回退(§5.1):按纯层给出的候选序逐个请求,单请求超时;整轮失败后经 -// queryUrls 发现远程候选(排除已试过的)再来一轮。刻意不做 hedged race—— -// 该路径对延迟不敏感。 +// 顺序回退(§5.1):按共享内核给出的下一步逐个请求,单请求超时 +// (NativePatchCore.nextQueryStep:候选序、queryUrls 发现、远程端点的 https +// 门槛、请求预算、响应 schema)。刻意不做 hedged race——该路径对延迟不敏感。 async function runQueryRequest( - config: NativeConfig, - appKey: string, + query: string, body: string, - httpsOnly: boolean, ): 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; - // Claims one HTTP request from the round's budget (endpoint queries and - // remote list fetches alike); false once it is spent. - const claimAttempt = (): boolean => httpAttempts++ < MAX_QUERY_HTTP_ATTEMPTS; - // Queries one endpoint base unless it is empty, already tried or (for - // remotely injected bases) a cleartext downgrade. Resolves to the valid - // response, to null once the budget is spent (stop), or to undefined - // (continue with the next base). - const tryBase = async ( - rawBase: string, - rejectCleartext: boolean, - ): Promise => { - const base = normalizeEndpointBase(rawBase); - if (!base || tried.has(base)) { - return undefined; - } - if (rejectCleartext && !isHttpsUrl(base)) { - // 远程注入的端点不得把 https 配置降级成明文。 - logger.warn(TAG, `ignoring non-https remote endpoint ${base}`); - return undefined; - } - if (!claimAttempt()) { - return null; - } - tried.add(base); - const response = await httpRequest(`${base}${QUERY_PATH}${appKey}`, body); - return isValidResponse(response) ? response : undefined; - }; - for (const rawBase of ordered) { - const response = await tryBase(rawBase, false); - if (response !== undefined) { - return response ?? undefined; - } - } - for (const listUrl of config.queryUrls ?? []) { - if (!listUrl) { - continue; - } - if (!claimAttempt()) { - return undefined; + let state: string | undefined = query; + let text: string | undefined = undefined; + while (state !== undefined) { + state = NativePatchCore.nextQueryStep(state, text); + if (state === undefined) { + break; } - const listText = await httpRequest(listUrl); - if (listText === undefined) { - continue; + const step = JSON.parse(state) as QueryStep; + if (step.found) { + return text; } - 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 response = await tryBase(rawBase, httpsOnly); - if (response !== undefined) { - return response ?? undefined; - } + if (!step.url) { + break; } - // 拉到一份可解析的远程列表就够了。 - break; + text = await httpRequest(step.url, step.post ? body : undefined); } return undefined; } -function normalizeEndpointBase(base: string): string { - return base.replace(/\/+$/, ''); -} - async function runWithinDeadline( start: () => Promise, deadlineUptimeMs: number, @@ -690,16 +494,11 @@ async function performAttempts( attempts: DecisionAttempt[], hash: string, originHash: string, - httpsOnly: boolean, ): 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) { @@ -709,14 +508,6 @@ async function performAttempts( } const deadline = isFullAttempt ? fullDeadline : incrementalDeadline; for (const url of attempt.urls ?? []) { - if (!url) { - continue; - } - if (httpsOnly && isCleartextUrl(url)) { - // 决策里的明文制品 URL 不得把 https 配置降级(SR-1)。 - logger.warn(TAG, `ignoring cleartext ${type} url ${url}`); - continue; - } if (monotonicNowMs() >= deadline) { if (isFullAttempt) { return false; diff --git a/harmony/pushy/src/main/ets/Texts.ts b/harmony/pushy/src/main/ets/Texts.ts index 9cb90f62..4acc0574 100644 --- a/harmony/pushy/src/main/ets/Texts.ts +++ b/harmony/pushy/src/main/ets/Texts.ts @@ -13,9 +13,6 @@ export function revealText(hex: string): string { return text; } -// Request path appended to an endpoint base. -export const QUERY_PATH: string = revealText('7514fcd4ad805d55263e08fc99'); - // Protocol values, version-info flags and storage names, stored encoded. export const STATUS_NONE: string = revealText('3418c1c1aa8a7c40'); export const STORAGE_DIR_NAME: string = revealText('0502e4d5af9f6d'); diff --git a/src/__tests__/harmonySyncAttempts.test.ts b/src/__tests__/harmonySyncAttempts.test.ts index b0e9383b..dcf2dce9 100644 --- a/src/__tests__/harmonySyncAttempts.test.ts +++ b/src/__tests__/harmonySyncAttempts.test.ts @@ -55,13 +55,12 @@ function harness(failures: Map) { ctx: typeof context, attempts: Attempt[], hash: string, - originHash: string, - httpsOnly: boolean + originHash: string ) => Promise; return { calls, run: (attempts: Attempt[], originHash = 'origin') => - performAttempts(context, attempts, 'next', originHash, false), + performAttempts(context, attempts, 'next', originHash), }; } diff --git a/src/__tests__/harmonySyncQuery.test.ts b/src/__tests__/harmonySyncQuery.test.ts index 02f87044..686a031c 100644 --- a/src/__tests__/harmonySyncQuery.test.ts +++ b/src/__tests__/harmonySyncQuery.test.ts @@ -1,137 +1,97 @@ import { describe, expect, test } from 'bun:test'; import { evalHarmonyModule } from './nativeSources'; -// The endpoint fallback of the Harmony cold-start orchestrator +// The endpoint walk of the Harmony cold-start orchestrator // (SyncCoordinator.runQueryRequest), evaluated from source with its platform -// imports stripped and HTTP replaced by a scripted fake. +// imports stripped and HTTP replaced by a scripted fake. The walk's policy — +// order, list discovery, https-only remote bases, the request budget, +// response validity — is the shared core's NextQueryStep, pinned by +// cpp/update_flow_core/tests/native-round-vectors.json; this checks that the +// glue follows the core's steps. -const VALID = '{"ok":true}'; +const VALID = '{"upToDate":true}'; -interface Config { - endpoints?: string[]; - queryUrls?: string[]; +interface Step { + url?: string; + post?: boolean; + found?: boolean; } function run( - config: Config, - responses: Record, - httpsOnly = false + steps: Array, + responses: Record ) { - const requests: string[] = []; + const requests: Array<[string, string | undefined]> = []; + const calls: Array<[string, string | undefined]> = []; + let next = 0; const runQueryRequest = evalHarmonyModule( 'SyncCoordinator.ts', 'httpRequest = fakeHttp;\nrunQueryRequest;', { - QUERY_PATH: '/q/', logger: { info() {}, warn() {}, error() {} }, BundlePreparationRound: class {}, bundlePreparationResult: () => ({}), NativePatchCore: { - // Keep the configured order: ordering is the C++ core's concern. - orderEndpointCandidates: (json: string) => json, - isValidResponse: (text: string) => text === VALID, + nextQueryStep: (state: string, text: string | undefined) => { + calls.push([state, text]); + const step = steps[next++]; + return step === undefined + ? undefined + : JSON.stringify({ ...step, n: next }); + }, }, - fakeHttp: async (url: string) => { - requests.push(url); + fakeHttp: async (url: string, body?: string) => { + requests.push([url, body]); return responses[url]; }, } - ) as ( - config: Config, - appKey: string, - body: string, - httpsOnly: boolean - ) => Promise; - return runQueryRequest(config, 'key', '{}', httpsOnly).then((result) => ({ + ) as (query: string, body: string) => Promise; + return runQueryRequest('{"n":0}', 'BODY').then((result) => ({ result, requests, + calls, })); } -describe('harmony query endpoint fallback', () => { - test('falls back through configured endpoints, deduped by normalized base', async () => { - const { result, requests } = await run( - { endpoints: ['https://a/', 'https://a', '', 'https://b'] }, - { 'https://b/q/key': VALID } - ); - expect(result).toBe(VALID); - expect(requests).toEqual(['https://a/q/key', 'https://b/q/key']); - }); - - test('an invalid response does not stop the fallback', async () => { - const { result } = await run( - { endpoints: ['https://a', 'https://b'] }, - { 'https://a/q/key': '{"error":"x"}', 'https://b/q/key': VALID } - ); - expect(result).toBe(VALID); - }); - - test('discovers remote endpoints, skipping tried, non-string and cleartext ones', async () => { - const { result, requests } = await run( - { - endpoints: ['https://a'], - queryUrls: ['', 'https://list'], - }, - { - 'https://list': JSON.stringify([ - 'https://a/', - 42, - 'http://plain', - 'https://c', - ]), - 'https://c/q/key': VALID, - }, - true +describe('harmony query endpoint walk', () => { + test('issues each step, passes its text back and returns the found one', async () => { + const { result, requests, calls } = await run( + [ + { url: 'https://a/q', post: true }, + { url: 'https://l', post: false }, + { url: 'https://c/q', post: true }, + { found: true }, + ], + { 'https://l': '["https://c"]', 'https://c/q': VALID } ); expect(result).toBe(VALID); expect(requests).toEqual([ - 'https://a/q/key', - 'https://list', - 'https://c/q/key', + ['https://a/q', 'BODY'], + ['https://l', undefined], + ['https://c/q', 'BODY'], + ]); + // Each call carries the previous state and the last request's text. + expect(calls.map(([state]) => JSON.parse(state).n)).toEqual([0, 1, 2, 3]); + expect(calls.map(([, text]) => text)).toEqual([ + undefined, + undefined, + '["https://c"]', + VALID, ]); }); - test('cleartext remote endpoints are allowed without an https-only config', async () => { - const { result } = await run( - { endpoints: ['http://a'], queryUrls: ['https://list'] }, - { - 'https://list': JSON.stringify(['http://plain']), - 'http://plain/q/key': VALID, - } - ); - expect(result).toBe(VALID); - }); - - test('only the first parsable remote list is used', async () => { - const { requests } = await run( - { endpoints: [], queryUrls: ['https://bad', 'https://l1', 'https://l2'] }, - { - 'https://bad': '{"not":"array"}', - 'https://l1': JSON.stringify(['https://x']), - 'https://l2': JSON.stringify(['https://y']), - } + test('a step without a url ends the walk with no response', async () => { + const { result, requests } = await run( + [{ url: 'https://a/q', post: true }, {}], + { 'https://a/q': '{"error":"x"}' } ); - expect(requests).toEqual(['https://bad', 'https://l1', 'https://x/q/key']); + expect(result).toBeUndefined(); + expect(requests).toEqual([['https://a/q', 'BODY']]); }); - test('stops after 8 HTTP requests, list fetches included', async () => { - const endpoints = Array.from({ length: 6 }, (_, i) => `https://e${i}`); - const { result, requests } = await run( - { endpoints, queryUrls: ['https://list'] }, - { - 'https://list': JSON.stringify([ - 'https://r0', - 'https://r1', - 'https://r2', - ]), - 'https://r2/q/key': VALID, - } - ); + test('a state the core rejects ends the walk', async () => { + const { result, requests } = await run([undefined], {}); expect(result).toBeUndefined(); - expect(requests).toEqual([ - ...endpoints.map((e) => `${e}/q/key`), - 'https://list', - 'https://r0/q/key', - ]); + expect(requests).toEqual([]); }); }); diff --git a/src/__tests__/nativeHostApi.test.ts b/src/__tests__/nativeHostApi.test.ts index fea3b554..3fdfa6a5 100644 --- a/src/__tests__/nativeHostApi.test.ts +++ b/src/__tests__/nativeHostApi.test.ts @@ -72,8 +72,7 @@ function harness() { STATUS_NONE, NativePatchCore: { ...harmonyConfigBindings, - getSupportedDiffVersion: () => 2, - buildRequestBody: (input: string) => input, + prepareRound: () => JSON.stringify({ body: '{}', query: {} }), handleResponse: (response: string) => response, }, mockCheck: async () => { diff --git a/src/__tests__/nativeTextEncoding.test.ts b/src/__tests__/nativeTextEncoding.test.ts index ef3e97f4..6fbed836 100644 --- a/src/__tests__/nativeTextEncoding.test.ts +++ b/src/__tests__/nativeTextEncoding.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { QUERY_PATH, revealText } from '../../harmony/pushy/src/main/ets/Texts'; +import { revealText } from '../../harmony/pushy/src/main/ets/Texts'; import { decodeNativeText, encodeNativeText, @@ -39,25 +39,13 @@ describe('native text encoding', () => { }); // The request path is appended by the shared round core - // (cpp/update_flow_core/native_round.cpp) and, until they move to it, by - // the platforms' own HTTP glue; the default service addresses only live in - // the shared normalizer (cpp/update_flow_core/native_config.cpp). + // (cpp/update_flow_core/native_round.cpp); the default service addresses + // only live in the shared normalizer (cpp/update_flow_core/native_config.cpp). test('the request path and the addresses are encoded in cpp/', () => { - const decodedIn = (prefix: string) => - paths - .filter((path) => path.startsWith(prefix)) - .flatMap((path) => encodedIn(source(path))) - .map(decodeNativeText); - for (const platform of ['harmony/']) { - expect({ - platform, - found: decodedIn(platform).includes(plainTexts[0]), - }).toEqual({ - platform, - found: true, - }); - } - const shared = decodedIn('cpp/'); + const shared = paths + .filter((path) => path.startsWith('cpp/')) + .flatMap((path) => encodedIn(source(path))) + .map(decodeNativeText); for (const text of plainTexts) { expect({ text, found: shared.includes(text) }).toEqual({ text, @@ -76,10 +64,6 @@ describe('native text encoding', () => { expect(offenders).toEqual([]); }); - test('Harmony resolves the path at runtime', () => { - expect(QUERY_PATH).toBe('/checkUpdate/'); - }); - // Every string literal in shipped native code, comments excluded: none may // carry update/patch/rescue/reload wording except the entries below, which // are fixed by a contract that cannot change without breaking callers. From a4e65b61b9823ef84946f01406e8a5ce62e6ec75 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 17:24:55 +0800 Subject: [PATCH 095/104] docs(audit): the https-only filter and round helpers live in the shared core Co-Authored-By: Claude Opus 5.5 --- CODE_AUDIT.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CODE_AUDIT.md b/CODE_AUDIT.md index 0af458d3..352dd1a0 100644 --- a/CODE_AUDIT.md +++ b/CODE_AUDIT.md @@ -163,7 +163,7 @@ Android 已兜住:`Application.mk` 改 `-fexceptions`,每个 JNI 入口经 ` ### 仍开放 **5.7(P2)https→http 降级**(部分修复) -已做:配置端点全为 https 时 `SyncCoordinator.ts` 丢弃非 https 远程端点与 `http:` 产物 URL;`harmony/pushy/src/README.md` 要求消费者 `network_config.json` 设 `cleartextTrafficPermitted:false`。 +已做:配置端点全为 https 时共享轮次内核(`cpp/update_flow_core/native_round.cpp`,三端共用)丢弃非 https 远程端点与 `http:` 产物 URL;`harmony/pushy/src/README.md` 要求消费者 `network_config.json` 设 `cleartextTrafficPermitted:false`。 剩余:`@ohos.net.http` 内部跟随重定向且不暴露最终 URL,https→http 的重定向降级仍可能发生;可接受 HMS-only 的话 RCP(`autoRedirect=false` + effective URL)能完全关闭。 **5.8(P2)鸿蒙 CI 门**(部分修复) @@ -171,8 +171,8 @@ Android 已兜住:`Application.mk` 改 `-fexceptions`,每个 JNI 入口经 ` 剩余:`e2e_harmony.yml` 仅手动,不在门内(无托管鸿蒙 runner)。 **5.15(P3)测试**(部分修复) -已覆盖:ContentRange、InstallRecord、ErrorCodes、ArchiveLimits、NativeConfig、EventHub;C++ 头文件 ↔ ArkTS/Java/JS 镜像的常量对等由 `scripts/check-constant-parity.js`(`bun lint`)断言。 -未覆盖:`readString/readBoolean`、`normalizeEndpointBase`、`buildResponseCacheJson`、flush-batch 深度语义。 +已覆盖:ContentRange、InstallRecord、ErrorCodes、ArchiveLimits、NativeConfig、EventHub、原生轮次(请求体、端点查询、响应缓存:`native-round-vectors.json`);C++ 头文件 ↔ ArkTS/Java/JS 镜像的常量对等由 `scripts/check-constant-parity.js`(`bun lint`)断言。 +未覆盖:`readString/readBoolean`、flush-batch 深度语义。 **§5 P4 余项** - `transferArchive`(`DownloadTask.ts`)仍约 470 行、大量闭包捕获的可变局部,未拆。 From 07717fe7969469b72f3400403fa86ceac431783c Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 17:34:15 +0800 Subject: [PATCH 096/104] feat(patch-core): one archive extraction accounting in archive_limits.cpp cpp/patch_core/archive_limits.h was a header of constants with the rules kept three times (SafeZipFile.inspect, PushyUnzipGuard, ArchiveLimits.ts). It now carries them: - EntryAccumulator(archiveBytes).Accept(size, compressed, symlink, name): entry count, reserved name, symlink, per-entry cap, running total and whole-archive ratio, per-entry ratio; AcceptExtracted for an entry already on disk whose compressed size was never seen (no per-entry ratio); - CheckEntryCount, CheckUncompressedSize(archiveBytes, uncompressedBytes) and IsReservedEntryName (install_record::kReservedPrefix, new); - CheckExtractedDirectory: an iterative lstat walk of a freshly unpacked tree through the accumulator (Harmony, whose zlib exposes no entries); - PlanUnknownLengthFreeSpaceProbe: the chunked-download disk probe plan all three platforms implemented identically. Where the platforms disagreed, the strictest reading wins: - the ratio is size > compressed * 100 (Android/iOS divided, so up to 101:1 minus one byte passed), and a zero compressed size past the 1 MiB threshold is an unbounded ratio (Android/iOS skipped the check); - the whole-archive ratio (Harmony only) now applies on every platform; - a reserved `.pushy-` prefix is refused in any path component (Android checked the base name, iOS the last component of each unpacked path); - symlinks are refused everywhere (iOS only; Harmony's walk followed them); - an entry of unknown (negative) size is refused (Android skipped it); - a non-positive archive size bounds nothing (Harmony skipped the ratio). tests/archive-limits-vectors.json pins the accumulator, the whole-archive check, the probe plan and the reserved names (ported from the Harmony bun cases and SafeZipFileTest, with `was` notes); the C++ test also walks real trees (reserved directory, symlink, 20000/20001 entries). Co-Authored-By: Claude Opus 5.5 --- cpp/patch_core/archive_limits.cpp | 189 ++++++++ cpp/patch_core/archive_limits.h | 110 ++++- cpp/patch_core/install_record.h | 4 + .../tests/archive-limits-vectors.json | 418 ++++++++++++++++++ cpp/patch_core/tests/patch_core_test.cpp | 151 +++++++ 5 files changed, 861 insertions(+), 11 deletions(-) create mode 100644 cpp/patch_core/archive_limits.cpp create mode 100644 cpp/patch_core/tests/archive-limits-vectors.json diff --git a/cpp/patch_core/archive_limits.cpp b/cpp/patch_core/archive_limits.cpp new file mode 100644 index 00000000..ff98ac3e --- /dev/null +++ b/cpp/patch_core/archive_limits.cpp @@ -0,0 +1,189 @@ +#include "archive_limits.h" + +#include +#include + +#include +#include +#include +#include + +#include "install_record.h" + +namespace pushy { +namespace archive_limits { +namespace { + +// expanded > compressed * kMaxCompressionRatio, past kRatioCheckMinBytes. +// A non-positive compressed size bounds nothing. Exact (no integer division +// rounding the ratio down) and overflow-free. +bool ExceedsRatio(long long expanded, long long compressed) { + if (expanded <= kRatioCheckMinBytes) { + return false; + } + if (compressed <= 0) { + return true; + } + if (compressed > std::numeric_limits::max() / kMaxCompressionRatio) { + return false; + } + return expanded > compressed * kMaxCompressionRatio; +} + +// The entry for messages: its name, or its position when the host has none +// yet. +std::string Label(const std::string& name, long long index) { + return name.empty() ? "#" + std::to_string(index) : name; +} + +} // namespace + +bool IsReservedEntryName(const std::string& name) { + const std::string prefix = install_record::kReservedPrefix; + size_t start = 0; + while (start <= name.size()) { + size_t end = name.find_first_of("/\\", start); + if (end == std::string::npos) { + end = name.size(); + } + if (name.compare(start, prefix.size(), prefix) == 0 && + end - start >= prefix.size()) { + return true; + } + start = end + 1; + } + return false; +} + +Status CheckEntryCount(long long entries) { + if (entries > kMaxEntries) { + return Status::Error("archive has too many entries (> " + + std::to_string(kMaxEntries) + ")"); + } + return Status::Ok(); +} + +Status CheckUncompressedSize(long long archive_bytes, long long uncompressed_bytes) { + if (uncompressed_bytes > kMaxTotalUncompressedBytes) { + return Status::Error("archive expands beyond " + + std::to_string(kMaxTotalUncompressedBytes) + " bytes (" + + std::to_string(uncompressed_bytes) + ")"); + } + if (ExceedsRatio(uncompressed_bytes, archive_bytes)) { + return Status::Error("archive compression ratio exceeds " + + std::to_string(kMaxCompressionRatio) + ":1 (" + + std::to_string(uncompressed_bytes) + "/" + + std::to_string(archive_bytes) + ")"); + } + return Status::Ok(); +} + +EntryAccumulator::EntryAccumulator(long long archive_bytes) + : archive_bytes_(archive_bytes) {} + +Status EntryAccumulator::Accept(long long uncompressed_size, + long long compressed_size, + bool is_symlink, + const std::string& name) { + Status status = Add(uncompressed_size, is_symlink, name); + if (status.ok && ExceedsRatio(uncompressed_size, compressed_size)) { + return Status::Error("archive entry compression ratio too high: " + + Label(name, entries_ - 1)); + } + return status; +} + +Status EntryAccumulator::AcceptExtracted(long long size, + bool is_symlink, + const std::string& name) { + return Add(size, is_symlink, name); +} + +Status EntryAccumulator::Add(long long size, + bool is_symlink, + const std::string& name) { + const long long index = entries_++; + Status status = CheckEntryCount(entries_); + if (!status.ok) { + return status; + } + if (IsReservedEntryName(name)) { + return Status::Error("archive contains reserved entry " + name); + } + if (is_symlink) { + return Status::Error("archive entry is a symlink: " + Label(name, index)); + } + if (size < 0) { + return Status::Error("archive entry size unknown: " + Label(name, index)); + } + if (size > kMaxEntryBytes) { + return Status::Error("archive entry too large: " + Label(name, index) + " (" + + std::to_string(size) + " bytes)"); + } + total_uncompressed_ += size; + return CheckUncompressedSize(archive_bytes_, total_uncompressed_); +} + +Status CheckExtractedDirectory(const std::string& dir, long long archive_bytes) { + EntryAccumulator accumulator(archive_bytes); + // Relative paths of the directories still to list; "" is `dir` itself. + std::vector pending(1); + while (!pending.empty()) { + const std::string relative = std::move(pending.back()); + pending.pop_back(); + const std::string path = relative.empty() ? dir : dir + "/" + relative; + DIR* handle = opendir(path.c_str()); + if (handle == nullptr) { + return Status::Error("cannot list extracted directory " + path); + } + Status status = Status::Ok(); + while (status.ok) { + const dirent* item = readdir(handle); + if (item == nullptr) { + break; + } + const std::string name = item->d_name; + if (name == "." || name == "..") { + continue; + } + const std::string child = relative.empty() ? name : relative + "/" + name; + struct stat st; + if (lstat((dir + "/" + child).c_str(), &st) != 0) { + status = Status::Error("cannot stat extracted entry " + child); + break; + } + const bool is_directory = S_ISDIR(st.st_mode); + status = accumulator.AcceptExtracted( + is_directory ? 0 : static_cast(st.st_size), + S_ISLNK(st.st_mode), child); + if (status.ok && is_directory) { + pending.push_back(child); + } + } + closedir(handle); + if (!status.ok) { + return status; + } + } + return Status::Ok(); +} + +FreeSpaceProbe PlanUnknownLengthFreeSpaceProbe(long long known_total, + long long written_before, + long long chunk_bytes, + long long reserved_until) { + FreeSpaceProbe plan; + plan.reserve = chunk_bytes > kUnknownLengthFreeSpaceProbeBytes + ? chunk_bytes + : kUnknownLengthFreeSpaceProbeBytes; + plan.reserved_until = reserved_until; + if (known_total > 0 || written_before + chunk_bytes <= reserved_until) { + return plan; + } + plan.probe = true; + plan.reserved_until = written_before + plan.reserve; + return plan; +} + +} // namespace archive_limits +} // namespace pushy diff --git a/cpp/patch_core/archive_limits.h b/cpp/patch_core/archive_limits.h index e3b4b8e0..10e3bc5f 100644 --- a/cpp/patch_core/archive_limits.h +++ b/cpp/patch_core/archive_limits.h @@ -1,17 +1,31 @@ #ifndef PUSHY_PATCH_CORE_ARCHIVE_LIMITS_H_ #define PUSHY_PATCH_CORE_ARCHIVE_LIMITS_H_ +#include + +#include "patch_core.h" + // Single source of truth for the resource caps applied to an update package -// before and while it is unpacked. A corrupt or hostile archive must be able -// to cost at most a bounded amount of disk, memory and time — never a full -// disk or a zip bomb. The caps are generous for real packages (a large full -// bundle with assets is tens of MB) and exist purely as damage bounds. +// before and while it is unpacked, and for the accounting that enforces them. +// A corrupt or hostile archive must be able to cost at most a bounded amount +// of disk, memory and time — never a full disk or a zip bomb. The caps are +// generous for real packages (a large full bundle with assets is tens of MB) +// and exist purely as damage bounds. // -// Mirrors that cannot include this header MUST stay in sync by hand -// (scripts/check-constant-parity.js, part of `bun lint`, asserts they do): -// - android/.../ArchiveLimits.java -// - harmony/pushy/src/main/ets/ArchiveLimits.ts -// iOS (RCTPushyInstaller.mm, RCTPushyDownloader.mm) includes this header directly. +// Every platform runs the rules below (archive_limits.cpp): +// - Android: SafeZipFile.inspect hands the central directory to +// CheckArchiveEntries, and DownloadTask plans its free-space probes, over +// JNI (cpp/android_jni/archive_limits_jni.cpp); +// - iOS: RCTPushyInstaller.mm feeds an EntryAccumulator from the unzip +// delegate, RCTPushyDownloader.mm plans its probes; +// - Harmony: zlib only reports the whole archive's expansion, so +// DownloadTask.ts checks it with CheckUncompressedSize before unpacking +// and CheckExtractedDirectory after, over NAPI (pushy.cpp). +// The constants a host still compares against itself (download caps, the +// manifest cap, the free-space margin, the streamed-bytes backstop) are +// mirrored in android/.../ArchiveLimits.java and +// harmony/pushy/src/main/ets/ArchiveLimits.ts; +// scripts/check-constant-parity.js (part of `bun lint`) asserts they match. // Keep the core internal to whichever binary links it (see podspec Core). #pragma GCC visibility push(hidden) @@ -19,6 +33,8 @@ namespace pushy { namespace archive_limits { +using delta::Status; + // Downloaded archive (Content-Length up front, streamed bytes as backstop). constexpr long long kMaxArchiveBytes = 512LL * 1024 * 1024; // Sum of every entry's uncompressed size. @@ -27,8 +43,9 @@ constexpr long long kMaxTotalUncompressedBytes = 2048LL * 1024 * 1024; constexpr long long kMaxEntryBytes = 512LL * 1024 * 1024; // Number of entries in one archive. constexpr long long kMaxEntries = 20000; -// Per-entry uncompressed/compressed ratio, checked only above -// kRatioCheckMinBytes (tiny highly-compressible files are legitimate). +// Uncompressed/compressed ratio, of each entry and of the archive as a whole, +// checked only above kRatioCheckMinBytes of expansion (tiny highly +// compressible files are legitimate). constexpr long long kMaxCompressionRatio = 100; constexpr long long kRatioCheckMinBytes = 1LL * 1024 * 1024; // __diff.json is parsed fully in memory. @@ -42,6 +59,77 @@ constexpr long long kFreeDiskMarginBytes = 64LL * 1024 * 1024; // reserves its own size), so no write can ever eat into the margin. constexpr long long kUnknownLengthFreeSpaceProbeBytes = 8LL * 1024 * 1024; +// Whether any component of an archive path ('/' or '\\' separated) starts +// with install_record::kReservedPrefix. Those names belong to the SDK (the +// completion record): a package unpacking its own record next to a bundle +// would be trusted as a finished install even if the steps after it fail. +bool IsReservedEntryName(const std::string& name); + +// The entry count of a whole archive (iOS knows it before the first entry). +Status CheckEntryCount(long long entries); + +// The whole archive: its total expansion and its overall ratio. An archive of +// unknown (non-positive) size has no ratio to trust and fails it once the +// expansion is past kRatioCheckMinBytes. +Status CheckUncompressedSize(long long archive_bytes, long long uncompressed_bytes); + +// Per-entry accounting while an archive is walked, before (Android, iOS) or +// right after (Harmony) its bytes land. Each Accept checks, in order: the +// entry count, a reserved name, a symlink, the entry's own size (negative = +// unknown is refused), the running total and whole-archive ratio +// (CheckUncompressedSize), and the entry's ratio. The first failure is the +// verdict; the caller stops there. +class EntryAccumulator { + public: + // archive_bytes: the archive file's size, for the whole-archive ratio. + explicit EntryAccumulator(long long archive_bytes); + + // An entry as the central directory declares it. `name` is the path inside + // the archive; a host that only learns it once the entry is on disk (iOS) + // passes "" and checks the name then with IsReservedEntryName. A + // non-positive compressed size with an expansion past kRatioCheckMinBytes + // is an unbounded ratio. + Status Accept(long long uncompressed_size, + long long compressed_size, + bool is_symlink, + const std::string& name); + + // An entry already on disk whose compressed size the host never saw + // (Harmony): everything but the entry's own ratio; the whole archive's + // ratio still applies. + Status AcceptExtracted(long long size, bool is_symlink, const std::string& name); + + long long entries() const { return entries_; } + long long total_uncompressed() const { return total_uncompressed_; } + + private: + Status Add(long long size, bool is_symlink, const std::string& name); + + long long archive_bytes_; + long long entries_ = 0; + long long total_uncompressed_ = 0; +}; + +// Walks a freshly unpacked directory (iteratively, symlinks not followed) +// through an EntryAccumulator: every file and directory below `dir` is one +// entry named by its path relative to `dir`, a directory weighing 0 bytes. +Status CheckExtractedDirectory(const std::string& dir, long long archive_bytes); + +// Before each chunk of a download is written. `known_total` > 0 means the +// response announced its length (checked once when it arrived: no probe); +// written_before counts this response's body bytes before the chunk. +struct FreeSpaceProbe { + // Probe the disk for `reserve` bytes (plus kFreeDiskMarginBytes) first. + bool probe = false; + long long reserve = 0; + // The reservation after this chunk, relative to the start of the body. + long long reserved_until = 0; +}; +FreeSpaceProbe PlanUnknownLengthFreeSpaceProbe(long long known_total, + long long written_before, + long long chunk_bytes, + long long reserved_until); + } // namespace archive_limits } // namespace pushy diff --git a/cpp/patch_core/install_record.h b/cpp/patch_core/install_record.h index 6289edc8..f724ce8f 100644 --- a/cpp/patch_core/install_record.h +++ b/cpp/patch_core/install_record.h @@ -45,6 +45,10 @@ using delta::Status; constexpr int kSchema = 1; constexpr const char* kFileName = ".pushy-complete"; +// Every name starting with this prefix, at any depth of a version directory, +// belongs to the SDK (the record, its temp file): no archive may ship one +// (archive_limits::IsReservedEntryName). +constexpr const char* kReservedPrefix = ".pushy-"; constexpr const char* kStagingSuffix = ".staging"; // A record is a few hundred bytes; anything larger is not one. constexpr long long kMaxRecordBytes = 64 * 1024; diff --git a/cpp/patch_core/tests/archive-limits-vectors.json b/cpp/patch_core/tests/archive-limits-vectors.json new file mode 100644 index 00000000..a10fd4af --- /dev/null +++ b/cpp/patch_core/tests/archive-limits-vectors.json @@ -0,0 +1,418 @@ +{ + "description": "Archive extraction limits (cpp/patch_core/archive_limits.h), shared by Android, iOS and HarmonyOS. `entries`: an EntryAccumulator(archiveBytes) is fed each entry in order — Accept(size, compressed, symlink, name) when `compressed` is given, AcceptExtracted(size, symlink, name) otherwise; `repeat` feeds the same entry that many times — and must stop at `expect` (a substring of the first rejection) or accept them all (\"ok\", then `count`/`total` when given). `uncompressed`: CheckUncompressedSize(archiveBytes, uncompressedBytes). `probes`: PlanUnknownLengthFreeSpaceProbe(knownTotal, writtenBefore, chunkBytes, reservedUntil) must return `probe`, `reserve` and `after` (the new reservedUntil). `reservedNames`: IsReservedEntryName. `was` records how the platforms disagreed before the rules were shared; the strictest reading won.", + "entries": [ + { + "name": "a small package", + "archiveBytes": 1000, + "entries": [ + { + "name": "index.bundlejs", + "size": 3000, + "compressed": 900 + }, + { + "name": "assets/", + "size": 0, + "compressed": 0 + }, + { + "name": "assets/logo.png", + "size": 3, + "compressed": 3 + } + ], + "expect": "ok", + "count": 3, + "total": 3003 + }, + { + "name": "an entry at the per-entry cap", + "archiveBytes": 536870912, + "entries": [ + { + "name": "big.bin", + "size": 536870912, + "compressed": 536870912 + } + ], + "expect": "ok", + "total": 536870912 + }, + { + "name": "an entry over the per-entry cap", + "archiveBytes": 629145600, + "entries": [ + { + "name": "big.bin", + "size": 536870913, + "compressed": 536870913 + } + ], + "expect": "archive entry too large: big.bin" + }, + { + "name": "the running total over the cap", + "archiveBytes": 2726297600, + "entries": [ + { + "name": "part.bin", + "size": 524288000, + "compressed": 524288000, + "repeat": 5 + } + ], + "expect": "archive expands beyond 2147483648 bytes" + }, + { + "name": "an entry at exactly 100:1 above the threshold", + "archiveBytes": 3000000, + "entries": [ + { + "name": "text.json", + "size": 2000000, + "compressed": 20000 + } + ], + "expect": "ok" + }, + { + "name": "an entry just past 100:1", + "archiveBytes": 3000000, + "entries": [ + { + "name": "text.json", + "size": 2000001, + "compressed": 20000 + } + ], + "expect": "archive entry compression ratio too high: text.json", + "was": "Android and iOS divided (2000001 / 20000 = 100) and accepted it; Harmony's whole-archive check already compared size > compressed * 100" + }, + { + "name": "no ratio check up to 1 MiB", + "archiveBytes": 2097152, + "entries": [ + { + "name": "zeros.bin", + "size": 1048576, + "compressed": 1 + } + ], + "expect": "ok" + }, + { + "name": "a compression bomb just past 1 MiB", + "archiveBytes": 2097152, + "entries": [ + { + "name": "bomb.bin", + "size": 1048577, + "compressed": 1048 + } + ], + "expect": "archive entry compression ratio too high: bomb.bin" + }, + { + "name": "a zero compressed size bounds nothing", + "archiveBytes": 4194304, + "entries": [ + { + "name": "bomb.bin", + "size": 2097152, + "compressed": 0 + } + ], + "expect": "archive entry compression ratio too high: bomb.bin", + "was": "Android and iOS skipped the ratio check when the compressed size was 0" + }, + { + "name": "small entries adding up past the whole-archive ratio", + "archiveBytes": 10240, + "entries": [ + { + "name": "small.bin", + "size": 524288, + "compressed": 1024, + "repeat": 3 + } + ], + "expect": "archive compression ratio exceeds 100:1 (1572864/10240)", + "was": "Android and iOS checked each entry only; Harmony checked the whole archive before unpacking" + }, + { + "name": "an entry of unknown size", + "archiveBytes": 1000, + "entries": [ + { + "name": "a.bin", + "size": -1, + "compressed": 10 + } + ], + "expect": "archive entry size unknown: a.bin", + "was": "Android skipped an entry whose size was -1" + }, + { + "name": "a symlink", + "archiveBytes": 1000, + "entries": [ + { + "name": "index.bundlejs", + "size": 10, + "compressed": 10 + }, + { + "name": "assets", + "size": 16, + "compressed": 16, + "symlink": true + } + ], + "expect": "archive entry is a symlink: assets", + "was": "only iOS refused symlinks; java.util.zip writes the link as a regular file and Harmony's walk followed it" + }, + { + "name": "a symlink whose name is not known yet", + "archiveBytes": 1000, + "entries": [ + { + "name": "", + "size": 10, + "compressed": 10 + }, + { + "name": "", + "size": 16, + "compressed": 16, + "symlink": true + } + ], + "expect": "archive entry is a symlink: #1" + }, + { + "name": "a reserved name", + "archiveBytes": 1000, + "entries": [ + { + "name": "index.bundlejs", + "size": 10, + "compressed": 10 + }, + { + "name": ".pushy-complete", + "size": 2, + "compressed": 2 + } + ], + "expect": "archive contains reserved entry .pushy-complete" + }, + { + "name": "a reserved directory component", + "archiveBytes": 1000, + "entries": [ + { + "name": "assets/.pushy-x/a.png", + "size": 2, + "compressed": 2 + } + ], + "expect": "archive contains reserved entry assets/.pushy-x/a.png", + "was": "Android checked the base name only and iOS the last component of each unpacked path; Harmony refused it at any depth" + }, + { + "name": "extracted entries within the whole-archive ratio", + "archiveBytes": 1048576, + "entries": [ + { + "name": "assets", + "size": 0 + }, + { + "name": "assets/a.bin", + "size": 2097152 + } + ], + "expect": "ok", + "count": 2, + "total": 2097152 + }, + { + "name": "extracted entries past the whole-archive ratio", + "archiveBytes": 20000, + "entries": [ + { + "name": "a.bin", + "size": 2097152 + } + ], + "expect": "archive compression ratio exceeds 100:1" + }, + { + "name": "extracted entry over the per-entry cap", + "archiveBytes": 536870912, + "entries": [ + { + "name": "big.bin", + "size": 536870913 + } + ], + "expect": "archive entry too large: big.bin" + }, + { + "name": "20000 entries", + "archiveBytes": 1000, + "entries": [ + { + "name": "e", + "size": 0, + "compressed": 0, + "repeat": 20000 + } + ], + "expect": "ok", + "count": 20000, + "total": 0 + }, + { + "name": "20001 entries", + "archiveBytes": 1000, + "entries": [ + { + "name": "e", + "size": 0, + "compressed": 0, + "repeat": 20001 + } + ], + "expect": "archive has too many entries (> 20000)" + } + ], + "uncompressed": [ + { + "name": "within both limits", + "archiveBytes": 1024, + "uncompressedBytes": 4096, + "expect": "ok" + }, + { + "name": "no ratio check up to 1 MiB", + "archiveBytes": 1, + "uncompressedBytes": 1048576, + "expect": "ok" + }, + { + "name": "at the total cap", + "archiveBytes": 2147483648, + "uncompressedBytes": 2147483648, + "expect": "ok" + }, + { + "name": "past the total cap", + "archiveBytes": 2147483648, + "uncompressedBytes": 2147483649, + "expect": "archive expands beyond 2147483648 bytes (2147483649)" + }, + { + "name": "exactly 100:1", + "archiveBytes": 1048576, + "uncompressedBytes": 104857600, + "expect": "ok" + }, + { + "name": "just past 100:1", + "archiveBytes": 1048576, + "uncompressedBytes": 104857601, + "expect": "archive compression ratio exceeds 100:1" + }, + { + "name": "an empty archive expanding past 1 MiB", + "archiveBytes": 0, + "uncompressedBytes": 2097152, + "expect": "archive compression ratio exceeds 100:1", + "was": "Harmony skipped the ratio when the archive size was 0" + }, + { + "name": "an empty archive expanding to little", + "archiveBytes": 0, + "uncompressedBytes": 1048576, + "expect": "ok" + } + ], + "probes": [ + { + "name": "never probes when the length is known", + "knownTotal": 1000, + "writtenBefore": 0, + "chunkBytes": 4096, + "reservedUntil": 0, + "probe": false, + "reserve": 8388608, + "after": 0 + }, + { + "name": "probes the first chunk, reserving PROBE ahead", + "knownTotal": 0, + "writtenBefore": 0, + "chunkBytes": 4096, + "reservedUntil": 0, + "probe": true, + "reserve": 8388608, + "after": 8388608 + }, + { + "name": "a chunk inside the reservation", + "knownTotal": 0, + "writtenBefore": 4096, + "chunkBytes": 4096, + "reservedUntil": 8388608, + "probe": false, + "reserve": 8388608, + "after": 8388608 + }, + { + "name": "a chunk ending on the reservation", + "knownTotal": 0, + "writtenBefore": 8388598, + "chunkBytes": 10, + "reservedUntil": 8388608, + "probe": false, + "reserve": 8388608, + "after": 8388608 + }, + { + "name": "a chunk crossing the reservation", + "knownTotal": 0, + "writtenBefore": 8388598, + "chunkBytes": 11, + "reservedUntil": 8388608, + "probe": true, + "reserve": 8388608, + "after": 16777206 + }, + { + "name": "a chunk larger than PROBE reserves its own size", + "knownTotal": 0, + "writtenBefore": 100, + "chunkBytes": 16777217, + "reservedUntil": 0, + "probe": true, + "reserve": 16777217, + "after": 16777317 + } + ], + "reservedNames": { + ".pushy-complete": true, + "sub/dir/.pushy-complete": true, + "sub\\dir\\.pushy-anything": true, + ".pushy-complete/": true, + "a/.pushy-x/b.png": true, + ".pushy-": true, + "pushy-complete": false, + "sub/.pushy/file": false, + "index.bundlejs": false, + "": false, + "a/b.pushy-c": false, + ".pushy": false, + "assets/x.pushy-": false + } +} diff --git a/cpp/patch_core/tests/patch_core_test.cpp b/cpp/patch_core/tests/patch_core_test.cpp index 37e42bc2..eaa82351 100644 --- a/cpp/patch_core/tests/patch_core_test.cpp +++ b/cpp/patch_core/tests/patch_core_test.cpp @@ -1,3 +1,4 @@ +#include "../archive_limits.h" #include "../archive_patch_core.h" #include "../digest.h" #include "../install_record.h" @@ -1738,6 +1739,153 @@ void TestInstallRecordUnchanged() { Expect(!ir::Unchanged(dir, kRecordBundle, false, passed), "bundle gone"); } +// ---- Archive extraction limits (archive_limits.h) ---- + +namespace al = pushy::archive_limits; + +long long AsLongLong(const flowjson::Value& value) { + return static_cast(value.AsNumber()); +} + +void ExpectVerdict(const Status& status, const std::string& expected, const std::string& name) { + if (expected == "ok") { + Expect(status.ok, name + ": " + status.message); + return; + } + Expect(!status.ok, name + ": expected [" + expected + "], accepted"); + Expect(status.message.find(expected) != std::string::npos, + name + ": rejection [" + status.message + "] lacks [" + expected + "]"); +} + +flowjson::Value ArchiveLimitsVectors() { + const std::string path = JoinPath(g_fixtures_dir, "../archive-limits-vectors.json"); + bool ok = false; + flowjson::Value doc = flowjson::Parse(ReadFile(path), &ok); + Expect(ok && doc.Get("entries").IsArray(), "vectors file must parse: " + path); + return doc; +} + +// The shared vectors (tests/archive-limits-vectors.json), including the +// cases the platforms used to disagree on. +void TestArchiveLimitsVectors() { + const flowjson::Value doc = ArchiveLimitsVectors(); + Expect(doc.Get("entries").Size() >= 20, "entry vectors lost their cases"); + for (const flowjson::Value& vector : doc.Get("entries").elements()) { + const std::string name = vector.Get("name").AsString(); + al::EntryAccumulator accumulator(AsLongLong(vector.Get("archiveBytes"))); + Status status = Status::Ok(); + for (const flowjson::Value& entry : vector.Get("entries").elements()) { + const long long repeat = + entry.Get("repeat").IsNumber() ? AsLongLong(entry.Get("repeat")) : 1; + const bool symlink = entry.Get("symlink").IsBool() && entry.Get("symlink").AsBool(); + for (long long i = 0; i < repeat && status.ok; i++) { + status = entry.Get("compressed").IsNumber() + ? accumulator.Accept(AsLongLong(entry.Get("size")), + AsLongLong(entry.Get("compressed")), symlink, + entry.Get("name").AsString()) + : accumulator.AcceptExtracted(AsLongLong(entry.Get("size")), symlink, + entry.Get("name").AsString()); + } + if (!status.ok) { + break; + } + } + ExpectVerdict(status, vector.Get("expect").AsString(), name); + if (vector.Get("count").IsNumber()) { + Expect(accumulator.entries() == AsLongLong(vector.Get("count")), name + ": count"); + } + if (vector.Get("total").IsNumber()) { + Expect(accumulator.total_uncompressed() == AsLongLong(vector.Get("total")), + name + ": total"); + } + } + + for (const flowjson::Value& vector : doc.Get("uncompressed").elements()) { + ExpectVerdict(al::CheckUncompressedSize(AsLongLong(vector.Get("archiveBytes")), + AsLongLong(vector.Get("uncompressedBytes"))), + vector.Get("expect").AsString(), vector.Get("name").AsString()); + } + + for (const flowjson::Value& vector : doc.Get("probes").elements()) { + const std::string name = vector.Get("name").AsString(); + const al::FreeSpaceProbe plan = al::PlanUnknownLengthFreeSpaceProbe( + AsLongLong(vector.Get("knownTotal")), AsLongLong(vector.Get("writtenBefore")), + AsLongLong(vector.Get("chunkBytes")), AsLongLong(vector.Get("reservedUntil"))); + Expect(plan.probe == vector.Get("probe").AsBool(), name + ": probe"); + Expect(plan.reserve == AsLongLong(vector.Get("reserve")), name + ": reserve"); + Expect(plan.reserved_until == AsLongLong(vector.Get("after")), name + ": after"); + } + + const flowjson::Value& names = doc.Get("reservedNames"); + Expect(names.members().size() >= 10, "reserved name vectors lost their cases"); + for (const auto& member : names.members()) { + Expect(al::IsReservedEntryName(member.first) == member.second.AsBool(), + "IsReservedEntryName(" + member.first + ")"); + } +} + +// Walking a stream of unknown length: no write ever passes a probed +// reservation, and most chunks need no probe. +void TestArchiveLimitsProbeWalk() { + long long reserved_until = 0; + long long written = 0; + int probes = 0; + const long long chunk = 300 * 1024; + for (int i = 0; i < 100; i++) { + const al::FreeSpaceProbe plan = + al::PlanUnknownLengthFreeSpaceProbe(0, written, chunk, reserved_until); + probes += plan.probe ? 1 : 0; + reserved_until = plan.reserved_until; + written += chunk; + Expect(written <= reserved_until, "a write passed the reservation"); + } + Expect(probes > 1 && probes < 100, "probes once per reservation"); +} + +void TestArchiveLimitsExtractedDirectory() { + { + TempDir temp; + WriteFile(temp.path + "/index.bundlejs", std::string(3000, 'a')); + WriteFile(temp.path + "/assets/img/logo.png", "png"); + Status status = al::CheckExtractedDirectory(temp.path, 1000); + Expect(status.ok, "a plain tree passes: " + status.message); + // 1 MiB + 1 of expansion from a 10 KB archive: past the whole-archive ratio. + WriteFile(temp.path + "/assets/zeros.bin", std::string(1024 * 1024, '\0')); + ExpectVerdict(al::CheckExtractedDirectory(temp.path, 10240), + "archive compression ratio exceeds 100:1", "whole-archive ratio"); + } + { + TempDir temp; + WriteFile(temp.path + "/assets/.pushy-x/a.png", "png"); + ExpectVerdict(al::CheckExtractedDirectory(temp.path, 1000), + "archive contains reserved entry assets/.pushy-x", "reserved directory"); + } + { + TempDir temp; + WriteFile(temp.path + "/index.bundlejs", "x"); + Expect(symlink("/tmp", (temp.path + "/assets").c_str()) == 0, "create symlink"); + ExpectVerdict(al::CheckExtractedDirectory(temp.path, 1000), + "archive entry is a symlink: assets", "symlink is not followed"); + } + { + TempDir temp; + // 20001 entries: 200 directories of 99 files + their own entry, plus one. + for (int d = 0; d < 200; d++) { + const std::string dir = temp.path + "/d" + std::to_string(d); + EnsureDirectory(dir); + for (int f = 0; f < 99; f++) { + WriteFile(dir + "/f" + std::to_string(f), ""); + } + } + Expect(al::CheckExtractedDirectory(temp.path, 1000).ok, "20000 entries pass"); + WriteFile(temp.path + "/extra", ""); + ExpectVerdict(al::CheckExtractedDirectory(temp.path, 1000), + "archive has too many entries (> 20000)", "20001 entries"); + } + ExpectVerdict(al::CheckExtractedDirectory("/nonexistent-pushy-dir", 1000), + "cannot list extracted directory", "missing directory"); +} + int main(int argc, char** argv) { if (argc > 1) { g_fixtures_dir = argv[1]; @@ -1790,6 +1938,9 @@ int main(int argc, char** argv) { {"InstallRecordWriteIsAtomic", TestInstallRecordWriteIsAtomic}, {"InstallRecordSealAndPromote", TestInstallRecordSealAndPromote}, {"InstallRecordUnchanged", TestInstallRecordUnchanged}, + {"ArchiveLimitsVectors", TestArchiveLimitsVectors}, + {"ArchiveLimitsProbeWalk", TestArchiveLimitsProbeWalk}, + {"ArchiveLimitsExtractedDirectory", TestArchiveLimitsExtractedDirectory}, }; int failures = 0; From 5fe601c4f806ee3baa9db8a7e17529d9038b1caf Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 17:35:13 +0800 Subject: [PATCH 097/104] refactor(ios): run the unzip guard and the download probe through archive_limits PushyUnzipGuard feeds an archive_limits::EntryAccumulator (sized by the archive file, for the whole-archive ratio) from the should-unzip callback and checks the entry count up front with CheckEntryCount; its own size, total and ratio arithmetic is gone. The reserved-name check in the did-unzip callback now covers every component of the path below the destination (IsReservedEntryName), not just the last one; an unpacked path that is not below the destination is a violation. The downloader's unknown-length disk probe is PlanUnknownLengthFreeSpaceProbe. Behaviour aligned with the shared rules: ratio as size > compressed * 100 (was integer division), a zero compressed size past 1 MiB is refused, the whole-archive ratio applies, a `.pushy-` directory component is refused. Co-Authored-By: Claude Opus 5.5 --- ios/RCTPushy/RCTPushyDownloader.mm | 25 ++++--- ios/RCTPushy/RCTPushyInstaller.mm | 112 ++++++++++++++++------------- 2 files changed, 73 insertions(+), 64 deletions(-) diff --git a/ios/RCTPushy/RCTPushyDownloader.mm b/ios/RCTPushy/RCTPushyDownloader.mm index e142303d..a51335c6 100644 --- a/ios/RCTPushy/RCTPushyDownloader.mm +++ b/ios/RCTPushy/RCTPushyDownloader.mm @@ -527,19 +527,18 @@ - (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)data [dataTask cancel]; return; } - if (self.expectedTotal <= 0 - && self.receivedBytes + (long long)data.length > self.freeSpaceReservedUntil) { - // Unknown/encoded length: the response-time check could only - // reserve the margin, so probe before any write that would run past - // the bytes reserved so far, each probe reserving at least the next - // PROBE bytes (more when one callback carries a larger buffer) — no - // write can ever eat into the margin. The archive cap alone (512 - // MiB) is far more than the margin protects. The partial stays: a - // later attempt may find the space. - long long reserve = MAX(pushy::archive_limits::kUnknownLengthFreeSpaceProbeBytes, - (long long)data.length); - self.freeSpaceReservedUntil = self.receivedBytes + reserve; - NSString *shortfall = RCTPushyFreeSpaceShortfall(self.savePath, reserve); + // Unknown/encoded length: the response-time check could only reserve the + // margin, so the disk is probed before any write that would run past the + // bytes reserved so far (archive_limits::PlanUnknownLengthFreeSpaceProbe) + // — no write can ever eat into the margin. The partial stays: a later + // attempt may find the space. + pushy::archive_limits::FreeSpaceProbe plan = + pushy::archive_limits::PlanUnknownLengthFreeSpaceProbe( + self.expectedTotal, self.receivedBytes, (long long)data.length, + self.freeSpaceReservedUntil); + self.freeSpaceReservedUntil = plan.reserved_until; + if (plan.probe) { + NSString *shortfall = RCTPushyFreeSpaceShortfall(self.savePath, plan.reserve); if (shortfall != nil) { [self failWithDescription:shortfall code:-1]; [dataTask cancel]; diff --git a/ios/RCTPushy/RCTPushyInstaller.mm b/ios/RCTPushy/RCTPushyInstaller.mm index b8c6f951..cf11f498 100644 --- a/ios/RCTPushy/RCTPushyInstaller.mm +++ b/ios/RCTPushy/RCTPushyInstaller.mm @@ -11,6 +11,8 @@ #include "../../cpp/patch_core/install_record.h" #include "../../cpp/patch_core/patch_core.h" +#include + // file def static NSString *const SOURCE_PATCH_NAME = @"__diff.json"; static NSString *const ERROR_FILE_OPERATION = @"file operation error"; @@ -68,32 +70,47 @@ PUSHY_HIDDEN dispatch_queue_t PushyFileQueue(void) { return PushyErrorWithCode(pushy::error_codes::kDeltaFailed, shortfall); } -// SSZipArchive delegate enforcing cpp/patch_core/archive_limits.h while the -// archive is walked (entry count, per-entry size, total size, compression -// ratio) plus two entry-shape rules Android's java.util.zip gets for free: -// - no symlinks: SSZipArchive materialises them with any target regardless -// of preserveAttributes, so `assets -> ../../Documents` followed by -// `assets/x` would write outside the version directory; -// - no ".pushy-" reserved names at any depth: the completion marker is -// written by this SDK only, after the whole install succeeded. An archive -// shipping its own would be trusted as a finished install even if the -// patch step that follows fails. +// SSZipArchive delegate feeding cpp/patch_core/archive_limits.h's +// EntryAccumulator while the archive is walked (entry count, sizes, ratios, +// symlinks: SSZipArchive materialises them with any target regardless of +// preserveAttributes, so `assets -> ../../Documents` followed by `assets/x` +// would write outside the version directory). The entry name is only exposed +// once the entry is on disk, so the reserved `.pushy-` names (the completion +// record is the SDK's alone) are checked in the did-unzip callback. // A NO from zipArchiveShouldUnzipFileAtIndex: cancels the whole unzip (the // 2.x contract the podspec pins), and the completion handler turns the // recorded violation into a hard PATCH_FAILED; the caller then discards the // staging directory. @interface PushyUnzipGuard : NSObject @property (nonatomic, copy) NSString *violation; -@property (nonatomic, assign) long long totalUncompressed; +- (instancetype)initWithArchiveBytes:(long long)archiveBytes destination:(NSString *)destination; @end -@implementation PushyUnzipGuard +@implementation PushyUnzipGuard { + std::unique_ptr _accumulator; + NSArray *_destinationComponents; +} -- (void)zipArchiveWillUnzipArchiveAtPath:(NSString *)path zipInfo:(unz_global_info)zipInfo { - if ((long long)zipInfo.number_entry > pushy::archive_limits::kMaxEntries) { - self.violation = [NSString stringWithFormat:@"archive has too many entries (%lu)", - (unsigned long)zipInfo.number_entry]; +- (instancetype)initWithArchiveBytes:(long long)archiveBytes destination:(NSString *)destination { + if ((self = [super init])) { + _accumulator.reset(new pushy::archive_limits::EntryAccumulator(archiveBytes)); + // Joined the way SSZipArchive builds each unpacked path + // (stringByAppendingPathComponent:), so a trailing slash compares equal. + NSArray *probe = + [destination stringByAppendingPathComponent:@"x"].pathComponents; + _destinationComponents = [probe subarrayWithRange:NSMakeRange(0, probe.count - 1)]; } + return self; +} + +- (void)recordStatus:(const pushy::delta::Status &)status { + if (!status.ok && self.violation == nil) { + self.violation = @(status.message.c_str()); + } +} + +- (void)zipArchiveWillUnzipArchiveAtPath:(NSString *)path zipInfo:(unz_global_info)zipInfo { + [self recordStatus:pushy::archive_limits::CheckEntryCount((long long)zipInfo.number_entry)]; } - (BOOL)zipArchiveShouldUnzipFileAtIndex:(NSInteger)fileIndex @@ -105,44 +122,37 @@ - (BOOL)zipArchiveShouldUnzipFileAtIndex:(NSInteger)fileIndex } // Unix mode bits travel in the high 16 bits of the external attributes // (the same field SSZipArchive keys its own symlink branch on). - if (((fileInfo.external_fa >> 16) & S_IFMT) == S_IFLNK) { - self.violation = [NSString stringWithFormat:@"archive entry #%ld is a symlink", - (long)fileIndex]; - return NO; - } - long long size = (long long)fileInfo.uncompressed_size; - long long compressed = (long long)fileInfo.compressed_size; - if (size > pushy::archive_limits::kMaxEntryBytes) { - self.violation = [NSString stringWithFormat:@"archive entry #%ld too large (%lld bytes)", - (long)fileIndex, size]; - return NO; - } - self.totalUncompressed += size; - if (self.totalUncompressed > pushy::archive_limits::kMaxTotalUncompressedBytes) { - self.violation = [NSString stringWithFormat:@"archive expands beyond %lld bytes", - pushy::archive_limits::kMaxTotalUncompressedBytes]; - return NO; - } - if (compressed > 0 && size > pushy::archive_limits::kRatioCheckMinBytes - && size / compressed > pushy::archive_limits::kMaxCompressionRatio) { - self.violation = [NSString stringWithFormat:@"archive entry #%ld compression ratio too high", - (long)fileIndex]; - return NO; - } - return YES; + BOOL symlink = ((fileInfo.external_fa >> 16) & S_IFMT) == S_IFLNK; + [self recordStatus:_accumulator->Accept((long long)fileInfo.uncompressed_size, + (long long)fileInfo.compressed_size, + symlink, std::string())]; + return self.violation == nil; } -// The entry name is only exposed once the entry is on disk (shouldUnzip -// runs before the name is read). A reserved name is flagged here; the next -// shouldUnzip refuses (cancelling the unzip) or, for the last entry, the -// completion handler picks the violation up. Either way the caller drops -// the whole staging directory, so the written file never survives. +// A reserved name is flagged here (any component of the path below the +// destination); the next shouldUnzip refuses (cancelling the unzip) or, for +// the last entry, the completion handler picks the violation up. Either way +// the caller drops the whole staging directory, so the written file never +// survives. - (void)zipArchiveDidUnzipFileAtIndex:(NSInteger)fileIndex totalFiles:(NSInteger)totalFiles archivePath:(NSString *)archivePath unzippedFilePath:(NSString *)unzippedFilePath { - NSString *name = unzippedFilePath.lastPathComponent; - if (self.violation == nil && [name hasPrefix:@".pushy-"]) { + if (self.violation != nil) { + return; + } + NSArray *components = unzippedFilePath.pathComponents; + NSUInteger base = _destinationComponents.count; + if (components.count <= base + || ![[components subarrayWithRange:NSMakeRange(0, base)] + isEqualToArray:_destinationComponents]) { + self.violation = [NSString stringWithFormat:@"archive entry #%ld outside the destination", + (long)fileIndex]; + return; + } + NSString *name = [[components subarrayWithRange:NSMakeRange(base, components.count - base)] + componentsJoinedByString:@"/"]; + if (pushy::archive_limits::IsReservedEntryName(PushyToStdString(name))) { self.violation = [NSString stringWithFormat:@"archive contains reserved entry %@", name]; } } @@ -535,8 +545,7 @@ + (void)unzipFileAtPath:(NSString *)path // (cpp/patch_core/archive_limits.h). SSZipArchive only exposes the // entry count up front (zipArchiveWillUnzipArchiveAtPath:zipInfo:), // not the uncompressed total, so the disk check uses a 2x-archive - // heuristic; the delegate below enforces the exact caps entry by - // entry. + // heuristic; the guard enforces the exact caps entry by entry. long long archiveBytes = RCTPushyFileSize(path); NSError *preflight = nil; if (archiveBytes > pushy::archive_limits::kMaxArchiveBytes) { @@ -551,7 +560,8 @@ + (void)unzipFileAtPath:(NSString *)path return; } - PushyUnzipGuard *guard = [PushyUnzipGuard new]; + PushyUnzipGuard *guard = [[PushyUnzipGuard alloc] initWithArchiveBytes:archiveBytes + destination:destination]; // preserveAttributes:NO — the archive's mode/mtime bits have no use // here and are one less thing a hostile package controls. [SSZipArchive unzipFileAtPath:path From 1c4731126d0d46e1b4d38c74575ce4cfec692d16 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 17:38:12 +0800 Subject: [PATCH 098/104] refactor(android): run the archive accounting through archive_limits over JNI SafeZipFile.inspect(archiveBytes) collects the central directory once and hands names, declared and compressed sizes to ArchiveLimits.checkEntries (one coarse JNI call per archive, cpp/android_jni/archive_limits_jni.cpp, over archive_limits::EntryAccumulator), which returns the total for the free-space check or throws the first violation. The Inspection class, the Java count/size/ratio arithmetic and isReservedEntryName are gone; the reserved-name rule now comes from the core during inspect, which both extraction paths run before any entry is written (the iterator keeps the traversal check, unzipToPath the canonical-path check). The download loop asks ArchiveLimits.planFreeSpaceProbe (JNI over PlanUnknownLengthFreeSpaceProbe) before each chunk. Behaviour aligned with the shared rules: ratio as size > compressed * 100, a zero compressed size past 1 MiB is refused, the whole-archive ratio applies, a `.pushy-` directory component is refused, an entry of unknown size is refused instead of skipped. ArchiveLimits.java keeps the five caps it still compares against itself; check-constant-parity holds it to exactly those. SafeZipFileTest keeps the traversal and extraction cases; the accounting cases moved to cpp/patch_core/tests/archive-limits-vectors.json (the JVM cannot load the library). The JNI registration test accepts a throws clause on a native. Co-Authored-By: Claude Opus 5.5 --- .../modules/update/ArchiveLimits.java | 29 +++++-- .../modules/update/DownloadTask.java | 20 ++--- .../modules/update/SafeZipFile.java | 86 +++++-------------- .../modules/update/SafeZipFileTest.java | 83 +----------------- cpp/android_jni/archive_limits_jni.cpp | 83 ++++++++++++++++++ cpp/android_jni/jni_natives.h | 2 + cpp/android_jni/jni_registration.cpp | 7 ++ scripts/check-constant-parity.js | 11 +++ src/__tests__/jniRegistration.test.ts | 6 +- 9 files changed, 163 insertions(+), 164 deletions(-) create mode 100644 cpp/android_jni/archive_limits_jni.cpp diff --git a/android/src/main/java/cn/reactnative/modules/update/ArchiveLimits.java b/android/src/main/java/cn/reactnative/modules/update/ArchiveLimits.java index 0be982c7..964d1f3e 100644 --- a/android/src/main/java/cn/reactnative/modules/update/ArchiveLimits.java +++ b/android/src/main/java/cn/reactnative/modules/update/ArchiveLimits.java @@ -1,19 +1,19 @@ package cn.reactnative.modules.update; /** - * Java mirror of cpp/patch_core/archive_limits.h — keep the two in sync by - * hand. Damage bounds for a corrupt or hostile update package. + * Damage bounds for a corrupt or hostile update package. The accounting + * itself (entry count, sizes, ratios, reserved names, the unknown-length + * probe plan) is the shared core's (cpp/patch_core/archive_limits.h, over + * JNI); the constants below are the ones this side still compares against + * itself, mirrored by hand (scripts/check-constant-parity.js asserts they + * match the header). */ final class ArchiveLimits { static final long MAX_ARCHIVE_BYTES = 512L * 1024 * 1024; static final long MAX_TOTAL_UNCOMPRESSED_BYTES = 2048L * 1024 * 1024; static final long MAX_ENTRY_BYTES = 512L * 1024 * 1024; - static final long MAX_ENTRIES = 20000; - static final long MAX_COMPRESSION_RATIO = 100; - static final long RATIO_CHECK_MIN_BYTES = 1L * 1024 * 1024; static final long MAX_MANIFEST_BYTES = 16L * 1024 * 1024; static final long FREE_DISK_MARGIN_BYTES = 64L * 1024 * 1024; - static final long UNKNOWN_LENGTH_FREE_SPACE_PROBE_BYTES = 8L * 1024 * 1024; private ArchiveLimits() { } @@ -42,4 +42,21 @@ static void ensureFreeSpace(java.io.File target, long bytesToWrite) "insufficient disk space: need " + needed + " bytes, have " + usable); } } + + /** + * The central directory (one entry per index) through the shared + * EntryAccumulator. Returns the total uncompressed size; throws with the + * first violation. + */ + static native long checkEntries( + long archiveBytes, String[] names, long[] sizes, long[] compressedSizes) + throws java.io.IOException; + + /** + * Before one chunk of a download is written: {bytes to probe for, 0 when + * no probe is due; the reservation end after the chunk}. writtenBefore + * counts this response's body bytes before the chunk. + */ + static native long[] planFreeSpaceProbe( + long knownTotal, long writtenBefore, long chunkBytes, long reservedUntil); } 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 d1cdd912..51d2d357 100644 --- a/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +++ b/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java @@ -333,10 +333,9 @@ private boolean transferArchive( long lastPostedBytes = baseOffset; // Unknown length: the response-time check could only reserve the // margin, so the disk is probed before any write that would run - // past the bytes reserved so far, each probe reserving at least - // the next PROBE bytes (more when one chunk is larger) — no write - // can ever eat into the margin (the archive cap alone is far more - // than the margin). A throw keeps the partial for a later attempt. + // past the bytes reserved so far (the shared probe plan, + // ArchiveLimits.planFreeSpaceProbe) — no write can ever eat into + // the margin. A throw keeps the partial for a later attempt. long freeSpaceReservedUntil = 0; // The archive digest is computed as the bytes reach the file, so @@ -351,13 +350,13 @@ private boolean transferArchive( new FileOutputStream(writePath, append), digest))) ) { while ((bytesRead = source.read(sink.buffer(), UpdateFileUtils.BUFFER_SIZE)) != -1) { + long[] probe = ArchiveLimits.planFreeSpaceProbe( + totalAll, received, bytesRead, freeSpaceReservedUntil); received += bytesRead; - if (totalAll <= 0 && received > freeSpaceReservedUntil) { - long reserve = Math.max( - ArchiveLimits.UNKNOWN_LENGTH_FREE_SPACE_PROBE_BYTES, bytesRead); - freeSpaceReservedUntil = received - bytesRead + reserve; + freeSpaceReservedUntil = probe[1]; + if (probe[0] > 0) { try { - ArchiveLimits.ensureFreeSpace(writePath, reserve); + ArchiveLimits.ensureFreeSpace(writePath, probe[0]); } catch (IOException e) { // The chunk is still only buffered; closing the // sink would flush it onto the disk that just @@ -530,8 +529,7 @@ private static void ensureArchiveWithinLimits( if (archiveBytes > ArchiveLimits.MAX_ARCHIVE_BYTES) { throw new IOException("archive too large: " + archiveBytes + " bytes"); } - SafeZipFile.Inspection inspection = zipFile.inspect(); - ArchiveLimits.ensureFreeSpace(unzipDirectory, inspection.totalUncompressed); + ArchiveLimits.ensureFreeSpace(unzipDirectory, zipFile.inspect(archiveBytes)); } // The archive and its resume sidecar live and die together: once the diff --git a/android/src/main/java/cn/reactnative/modules/update/SafeZipFile.java b/android/src/main/java/cn/reactnative/modules/update/SafeZipFile.java index 0c1ea49e..ab04803a 100644 --- a/android/src/main/java/cn/reactnative/modules/update/SafeZipFile.java +++ b/android/src/main/java/cn/reactnative/modules/update/SafeZipFile.java @@ -7,6 +7,8 @@ import java.io.OutputStream; import java.security.DigestOutputStream; import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Collections; import java.util.Enumeration; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; @@ -18,71 +20,30 @@ public SafeZipFile(File file) throws IOException { super(file); } - // Bytes written by unzipToFile across the archive: the sum of the central - // directory's declared sizes is checked up front (inspect), the actual + // Bytes written by unzipToFile across the archive: the central + // directory's declared sizes are checked up front (inspect), the actual // stream is re-checked here — headers can lie. private long totalWritten = 0; - static final class Inspection { - long entries; - long totalUncompressed; - } - /** - * Walks the central directory once, enforcing the archive_limits caps - * (entry count, per-entry size, total size, compression ratio) and the - * reserved-name / traversal rules, before a single byte is extracted. + * Walks the central directory once and hands it to the shared accounting + * (ArchiveLimits.checkEntries: entry count, per-entry and total size, + * compression ratios, reserved `.pushy-` names) before a single byte is + * extracted. Returns the total uncompressed size. */ - Inspection inspect() throws IOException { - Inspection result = new Inspection(); - Enumeration entries = entries(); - while (entries.hasMoreElements()) { - ZipEntry entry = entries.nextElement(); - result.entries++; - if (result.entries > ArchiveLimits.MAX_ENTRIES) { - throw new IOException( - "archive has too many entries (> " + ArchiveLimits.MAX_ENTRIES + ")"); - } - long size = entry.getSize(); - long compressed = entry.getCompressedSize(); - if (size > ArchiveLimits.MAX_ENTRY_BYTES) { - throw new IOException( - "archive entry too large: " + entry.getName() + " (" + size + " bytes)"); - } - if (size > 0) { - result.totalUncompressed += size; - if (result.totalUncompressed > ArchiveLimits.MAX_TOTAL_UNCOMPRESSED_BYTES) { - throw new IOException("archive expands beyond " - + ArchiveLimits.MAX_TOTAL_UNCOMPRESSED_BYTES + " bytes"); - } - if (compressed > 0 && size > ArchiveLimits.RATIO_CHECK_MIN_BYTES - && size / compressed > ArchiveLimits.MAX_COMPRESSION_RATIO) { - throw new IOException( - "archive entry compression ratio too high: " + entry.getName()); - } - } + long inspect(long archiveBytes) throws IOException { + ArrayList entries = Collections.list(entries()); + int count = entries.size(); + String[] names = new String[count]; + long[] sizes = new long[count]; + long[] compressedSizes = new long[count]; + for (int i = 0; i < count; i++) { + ZipEntry entry = entries.get(i); + names[i] = entry.getName(); + sizes[i] = entry.getSize(); + compressedSizes[i] = entry.getCompressedSize(); } - return result; - } - - // Files the SDK itself writes into a version directory to record its own - // decisions (the ".pushy-complete" install marker today). An archive - // must never be able to ship one: a package that unzips its own marker - // next to a bundle would be trusted as a finished install even when the - // patch step that follows fails. - static final String RESERVED_ENTRY_PREFIX = ".pushy-"; - - static boolean isReservedEntryName(String name) { - if (name == null) { - return false; - } - String normalized = name.replace('\\', '/'); - while (normalized.endsWith("/")) { - normalized = normalized.substring(0, normalized.length() - 1); - } - int slash = normalized.lastIndexOf('/'); - String baseName = slash >= 0 ? normalized.substring(slash + 1) : normalized; - return baseName.startsWith(RESERVED_ENTRY_PREFIX); + return ArchiveLimits.checkEntries(archiveBytes, names, sizes, compressedSizes); } @Override @@ -114,9 +75,6 @@ public ZipEntry nextElement() { if (null != name && (name.contains("../") || name.contains("..\\"))) { throw new SecurityException("illegal entry: " + name); } - if (isReservedEntryName(name)) { - throw new SecurityException("reserved entry: " + name); - } } return entry; } @@ -137,10 +95,6 @@ void unzipToPath(ZipEntry ze, File targetPath, MessageDigest digest) throws IOEx if (!canonicalPath.startsWith(targetPath.getCanonicalPath() + File.separator)) { throw new SecurityException("Illegal name: " + name); } - if (isReservedEntryName(name)) { - throw new SecurityException("reserved entry: " + name); - } - if (ze.isDirectory()) { target.mkdirs(); return; diff --git a/android/src/test/java/cn/reactnative/modules/update/SafeZipFileTest.java b/android/src/test/java/cn/reactnative/modules/update/SafeZipFileTest.java index f66eb876..99025bce 100644 --- a/android/src/test/java/cn/reactnative/modules/update/SafeZipFileTest.java +++ b/android/src/test/java/cn/reactnative/modules/update/SafeZipFileTest.java @@ -48,19 +48,6 @@ private static void drain(SafeZipFile zip) { } } - @Test - public void reservedEntryNamesAreDetectedByBaseName() { - assertTrue(SafeZipFile.isReservedEntryName(".pushy-complete")); - assertTrue(SafeZipFile.isReservedEntryName("sub/dir/.pushy-complete")); - assertTrue(SafeZipFile.isReservedEntryName("sub\\dir\\.pushy-anything")); - assertTrue(SafeZipFile.isReservedEntryName(".pushy-complete/")); - assertFalse(SafeZipFile.isReservedEntryName("pushy-complete")); - assertFalse(SafeZipFile.isReservedEntryName("sub/.pushy/file")); - assertFalse(SafeZipFile.isReservedEntryName("index.bundlejs")); - assertFalse(SafeZipFile.isReservedEntryName("")); - assertFalse(SafeZipFile.isReservedEntryName(null)); - } - @Test public void entriesRejectTraversalNames() throws IOException { Map entries = new LinkedHashMap<>(); @@ -75,20 +62,7 @@ public void entriesRejectTraversalNames() throws IOException { } @Test - public void entriesRejectReservedNames() throws IOException { - Map entries = new LinkedHashMap<>(); - entries.put("index.bundlejs", utf8("bundle")); - entries.put(".pushy-complete", utf8("{}")); - try (SafeZipFile zip = new SafeZipFile(zipOf(entries))) { - drain(zip); - fail("reserved entry must be rejected"); - } catch (SecurityException expected) { - assertTrue(expected.getMessage().contains(".pushy-complete")); - } - } - - @Test - public void unzipToPathRejectsTraversalAndReservedNames() throws IOException { + public void unzipToPathRejectsTraversal() throws IOException { Map entries = new LinkedHashMap<>(); entries.put("ok.txt", utf8("ok")); File target = temp.newFolder(); @@ -99,15 +73,8 @@ public void unzipToPathRejectsTraversalAndReservedNames() throws IOException { } catch (SecurityException expected) { // expected } - try { - zip.unzipToPath(new ZipEntry("nested/.pushy-complete"), target); - fail("reserved name must be rejected before extraction"); - } catch (SecurityException expected) { - // expected - } } assertFalse(new File(temp.getRoot(), "escape.txt").exists()); - assertFalse(new File(target, "nested").exists()); } @Test @@ -118,9 +85,6 @@ public void unzipToPathExtractsFilesAndDirectories() throws IOException { entries.put("index.bundlejs", utf8("console.log(1)")); File target = temp.newFolder(); try (SafeZipFile zip = new SafeZipFile(zipOf(entries))) { - SafeZipFile.Inspection inspection = zip.inspect(); - assertEquals(3, inspection.entries); - assertEquals(3 + "console.log(1)".length(), inspection.totalUncompressed); Enumeration it = zip.entries(); while (it.hasMoreElements()) { zip.unzipToPath(it.nextElement(), target); @@ -133,46 +97,7 @@ public void unzipToPathExtractsFilesAndDirectories() throws IOException { StandardCharsets.UTF_8)); } - @Test - public void inspectRejectsTooManyEntries() throws IOException { - Map entries = new LinkedHashMap<>(); - for (long i = 0; i <= ArchiveLimits.MAX_ENTRIES; i++) { - entries.put("e" + i, null); - } - try (SafeZipFile zip = new SafeZipFile(zipOf(entries))) { - zip.inspect(); - fail("entry count over the cap must be rejected"); - } catch (IOException expected) { - assertTrue(expected.getMessage().contains("too many entries")); - } - } - - @Test - public void inspectRejectsCompressionBombs() throws IOException { - // A megabyte of zeros deflates to about a kilobyte: far beyond the - // allowed ratio once the entry is large enough to be checked. - byte[] zeros = new byte[(int) ArchiveLimits.RATIO_CHECK_MIN_BYTES + 1]; - Map entries = new LinkedHashMap<>(); - entries.put("bomb.bin", zeros); - try (SafeZipFile zip = new SafeZipFile(zipOf(entries))) { - zip.inspect(); - fail("compression bomb must be rejected"); - } catch (IOException expected) { - assertTrue(expected.getMessage().contains("compression ratio")); - } - } - - @Test - public void inspectSkipsRatioCheckForSmallEntries() throws IOException { - // Same ratio, but under RATIO_CHECK_MIN_BYTES: small highly - // compressible files (JSON manifests) are legitimate. - byte[] zeros = new byte[64 * 1024]; - Map entries = new LinkedHashMap<>(); - entries.put("small.bin", zeros); - try (SafeZipFile zip = new SafeZipFile(zipOf(entries))) { - SafeZipFile.Inspection inspection = zip.inspect(); - assertEquals(1, inspection.entries); - assertEquals(zeros.length, inspection.totalUncompressed); - } - } + // The entry accounting (count, sizes, ratios, reserved names) is the + // shared core's: ArchiveLimits.checkEntries over JNI, which the JVM + // cannot load. cpp/patch_core/tests/archive-limits-vectors.json covers it. } diff --git a/cpp/android_jni/archive_limits_jni.cpp b/cpp/android_jni/archive_limits_jni.cpp new file mode 100644 index 00000000..92c2a966 --- /dev/null +++ b/cpp/android_jni/archive_limits_jni.cpp @@ -0,0 +1,83 @@ +// JNI glue of ArchiveLimits.java over the shared extraction accounting +// (cpp/patch_core/archive_limits.h). +#include + +#include +#include + +#include "jni_natives.h" +#include "jni_util.h" +#include "../patch_core/archive_limits.h" + +namespace { + +using pushy::jni_util::GuardJni; +using pushy::jni_util::JArrayToVector; +using pushy::jni_util::ThrowRuntimeException; + +void ThrowIOException(JNIEnv* env, const std::string& message) { + jclass exception = env->FindClass("java/io/IOException"); + if (exception != nullptr) { + env->ThrowNew(exception, message.c_str()); + } +} + +} // namespace + +// One call per archive: the central directory's names and declared sizes +// through an EntryAccumulator. Returns the total uncompressed size, or throws +// an IOException with the first violation. +jlong pushy::jni_natives::ArchiveCheckEntries( + JNIEnv* env, jclass, jlong archive_bytes, jobjectArray names, + jlongArray sizes, jlongArray compressed_sizes) { + return GuardJni( + env, "Unexpected native exception while checking an archive", [&]() -> jlong { + const jsize count = names == nullptr ? 0 : env->GetArrayLength(names); + if (sizes == nullptr || compressed_sizes == nullptr || + env->GetArrayLength(sizes) != count || + env->GetArrayLength(compressed_sizes) != count) { + ThrowRuntimeException(env, "archive entry arrays differ in length"); + return 0; + } + // Before any name is copied: a hostile central directory may list far + // more entries than the cap. + pushy::delta::Status status = pushy::archive_limits::CheckEntryCount(count); + if (!status.ok) { + ThrowIOException(env, status.message); + return 0; + } + const std::vector entry_names = JArrayToVector(env, names); + std::vector size_values(static_cast(count)); + std::vector compressed_values(static_cast(count)); + if (count > 0) { + env->GetLongArrayRegion(sizes, 0, count, size_values.data()); + env->GetLongArrayRegion(compressed_sizes, 0, count, compressed_values.data()); + } + pushy::archive_limits::EntryAccumulator accumulator(archive_bytes); + for (jsize i = 0; i < count; i++) { + status = accumulator.Accept(size_values[i], compressed_values[i], false, + entry_names[i]); + if (!status.ok) { + ThrowIOException(env, status.message); + return 0; + } + } + return accumulator.total_uncompressed(); + }); +} + +// {bytes to probe for (0: no probe), reservation end} before one chunk of a +// download is written. +jlongArray pushy::jni_natives::ArchivePlanFreeSpaceProbe( + JNIEnv* env, jclass, jlong known_total, jlong written_before, + jlong chunk_bytes, jlong reserved_until) { + const pushy::archive_limits::FreeSpaceProbe plan = + pushy::archive_limits::PlanUnknownLengthFreeSpaceProbe( + known_total, written_before, chunk_bytes, reserved_until); + jlongArray result = env->NewLongArray(2); + if (result != nullptr) { + const jlong values[2] = {plan.probe ? plan.reserve : 0, plan.reserved_until}; + env->SetLongArrayRegion(result, 0, 2, values); + } + return result; +} diff --git a/cpp/android_jni/jni_natives.h b/cpp/android_jni/jni_natives.h index 3d667e49..6e195a97 100644 --- a/cpp/android_jni/jni_natives.h +++ b/cpp/android_jni/jni_natives.h @@ -31,6 +31,8 @@ jstring InstallRecordCheckActivation(JNIEnv* env, jclass, jstring version_dir, j jstring InstallRecordSeal(JNIEnv* env, jclass, jstring staging_dir, jstring version_hash, jstring bundle_file_name, jstring artifact_sha256, jstring known_bundle_sha256); jstring InstallRecordPromote(JNIEnv* env, jclass, jstring staging_dir, jstring version_dir); jstring InstallRecordBundleSha256(JNIEnv* env, jclass, jstring version_dir); +jlong ArchiveCheckEntries(JNIEnv* env, jclass, jlong archive_bytes, jobjectArray names, jlongArray sizes, jlongArray compressed_sizes); +jlongArray ArchivePlanFreeSpaceProbe(JNIEnv* env, jclass, jlong known_total, jlong written_before, jlong chunk_bytes, jlong reserved_until); } // namespace jni_natives } // namespace pushy diff --git a/cpp/android_jni/jni_registration.cpp b/cpp/android_jni/jni_registration.cpp index e0e6ea60..e5a7546c 100644 --- a/cpp/android_jni/jni_registration.cpp +++ b/cpp/android_jni/jni_registration.cpp @@ -75,6 +75,13 @@ const std::vector& NativeClasses() { {"3802fad5a28e5b4d236d49aff9b5", "723bfed0b88a274923311bb6e5a7826444205fa8d2d1b98373002008e8c4ef8e8e655d3f09b0", reinterpret_cast(&pushy::jni_natives::InstallRecordBundleSha256)}, }}, + {"3919bbc3ab8a6b512c3e08f0c0b6df60452311edfbc8f780624b2d1de38c81af997f5d270bc7c1a88b8b6f", // ArchiveLimits + { + {"391ff1d2a5ae6651303619ea", "723dcffda48a7e446d331df7d1fca379582e0ae6a5e092ae580606", + reinterpret_cast(&pushy::jni_natives::ArchiveCheckEntries)}, + {"2a1bf5df88996d40112f1dfad38382624822", "723ddefb84c2536f", + reinterpret_cast(&pushy::jni_natives::ArchivePlanFreeSpaceProbe)}, + }}, }; return classes; } diff --git a/scripts/check-constant-parity.js b/scripts/check-constant-parity.js index 6e1e6b49..82606a93 100644 --- a/scripts/check-constant-parity.js +++ b/scripts/check-constant-parity.js @@ -115,6 +115,16 @@ const javaDir = 'android/src/main/java/cn/reactnative/modules/update'; // Must always be parsed from the header (guards the parser itself: a regex // regression would otherwise silently drop the pair's coverage). const archiveLimitsRequired = ['UnknownLengthFreeSpaceProbeBytes', 'MaxArchiveBytes']; +// The accounting itself lives in archive_limits.cpp; each host mirrors only +// the caps it still compares against on its own (download, manifest, +// streamed-bytes backstop, free-space margin). +const archiveLimitsMirroredByJava = [ + 'MaxArchiveBytes', + 'MaxTotalUncompressedBytes', + 'MaxEntryBytes', + 'MaxManifestBytes', + 'FreeDiskMarginBytes', +]; const errorCodesRequired = ['DeltaFailed', 'InvalidOptions']; // The record logic itself lives in install_record.cpp; the hosts only mirror // the staging suffix they still build their unpack directory from. @@ -134,6 +144,7 @@ const pairs = [ parse: parseJavaConstants, mirrorName: screamingSnake, required: archiveLimitsRequired, + only: archiveLimitsMirroredByJava, }, { header: 'cpp/patch_core/install_record.h', diff --git a/src/__tests__/jniRegistration.test.ts b/src/__tests__/jniRegistration.test.ts index 753e2789..a2e39084 100644 --- a/src/__tests__/jniRegistration.test.ts +++ b/src/__tests__/jniRegistration.test.ts @@ -37,7 +37,9 @@ const javaNatives = () => .replace(/\/\*[\s\S]*?\*\//g, '') .replace(/\/\/.*$/gm, ''); return [ - ...source.matchAll(/\bnative\s+([\w[\]]+)\s+(\w+)\s*\(([^)]*)\)\s*;/g), + ...source.matchAll( + /\bnative\s+([\w[\]]+)\s+(\w+)\s*\(([^)]*)\)\s*(?:throws\s+[\w.,\s]+)?;/g + ), ].map((match) => { const params = match[3] .split(',') @@ -73,7 +75,7 @@ const registered = () => { describe('JNI registration table', () => { test('matches every Java native declaration', () => { const natives = javaNatives(); - expect(natives.length).toBe(16); + expect(natives.length).toBe(18); expect(registered()).toEqual(natives); }); From 65ec36cbc425a211d8bb78e6a766b7aae960f2b9 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 17:40:18 +0800 Subject: [PATCH 099/104] refactor(harmony): run the archive checks through archive_limits over NAPI pushy.cpp exports the extraction accounting (cpp/patch_core/archive_limits.h): checkUncompressedSize (whole-archive expansion and ratio, synchronous, returns the reason), planFreeSpaceProbe (the unknown-length download probe plan, per chunk) and checkExtractedDirectory (the post-unpack walk, a promise on a libuv worker). zlib exposes no entries, so the accumulator runs over the unpacked tree there. ArchiveLimits.ts keeps checkOriginalSize (the getOriginalSize gate, Harmony only) and ensureFreeSpace, and maps refusals to PATCH_FAILED. planUnknownLengthFreeSpaceProbe and measureExtractedDirectory are gone, and so are InstallRecord.ts's RESERVED_ENTRY_PREFIX / isReservedEntryName. The walk no longer runs on the JS thread and uses lstat: a symlink in the unpacked tree is refused instead of followed. ArchiveLimits.ts mirrors only the caps it still compares against (MAX_ARCHIVE_BYTES, MAX_MANIFEST_BYTES, FREE_DISK_MARGIN_BYTES); check-constant-parity holds it to exactly those. The bun tests keep the getOriginalSize gate and cover only the binding (arguments, error code); the probe and ratio cases are the C++ vectors'. Co-Authored-By: Claude Opus 5.5 --- harmony/pushy/src/main/cpp/pushy.cpp | 111 ++++++++++ harmony/pushy/src/main/ets/ArchiveLimits.ts | 146 +++---------- harmony/pushy/src/main/ets/DownloadTask.ts | 19 +- harmony/pushy/src/main/ets/InstallRecord.ts | 7 - harmony/pushy/src/main/ets/NativePatchCore.ts | 27 +++ scripts/check-constant-parity.js | 8 + src/__tests__/harmonyArchiveLimits.test.ts | 205 ++++++------------ src/__tests__/harmonyInstallRecord.test.ts | 12 +- 8 files changed, 251 insertions(+), 284 deletions(-) diff --git a/harmony/pushy/src/main/cpp/pushy.cpp b/harmony/pushy/src/main/cpp/pushy.cpp index 9cf443ad..b198618f 100644 --- a/harmony/pushy/src/main/cpp/pushy.cpp +++ b/harmony/pushy/src/main/cpp/pushy.cpp @@ -13,6 +13,7 @@ #include #include +#include "archive_limits.h" #include "archive_patch_core.h" #include "digest.h" #include "install_record.h" @@ -1036,6 +1037,113 @@ napi_value CheckActivation(napi_env env, napi_callback_info info) { SettleInstallRecordWork); } +// ---- Archive extraction limits (cpp/patch_core/archive_limits.h) ---- + +// Reads `count` number arguments (byte counts: integers below 2^53). +bool GetInt64Args( + napi_env env, + napi_callback_info info, + const char* name, + size_t count, + int64_t* out) { + napi_value args[4] = {nullptr, nullptr, nullptr, nullptr}; + size_t argc = count; + if (count > 4 || !GetArgCount(env, info, &argc, args) || argc < count) { + ThrowError(env, std::string(name) + ": wrong number of arguments"); + return false; + } + for (size_t i = 0; i < count; i++) { + napi_valuetype type = napi_undefined; + if (!GetValueType(env, args[i], &type) || type != napi_number || + napi_get_value_int64(env, args[i], &out[i]) != napi_ok) { + ThrowError(env, std::string(name) + ": argument " + std::to_string(i) + + " must be a number"); + return false; + } + } + return true; +} + +// checkUncompressedSize(archiveBytes, uncompressedBytes) -> string | undefined: +// the reason the whole archive's expansion is refused. +napi_value CheckUncompressedSize(napi_env env, napi_callback_info info) { + int64_t args[2] = {0, 0}; + if (!GetInt64Args(env, info, "checkUncompressedSize", 2, args)) { + return nullptr; + } + const pushy::delta::Status status = + pushy::archive_limits::CheckUncompressedSize(args[0], args[1]); + return status.ok ? nullptr : NewString(env, status.message); +} + +// planFreeSpaceProbe(knownTotal, writtenBefore, chunkBytes, reservedUntil) +// -> {probe, reserve, reservedUntil}, before one chunk of a download is +// written. +napi_value PlanFreeSpaceProbe(napi_env env, napi_callback_info info) { + int64_t args[4] = {0, 0, 0, 0}; + if (!GetInt64Args(env, info, "planFreeSpaceProbe", 4, args)) { + return nullptr; + } + const pushy::archive_limits::FreeSpaceProbe plan = + pushy::archive_limits::PlanUnknownLengthFreeSpaceProbe( + args[0], args[1], args[2], args[3]); + napi_value result = nullptr; + napi_value reserve = nullptr; + napi_value reserved_until = nullptr; + if (napi_create_object(env, &result) != napi_ok || + napi_create_int64(env, plan.reserve, &reserve) != napi_ok || + napi_create_int64(env, plan.reserved_until, &reserved_until) != napi_ok) { + ThrowError(env, "planFreeSpaceProbe: cannot create the result"); + return nullptr; + } + SetBoolProperty(env, result, "probe", plan.probe); + napi_set_named_property(env, result, "reserve", reserve); + napi_set_named_property(env, result, "reservedUntil", reserved_until); + return result; +} + +struct ExtractedDirectoryWork { + std::string directory; + int64_t archive_bytes = 0; + pushy::delta::Status status{false, ""}; +}; + +// checkExtractedDirectory(directory, archiveBytes) -> Promise: walks +// the freshly unpacked tree on a libuv worker thread and rejects with the +// first violation. +napi_value CheckExtractedDirectory(napi_env env, napi_callback_info info) { + napi_value args[2] = {nullptr, nullptr}; + size_t argc = 2; + if (!GetArgCount(env, info, &argc, args) || argc < 2) { + ThrowError(env, "checkExtractedDirectory: wrong number of arguments"); + return nullptr; + } + ExtractedDirectoryWork work; + bool ok = false; + work.directory = GetString(env, args[0], &ok); + if (!ok) { + return nullptr; + } + napi_valuetype type = napi_undefined; + if (work.directory.empty() || !GetValueType(env, args[1], &type) || + type != napi_number || + napi_get_value_int64(env, args[1], &work.archive_bytes) != napi_ok) { + ThrowError(env, "checkExtractedDirectory: expected a directory and a size"); + return nullptr; + } + return QueueWork( + env, + "checkExtractedDirectory", + std::move(work), + [](ExtractedDirectoryWork& w) { + w.status = pushy::archive_limits::CheckExtractedDirectory( + w.directory, w.archive_bytes); + }, + [](napi_env cb_env, napi_deferred deferred, const ExtractedDirectoryWork& w) { + SettleWithStatus(cb_env, deferred, w.status); + }); +} + bool ExportFunction( napi_env env, napi_value exports, @@ -1284,6 +1392,9 @@ napi_value Init(napi_env env, napi_value exports) { !ExportFunction(env, exports, "sealInstallRecord", SealInstallRecord) || !ExportFunction(env, exports, "promoteStaging", PromoteStaging) || !ExportFunction(env, exports, "checkActivation", CheckActivation) || + !ExportFunction(env, exports, "checkUncompressedSize", CheckUncompressedSize) || + !ExportFunction(env, exports, "planFreeSpaceProbe", PlanFreeSpaceProbe) || + !ExportFunction(env, exports, "checkExtractedDirectory", CheckExtractedDirectory) || !ExportFunction(env, exports, "crc32", Crc32) || !ExportFunction(env, exports, "getSupportedDiffVersion", GetSupportedDiffVersion) || !ExportFunction(env, exports, "prepareRound", FlowPrepareRound) || diff --git a/harmony/pushy/src/main/ets/ArchiveLimits.ts b/harmony/pushy/src/main/ets/ArchiveLimits.ts index e03495c3..f3a2a259 100644 --- a/harmony/pushy/src/main/ets/ArchiveLimits.ts +++ b/harmony/pushy/src/main/ets/ArchiveLimits.ts @@ -4,61 +4,35 @@ import { ERROR_FILE_OPERATION_FAILED, ERROR_DELTA_FAILED, createPushyError, + getErrorMessage, } from './ErrorCodes'; -import { isReservedEntryName } from './InstallRecord'; +import NativePatchCore from './NativePatchCore'; -// ArkTS mirror of cpp/patch_core/archive_limits.h — keep in sync by hand -// (scripts/check-constant-parity.js asserts the values match). -// 损坏或恶意更新包的伤害上限:最多耗费有限的磁盘/内存/时间,绝不撑爆磁盘。 +// 解压资源上限的计数规则(条目数、单条/总解压量、压缩比、保留名、符号链接、 +// 未知长度下载的磁盘探测计划)只在 cpp/patch_core/archive_limits.h 里实现, +// 三端共用(这里经 NAPI)。下面只镜像本端仍需自己比较的常量, +// scripts/check-constant-parity.js 断言与头文件一致。 export const MAX_ARCHIVE_BYTES = 512 * 1024 * 1024; -export const MAX_TOTAL_UNCOMPRESSED_BYTES = 2048 * 1024 * 1024; -export const MAX_ENTRY_BYTES = 512 * 1024 * 1024; -export const MAX_ENTRIES = 20000; -export const MAX_COMPRESSION_RATIO = 100; -export const RATIO_CHECK_MIN_BYTES = 1 * 1024 * 1024; export const MAX_MANIFEST_BYTES = 16 * 1024 * 1024; export const FREE_DISK_MARGIN_BYTES = 64 * 1024 * 1024; -export const UNKNOWN_LENGTH_FREE_SPACE_PROBE_BYTES = 8 * 1024 * 1024; /** - * 解压前的总量校验(zlib.getOriginalSize 给出的归档解压后总字节数)。 - * - * 与 archive_limits.h 的偏差(有意为之,无法消除):头文件的压缩比上限是 - * **逐条**的(单条解压量 > RATIO_CHECK_MIN_BYTES 时才查),但鸿蒙的 - * @kit.BasicServicesKit zlib 只提供整包解压量(getOriginalSize)与整包 - * 解压(decompressFile),拿不到任何条目的压缩/解压尺寸,也没有逐条钩子。 - * 因此这里只能按整包近似:总解压量超过上限、或(总量大于 - * RATIO_CHECK_MIN_BYTES 时)总压缩比超过 MAX_COMPRESSION_RATIO 即拒绝—— - * 20MB 归档 100:1 的 2GB 载荷在解压前就被挡下,而不是先解出来再量。 - * 两个方向的差异: - * - 更松:单条高压缩比条目被同包大量不可压缩数据"稀释"时整包比可能不超限 - * (其总量仍受 MAX_TOTAL_UNCOMPRESSED_BYTES 与解压后的逐条 - * MAX_ENTRY_BYTES 约束,伤害有界); - * - 更严:大量各自 < RATIO_CHECK_MIN_BYTES 的高压缩比小文件,逐条不查, - * 但累加后整包比超限会被拒绝(真实更新包不会出现 100:1 的整包比)。 - * 解压后 measureExtractedDirectory 再逐条核对条目数/单条大小/总字节数。 + * 解压前的整包校验(zlib.getOriginalSize 给出的归档解压后总字节数):总解压 + * 量上限与整包压缩比(archive_limits::CheckUncompressedSize)。鸿蒙的 zlib + * 只提供整包解压量与整包解压,拿不到条目的压缩尺寸,逐条压缩比无从查起; + * 20MB 归档 100:1 的 2GB 载荷在解压前就被挡下,解压后 checkExtractedDirectory + * 再逐条核对。 */ export function checkUncompressedSize( archiveBytes: number, uncompressedBytes: number, ): void { - if (uncompressedBytes > MAX_TOTAL_UNCOMPRESSED_BYTES) { - throw createPushyError( - ERROR_DELTA_FAILED, - `archive expands beyond ${MAX_TOTAL_UNCOMPRESSED_BYTES} bytes ` + - `(${uncompressedBytes})`, - ); - } - if ( - uncompressedBytes > RATIO_CHECK_MIN_BYTES && - archiveBytes > 0 && - uncompressedBytes > archiveBytes * MAX_COMPRESSION_RATIO - ) { - throw createPushyError( - ERROR_DELTA_FAILED, - `archive compression ratio exceeds ${MAX_COMPRESSION_RATIO}:1 ` + - `(${uncompressedBytes}/${archiveBytes})`, - ); + const reason = NativePatchCore.checkUncompressedSize( + archiveBytes, + uncompressedBytes, + ); + if (reason !== undefined) { + throw createPushyError(ERROR_DELTA_FAILED, reason); } } @@ -90,35 +64,6 @@ export function checkOriginalSize( return size; } -export interface FreeSpaceProbePlan { - /** 本次写入前是否需要探测可用空间。 */ - probe: boolean; - /** 探测时要求的字节数(不含 FREE_DISK_MARGIN_BYTES)。 */ - reserve: number; - /** 计划后的"已预留到"位置(相对本次响应体起点)。 */ - reservedUntil: number; -} - -/** - * 未知长度下载(分块/编码响应体)的磁盘探测计划:响应到达时只能预留安全 - * 余量,所以任何会写过已预留位置的写入之前都要探测,每次至少预留接下来的 - * UNKNOWN_LENGTH_FREE_SPACE_PROBE_BYTES(单个 chunk 更大时按 chunk 算)—— - * 任何写入都吃不到余量。已知总长时由响应到达时的一次性检查覆盖,不探测。 - * writtenBefore 是本 chunk 之前已收到的字节数(相对本次响应体)。 - */ -export function planUnknownLengthFreeSpaceProbe( - knownTotal: number, - writtenBefore: number, - chunkBytes: number, - reservedUntil: number, -): FreeSpaceProbePlan { - const reserve = Math.max(UNKNOWN_LENGTH_FREE_SPACE_PROBE_BYTES, chunkBytes); - if (knownTotal > 0 || writtenBefore + chunkBytes <= reservedUntil) { - return { probe: false, reserve, reservedUntil }; - } - return { probe: true, reserve, reservedUntil: writtenBefore + reserve }; -} - /** * 目标所在文件系统必须还能再写 bytesToWrite + 安全余量。取不到可用空间 * (返回 0/异常)视为未知,放行。 @@ -153,55 +98,18 @@ export async function ensureFreeSpace( } } -export interface DirectoryMeasure { - entries: number; - bytes: number; -} - /** - * 递归统计目录里的条目数与总字节数;两者任一超过上限立即抛错(不必扫完)。 - * 同一趟遍历里拒绝任意深度的 `.pushy-` 保留条目(完成记录只能由 SDK 写)。 + * 解压后在 native 工作线程逐条核对刚解压的目录(archive_limits:: + * CheckExtractedDirectory):条目数、单条大小、总字节数与整包压缩比,拒绝 + * 任意深度的 `.pushy-` 保留条目(完成记录只能由 SDK 写)与符号链接。 */ -export async function measureExtractedDirectory( +export async function checkExtractedDirectory( directory: string, - acc: DirectoryMeasure = { entries: 0, bytes: 0 }, -): Promise { - const names = (await fileIo.listFile(directory)).filter( - name => name !== '.' && name !== '..', - ); - for (const name of names) { - if (isReservedEntryName(name)) { - throw createPushyError( - ERROR_DELTA_FAILED, - `archive contains reserved entry ${name}`, - ); - } - const path = `${directory}/${name}`; - const stat = await fileIo.stat(path); - acc.entries += 1; - if (acc.entries > MAX_ENTRIES) { - throw createPushyError( - ERROR_DELTA_FAILED, - `archive has too many entries (> ${MAX_ENTRIES})`, - ); - } - if (stat.isDirectory()) { - await measureExtractedDirectory(path, acc); - continue; - } - if (stat.size > MAX_ENTRY_BYTES) { - throw createPushyError( - ERROR_DELTA_FAILED, - `archive entry too large: ${name} (${stat.size} bytes)`, - ); - } - acc.bytes += stat.size; - if (acc.bytes > MAX_TOTAL_UNCOMPRESSED_BYTES) { - throw createPushyError( - ERROR_DELTA_FAILED, - `archive expands beyond ${MAX_TOTAL_UNCOMPRESSED_BYTES} bytes`, - ); - } + archiveBytes: number, +): Promise { + try { + await NativePatchCore.checkExtractedDirectory(directory, archiveBytes); + } catch (e) { + throw createPushyError(ERROR_DELTA_FAILED, getErrorMessage(e)); } - return acc; } diff --git a/harmony/pushy/src/main/ets/DownloadTask.ts b/harmony/pushy/src/main/ets/DownloadTask.ts index 5b30cec6..6b6fc6f4 100644 --- a/harmony/pushy/src/main/ets/DownloadTask.ts +++ b/harmony/pushy/src/main/ets/DownloadTask.ts @@ -21,11 +21,10 @@ import logger from './Logger'; import { MAX_ARCHIVE_BYTES, MAX_MANIFEST_BYTES, + checkExtractedDirectory, checkOriginalSize, checkUncompressedSize, ensureFreeSpace, - measureExtractedDirectory, - planUnknownLengthFreeSpaceProbe, } from './ArchiveLimits'; import { HARMONY_BUNDLE_FILE_NAME, @@ -244,7 +243,7 @@ export class DownloadTask { * 解压前读取归档解压后的总字节数(zlib.getOriginalSize,API 12——RNOH 的 * 最低 API 已覆盖)。读不到就拒绝归档(checkOriginalSize):zlib.decompressFile * 没有逐条钩子,不知道展开量就放行等于允许一个 20MB 的归档在 - * measureExtractedDirectory 跑到之前先把磁盘写满。拒绝带专属错误细节 + * checkExtractedDirectory 跑到之前先把磁盘写满。拒绝带专属错误细节 * (ORIGINAL_SIZE_UNAVAILABLE)并单独记日志,与"超限"拒绝可区分。 */ private async readOriginalSize(archiveFile: string): Promise { @@ -270,8 +269,9 @@ export class DownloadTask { * 解压 + 资源上限(cpp/patch_core/archive_limits.h)。zlib.decompressFile * 没有逐条钩子:解压前用 getOriginalSize 把总解压量与整包压缩比挡在 * 上限内(20MB 归档 100:1 的 2GB 载荷不会先解出来再量),按已知解压量查 - * 磁盘,解压量未知直接拒绝;解压后再统计条目数/单条大小/总字节数并拒绝 - * 任意深度的 `.pushy-` 保留条目(超限即失败,staging 目录由失败清理删除)。 + * 磁盘,解压量未知直接拒绝;解压后再逐条核对条目数/单条大小/总字节数, + * 拒绝任意深度的 `.pushy-` 保留条目与符号链接(规则见 + * cpp/patch_core/archive_limits.h;超限即失败,staging 目录由失败清理删除)。 */ private async extractArchive( archiveFile: string, @@ -292,7 +292,7 @@ export class DownloadTask { } catch (e) { throw toPushyError(e, ERROR_DELTA_FAILED); } - await measureExtractedDirectory(unzipDirectory); + await checkExtractedDirectory(unzipDirectory, archiveStat.size); } private async recreateDirectory(path: string): Promise { @@ -574,9 +574,10 @@ export class DownloadTask { `archive too large: exceeded ${MAX_ARCHIVE_BYTES}`, ); } - // 未知长度:见 planUnknownLengthFreeSpaceProbe。失败保留 partial 供 - // 下次续传。 - const plan = planUnknownLengthFreeSpaceProbe( + // 未知长度:响应到达时只能预留安全余量,任何会写过已预留位置的写入 + // 之前都要探测(archive_limits::PlanUnknownLengthFreeSpaceProbe)。 + // 失败保留 partial 供下次续传。 + const plan = NativePatchCore.planFreeSpaceProbe( totalAll, received - data.byteLength, data.byteLength, diff --git a/harmony/pushy/src/main/ets/InstallRecord.ts b/harmony/pushy/src/main/ets/InstallRecord.ts index fa34c189..35af5704 100644 --- a/harmony/pushy/src/main/ets/InstallRecord.ts +++ b/harmony/pushy/src/main/ets/InstallRecord.ts @@ -12,19 +12,12 @@ import { // 稳定错误码。 // STAGING_SUFFIX 镜像 kStagingSuffix(scripts/check-constant-parity.js 断言)。 export const STAGING_SUFFIX = '.staging'; -// 归档不得携带任何 `.pushy-` 前缀的条目(任意深度):这些名字保留给 SDK 自己 -// 写的记录,与 Android 的规则一致。 -export const RESERVED_ENTRY_PREFIX = '.pushy-'; export const HARMONY_BUNDLE_FILE_NAME = 'bundle.harmony.js'; export function stagingDirectoryFor(versionDir: string): string { return `${versionDir}${STAGING_SUFFIX}`; } -export function isReservedEntryName(name: string): boolean { - return name.startsWith(RESERVED_ENTRY_PREFIX); -} - /** * 在 native 工作线程计算 staging 里 bundle 的摘要并原子写入完成记录 * (schema 1 的 bundleSha256 必填:算不出摘要就安装失败)。失败 = PATCH_FAILED。 diff --git a/harmony/pushy/src/main/ets/NativePatchCore.ts b/harmony/pushy/src/main/ets/NativePatchCore.ts index adbd43cd..93f76153 100644 --- a/harmony/pushy/src/main/ets/NativePatchCore.ts +++ b/harmony/pushy/src/main/ets/NativePatchCore.ts @@ -54,6 +54,15 @@ export interface ArchivePatchRequest { hostCopiesResources?: boolean; } +export interface FreeSpaceProbePlan { + /** 本次写入前是否需要探测可用空间。 */ + probe: boolean; + /** 探测时要求的字节数(不含 FREE_DISK_MARGIN_BYTES)。 */ + reserve: number; + /** 计划后的"已预留到"位置(相对本次响应体起点)。 */ + reservedUntil: number; +} + interface NativePatchCoreBindings { syncStateWithBinaryVersion( packageVersion: string, @@ -113,6 +122,24 @@ interface NativePatchCoreBindings { bundleFileName: string, grandfathered: boolean, ): Promise; + // 解压资源上限(cpp/patch_core/archive_limits.h,与 Android/iOS 共用)。 + /** 整包解压量与整包压缩比;超限时返回原因,否则 undefined */ + checkUncompressedSize( + archiveBytes: number, + uncompressedBytes: number, + ): string | undefined; + /** 工作线程遍历刚解压的目录(不跟随符号链接),超限/保留名/符号链接时以原因 reject */ + checkExtractedDirectory(directory: string, archiveBytes: number): Promise; + /** + * 未知长度下载每个 chunk 写入前的磁盘探测计划。writtenBefore 是本次响应体 + * 在该 chunk 之前已收到的字节数;knownTotal > 0 时从不探测。 + */ + planFreeSpaceProbe( + knownTotal: number, + writtenBefore: number, + chunkBytes: number, + reservedUntil: number, + ): FreeSpaceProbePlan; /** CRC32(zip/zlib 多项式)。pdiff 拷贝前与 copiesCrc 比对用 */ crc32(data: Uint8Array | ArrayBuffer): number; /** 原生 patch 内核可消费的 diff 轨道版本(2 = hdiffv2 轨道) */ diff --git a/scripts/check-constant-parity.js b/scripts/check-constant-parity.js index 82606a93..6f729bb1 100644 --- a/scripts/check-constant-parity.js +++ b/scripts/check-constant-parity.js @@ -6,6 +6,8 @@ // // cpp/patch_core/archive_limits.h <-> harmony/.../ArchiveLimits.ts // android/.../ArchiveLimits.java +// (only the caps a host still +// compares against itself) // cpp/patch_core/install_record.h <-> harmony/.../InstallRecord.ts // android/.../InstallRecord.java // (only the constants a pair lists @@ -118,6 +120,11 @@ const archiveLimitsRequired = ['UnknownLengthFreeSpaceProbeBytes', 'MaxArchiveBy // The accounting itself lives in archive_limits.cpp; each host mirrors only // the caps it still compares against on its own (download, manifest, // streamed-bytes backstop, free-space margin). +const archiveLimitsMirroredByArkTs = [ + 'MaxArchiveBytes', + 'MaxManifestBytes', + 'FreeDiskMarginBytes', +]; const archiveLimitsMirroredByJava = [ 'MaxArchiveBytes', 'MaxTotalUncompressedBytes', @@ -137,6 +144,7 @@ const pairs = [ parse: parseArkTsConstants, mirrorName: screamingSnake, required: archiveLimitsRequired, + only: archiveLimitsMirroredByArkTs, }, { header: 'cpp/patch_core/archive_limits.h', diff --git a/src/__tests__/harmonyArchiveLimits.test.ts b/src/__tests__/harmonyArchiveLimits.test.ts index 9862639a..13190594 100644 --- a/src/__tests__/harmonyArchiveLimits.test.ts +++ b/src/__tests__/harmonyArchiveLimits.test.ts @@ -3,49 +3,54 @@ import { createPushyError, ERROR_DELTA_FAILED, ERROR_FILE_OPERATION_FAILED, + getErrorMessage, type PushyError, } from '../../harmony/pushy/src/main/ets/ErrorCodes'; -import { evalHarmonyModule } from './nativeSources'; - -// The pure limit logic of Harmony ArchiveLimits.ts (ArkTS mirror of -// cpp/patch_core/archive_limits.h), evaluated from source with its file -// system imports stripped. -interface FreeSpaceProbePlan { - probe: boolean; - reserve: number; - reservedUntil: number; -} +import { evalHarmonyModule, source } from './nativeSources'; +// Harmony ArchiveLimits.ts is a thin binding over the shared extraction +// accounting (cpp/patch_core/archive_limits.h, replayed against +// tests/archive-limits-vectors.json by the C++ test). What is left to test +// here: the getOriginalSize gate (Harmony only), what it hands the NAPI +// module, and the stable error code it maps refusals to. interface ArchiveLimits { - UNKNOWN_LENGTH_FREE_SPACE_PROBE_BYTES: number; - MAX_TOTAL_UNCOMPRESSED_BYTES: number; - MAX_COMPRESSION_RATIO: number; - RATIO_CHECK_MIN_BYTES: number; ORIGINAL_SIZE_UNAVAILABLE: string; checkOriginalSize(size: number | undefined, failure?: string): number; checkUncompressedSize(archiveBytes: number, uncompressedBytes: number): void; - planUnknownLengthFreeSpaceProbe( - knownTotal: number, - writtenBefore: number, - chunkBytes: number, - reservedUntil: number - ): FreeSpaceProbePlan; + checkExtractedDirectory( + directory: string, + archiveBytes: number + ): Promise; } -const limits = evalHarmonyModule( - 'ArchiveLimits.ts', - `({ UNKNOWN_LENGTH_FREE_SPACE_PROBE_BYTES, MAX_TOTAL_UNCOMPRESSED_BYTES, - MAX_COMPRESSION_RATIO, RATIO_CHECK_MIN_BYTES, ORIGINAL_SIZE_UNAVAILABLE, - checkOriginalSize, checkUncompressedSize, planUnknownLengthFreeSpaceProbe });`, - { - ERROR_DELTA_FAILED, - ERROR_FILE_OPERATION_FAILED, - createPushyError, - isReservedEntryName: (name: string) => name.startsWith('.pushy-'), - } -) as ArchiveLimits; - -const PROBE = limits.UNKNOWN_LENGTH_FREE_SPACE_PROBE_BYTES; +function load(refusal?: string) { + const calls: Array<[string, unknown[]]> = []; + const NativePatchCore = { + checkUncompressedSize: (...args: unknown[]) => { + calls.push(['checkUncompressedSize', args]); + return refusal; + }, + checkExtractedDirectory: async (...args: unknown[]) => { + calls.push(['checkExtractedDirectory', args]); + if (refusal !== undefined) { + throw new Error(refusal); + } + }, + }; + const limits = evalHarmonyModule( + 'ArchiveLimits.ts', + `({ ORIGINAL_SIZE_UNAVAILABLE, checkOriginalSize, checkUncompressedSize, + checkExtractedDirectory });`, + { + NativePatchCore, + ERROR_DELTA_FAILED, + ERROR_FILE_OPERATION_FAILED, + createPushyError, + getErrorMessage, + } + ) as ArchiveLimits; + return { limits, calls }; +} function refusal(run: () => unknown): PushyError | null { try { @@ -56,78 +61,10 @@ function refusal(run: () => unknown): PushyError | null { return null; } -// Unknown-length downloads: no write may pass a probed reservation. -describe('harmony planUnknownLengthFreeSpaceProbe', () => { - test('never probes when the total length is known', () => { - const plan = limits.planUnknownLengthFreeSpaceProbe(1000, 0, 4096, 0); - expect(plan.probe).toBe(false); - expect(plan.reservedUntil).toBe(0); - }); - - test('probes the first chunk and reserves PROBE bytes ahead', () => { - const plan = limits.planUnknownLengthFreeSpaceProbe(0, 0, 4096, 0); - expect({ ...plan }).toEqual({ - probe: true, - reserve: PROBE, - reservedUntil: PROBE, - }); - }); - - test('skips chunks that stay inside the reservation, edge included', () => { - const plan = limits.planUnknownLengthFreeSpaceProbe(0, 4096, 4096, PROBE); - expect(plan.probe).toBe(false); - expect(plan.reservedUntil).toBe(PROBE); - expect( - limits.planUnknownLengthFreeSpaceProbe(0, PROBE - 10, 10, PROBE).probe - ).toBe(false); - }); - - test('re-probes a chunk that would cross the reservation', () => { - const plan = limits.planUnknownLengthFreeSpaceProbe( - 0, - PROBE - 10, - 11, - PROBE - ); - expect(plan.probe).toBe(true); - expect(plan.reservedUntil).toBe(PROBE - 10 + PROBE); - }); - - test('reserves a chunk larger than PROBE by its own size', () => { - const big = PROBE * 2 + 1; - const plan = limits.planUnknownLengthFreeSpaceProbe(0, 100, big, 0); - expect({ ...plan }).toEqual({ - probe: true, - reserve: big, - reservedUntil: 100 + big, - }); - }); - - test('walking a stream never writes past a probed reservation', () => { - let reservedUntil = 0; - let written = 0; - let probes = 0; - const chunk = 300 * 1024; - for (let i = 0; i < 100; i++) { - const plan = limits.planUnknownLengthFreeSpaceProbe( - 0, - written, - chunk, - reservedUntil - ); - if (plan.probe) { - probes += 1; - } - reservedUntil = plan.reservedUntil; - written += chunk; - expect(written).toBeLessThanOrEqual(reservedUntil); - } - expect(probes).toBeLessThan(100); - }); -}); - // getOriginalSize unavailable: refuse (never pass through), with its own detail. describe('harmony checkOriginalSize', () => { + const { limits } = load(); + test('passes a finite non-negative size through', () => { expect(limits.checkOriginalSize(0)).toBe(0); expect(limits.checkOriginalSize(12345)).toBe(12345); @@ -150,44 +87,36 @@ describe('harmony checkOriginalSize', () => { }); }); -describe('harmony checkUncompressedSize', () => { - test('accepts sizes within the total and ratio limits', () => { +describe('harmony archive limit bindings', () => { + test('hand the sizes and the directory to the core', async () => { + const { limits, calls } = load(); expect(refusal(() => limits.checkUncompressedSize(1024, 4096))).toBeNull(); - // Below RATIO_CHECK_MIN_BYTES the ratio is not checked. - expect( - refusal(() => - limits.checkUncompressedSize(1, limits.RATIO_CHECK_MIN_BYTES) - ) - ).toBeNull(); + await limits.checkExtractedDirectory('/data/_update/abc.staging', 1024); + expect(calls).toEqual([ + ['checkUncompressedSize', [1024, 4096]], + ['checkExtractedDirectory', ['/data/_update/abc.staging', 1024]], + ]); }); - test('refuses an archive that expands beyond the total limit', () => { - const error = refusal(() => - limits.checkUncompressedSize( - limits.MAX_TOTAL_UNCOMPRESSED_BYTES, - limits.MAX_TOTAL_UNCOMPRESSED_BYTES + 1 - ) - ); - expect(error?.code).toBe(ERROR_DELTA_FAILED); + test('a refusal is PATCH_FAILED with the core message', async () => { + const reason = 'archive compression ratio exceeds 100:1 (2097152/20000)'; + const { limits } = load(reason); + const sync = refusal(() => limits.checkUncompressedSize(20000, 2097152)); + expect(sync?.code).toBe(ERROR_DELTA_FAILED); + expect(sync?.message).toBe(`[${ERROR_DELTA_FAILED}] ${reason}`); + let walked: PushyError | null = null; + try { + await limits.checkExtractedDirectory('/data/_update/abc.staging', 20000); + } catch (e) { + walked = e as PushyError; + } + expect(walked?.code).toBe(ERROR_DELTA_FAILED); + expect(walked?.message).toBe(`[${ERROR_DELTA_FAILED}] ${reason}`); }); - test('refuses a whole-archive ratio above the limit', () => { - const archive = limits.RATIO_CHECK_MIN_BYTES; - expect( - refusal(() => - limits.checkUncompressedSize( - archive, - archive * limits.MAX_COMPRESSION_RATIO - ) - ) - ).toBeNull(); - expect( - refusal(() => - limits.checkUncompressedSize( - archive, - archive * limits.MAX_COMPRESSION_RATIO + 1 - ) - )?.code - ).toBe(ERROR_DELTA_FAILED); + test('the download loop plans its disk probes in the core', () => { + const task = source('harmony/pushy/src/main/ets/DownloadTask.ts'); + expect(task).toContain('NativePatchCore.planFreeSpaceProbe('); + expect(task).toContain('await checkExtractedDirectory('); }); }); diff --git a/src/__tests__/harmonyInstallRecord.test.ts b/src/__tests__/harmonyInstallRecord.test.ts index 10d72ab0..8dbedaa8 100644 --- a/src/__tests__/harmonyInstallRecord.test.ts +++ b/src/__tests__/harmonyInstallRecord.test.ts @@ -19,7 +19,6 @@ interface InstallRecordModule { STAGING_SUFFIX: string; HARMONY_BUNDLE_FILE_NAME: string; stagingDirectoryFor(versionDir: string): string; - isReservedEntryName(name: string): boolean; sealInstallRecord(dir: string, hash: string, artifact: string): Promise; promoteStaging(staging: string, version: string): Promise; isInstallComplete(dir: string, hash: string): boolean; @@ -57,7 +56,7 @@ function load(rejection?: string) { const module = evalHarmonyModule( 'InstallRecord.ts', `({ STAGING_SUFFIX, HARMONY_BUNDLE_FILE_NAME, stagingDirectoryFor, - isReservedEntryName, sealInstallRecord, promoteStaging, + sealInstallRecord, promoteStaging, isInstallComplete, recordedBundleSha256, verifyInstallForActivation });`, { NativePatchCore, @@ -91,15 +90,6 @@ describe('harmony install record paths', () => { ); expect(module.HARMONY_BUNDLE_FILE_NAME).toBe(BUNDLE); }); - - test('every .pushy- prefixed archive entry is reserved', () => { - for (const name of ['.pushy-complete', '.pushy-complete.tmp', '.pushy-x']) { - expect(module.isReservedEntryName(name)).toBe(true); - } - for (const name of [BUNDLE, '.pushyx', 'pushy-complete']) { - expect(module.isReservedEntryName(name)).toBe(false); - } - }); }); describe('harmony install record bindings', () => { From 629b641e855989df00d5fd8e851c7268e636a66f Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 17:42:05 +0800 Subject: [PATCH 100/104] docs(audit): the archive limit accounting lives in the shared core Co-Authored-By: Claude Opus 5.5 --- CODE_AUDIT.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CODE_AUDIT.md b/CODE_AUDIT.md index 352dd1a0..1b49ad6b 100644 --- a/CODE_AUDIT.md +++ b/CODE_AUDIT.md @@ -78,7 +78,7 @@ 剩余:两份 `UpdateModule.java`(oldarch/newarch,均已是薄转发层,只差基类与注解);`buildToolsVersion` 仍设置。 **单测建议**(部分) -已有 `android/src/test` 5 个 JVM 测试类(CleanupKeepNames、HttpUtils、InstallRecord、SafeZipFile、UpdateFileUtils;CI 不跑,本地 `gradle testReleaseUnitTest`)。未覆盖:`ArchiveLimits.ensureFreeSpace`、`runCheckRequest` 次数上限、`BundledResourceCopier` 的路径/密度解析。 +已有 `android/src/test` 5 个 JVM 测试类(CleanupKeepNames、HttpUtils、InstallRecord、SafeZipFile、UpdateFileUtils;CI 不跑,本地 `gradle testReleaseUnitTest`)。归档计数规则不在 JVM 测(`cpp/patch_core/tests/archive-limits-vectors.json`)。未覆盖:`ArchiveLimits.ensureFreeSpace`、`runCheckRequest` 次数上限、`BundledResourceCopier` 的路径/密度解析。 --- @@ -108,8 +108,8 @@ 剩余:`RCTPushy.mm` 仍约 2150 行(目标约 900);`PushyInstallRecord` / `PushyArchiveGuard` / `PushyDownloadRegistry` / `PushyState` / `PushyCrashRescue` 未拆。 **3.14(P3,可测试性)纯决策下沉 C++ + XCTest**(部分) -已做:`__diff.json` → `PatchManifest` 解析含类型校验下沉 `cpp/patch_core/patch_manifest.h`(`ParsePatchManifest`,向量 `cpp/patch_core/tests/patch-manifest-vectors.json` + fuzz),三端经 `archive_patch::ApplyArchivePatch` 直接传 manifest 原文,`PushyPatchManifestFromJson`、Java `appendManifestEntries`、Harmony `parseManifestToArrays` 已删。 -剩余应下沉到 `cpp/` 的纯决策:安装记录校验 `install_record::Validate(json, hash)` 三端共用(目前 `install_record.h` 只有常量);归档限额累加器;下载注册表 join/defer 规则与 `Content-Range` 解析(Java/ObjC 各一份);crash-rescue 门控与检查端点尝试策略;`PushyIsSafePathComponent` 放到 `IsSafeRelativePath` 旁边。另建 `ios/Tests` XCTest target:`RCTPushyDownloader` 对 `NSURLProtocol` stub(416/206/redirect/encoded)、`unzipFileAtPath` 对夹具 zip(symlink、保留名、比值炸弹)。 +已做:`__diff.json` → `PatchManifest` 解析含类型校验下沉 `cpp/patch_core/patch_manifest.h`(`ParsePatchManifest`,向量 `cpp/patch_core/tests/patch-manifest-vectors.json` + fuzz),三端经 `archive_patch::ApplyArchivePatch` 直接传 manifest 原文,`PushyPatchManifestFromJson`、Java `appendManifestEntries`、Harmony `parseManifestToArrays` 已删。完成记录的解析/写入/激活校验在 `cpp/patch_core/install_record.cpp`(三端共用)。归档限额计数在 `cpp/patch_core/archive_limits.cpp`(`EntryAccumulator`、`CheckUncompressedSize`、`CheckExtractedDirectory`、`PlanUnknownLengthFreeSpaceProbe`,向量 `archive-limits-vectors.json`):iOS unzip guard 直接调用,Android `SafeZipFile.inspect` 每归档一次 JNI,鸿蒙解压前整包校验 + 解压后遍历经 NAPI;三端规则漂移取最严(精确比值、整包比值、任意层 `.pushy-` 保留名、拒绝 symlink 与未知大小条目)。 +剩余应下沉到 `cpp/` 的纯决策:下载注册表 join/defer 规则与 `Content-Range` 解析(Java/ObjC 各一份);crash-rescue 门控与检查端点尝试策略;`PushyIsSafePathComponent` 放到 `IsSafeRelativePath` 旁边。另建 `ios/Tests` XCTest target:`RCTPushyDownloader` 对 `NSURLProtocol` stub(416/206/redirect/encoded)、`unzipFileAtPath` 对夹具 zip(symlink、保留名、比值炸弹)。 --- @@ -171,7 +171,7 @@ Android 已兜住:`Application.mk` 改 `-fexceptions`,每个 JNI 入口经 ` 剩余:`e2e_harmony.yml` 仅手动,不在门内(无托管鸿蒙 runner)。 **5.15(P3)测试**(部分修复) -已覆盖:ContentRange、InstallRecord、ErrorCodes、ArchiveLimits、NativeConfig、EventHub、原生轮次(请求体、端点查询、响应缓存:`native-round-vectors.json`);C++ 头文件 ↔ ArkTS/Java/JS 镜像的常量对等由 `scripts/check-constant-parity.js`(`bun lint`)断言。 +已覆盖:ContentRange、InstallRecord、ErrorCodes、ArchiveLimits(`getOriginalSize` 门与 NAPI 绑定;计数规则见 `archive-limits-vectors.json`)、NativeConfig、EventHub、原生轮次(请求体、端点查询、响应缓存:`native-round-vectors.json`);C++ 头文件 ↔ ArkTS/Java/JS 镜像的常量对等由 `scripts/check-constant-parity.js`(`bun lint`)断言。 未覆盖:`readString/readBoolean`、flush-batch 深度语义。 **§5 P4 余项** From 6eac1975332b0c634ca2848a9d1a3b7f4ce215d5 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 17:43:01 +0800 Subject: [PATCH 101/104] build(android): rebuild librnpushy.so for the shared-core JNI surface NDK 28.2.13676358 (scripts/build-android-so.sh pin). Picks up the native_config, archive delta, install record, native round and archive limit bindings added on this branch. Co-Authored-By: Claude Opus 5.5 --- android/lib/arm64-v8a/librnpushy.so | Bin 342896 -> 418608 bytes android/lib/armeabi-v7a/librnpushy.so | Bin 212920 -> 261696 bytes android/lib/x86/librnpushy.so | Bin 318960 -> 400712 bytes android/lib/x86_64/librnpushy.so | Bin 340816 -> 420928 bytes 4 files changed, 0 insertions(+), 0 deletions(-) diff --git a/android/lib/arm64-v8a/librnpushy.so b/android/lib/arm64-v8a/librnpushy.so index 59a3ea9fd1f690a5340cd6d10739322b5cbbbc9e..5156a7b597a60f0c9621432b1f8f79e2a19cbff7 100755 GIT binary patch literal 418608 zcmcG%4}4?Sb>Dd(@JK$AZ#ZvepcyD0$B-?_R6Lfz|DZCmX=@}?&cvprvBTEmB|-cd zFv5^92}&4elA;OIAR9JCW46II%q~7zezr`?CTMrEVK>|a>4(iCUG`ICKa*WLOI^hKtfwkQOsVVcf6O8L@BXv0P$B5AeGeRe0oOS-vroV8f5_|QHzFJQn7_LY z7W9|yC)>G-udL;#7UD~b^KtP6elHD1V)*ly{tOI_j*SkDMEvWxs~&vSgO@z`&w22F?7@H4 zga3vHKlI>#>cLG+#EAgu8EDsX9uN z|NAVe9!A||jvnF#MN4$Zr{QGVA%8!Y!WWWEB zAN+BdFSv7tF&uw4zE9&T?d9FR6JPo1#+ULxdHh-V4}2~-f9!wCykrnxS-$*y0^euw z?Ux6xkIWTA_>Q~SS^WMGzA|@uP4P?Wulyt1AV1%Z?*zUV@%<#eGFg;uk{Zg-7o0!# zKVQZ_N7eDG>hI_A_nP`mf8-dR!Z$zwe}k^y^5^I9eG1=?<2#A3{Jf6uEBFrM+k@|O z_Cea1k1GW5_oMhe ziEl5yGUtEX=Q|z)2f(i4`w`cVNRgBU z!XAaq%;5aOBL2!VGlTZMoLktp7jG}kXA>g1d}kqsa|_Yi33(%@(3OSs{KA{Sb~WtZ zx8IaC%g)1W>9+itqypsKZr_e(#BIB*+baw5AUThQE{bF_y|SDVZ!IrmmgX0-Nz~`$ zx0FR>7T*@1{oHFapI^AXh`P_rM4d(3OnULwjC~E5Bb#l1JDN@}#$;7vQiP>MLVR_3 zaVd+IBoc4phS>xRZxBS4?*3#eToUEeVRq}SQ ziLT_{il*iDubvbgTU?0G%q>BOSadmig4G?D;`SY>9&aY@%)}Gv1UiuryyIQfwaa`v z3TH$`V-WAedT}9^5N}17RaL2w+bi&0sE@`GQWbc6$)?mnxZ8}~F37vQ9KCf~52pyF z5EUZp8-uFS=`t(Xn24um;DJj3GnrHzgJ6Cx_Wbgqxc=IwUzP{AeyiXXZ~+z&{~ZD_xBwhWc=apIXujG{`;Kh7h!zkryM@Pcv9mf0r#3{eZ_-79sUSoV$ zl~ZT@e^NRhF#azeI^{GNpZZy+K265k%HLXy|5?K+r_Fd_+bKt=-7@m$|MCBD>s!lnxjQ_0CA;tKwD*wzdKK*M>IXT9Et=sXpJmVL;9A02N@K2p`ij4o@ ze{kwwVw}n;GrpzD*jnJjFZkm#_xXJ zDJR7EzyDpQoPNeXqVx|lzWT#XIT6NND!xrH{+s{WDSwjj>4Hu3@!4BW|7|h;cP^-S&Ui)H?=b$$%D&1t z-H+NqenREcnf=eGat;_Lotun@lTQ6xj9*smZ8M%#T&U})K4rx%#%t<0$oRIh4>7L4 z>ge3hIF(QJrTdZ&5oZ4zN{0!?NzW<9GwOJnanfOyand2hIMtKNU&M=MJ;~Bl1tish-r&yqr3-{}1G!j8nff88_5;X)#Xz z(`KCfN2u$lKGa?+k2v{{#q7@nobelEoZ1;;oXY8E{KKl8Fyltb8FvxJshkPMUsmNz zGEViFW}M2MWqkNEDy}k4aWBI-#mSrp&oh2im0w^y{dwoUMaIQyVKj+l5!T8j7IlRgEu4)&xgX&N1X*2tT>UW|1 zh3q5Bj{Jx?^|!_Be@vAVVmz;o`x!s}38(&H4<7O06O5D2lZ=b6JLOL?PWID`H z<7A&=yrt|jjFUb&#v>P1KFm1vOM!7i*%uk7`;{4QspBoiN#}|O-|^s8#z}`7<87rw zopG{1V4QSrFi!SO#z|*tANe8a)@JszN;fh8Xx^Jqb{6Ba>Nv5VMZYgH}_mplK#!0t4<8^ghV4QR-dhn75FEdW< z+G4z+bf_>+_B)J|zf~D0`x@h<8?}$?cEId^UFp_foOEk3KJmSdU$z;)@(YeX2zCDC zzKR=+4_6#}i}4-BgN&R1$gvMGepB&&#!0s@S5 z_}hQsaPj7&@v24pQH_=lAa8ODE4 z?a$;H|8@BS3qD20Yex<*Gyean`d1kLGx;J3K2^rQruLEQjDNggn~MhHf1&hjG5);T zrxoc(_5T6oZx-Xf(c#o5#Q5L+bLYNc#(z=y+XUl3s>+{Y{6DJp&NBYzf9jNw=#Vf=X&XR3^UP(7!pGycz&J`KiSR_$sr-l6sn#loZd|E3zhmIn_p{*ux;?7=4( zFRJ57#wmZ8^5D}Re3o&Vr&5fE|HPRGGmMjcj&Yg?^Nf>yfpMA#seODNEHV4ROOBtE z8K-%$!njy;`e}!8(yi*jYaYDLIJN75@rVD;(XGKa**6&{-CB&3eVcL8joQa`GgP~Y zN0n|CqayIO#UQIN47!PP$S1 zxNg(T{{L0F%`#59Wf-rj;~e9pTi$~gJb00DYFCNz2~EBMCq$WXvfpBybgM8<_B)J| zZqz=mTaDR2D_@Alr_MO()?hsRRmcCDjFWCH58n3RLX}7Lq;?sM*E${DEXK(`$T;a1 zVw~*z87JMSeN-PBrx9lV1*O{r%mhVJi|D(E5~@raCFNvPWA=H zNw*^7WM5*Obfflh-L{y0Ug=h0oOG))ZY??fR%4uWt9$SR58hy$+SO!yx7*RJ#W>lw z87JL@sxRq4_6Fmm8?}$@$=`y^ep~4lVw`jfGoDw+5ynZk2@gK$!KWCfc1<(B-s|W# z%Q)Gm7$@B_jFWwiang<2$8{?(``=Z%6&WYp%8a+v@fPExTg8L#c|b>-71We{SM=#Ta|ILuQ5)#QTw=V2h9GfO1B2%q+5&eiLB$7ZN^DAq0ZBNi5ni= zVw~C)WPIu)j&32w$-bX)(k;w5*+&>B-Kc$3f0`F2nf<2HZHjTyZIZ!6u3jFWwdanh~KIN5J8PP$S1xNbYlzOHnuGETbH84s;E{&v7P>DKVz zO%L8;oZ8i9eES*4--Nn9wTtWx#z{Aeak39GPP$S1sC@Fber9icn=>zj87JK)7%!^h zNybUHDGxsF!Dkt#cBL54eALk`!#LUJ7$@EGjFWwVang<2$8{?)`w696nQ_vs!nk{E=BZW+dDoaPw+Ii*{kanh~G zctahR7$@Dz9(>D#R~V;u?J$02%+amNIN8@2C*A6dll=kXq#L!5{EBpIGW*|Ay0sW5 z-Nf6E=EGC!*kGJ=vpjgvgNGQWcJ(tpJ?`ihX1t+zgmd-&%mm|9{v_k18?}$?HqGom z_yK2Lm}Q)F%P?M5$2rDHx4Z{0c<>_Q)UFcalP@~Dl^H))e2a1Nw+iD_{tn}$8?}$? zR%7Elnw+iE=Ta|I^j^l4N#!0uj2S4!O4aTWmO~&^>;po$1{N^Ve z-e#P16RN(X1C?(uPP$S1$e#Qy$m}m&cH&!zandc!cwQYx7$@B(JouyspJJTaHO+WN z=`+hX*{2vM-7<`meU5R`joQa`D=_;nDBX&TlWt|kTk3d=anh~g!FN1(m2qlUjq(0# zj$hRoC;J1&Nw)^$WZz_*bfflh-P+9lhm~&PYmep|q?^U~tU3-dPP&CWc)te^GfwS_ zF#hx_j&2i-ll>&)q}vqZWIxR~=|=72x}})?J*8WQandc%cwHSA7$@C|9=zni%ZyXI zwirMDjH6qHakAfGoOG))PWCm%NjGXA*X@AWKTx_g7$@CYj8A-@ z9^7J_+7)EHG3n?QVw~*z87JMsjFWwYang<2NA;(9VUpQj{Gc;0OfgQn%`#q5$0^21 zpNt33dGI{r)UE>KH>Vukij0$eiE+}c%sAO^F;2Qs`?zj9%>L6#w<_bLTb=RHUB}-J z7$@Bt9=z$nTZ~h?+KdNYcXSi#{?snAHy9_~EXK(`$T;an?W6L^-};&T_bc7PjFWB? zj2G4MB;%ypln0;o;IoWVyHbo#-*9xxFi!S4#!0t4<78i8oOGl1aotMHep~5QW}I}Z zFfP8|@wXkuNw=y8uX*q~izRfu4M(yLe8LHjH8%j5e zandcsxjOD=oOBC&@Q4SWV4T`D$$0&H9NnfEC;Mr}Nw-6T%f#%Yf6bKmaF z3wg##w<6;WbzEYcbSr!CEe~E{oZ7X+_`zRsbgME>_BF;yw>slwf515DM(rcNBHfzI z{tcyDi*eFT{J^96@RT|>7$@B<4<7X3A;zg){fzJ4baV?dPWBPTNw*2c$$pY?(v8~3 zb(?1PKcaM-Wt?=&FkV&1ImSu1yaz9M@FL^Xt`g%1UvYFRGfwtfjFWB^#>sw%ang<2 z$91bQ`(IYN)fp$<8jOekn&W>>#!0u92XA|Dp~|CrQo9VskN+!2H;Zwy4>C@=g%~IM ze#S{RY9G~y#%YAv|B2FVf^pJqit(~Ko@ShMoAux+51wJ1+LdGc+MJ_Xo^i4-FiyG^ z87KP^_063AI;)ZVVrcUGH&G^f2%Q0y45}Sfd_9ePVH(kem&vn)?%FO z+l-TLLe-aaAbW#x(v8|j_T+CtW`9HJ7Gj)q3p1Wq#}UR!w+RnE>A|NMr*=&CH%K>&@mX~oWSn#hdGLM@9%h`{6=8h$ zO~>CR7$^Hl#;a+^eu{CjpJtqNqxNy#Qq2C3m2MfvNw+-Xb#+`|oOCOC@RA2FGfwT= zV*EzN(V@aP+3zq;{#Iq2>}!mZZqz=m+X1uxV91#l8jO=}EygE)(DBPQDKVzO%L8;oZ8i9d~?asO{n`*yU5;PJiYALTa1%^ka5zD+DGM+zx6Zw9i>~Canfyq z@hx>c$vEjY<-w;t_$=ept`y_NtfNDQak9@bKKYhopJ$xx3yhO)MaD_D665_(Iq|K` zIO#?@as4aIzNPfvVVvqw^Wb&HN&f>6-tgc}#z~(R<4?cs=-FnR?8WM%dByA<$KGI^ z>@CJg{}AIDb==Q5wKwd+BOZK$ajMTG<1JPHDaOfunsFMZvy78{ig9W$wU7Hrj@hS_ zzvUSxe=9QHP{$?4Nw=~G-}2xU#;IL9j9>dcr{AlLlYNbGniuMfll=kXq#L!5>(*rU zKdE$UF;2RPAAZztr_`~*IO%42@Sq0|F;4C3XS{jW(Jjn4*+&>B-6j|(`$@)0H)sw%ang<2 z$91bQd*6qhd7;iY>DFL8{5Kr`Ycfu{wLEy+g9}w2)sxy~Fn;s{j&2s?WFKUlbPF*~ z_Wg{LZqz=i56!0$W$b)0(@M7r zA1OY|c;ttia#DT~Nw+-Xb#+`|oOCOC@RA2F zGfwT=Vx0ELD~yx<4&&r+RmRD_#yIIl?c=%~F#EXDt-(0y)?$3(M;*UxGfuh*b)N1^ z-0smY|Jw1l z1I9_Wh6itY@D}6Lt~TSrzv1X6)cvVlWN$D|x><~qeUNd|joL@$lfU&dd-G{0zJ(bl z-6j|>s^dwp)UE@@^)*MI2IFMkWSn$sF;4bv#z{A7AJ@%L z?I!;HN;ivf(k;ZfI__tjbPId%hzFlwoZ2V zE7GmW?EjV0t;IO$Ce|L!ho{uB!8pY$i*eF3$T-=D7^imiGalM>bPF@Ssd$8O(rtoq zDu0r3(v8~3b(?1PfoGg~VU}^yEyH+K9p@M)eexc>;K7TGQ@cuxN46Z@%8Zl!7UQH_ zg>kaqVVrcM_Ho^6%>EUnTb*&zt-*NsZ#w?hWSn$sdGNLe7pgp}C$-C9eDh}=-7Ln* zKFB!f7Gj+2`xz(QsC`r)8mAFv|F+U?f^pJqit(~Ko@U(o2Tr?Y8J|!*#dt>X4CB6T%f>~oBhZqz=mTY=f1dDfX1ij0$PWyV|Tc#Cn; zt>VFVJb0CHYFCZ%o4?@bR%e{-4;Uxi8jO>DlX23G+Q)TkGy6{{-NfH|G~XcIEXHTm zagcG+E#$%bJ$RULYFC8utN+;1ZGv&KpJbeLn_`^orx_>RsC`_w6tlmrbjvVKy5$+K ztK$OWq+8L0mppixacb8V-71We{SM=#Ta|ILuQ5)#QTw=V2h9F|P`Wi3C*4|% zPZS-$Y%@-}33ZZ z(rt=y(ruRUiaJg)PWgYvgXb70J@brHy9$g?{F0+vk?|Xfml!AA%8XO_Ta1%#)IP4; z4zut6s538A87JN9jEDZV<8KFyZ~qggT@A)bpQZ<}<`9a1>H)_?SuVa7?f3C4@+c#?6_XUc<5d+=Gtsa+|?^Sh2d8OF&z z$2jShXPoQ{jFWEEKCWAd*)J&F%8Zk46~@I+IR3W7IIS0|jFX-<4_;@S+I7Hq<$qEB z%y_Hn@FwG=TZ?fjzs)%5M(yLe8LHjHe^%*cF;2RLI9JF0jFWC*#!0t`2cKY^+BM1e zwSVg9GsXCgJ%>*-PP)x9PUWWTpB{tcyDo^jHx#5moz?7_DfFEpKYRT$q_ ze24LSf9Tj(8Q=ILhu0XdC|+m0s`vrpb;TQuSO3^4zsb0Mc4f$w;12?`RsrHE68|R@et#!1E-vR#)l6b9%g)7Q~#Z7#+$PaxBkwf^^ZU2 z{C6CJjAxRLU5Ih|@ALLEKB?@(jMIPbIl?&k+XUnE-_M<7oc_Dd(~KLpoO;eOPXArq z6ypt5&kW}Yr<@$)hO*ByUQxWjIO$VlyrAq$jEiqL_boHtK5+OJ$1f1vmQsE}CoC6tJk7BWy0}xK!$U6KEfn~Pr3LR7oT?VJ{O;L@v|Z7yDQ@ejCo&BZUfc-_T6=;8-1{_QT_aPbeh zc+NPlw_N-g7Z1Alvo0QT@sGNAzl-;~c-Y0C zbMc6ae}{`txcJ9heA30QxcHQdf852VUHo|$pLOvUTs-CCVHeN1_<)P&Tzt^Q^DaK* z;sqBUcJZQ%kGOcr#YbJd?BZiCzUAT(7q7VZxQp+&_=_%Hb@A_X@tTWYb@953zvSWv zF8&D@Z@Bm;UA*by6E5Cz@lUyU+r>Za;-Ylw|JPjHaPe1M+;Z_(T|DUGpKDz9|ALEWT>Nzx zf9D;|5+%&YCCw`M#Flnf2(U{6&KC*PG;uw!tBF~oqKOnv$#V&T4!IU6c8F>7NEdOV zp^LP2Ul(`vnlAF1v8#)MmPeTdU*MiDHhh6?T~vI=rY?4T)`l)>KJ%ep-1nI+zi9ZZ z2Y%7==?8vs>@$$oJoIfinE>u}S(H&uSu;yMv8@Hxe4?ru4>eKK(so_$ifJLHjR&Yk z0M*DDbyZZ>`-BGVC9rc2v?D^%*_Wk!zE!3}1WbogB4s2>-RkMc^*pF+tqmXy4 zb&6fh+U*qknjugDbEi`jeMY%Ulzrw#m)Q0}BjnZ*!1dOn_3}&}$R*2aU+L708%1h+=YXQiO z8sqm3A55PE#u?s0(*m&I-RWLM#X@>9VwO}OhHHtpb(5^dr14ybXMNV=?_c4{I33hZP1K}r~Cg>*Pny1w%_7E@!9n4oYc(}@XV`bTufp@w0$9D zgxS6O1ge%bAL;L&oVBB2Btv`8I-kP`tH>q?@A)9ntTdHceiz(Yy0*F6jj=5?-qg*u zAu7^EiybV+$jBtiJ16S+Rkw%pp|*IFar-BD?V$hLsWhCQ1s!N2Z-1m2*d^&y6Q)>t9}FX zP|c6l-uDOAIz-d27dj9nbqv;)-+&Zt8B_Cm;J8Dq>w2?8lyn`NVjH?q?|@fM3UQ>% zQjhgLN7t)2%%We+;&yk(7P2W>n;@bL)ObppE#1$&-()K_I00}s_cFl z7Gl_ni)*MLjzW?*RF*Q{+HywE7i)}~ODY!ePpYSvgg zC)PEqVPQC!2bR=l*OCI+vA2B|ZWnBxg@0KGXHm)E&RGQf;Jve==*JDV{K3t$=rOZ+ zRvgRTtmuLAS+TF1>u1G*4(~2>1Xs_A%?>MnPTcFzTb8JH7>AaeAsxhml0~2655wYy5I2mnU)+R`09Bn!b&|D#Ou^7TPp0 zIhh*$-jAv>k*w+e;v38XwRW0GPD~OP&|kX@-BjcJQo<2zBg?`k+^xR ziOi0}yN9p9-)C=t^=ulr>xw;^5Tz#k|hM)|FE zIp}*y{{k%XMt_Cqd-Y^hAZ(>l{_qv3Rl2giMBsC?UjudIbC&QC$+h^>Y zS0+z6v*33U&#IYF{8(NA_d+{LGQjNkj3YEv&SiDvsB%`2nRVM|p<@biJ}CJuG^t|u$&O!^=al`2 znwrdvRu3Ez#qMdwQ4i8YxB=ua*L#poBTf}{>kt;Yx!WUl?B8|Wtn?tO#Wii+#L3kT zbWW|qDD{YXhkOjNtoe~r~^=7wN?*!QDv>tSeolY26?Q```z0oa> zI?Y;-Xm?JbxGoDn54zy=tuCwGjikdk>J~)K_pp~)1d5^?%^PO<@!x|D35~zZM$wjkQ*7h?R5=pN8vbk4nrQ{UNh}ASK&l6 z!!u`mN5dqs3d2>+LU>%qr3kEAAb(aSAqc6IgjCPM0j=$`2p{?e41E^D>!wd%J&Rd& z8cO+%QXdA7bstyyEnK%{qTZz!`k=3M*e9A@`hFi$JIKCkSdBih zZ|DzUVnCveZUa9n-R2>pzWw`Qx4DZsqQ?Z^=+Vo4VzWn9^j?p?j?!&j>(PrSrsoP= zq{phE8ohcG)#wG->3teM4|~zG$Gs+g=1qO0Ppq0aS24{U)aOh9_~99S6({@d+g&L8 z#T3JsyOAqTOxpdV@zt_ph6eAT0n}!J$?u2?h zF3;_`eghU|4Vv0ErX70&IdQY!K7LZ5{Xuh06Hg=I44Y|j&Ach5Y;#%W`KuRV(P~=@LeQ;nCfv{JIM=-5E3f2p2ReX zcI;^;Mo3+*B5?gQ@-AG9;e8$h9+`$X?9}g{7Yz){^BA1CPqoWz0(9xE^J3jF+vhP1 z%?IZ(TU%A|ZfoznXmul-66G^y`MlUZgXB$?ZHbT&A@hc~VqO!Gu+2V*X%NdHEJP5` zcK!M>*8cvR_`R>&&6U@^-+p<&c9zPiOf-K@&N8^i`&(;d|59tP?y#IcZ{kMy;9l<) zL;h22O~-%2;^~ko*S$Z0;gEWoT4_lb(zzYI*lDulsomRDKPdHYnp!S!&HElG&H$9yoW6y9~O2w`~C?b zwKW@lEXYwSY_ROfqO5x|PsBAyvf=$z&D{2*5WOsCe7Pb;S$1-RyR|Vmks0{pLZxUI zUY4`J>>6C@+lTm0yM7v*CvwxH@g(YI)t|(SZWNzHf@Kz-#O7159mKp~90!qSnfHR2 z9)iuFTuiM!i6w>!%Y28q|D-5&1Zq!;?T!gl0Q*lc>9jV3n2xR0Am(86;U%%xY1D(F z-Wk{niYEH`5+-4D{gPPi3N|mHi_u$qU4iyRIbj@K#Drn(U&LH&RxgUOVb(68qXQKb zBlGz}xAowXC}Ebkgf2FBf=J)Z;w4e*wl*(e4nbM@9v!&UbGd|TdW`ZVOcnaZB~kA| zP;B-XYnMc;=W^kaDE68=7sW>Jm4_F_ey_3mgt*^(xg8L#UQCB;rn!4jl;pgzXByk6 z${B0@qFe-E9y?aC+ z8;=J*Im8_9%vHt>aTV2r-##wxm;(96)0oe~2>Y;k!QQ(y?)$_Iv+BdD6vd_G;$&3| zl;wgHMImDE$%wrvy#&`RQ#`Bo!VLr4y_j<_sUV9CV*J#wz1NFG+OT&G0GcvYZ2PhY zy;z1LW0&2B1aMnsPP>L#??wJ(uJww1xAhPiTXzJRSa)EnSG2n=go#4Wx@;ltd*P;u zD3Q|dX-K*;1Lx8XH?Crio4Tsnj1zKLVUDS(Ip*hefl2eGnGayk;rN2cVd!G@d~`wX zK0LS}+jnpQ?K5i^WZEvrjJbCKV@9uDkPD983s`VK2keJz;~F_|_ObtR0gco*FQ5x# zM`CZJbOCDyoGc)dy@0_YbCzwnQ;V^(dI4j_L}KYT^C;ABK0J>#l_{q*-E3eolerZ} z(BXNkuh2yguz`zdO{Q5$c=yjE;f0@WbeNd(wmMMIy$*91wjHQ$y+h8Phw|r9hqZNH zJnXPA53b3qx7=x!&WlQ?RfKV8U=7~fY30w02l8YaA@!UnbXmt(7k337!q>X22j`Hk zp{RX((R|ot9h^g+7B~usJkngOW@W#j3-`{U3(ehgn2m!60c390HYP*^Lk**G69sl# zSRQY62X_OaiX}C?RxYL=ben~9n6FK&;2(AewgX7(EUe?pJt(%)V;x~h-(x+nFfaof z0r8;6x^IbNd2+Sasw4C4wXmbm>V*Ukd#xQT-OUiX#+0l0ebXvq7r?ZzB0n+%1yuQr zg;o648FLk@_A}-&vf?us#)oIj2iRyhVy!EGQJ;xCx!rdxpEAMM9_n_oZWaS# zT3-ubW%cj^#)N?~RS-AZ=wk%#3vy*`J5=ieGH=~>D>QUpGvHfTWFMmI(+c6ig zhC=V|`mDwU?9Q6|7v#40JvbY-)^G#cbBcakzKOVNI||$dj)G}Jmp)V0QBuuzoclUN zfuF1*HtEKGKspLk>M)xZu%jxyeM7p$c8A_Tu@rIT)?H}9nqeTGuNx?D-!P6Z ziTj3upnhmzmPAgbBQh6y%zKwGokH(QkB&HuY<=sJ*y)kt^ytlt=wbt*_Mpc^Abrqd zZd^nk>v>$+i#u%h8m)`sUayIOUF|gynfH5fWusSLMb+fbcCUGiOHBMMnL6f(nrR+h z6!%T3i)p~uk4zI`v~8Mj?bS2p5uEJ|tT)dXP58R}Sv_MO2E_guTyl8EfFD0NW7Hw# z8Dkw9^##^0iuFD?*u6e;^`fZunQg@HJ{i%E`V2($`dRb-6XNhJly9Cj5Zv=tp!S4V zx2!5czjYb2Mb$DZcF;$3Kd?|%+cFU1^XD+2OXtiY?9NS~#^(YU{OxlM=Q$^Q@OO1* z6MpleobyW;$@AgdsKR9R+c2*f=yVz2dBm_avs@O#)Bmeaa%AFgT|$R9SOKUGWv zUbbTz@W!ec*n@wY7F_UwJPCg~LO?!aVOdf-6Tnhr`;3L~uzThzPM!&1`Lfohqf7c^ zWZ&q+7`fkPVvcC^VJtlCGdG_Q1?e&CXHED-`K(z&YH(J%>dsl|sz?@a$yu`<5RJ1Y zQh=kgCLFeX)`WwuTBt(FGVcfAUnYFDVwro$4=nRuK-A>PLpWSOG%a&GAdW4w3`d5W zA#k0`;P&SNm_mx@@z7AlxgW!#PA0dzh?oD>vmsQ?n?y zxZrDethJ`Y`HWTh1R+qDJ6z_mANxwEcL`fX*dE6&5w7%E*c!rP3goVP^5lJ=iET;* zz5}^UDR;r}9AO{XEbV`tai(f)9i&h%d{9T@3i6f6HT$>oj1ud){;+-g!RrQ|q29-a zMJK#e$CJ*YuHWlKw5*-vMH6c8&%}mY9Z%%2YwqjEqXBGIp?277?^02@?Tm$V5O2N!SLryUV z2DaM+m2S*2<~|<5$uVE`n>7QEv5h@LZb`z4{6-ZQ;?a%)6^u8uEZo& zF=S4&)qRrFVDGrvp<_>ZuVV@tcAERBW~X((8|wuVo6qZACN`hTUB@aX-11Q}ij6<4 ziLf8IA!UNk*1P3&T*IE9TysX7hAlUn8RFc=--3!pw5 zK6%e=na)*x0c`Z4XVD!GeF1b$QD$-*ekuEwUx(t91rS?rck~d`#yW@ z8z^uR^_UH;_K0i*AzHhPo!EVBX?J6&2T)HW#E5L#Rp5I*T#W4#sN2HEZ#Oa*G$-%x ze}GQ(hmf4WWgs!;kZzgsqQQ9VhO7P3saJIiqQi%vWm)O~??H!PHjzCn-KmDHAasJQ z$-0zhyTe2ScRH+x@aYce+4nnS!y9s!_qaoPa~@kp@a#@=51uVIcuSoooEPg!^ulTv zz*?7dwUXT0t#p~2@Km|Je9#pr!mJC=x}+X2+I#QTt`E;Gu>!hjVgZzr+gVxM3F8yn zTNt0%pv3@vD7WI|*p<#PN*Jz_KSDz}+$TZ{Xpvb`4&m1#n~g?FZcN!%p|* zeQM7Rr*fz%^3%44sN`%S!%nRW)8guNyPh|leP{%*Y5As0Du5t%SB9wq-cXT`B12Hg z-c#7M_Y`V!j~Xsemiq`0Gj9jEI`URO;tN`a6c&ZGeb@+*I;a>f*J^2MQ=iDmM_aq{ zy_i`E-`QBvzA~9^=zi=oZ0mlw+otYEy1b$L+uGCjbpM8Y&g{nxc65KyuNOi5ChQx2 zeO>oI@MG@<*{*!Jxz=Hp@fcSwi|}Z$-l5&=2+6mKecvze&XSW)$$QEV>i5)S;8+t` z*`HYDLM!wtw8I1kQq{6#$M^3|TQ>{)sbRYu3zwq`2rL>zyD8iB-P&;2*wOqEJiPHw zU>DUtX{~AgX*9mB>9`3Rh$lS{q(2;Elg6j5$`IpJRjglNb=~-)_EHMZU;P_+#A!Og+@I0?K zbhu(vHUF@2PxD{3AjWm8sQGW+mC1vSwK?{tAUqzAAy{hGhEH2_>9F>$@?O!N@5gHd z{xEjo{8!Dae{xn1DE+=Y3~O>2qNIJzI?^<}(WUBjjhBNlGiB^|$hcJRKmd|&79ce5 z;iUlG!2U|U107}e?HMs8@N9eo`=}}Mhcf~{Qzo{*v*>KcZXQD!3Sr>%qF0UmUcB&O zVE<#=mp<&pygveLQ5xA9JruWb^(y9?tq9HY;|GhU{%B8+8z598~E&B(IzHB1)qNwVZ)y?jx>KJfWBXqimq#y>zco! zWw7M;$xX^FpV^fAlMy-U;XIn1>nQ&{w74JaXxe|PT@P(_`0;{6u>%fe)pb8QPR=qG zhV-t?C+{QG>G0!K283h;Mi~I`LWjTL3t_k;r&;Up@A_~O4;>G6e_eVCUi>I@`0_W`3*1DhvA`N0=q03*B&UT1QxXb@QQuKbl3-w3A zcxL*r(_fMU25+ylJJFIb)~~+cI?6%X(206puXg$C`qjNI|5ir^zssHSR@F}PsMEjS zDFOU`Y1?06z;GA7{UhIMRchsI^c$x~;!=3msVM)3NEf5P1M`LD~dosM8^qtXZ& zyP6Erb)Sx@=)isz33EBB?Zbw#mOl*{!s6x1cR+Fb3Hc7#Q~*_r%iZrx-0{6n*m=9$ zM~tKCclElQw;&B3dF{*j4?nk&jLA1iQ1N}O|3JPErSHkNAkrv8#%0qk-qHWPP6!5M&sy!D?5nmZsSYIism*nX9<*ww%zI z7Iu*nq8}PSo9=5UUR;MT_~6)x7+HGW`CjKtr)sL4jSnJV-1A|p1RK*nZP$nLPH*e< z@1ytqFm`B>@M%G~MN5+p|L~S5esANQ1Q}xx67Bk2#-xlfjJ*y_aM*E^^M--n$1*QL zeX$3G+pgNLO6?&hm2(yD^ghlNa2;NTfY;XStLv`onz$$4IobBfwd|IRe5Yl;*Evk` zn9^iB@PaD*d++4BXX6%zg`7L;S`fdm!HirJw)hRvs`3hIb0{v;lk5#!AN+zoC>Z+S zxBWT^+Wmo)d|NCh2lSfHz<|Q8G@5eXF68~3hhO`|U3ooX6_QXqQa~vOC)dx)egCvf zauC+gJqTkJnX1Z4%JRSYc@&qHiS46;sOz5y%NOY~@|_i|VUfd>eRpNI$+B?{AZ@~+ zh@CAT7G$UAt_0dLGY4I>v!0SKN7sFufMhoUsOE-t6;(!_u`S)y9dQP8_%d0>+v$Mb`2%p#f*-C5p-M0`$#((>&z0y z_^^D#96wgyZHM=|1L13ryvr-!^)~viKFXfdOw+ooUB;vuGOs*(ZbbX28klkwbfAUg z($7}JerpZy|I~aMHWyhP@qWt#|BnA29_Mt2@u+vTH?-X=Hsw|>`t!@s1zTh->|0=$ z3Xeaq!PsG753<_X)N#=6HL#zVm+uaXN}t^6YxNn}BfodHqDk9x2DaN<=cKQ$UXWYr zTNgsIqzl)bev!}Z;KscsUhCNEHSvbXywg?fQOrrqd%$oCWoxvWDLr`68lVLxqsbQ1JzVF5t zv{$e&N7m3~;=Pw<*EFs*0*82~rQ4|W;n9fkpbyW!+bE_}?@Ogo2@ih<5W4Ey)b+Qb+8L@{wLhN-~GK3b-hwh)(D(B&qI6D2leiR<_2>!c7=l3=99?H*9WmZ4J@kl{vfg&!FlM!D zdi>FI&tL+R;;rL>k<1h1%)O~izN=hW2I?_v+|VZU=|{_WLhH7l73j!OPOJ0I2I_C= zcQx$!xNG^2Jy%N~sc1UZUiY-eou5FmXQZ|3dPbYVAU-YDX`{*YN4rh^Y1i#xH=unO z!BeL7p$GCkW-J~wOo*6vu%EUnR}UCDu9BTTmP;vl4c-iO7l-n`h?gBR2!DE3!+IJ{ z1Xo+tu_G~=$4ivHr?CxTC$S~u*LXX^4?o%V2k`UIAArT0u9tMJD4&x(a#Q$LbSaja ze=8A6U2ROVNep zl;M`a|77dRNk4A<^2NMH> z$)UM$d}J;j85oKWM+S$7N0Y;`(dfWjB9a&z9~l`O36CY?v3PVe5{rjN6C<&qv4L=6 zI1-KyM@GlO!;#_0P-0{RcN&b&MdD-e;i2$QI6e?djK^@p$k5=xaAI&YJdhkr$~%ok zxDP6OY;l2zEgbZ#*Gk^2?@xs6h&J< z_L6uzmWnRTWW~zD^8Bp@=&+oEl&6R3#f4iT`416_Ct~UBolK%Xx{_UdPQ(XCPdjNL z3*naMzm^ECEWEj}`1S$>jL#>N&wuao;zDR)F^e0oEX0NClga3EO1!+7!8J~2K;&#< zAsf0qzkEBIjio~Uv8CA1;BzPjmnD+(sG11D5kj|Dma}$&bBWL#n)jwip|ICvlZVC! z=8^;PFgheL78#8WCWhvQ#*<@^a$tOXG%_+So>>khPrP9%kxoRH6DTs9$l=tB&%|F0 zJ+mzA=FZF{(~HsUz+gnMhKJ^FM{mt9M3?S}^!(h)!rSu;_I06; zFa*TV(7-r)eRys(HZmF=2@gt_2q$CG2jdC&%G}UUcq}n4V$lVuc_tl&qss!*2^56# z60yZ43?kGz4)2HB%d$ezq^#fk!g4m6PDAo{4H_*# zvh8aE|3rtyqOnv$j13OWB@^-RTqF`592*`VL^B5F=HUEM>Al0l!-L@wG$J`1k46*Y z!x%Y3gK@NDAQBrNijGGjV=x^W9T|y@#pa@Ou{g%X_()_(j-^B_W{)ilwSj@5(Sfn# zKx`~J6d50b7vMMy#pa|;@x(}cBsn-fFaV)q@uBG4_|Wjc$jDG)Aeoqphm+Cd*xcCI z9EMaJ#^bR_bYy&NG%*$%PGH~_`v8;6oDrZizY_qhR4DqLnC8D zBLm53LdbzCA09%jNie~SdJ~tXQXqz&*sIW@xl1O+<0VYY$zNa42L6# zTf>PY{v^=F@u6gRd@hW>N579J;N_9A@qr|IbtoE-MWFr2FapECXmU`Vo{P#+A4x{z zV{oR}7=#`g!f+o!SB%Xi$45qoMiN3cApz%KUP))gp7T zOH0u^%a}%!NjsK=(usvz*%YQ{%;3%xyMSnB&*-s)?J=RZQ*sVoN<{6j7=3r75}*Ct zYfx}Oo_`Te7+qdQj6I!Cu7sZX+Db^3dpnU$Eh0>=EX>(+P5*OWI_b;jzeC8`X)%f5 z_GJtQv~p%X`Qm%_E2jUM#bk0hk$p_p_CJ$ec`mfLk`19uq#U;rJv|~5!)QWBi#R5Y z0r_iK#D@mN%dfq1l8wwe)gK5Unhv?T(@srjn7O*>^GoxXY(gqxpUk9)(y_(Nd}2AI z3XCp<7U#YfLJQ~+wdcb}#CvA>`Dd1)U2JhB9j6HtPHG1M^gRa6Tyi9y#Hbq|jSq(h z5K{)?7+6CHGlQdp$YuuLM`uPene-idPEq+ATr3+E*~KY1PNG1)a=UA!h8CRiUl*v$;bH2NjP}fE)IT;93r%wkOL`%+4-&L(!BIc zHFTVWL8hkBZ1j0}7946QEMB15oAqQg{=9fTv$CAJ^KmufC&cHF1Vm*hT1iKj?2H$F zb0zfEl>`J>mM)lFir%`7%**Z?q`+|*PZ4h8gVB*_Vsvz50Qu)AlFKL(BvS3WHRYceC^_}NMWWJtVl8Is_8hbN(D?w?0Y-MQ)HKs#^Q`;w`ZxQ?EK?jNGeA01L8k%wf zOT?1NI8yQGSOU3w9GOZyi77T2iw?<@V=xhhC{d*6mCX1wFD8&!AxVl34UEOnrs&XMG7=w6MkC?T=rB5ZU@#F!;izq7U<~=u z;81uNH$_wDqLJj#DAKVZtU6-x1S&B%jJ3w_9F{*2BNm<;A0HoqRG4)m1EYzdNKz!1 z7H=a`*vkOqgL3M1{6i)Qr>m0J(&@ylX!=u2w=l*QvacY?b@GViDLVrX#TQpF7=)S? za##qcCCYn_Kqq3)Byw0!#O+DidsasLz4l57>xalhmiISAwrIgLcip&T$kAkj-lbf(IG7ThOhuZ9vdHXuK*K~@xe%R zG&zDeizIOnc`Wjq(edFhx&-47%SEKah`jNU(ZN`Fd|)V%hz%#B`WK0a%Cs47$h)fgXgF^`NVYH@tU(<4@F}!te&1a3H#4JE8=&M08=EITFhkR z4HCCA**h5fa?$@3%9N|DOcpB_EZb1ecxWY^zzRPicOcYy%)DJ7+Y;*~6k#--{87D!D z6Lqh;5H1nKuzXbuT6=jnw=$p3s$@ajyoqLgN~N4Ky2UX)%|#Gy;JFy5^GnOw8}qjl zNONPk=*+E^=u&(}Es8PPV$qdbDF~vbTBKQ6L_D&8fQrvV-;T~_#S&&?bTNd=I`RHl z0o@b1I}3};^}*4J5<2l?Z5<=PRo8>$=q1H z1}73p8A<-LhF1b(E`ix-5Yfbr?!QUlPwh z|GeC#!NyElCKW3Sa;iBU$;=DZ2IVkLhcHaX#B+~~ zP}i~wY1FH7?IKe;q*k}&FF0)!8$sgQ$xh+|97B4aw2CJZ8JzsIv-4tS#rBQueKDLk z^-xp-X(6Tcp_gBOd1w#@$UHHl*_%nrc51bbOCIyqf}41mQknjlCUT1rALFRvphSQZBC{Ywa9dK zgIDa0Il0Lxd*H z5u-xC-Lp5BWMc1ZAe{D!vzsi#Z6f|@nO`6d+h*`eD7ZK4ccSh-UB+>7sgWsd#C-I>`x)SaT+xJ8_h&FMUGH4ZI>3t|#4*!=8N{DV>rE$JXr}(r&CO zVxx}ESQTI~fra20LO3?7=iud$xnc3;$2{PnB>T}2j?k2V$DM|9kxj`xw~xytD~&W# zDdLPe1S^a{mFU?i;px7Fo&4GED>u?;h*QH3xFnM4DlAhxsHH+fulaLz(`17BWg^iCy z<9McoVd7+Au~9sV8^gnZh!`Bl`U-p6<8irg$J%KO>+cabU;>LOEcY?fjts=(W6~ex zBLl472ELU8MLym7RwQsk@liaW36Blp$>KnGPCjl-BqH%h65AcIA-E(SHDap+`w9bC zBH{qM?(kHsw#G(AM@EO^XeKr#B9ZspB*adpoVDMOy8_>XmEEW9Juq6mzSp)5u2j3D zpHIi-0stFW*bx8NH(OasUw>P!9#1kExGT*l;;pQ`;buE_X8zQrU$VCb1*QeHEOef0 zg^;S?(W!jInzb#42C-fEUQ@&A;_=;+!|B`*M>0IQhyZ??Fg$j=oDLLwpOton?QHT& zD6zDJ!GWpI-qb(IASuz2QOMqJeb-LL&=__cBFRV$F&-Na*up?ok0t%w5S}n&`G%)r z-YZ!gG<7t=GwqrLw5lK89kj=huXJZ8WCbTMfw{(f`B*RojV zt=zt*az^Be%PBn4e)GnnTm+u%&q8bLEj;y}{lT_}zR5;ydI`&bhJdL*}yka)s1@c0fZ7E zLe(Ngf(9v4Fm1EDSqu=^0tMm{pkk3I-QDccmBNPI0KvCvm7-OvR;^m3VAXo9QZ;DN z`dYC{)v8si6ezqEtM=8Xd8J6b7LE2k|M^HJlXN$W_jhmh*L^bokMn)boH=tcLsPKa zwp9)kmbyddaxrl0O^2FgvqM5%ez~4WBBQ;!T5eqQTBjfC?LmE2R=YZi@7~X?Ej7P# zRwo z2?|mcGvew3g|stckT6a=XPu>lNL5*%jA!f z0VgjOedNx(y1H;+$KJSiFLOTirU*+932z6Pt|!&9$>4TQj+%d?Sh1d^&l7b!b4xSN zw2Rk+v^Ah}xN2n@%zIP<+;=lGh#Q!Rp7xfw+%)7y5^MeqoAryI zGmNW8rV{G*fc(Wt(It%B|B)b-vk@oH+P_;}6e_x7Z(1|jXTHB=ZRUFADW{#?u;m7Y zG3WB#cc_;<2c+pAx;1mZQK~?;1+ai0H$scgoBsTjuhgVm5VN3h2lwpM znTfaVJTFzQjxqlT*ZxH-Vyu^=}KHpzler*VgjwMTUdP_(YA->{?oXfv9^{GE$#nJ}=uN^WMW_q!GK zbRk>B3Uhmz@*HfYBe%;do9(0;DMLShn<&Qsf7~tST57AJEyk-&T$`c5V(H=ds>}6ui7VPJyQV zG5U2u`JIXSbUQ%?lwInU47n<%bIFi?$2RRg_PIVQf#%x{>S~QhjZKR;?Y?Q}?)_)vum6e{J@Qw3rgdv|bLVN19T6F7^Ea4` zxu9H;sO?WCeGJ*^ZVa}hYP&v2ka43ixj%oMcp3}d>RO(<@KM++tDp8a2RO}l?osk* zAeolQ8Hky$u|A~Etaqq$Kr|Q1epIG}ekNC16W7j0`Msl(N`1$Odg;o(5cZX69Yr4# z3m1v%%t5`RH12p=bh=nuLyzrL)QzCc#)aJuCMY+brQRo1&VPk^?M9u;+Qz@tW@S$& zt%&skZVWZ-VT>sn3}jiH9Rcx+^S382qmDU&%IZ#Hhumi68eHG(q_zqwi^yhL_Sdmt=>~N#o4!A+qG?E>=+&`B zEpw?&sU&H;+PwM-E&;gE<7}tiOw?AljZ2@s2by=^rp^o4uF=5m0QDZf+M#aVcNVvy zxMW8DBhR9+^eWJ5)L{+Fb3;`ErcAb;Elaim4p-LtYTbwsu!w-Vqh~)6Q@sh$jDaW z-Fr-fzIsC`zqrWtigEo@s5^ZRrQTzb+V&7-M^aiSE15-C0s0oHIPF#ai2)0fw{qde z7Rcf?*K^qF;$=SoJ5kW>$X}l73$#q_l6!X*R$*`NkUJ&CL2#;~&9M2A8+FMJCUV9l z?)mu|46$ZlRM=^SuWSZa&$7lz)_dDNdK$N0m=DVK;o`k;2d&`zPW^+`aD2169rDs< zV|_}t-WVI!^)=km`Le=^p->4@BE~NV%HIDoEdp!vYjs|3x~ZxSl?2KjZDx;bnAyq3 znqXi@6L+&&ZjW=jW5?Fbn|HFjs%|!BrpUJY3Qwy$m}nk)k2ANajO%c29`D@G*-qUm zXH?Rg^3`?fxyr4bvO7VN%?{7o_Oa%aUvyW0t{_`XDHiTuX0#eBh4~-OYBW?;?rjM*U7VtQ_TKlo2=hdv1$VJ1q-9|aow>`|0RRT zQrrDpIzcnGFv~@R{?naXWmh9xn61Cy*=W_18!S6^?2<`#V8`Z0cEJVtVNC-!0Gi}P zBfkaOcR<;)i8=kQcwk37=;wz8o7m|kTc&nx-CDu22s_Jm-KZ{%+{_eFz18|}ol&`o z*T7bH|CY^6ueY$>YY&@5w5?$5;N8Q7o++Db1!I|orp?_WxoBd409!lct_8a|cFJ!u zjGMKEilv8??zA!*sO-bY*L+uDqb+OlvS*0>wX_?3I}X2INp|ezQkZ4BhRugqG?z;8cai+6W_LU)lPq?0?AgibK|Sde?dWJ=|2>zRad=JxccqQ%1u8VTgCD*~ z-`c&O-$v*Ig~Cqr-HhkPEX=Ui`wiKX3sbrf7yar6Bnql%5w7@h)mVbZs%VOpzmSkM z99f@Nns%ud{rvcg;b#lu(Pn=9#qL|y%D7k}(%ZK0+m|p;dJnY*EL*#b-4^NsFpIjZ zao)_v7wN0}_RA(@Z8=`QLH?$~+2JhH*YmhWlS$S=&jAORo?TD%d6|n?}+3Fy86ed{BB+Q z{6*)A&a6&;Agq20@8;A)&!>ssgBzc)qEzYM`NgC9;V0zk=Tx2Aubvgfs~;<2{fln+ z_N$J)vajQad+vYY&QnhO$3RD;1+zxToqHE+4)XaAUb@U#E*;2*C0?loJVdEqnHj=b~y z4?p^*+WQB;z$-~>dm*E{lo9RIC&r3%W@4NiFpSiO02PbAe{Bz%tcRzO5rejwV!*BMy_&Xyfx;nSKEOL9x z=?_k(17CI5ZoTXa3)`>w(a?3z?mN5V&+q(Ta^J4crm9mjJudqX`u@KDQ2(Dgqa%O& z%ySR@C3Va9Km6!xkNnw6cQZd2rv*HH0YCJ8w@sOg3AULv0Yp9(m2Fo0J!y zcuegn%B7cHYQOZ9d~;9>b47pkqE9d7Yc+;HsIm89jc;C4+1+zC@$fi`<9Dg-dHmxl zcmL!kLhtp%_6uR=ww5izPY=BGCK&$jA^)dg|I4N)AA?#tmF}f2cRYN-Hf4C>uGa@{ z+osHHzu~6u+`mmZ_e1T=ZNJ_Y?t3tG@|R!K9DVvXk1fCS@@6ByHUgH9(SGCFAx|FE zzIaa8IQ%ZnGJN52s-XL0gPP*ke?3C|@}p0@M&0-7HWl%BGjrz3@Wi1&(8s(_Z8~=1e4tge^&vNTo_- z9C!hL(obbfkvQdjv8V8N8h>Z-CyuY?Pu>G6iIn$gZsYF~{x0S33jVImA?o1`!;{y;qQC={eZtm z_>&U+f0f=lh|8Mh4ZrQwr74j`x*|N~U@@Nx3tKJmk*PB84c@s(Gb~cf*V-+_RR822z{r}bO zX&w9i@~&4Y`mVjrixj`n4sRa^^kGSN!0;JV(H0yE?fwzp$lToowVzK$+iA zVO7{vi%&xX>t`&as`~?QYiQJdY-JL#Fv^VUdi95FV$sCi8(DcPm;{U!16^dZZEx_) z<*xiT>$V2wbk`r~*n5-u%Pf91xo6)+O0mHfjO7P>8(K6H+=0ENMv`lpP^zKQ=I*fF zW?#15YroBYzx`>x%RhcWWdA$g=j_`Yx7jy4685Lmo~~(&|G(MsPvrxSt+pra-&L-) zrR~$U|4j(Jw*RHPizi8Hp&es?vExkpcI8CdcE{Puo0Z#a4)xyyIvut*=-(K9^l!J= z3@bxo@Yh)U!#2-TBxd+F$8%^5|8?s5Zq@(Kwp(oPRv!i?eQ&__1=|g_W!v9u582MO zzuopqa@b1FWn6n9eNq13_+OoJ0uMW}{(B0^QMOmv&L^HLZP#-20^2`${LTxNi)@$M z&a|Iq*F0J%WBJxV{{KoPHeP>8vpxU}s~7qP*)BJ(Iy^}VS|%^RYu;T(p2@B;bC?$x*M)PBNUX*B-VstfNGns3>@ z`}PV=8~n}cjmQekgWFiOtU4V%^H0_cHX%7Xi)doHRVjxv7n=jpe* zBEP1qSp~cKhRRE!VbkkwmK$$z851h)=$o&;<7RF*vGQtk^{Cu?sMNhd;wtiNW=DDj z#o5$IyW>KeRS7M@&nJ*Z2HBgRZweYW8BrQkb6#Qk8ykweqO3bs=-IGo8~uURy2GfZ z27hG7O*`W|D(ylI7qM&ZFj`L2#ro|*wFoMxzTn0C*q&P15YX7DhAOwSq?qtxTeFN% zhdzlm_%GbgPK8QiYS^TiUnRfAs4#U6jhC`#C&FrPg$|8dquP^hTK8$Ei03ipN>o>A zKj2e;6kI_`Y?9`3m_D49c=k#BFufXe`vVmgNJA4RSM8w;m3nNI4Xyk3sO>^;6BSJr znq*R?p3f_sU2ScZn|_rhtLaL9Ypecbr^4|s*v#CmLfb|e@$@Z?SLjQSQm2)4gH3v0 zHvB5_*?LLmzMVTd4>wB$M9!sqDs6O4v~hlGy*GJ`qn*iwR33WhQ{ssrbp?46*x5do3$qfK5)W?5V=3CYCZlz&cCrK6hH8x(v203olF>MhS8swe|T^m>! z;3oR+{gv7`+3f?q5avph4O`8uCF&g z#?xf^h{KwGU(MjZ^y8dGZs-rijr>=M?PJQia*y&$<#^l6Y`3%i)vG+CJfK{oG}>Nc z`?~GDJjZoPd4yMQ4l8e9U9ZxAw<$;4c$R_k7u#QKf9C(+vZnt>rBC@4Yy2(zdxG#D zQ+`j}N7?MQXO#>l)IIOl-qft z>Kko))Kb#Q_WAwqQ~tew4(_Y`JO4Uu$J!&@pgGNU$&c69GhbO>zhr)W{ZH_?U#zcx zd}4k5_@~y_U&PHD`ud~O>+3H@_ET)$L0h`_ z%j@e$BL4-)FZ|8=`sSa|zP_`*{!(n-`1tzz$zPaZ=6;K4-)<)`92n#ODW47I@?K(*UY?< zSH5tQa`rLqZ1Q~(uEJ&LI-R$Oz*?w0&g0EtE%e^cZMp?TY34Wy<8Ti0d~GEQqv+?J z;FT&IFTpgdd6GC_45rYpE^;S>PZ`v z_rRxN5KcgOkIfWphqEvV=V32gfs2jBoq!%?^br=j;a zUiwA+Ik?Pm*ke=HU>~gEG3_-kB%Lqdtj2|Vb}{7;3!;zlW-Z% zLGKBq7sjB64Fm(Q9uC1UoP@1#8g{^0*ahcdKg_@pxC$qrtJLiI~y_yWuDtf@zqB zvv3;bV2<$S&!!#7F#hAJmyr%!4bk8-$XgW$Y#=uybE^1G1v<;a1DL;7TVQU$nRGADO`X_*cT!k z7(JJCVc!jBIi3iUKJkaMFmeIu`6^@NHk;yy-7q71*v9ejMYOYX3Fl(c3CCay%)&VA zyM%U&UGHU(;{iAb({LQ-;2dnZoczOH=sJ&jfVFS|h9J{ur45E5zbsW+U@z>4qc8)f z;R;-UYcLC4uOz_jKWqJhcTFfov;t~z+pH5$KeQ^foZr1r=jfn znuE?T>4i0L8P>xr48t|p3Z2{O&(H(MDVO*aj29e_!9kdYOUP&22xpxB@&^3j!gcg} z^!^z2%yHmNqzex3B)@QG7v=UK>5bFgVRjF8FuE5zSlh{Xfqw^FglljC`OHm}&(~vKG?}|+dJt89JfRFHsXO^*aZWy7q-A57=vk;fD5n(uE0U)yp!^WZa4*fa2^I> z28Lk{wm?ra>4QEPgYxd=IE=sqY=_-23Hx9#9D+k|9H!wUoQBhI0nWlKoQKLqj1RCH zF2g#Qg(0{GqtMwydxjp!qM1?)`(Pa$hJH8>LvRMRz(v>!S6~bdQhw<^`X>y&pZ0w* zK^#bc*2DZSyFVTKr{g?5R{7n!J z$H8xqFOG+y=bN;vN$MYY7o3Ija0zB%4m!U@dS6R9z#vS)2<(B)`1__vFC2pH$VcHQ z$1M*N4xEBXCSf==nb71czYFcG3+4a1}xB`8%#3SkX3HghX9=Igwf%P1xU>K%hJDh+?I0<{<8XSeAKcyVt44i`* z==>Jr_s{5;9Os~)u_)|Ip)SPk1?8*<;z>HlyE`jH2J!T85<0!BFQhV5_wE+B7z zgzz6Cd{_?`VJn=Or#`V;g+s9AQSuE(;38arIavJ|?dK}e0qbB4hF}s#VIPdc0hod# zun$haVK@cH;XIsyS-1$-;0km;PWrB<{9z6B!FuS2Vb}~?VFY%-cGv|wU_VU45m>)K zdBV9TC?Cn+66Fj#eoeW;y5CT5-)7wWE%9PkyG(oIxbAn97p#94eJlMRdf_At!+98k zHLK(ghF~9TgTt^Jj>BF!NI2DhBOR~{=3vJf`9?nqmtoy=24Z~?~U_$a&53u|4x{|cty3>~G-7hC!$P;jc9%M1v}vk?1l?)0ItAMxCSSoE5Q3c zp$9I*TDS)5p!kM08Ou`=63;W;@9Dr#!3a8;XT!2$B z3umCxPW-SMF2XvPfg!jGqfoh)b`EP`3f93s=!e5F2*+U*&cHUf2;1Qb?10W4v`1J2 zQ?MR(!!YcHt#AN#z#-TL)1oKeJu%ul$DucpAF+dTa1AEW*I!RRfb(z#d0jjGfa7tv z28VZ0?mHQ0U>$VsB!AEiqp$|Xp%12@AJ%=Jaby?if|)q&6!z{RpK$aB>JtujP!8Cq zq5B8OZ=^rL&Q8ijp^7`r!hMK;P}e4+F3pcEMq| z45#4=^v+O@|3Q2lXJ8x0U2i3QaJ-xR!uq?22Ts3>^kCQhZpvpj{i>Jv-~{ykko>-f zc%kckgoC`Lk8*}Fm_j}S2RV+vpYaX4K0rCb2pmSg0@pYWevta+cnntWA-&KKv#=TF zU>oG!+6s?2QM|Al*24i9f}=13Ct(|$gY9q`cEUB7gzg(?kI)PIU;qxo7B~)La1tiq z4D5mPa1buSF_?iLG0(OVueKYmta42nX84kc!r$d>7t1t({ zM>rJ!jg;$=yvi9)!yXtq%At(IIhch}mxDDR{9qf*K^|DA1drxDov;t4VYmu=7(d2A zUn6~P2Yn3x7vMKbKZ0qFr;fu8uECm{N%slFi+;7*q0GXe7daI7PYD0T4y6Tl!+Pun zU^mC}FwAiVj&NM_5{J^taXp;lI8{Tu91p@?I1Gp2C``jyxCocw3iO;v{`S!>p&tfe zGi-rva2WQ&SvU?C;0*M?)S;}w81yA*KPO=iJ76blttEXTe;M(?3|xU-r#KYnEu{BU z>|yK6sUJA_3Wt(`Lq3P1{FHKh74^8E`aIL2w8H_|ihOjFLm3qLW`{BcGcW_YFC(7^ z7=I(kv5Q?zyd3vjLpdfnem&(3b8r&2T}wJ{rQf~5p+sPPjBw#1oP&LDbSSRdXb*3q zykQom;L!DyBaG~DDAOzMcZzbp!J))qJxoD& z2jRe4xBz`H3y1bPl)xR-!;ORoXJ8NP=p_9x2Unr{Cery9>f>gI5{1M2$k)#p*OQb7 z$2GSSE?i7ezyCpfzlHVzvt85=9D676!L~c85BwHkKWy)zp5Zd&rkWDGi~PY}n1Bm# z6n5NA{dZCBpP-)LG8}^GdnkW6G=%=Gl>fcxVeNhB;WAu=W1pn`y^ZqtJmmp1W3)3E z{WAG_JN*KNVeA3gKdk)<`G?uBq3@>O{U_;$ORyhyf0J~>z$EE@2T6xP*!3-kl7zJn z(LP}RH0>A0zK8yu`dNFXz81 zr_ur2j^Rz#u;&F%WdXX6bt~PjD(ja0sU1YPC}dJVJk{Asrk?Pjo8Y_fbD5Ih7dn@(%X_=swx0%)(xuQ|ZM% z{7TZ%N4n~qN)%>ZoWl`+_Mu2adu%=u2v;k2hPuVaSF1(XNr=x!!`99LiDRA%4`bobMq zE+$=Y@DlWJ435Id7V-&`mr*VsBEOeAm1fxYTEd0nZ~)eBcPi5`aRu?i)+qJzVdA-x za)dQkIh7IN)r1HAt(4qa z@p0UGE#)>qeZGNmgSFQ=l?hk}XJH`bR6HLg-gc+b28VVym44WK123wEwKqDI+K*KW71o6PxThPPQe##LB4mcI(AnhB5;6RdeKyxAmwo{;q{YFxcDLZ z0j&Kf;laem@Si7~LE?dfchf#$^F8GEKH3SKgh?29lyLwiVEYjEum{eeUxf=C$L^(k zKS@7^^>7h-9wQz1kzS6gKM6S=8g?ovj%Q#m$FrZ}HR2q1e2(&kp3hUh!_@N^s7F{k z#&`hR;4~b78IgaH_WUW@FKmPDY1%2w!U0(O0QrCsI0>7-Lb#u%zQ2k+%)u_WGEO^# zz6Z%aY=fT9kZr`uE*SqB?Hew^6*&8K>_#|-5x5H5VPJy%!5Ex?*>5-% z<+G%Fl6DB!U@Khu7UcqG9-^M$B22?9oQ65LAjjXP-~2DiXNr1(A=m<2U<@WA!!V-NKeX z5-#lc6Xo;;((ymU3kUy<9~^iZKe)0&`=foto}oNo9JWh)fdkN^Xdl~Ku2O7WFSDKG zK1G$)AYT4*K`(ao{4F7KX)dZ=nD6rzSu3&`WYs3S4qaBO%Y#AEo{)g|tgv zGW^vc3qma}Ehf=9ZPG6JE2HzGb3Rz=C-H>PdC^H)Uc%Sv@k*bK!Ba=RP~5f~HgKOSml0C?DOqe zYt5qXLqCMRyNq7a!qivsJcO(0?L??or1;LDYeuKG2}@rReFl93eV_PR;+C*oP8sXU z*NG3^Dmo)CQm#Q{8Dv2HNE{Ik7LbKRVo8Jebn4-_MbAfM-N>?*um_MWS!AQg=8>6w z8M2jGi`^WuDT{0w*|^+##8b0 zqH}(~{5TduSA(uxd~N7t-Av7=npSOmk}#9#TG5sB>qpm)E+}cS_>H0KDx;IKo%ikih0c0y8Xf>aUv@?;UMH=GZkM;PlG0r8Tt3@}Cu1)MLX$hiRLDw!iJB>#l zi^Q)LU0}9!td+DS(8YgJ+>UyXbs`H&SY_&U1bq+sR?(a4$IIaqx=C~~U1yymNV{3a zW)+)q?MC#@qbawamLHcTKR)z9^yS)47+n~hU*hk~7lT?4F?6yfWv-J1vM92H)yaVP z{j7YQq|qhNsr}g0KO~M>@tZ3y+a+W%WE+*OJfJLzzFgVXq8mfEQQ1n_gt1x1W>E5< zD&${uF?8)eFW;|tnXl51E-Zegw&me)0Npq`s!jb!Sfj{BH?W^ZH*2z&I$c0Eg{(zF zC^OEiq0gYN6TK;Z@vT0F_JS^y*A=} zoo3Ek53(>aYaP@R#xS}Rx{OT%bf4UA9i#6#eyIAs7vC%K%HJIJ{>SMPe3$vB(Vrzf zS#)#gx<$uFWRkeh0%axN`G~9n_`2lh%rw}q9vU;(z^jp!#(EHI7 zR7o9(z6*Uu{3UM|e@Vv>`qd{3Wh(hgBU?iz{*pgqE-Uu4W*x_3zl2QruhMoV{yB6$ zbZS1!#9#XY^1i6gf3X)IKe8~g2;Z$~7JU?bC;C3omuYti^b6<%qPMiWK6G=xEZ@aSizU1nWFchgxTuG> zsLP}Y$VcK>K^CysIgcxq)#x%K-a35ikx3b_#fuD;>5F<#^rCLVcIH>qC*RARd&Q;; zo7rD8eu@okC4a3rR5(8mW7GE={d{3M_sO|v5?%Xm^}cTQ|9F)if|loH>{hXpdXkTn z!y2+RWb$1;LO1`3ol%Bj??smQQ~CB9Lf4C~MFJ?(W@6~m=tnouccBkv^}b^9N2Lt$ zpYv(G?{X}8P9yUpTa++M=B(O`RnonH-Si5{5<62r6WuDhRdf=bStsq>^Fqc2bQ`sE zKl++y%D3|fx&S((%}99d$m)=(WmIN7=tdt!ucp&fza&u^Lf4JXs5fC6nf%7fYA?E3 zbmQp4e7EK+gKh!cM)`6bPnn=ES6)7Ju4hT7gloC(45JI7EBF=0A2t8zJJ9!uZ@K)V zpGR*U4<$cC=(6an^(49pbn;uadI{HbT`uJ|kIuPTs0Wc{kgZ`aX_AjHhfKlFFM=}d zul5A|(ASBckHqUomO~a6L094YBjH8RdH=d`f9OQtg1%dP%albQ`cd=&(OcT=D7qDpi94|?j|9j)}gLL*K^r|oR!fIrhgZKo{D}O64-xg%6_&8C?N5Y9A zTSgWZfu($%vQ=bi8!=rUi7u<_B4TXHi|AZ0p>Mr_z0qZKK6I_ceaoN0vrr&PNK_h{diC*^osxj7-{!5uW669N7|OueL8!8<+U! z(7BIe?>OJ(BeG>=zLWGez_Em}hOG6>()KRxxB8{bG0^q%yq;HFLww&nV5J^D`c0nwZKt(t#yJ?PAJ97C2u zCS@TXAAbpC-N>>cV7$xE`AzqS#C{OF=5uXIyV#W(Lnb6X^lBS0^=t8+N0&yYmb=AQ z^sDHX(GTijdp}|y(n?+{(`t+c&ePk!rG5F)wVhv_&t_y1i>wV<7@4^bb?P#sElas| zBMVyW2Fl1rb(vbv_=)c%GKp8(fqXt+pEv&GkaGVR#n>(ymu-vF`@ zc4k=%GCwkN-eSn=EOrTGwH8?qGLOY~5Sa^^P0qekqsdmw8+xP z`Yp0)WW5&I0QeHQJt`1!iJL@=;M%Rtb+^?o}nNgmSwgqJJTkk#z!!ia){A=iQ=mVlR zjb-9n?d8AM^1C`<9k->O1-y(C=*?x@g3OJqX#7W(il>9BAU%rapX6E&U`SdEfY#E*8-E%7ABf3!DuaIWZ z`_X%@F0YrgM$y-!H_|GyI5M9_mO@sGO!V>*_93f5W-ixZWNu`VC;5ooI5J6txed&e zkuB;nb9gJrocNm4=X^Q!M4rvE8e~!qW?4NlDK{fLDUUF+Il^n_d%1dONAGDZKOgTx z7kHhy{}k$I5S{ay((}5M!?=Wr&YO?3;5Ub^x{OZJniXAJ`Ls&8x?jPZ2)(&ZyvU-+ zj691jfUMafYe5#Y$YRLqk)%I&T@B zl#S~&#$0qEzFW&q!mUH!j9#rb)B3jfHlu4lh;JNyR~g@We7n&Nq8pL2DYvFrLw z0_ZYjbP`t--7>mn@hcNo0=+9ySeuaedyx5%na7$zWPt+(nS?!ttob(c8b)#3n?)bK zU9WrWCF~_+QDkwx%SZAf&uJS*)-D3my=#d>-pjO%E+RTh`b96#a`U7%KCed6ccHhQ zS0&y~bOY#UhI-hAaZ8>7H-yeFy3XS3k`Z+McWit>c-aOWMk!6uN)ljr>0>~DTg+x}Sj9T?@Hqs~2SHGovJxVzZpjZB*eEp=+ zHKW_8e&*08(DzB&@?&XX?PUf1BKmUm<35A7fv#Nr)S=6vD_1|w=vKNmuAeyiy0;eB zMGBc8Sy0kxDMP8hLG&%?U83hBvN2>KWahehsA<&NtJnw8)xW)Pt`z$SvN~kyyu>o!?La5blxf3G%Wom9 z9(1+uC=P27Sq(C4SkjKi(KX+>@!W47eH4AG#6=r0&JSJ=SJ3sMi|M*@YnJldz$t7B zZKQadk>?4@^J3I8CmczmJX$y+rNXn@ld38_m{4I&B#vv4A?uX5 zE&W@{I)y%oUM+V^UyY!sQqD<6?9AWI{Ya*&V6vdE^9 z8Re$NeKz5JMxW1F?q}7an?%lU5Dp=$|6=+1ti&;iz7@UdV=2Q0bjp`Y>r4Dr(bbmGNjf~QW^6+z-{s@w z&xb64Y()gj{R(S#h5Pg^*eL1pV@_PcL>CkPd^r~8_iFyJNnm4+cL-T0vV+AtC1F2M zKHfzM6WvDfs^#XV?O;==!{V{di+&Qlxvm1p(iT|@vJs0+p1(PW%q#ikg6(xtk&YYfXri& zWsx~8GNq9*oHk~TqZ-+YMOKF_W08fBEg>`KBZ_R^Vi!j?YmudpOF$d-`RNhbJ67D>$c8O;^32%*i=A^5V~EAB23fa7CeNo$T4eI9 zT6xBlxsK$ywVlY!EOzqD+pr}(dH!t(nK>=; zEL^cOm!CWrH(>E~ZRY%Iv8zSqLuSs0A6czMCePEYw)nOob6M;>O(L5^W=`K6vKfnP8JRp^%v=U*$i^))_ZHfaMdn2|WC<^TY``LGK_<^Q zGp8kntj7{w0$Iu;>p|9O@f}1Kx7dv#YqQ9vkVP!Ad1PUWEQ2gyk>!xpAv34h6Xcv@ zvGXCTHrYx03L*^vB;*7 ztrC~HZs(C@EV2x;MP$_kAs^|7IbBwi|E-Kw*qLQMWV4p=g2<*Ub`fL~7Q1$2V-~w4 zvJs1{7uk?SCeLLaKvtusSWB}!qj?aSc^r}FHH)3O3>J{}T4Y&dU6$~aa~S6=cJh4Z zq(xSTOrB9^jw6IDW|2jaMJ=*8vSy1cg)C^1$#bLYk(tYW7@5yvH;zo&mr<8;zMesr z{kwjxm*0z2_-$PVea}D4>kox_uK2rN!`uVCdhRZBy;g^Q@t^uU+j6bbjIM9pye3|_ zUXy26r_rg`LBu2VCeN{+LS`<@US#9Q%z2mRT8~*|@{H>di)p&Mnr`Cr_SBp-b-`#^Q z!guRhf#h)neHy)yZ}FKxHfF@d+6H?#iheu2fPNl*hlH7*zZPF}=CG+bp*UZj^RRzO zp-dz$AF{bqdHz&USX$rmB5Ow9`Es6DRirP(C2gexn?-C!#HQ#Px^NGu4pl#Pqo>)G zu&l#y52(H5_b%G6U8F1~v7LM+&(-3)d?c-N$m&k#*-s)+%ek<(#fxYK{RnzB9c9Wz z>Pntx?yD>9JEHTWTS3>tcXPi|>mObJ8G73_^$kgD2f8tI=C;;_Y#5nQufl$0L&%Ig ziEKodne8T!4Ps|*le5SqezV;YvObG!6; zZOCF4yG~?LWahkemyr$VGILr+ku~FM&hI3$kVQ6!EMSRu8CjjhZVj2&B6D9rfAANV zfft$EA`2i>EV344tFJCjUkq8+B&)+Mfh_S}o{@GG2hC?SAM0t>2f3;)_bHy{V-Y^} z%jRRmb_m-Ep1&64yK3uYkxO69ZgZdFNs#Aaq%3udcMm?($|74 zi>yxs62G4gP_(#S#htcO{1GEa2@F#SuLs`{&y$lrBp(TH5SctJzU)3F95vGO_=1|EF#grs=nFj8j;$+N+))OsGMiJhQi`?~ zf_Op=BY;~QVaT)Y=J{Se{r0iqQ+?dpPKm3^ov{B~Osf-VBcu4|@Q+HIe5CD7A`9Nb^B6@Sc`)U-%HC?qaq}@!Ct2*fu^$lo zNVzs@Hz)luNxQ`3yNGiae%<0%5)Z99-xHgUk+N;YzJ5qQ$81oxNmCgSQNAzq<2!(_ zOXA`q@sA+uMJDBFieEqB7vi5M!BST9dKj%@&qvafK{ihq<06nW6_>x>uDXg!Uy>zp z%6kbS_cG@@MBQbJytTTMxa0Wy@8fxu2Z`H0UgRuAug;Z*@m;`|pBZTBF71o@jhrg` zjQfzehaQKe?4#H&U_0U9 zfRD)H$ReY~=e-oNK8vgm*`!HUi|;Tp*B3Zn@LjF9{P{wk|LS_9y{Pq%Ecf|B85($&$49xpP6GU>LdvrD~Ds_Y}~ zQxIvVA-&Cu-y(jagylBc{IIR~EOzXD9!J(@K7DERe;IZ2MV^~0@$r##)gfC!HlRvq z=P-mU@g<(eYUHnYOfklEheE%yo@niykcwsc`HwJXzsxg^%ZI6sQ}(qYUv;dI^vvQr zk!Idr-q%QvWvEfdDSJy%kjMy6%Fuf`W%vNkh?X$h%AGSTvk=T=%xuFi#6yba_+HwM ztL($(mfU=d`ACgd-tjSuf4kvdm`|za*ec2|Kbg|&T$;GpNDZQW8X5eZe-)7 zVd)oXRrX#}aK`y4jlDO*Tmj#~6F89bMtjl4n|9uCtR2g2u`myk^i(-q_EI4opu|v` zuhqGXSs4*!U~yE-ex5WhKE`vC584hKsgh<%7CZQ>el2UXzoHGw7+!j=G}@74pdD#ZStZkK{Rrtox6L7nY1CfhhHb->3w%!Tgq)WPW%u#uJG% zfp7dzhZm>#$vbftpNHQPeyv%aZC;vIZ5*|Cn^Ib64>ebEO^siAL%-5?ByBm0U-*A` zhPu&~%lC==b#{_gZRuY5>uh=7&iJ1TYYGy68rc}KrP6fe$MSwtj?}WRidrS=IbGh{ z6MnjQo+j_`2_bVy_ERHbx|6Qk#d z-aV8fto*f+wQTJ3hbkI%eWsUi76?0Wkg};MDVr)ziurO^OH0amp0H-0JN*9Him!+N zf;@A7+Bo;g^;YTHiDRC$m@l@}N_RAuk!p3-LAWFTG+qB&%gNy~=T7a2l9yrphIy9% zqA`apcW$tAZn2ynrEV7S4cmB*_7NP&IF+zYzm137RXIWq_bG{@)0~iZq?V&e(=v4{s+0{rsU1d;0g0JcZ_fnr##R6J{fzYW(`{Cn~5KAHn*chUS>gFe3GObZQK&lzU2oL_s;MDZgmB570d zF08Rv(JqYsDS0biXLa<_mJ7{7YsZ`h)%Ru9pUrdQ589U)uL{nOYe{LLZ(4+e7vL{U zhQofILC^Q{V`<7XL}_V}GM0C6jp5f}jCbYhNIf5zVpbcel*0u6^9`o9RTxig)K9mq?42g#Bux&mFkL3=YvB>x<=tP4n|b$yQ6Gm0%b`#? zWkb`-Q{EM}wAFD~b9$+V8T?w#Ieb5P=U5uQal$BHf95Mgt^OpwAb$0);av?I`Xx-I zulY&6CGhLVugqAQuQ&6I%sh7-!&l*U$Z#4*Zfwa-g0E4qDxgx+6Ik zl&NoNxBlx$|Ao(QY!&|m{okcw^-|o}+XD%_d`(l6nda?AJBIS_9 zx3h)!jTn8dOrM!Bm7q3vv@cmjYW>Fu@UpUDX=9vYtmJ$yehK_WFXx>cMt>=!LHDbn zgXFud+Hj*aql9m(Q6oojQY^-Ul_}Jj&6MQg;kb2wMn)4Sn?t7q6<)%8_z#i zt;MP3A3yK4y!*w7yQCh5&84Zojdng+3UB$oB-kN}PAx0JTpS_k;X7eyX$^Y5B*09RE?n-Vk(r0-46E0V zq`9`8u`Y3VW#q@N6+h(`&M8K{hKrV~9lg|P*tA?NKE3#K@8?}OhEKHEM^=BL7N1Fc zJV^!wBY*A1K5~uHZt+>cXYp3XUn8F4J}38t&0S0hE7bQ6`bsxr9pBZqRyrS6m+{qw z_@dcxq1|*4M(`cHYs!e%+TI-1=9aEHN*xU2*TOrPhK;zber4A9#BTw=G=B9)*;wbG zEc{!hm+Jix&rZh09^O@C#8-4rHJ&dWNuac`F!nj@JBsa%F^mgob8)f`Q8-k8E7y&` zk9S4o+qb1Yn0*vHxJxQ=j^i_g&-0CQ75{1cC(EZ&e8%4GP`n$}xqYt4sm!v8S|=lf)6>iQtBkmd>ZCKDTB#EcHuKmoV_z-y zd}JKS=(4ubwB*OyqE#Gytd+d`;;h@epZAzaU^0KQ)X{uC1?p)^jjs)#x(}GnlZEoi z$G2*3@p^pHb_ejS?LYiBKZ{=vepw@(g*f%=u1QOIYh~ly4L{^ix{lKGSLiGGv<@;S z%wM1BX>G+X+7&@zzA0qj5Z%~?V+AO$veh2jDOCW5AFI7ztv&hJ+`6Wh&A16zfa*e{2AU= zW~9^7rkJ&q^uaNFdPgevk#@g?PvWz@3(ZkdKl!?KnP)6|-HLDR4Ya3G%H$xv)8^cn z`b-ea*DyWa$q*hX_O3&n#i&2*Vin!^oC&na3#a z4d*V5;v39ed~fF3>+8Hf&#jf9pB|s&)+Pv8e}vBb3LegROAM~-ZAp`_T@dVQS9@3 zA`9{B*P`B%d`UYC+{ktGw;1z^<1ywucFr2zFyhzx?RTf5qyX7 z-MG(CcSU`M?Blm6>HIc>*GBaw>*hs%rF~7p@pbYy#rsJ&3}@z$;YgjydvR9@C(U>H zNV#+)>;F#KYYd|e6kUnwLy0;DOyZlv7pNbJV~&H??-t9Jk&RhoYsj1r^KMs@ulpvJ zmnAlW=wmF%9T;sbS!NsMHuey@y=U`zs$Hpi59h?FlJ-;%;2-I zfzP4N1yVjMgc19G@j1qMGi4?+qkb)8k0mW7-~f?_ao}hNJ~kd43_lC7W`)LbI#I^j^IGbGjZ@WzCq{4YkbxFnD73aayIf(+*ggY z1;+?EGMygtYjmynug#mTX$pDBx9N@6IQ#MKc=Yh*zS586oy&9hjq!bhv9#z;x6zNp z*O_3h@z~+}N_~a#P2)Rh%mt*Zitk5o(O4)Ysd(vgDf|LYFfTLexwwqA@vznGsojdO zuawBtdFYIU_hi{T=<{B4iL0k5b^K9oVLpOi{#vk1UGzv095^W!&$U(F)#88*tgxSu*goGZF zr|=6em-RFHnxo8pbMt_#R;h;i-!0>l-dB<(eMQP>7{5^V@aG4TzeRjU@y)Nl6xWGtBQ-ZmwQqWE`QBa;%t3&(yCxx6|Hu zmwNu1z>;6NA}}Yus`ya74rwKfS>Ds#Nm@$APtJzAzuI=x`p4dNHScW}dp=TzgUE7( z(Juny+~PHUqF*KCE7X3mE!#JPtGkO zGvX^ckINNQ{$xgp(Kav*;}`iC@02g~Q~R}C2gb4K!A6Zw!q&=EJ)g++TMzo=x;_`T z=*_E}lJ+#VwKk_(&Jv}}C8m)D?YytqnEOo~{C>V;jL9E1rFZnT>&WUmNYhcgN7-nL z`ODTSN0Rz4{7R30PyEf;=Xil?ew;b@+|xc$e(I@Sv-K0!+|j%v+K4+GG5-SgK2H@} zA_@tAie*51PtmbfFAIc|Jcf6uOCia=b z7dt*8TSC@pl1Y16Mb?XKO^gcdP5+^A6(=IShpIaK zb=yiN!Lp4Su~AdfXPMm=Nz^*&C#;$m@=oxwY18Ut9Get2tAUr?DL-O!=oST zUXaQO5l--Jlxww9nK9;$8|)K}F^4oSD@wUYo>TaDzQn1fi9@yR_aPfXmJxw{l#XsX zQRp^lY}#u!wvn*svGJW)T*et)CVKe@bI82d88Rs!&)aGD$X3M2@_U~lt)k7-ICX@O z-!kRf4-yHljc}$3N5Yhk$U2d&B6F(}P1cPpYm!MF4j@}bCT+dAKIx;T`jqi`O1BLe z^-y#jt-S`&;ieJ_m*AvO&%gMdRGc19H}hs>UTGhETKMyI)AnnfioBOwO;7nYV{Dgm z$Q;SiY}9g+uo8sjKh>$Eo&cftY{H4CU@8q1^ zsO5TQY{fq$fuhU18I_t*GyOTwjx)G4~yH5sHRU=uJo@td={Odt6{D=Y!7y9dU}f z+#W*-Yk;t3FLW06#tt8JKCAXOOpLjO@vsJUoL(ZV%r>X>-o3t-Q{3<@Owl<~hk3gaa3002qv+BO zotM7_Y~@)${bKt!Juhk+)#jqsYYv;<^GTzW(XTctBkfnsQl_&1sQN^8!&l-|?S;Z$9(aZaojmx{1@jCKa`Y%4Nr}eaNP~I)rX0i2(?IXIaF&#%H@pfX9 z!G@^$U9WCarrpTeYlcQ6a{|}>_H%fTxKPWRc72?ifo7}0F!Ebe2Qys}T<6-QRy(w+do*vO{ zO4_*A!X*!3?B)L72w}UvsoNi-%%t6>@Ec&wR{F=r@f4nG?!#vcpGDTgmiX@ax*k`V zadjTMFxSP)V)q%{u1vd?FqI=&w`L%g@zV7^tF1acQR}!4y9jg4C9%6*w=2_+z35x9 z4R-70e4TDk_4Aa38O7H1ZtdKbx6PNm7Pqvs z6m~J}#-;xM_I<70THBdE(n+|Z*e|_@c_iOA%9FIKIqb&XOZn5^T}^sg%eF^tJe0IJ zUG(7(kQT|`R}1+oK38gOUeXf4e&&OmTlwyKShu(2QR*y)U2nf$XZKs}3Uw@L=*3nx z`{blNjDEC1c}Un3*tdRyKS}eS^svjcN9m)>*v{O;gob&q>uD($%D&=Zey$-~Pr{%G6y&xqgZC@!fTjZlAB$LVZb^g4nivnR`K! z597Rkh%~AB$1n8&>&W6~oO{dp4Vm(f&*)b<&xy}L&+&!)OZ_e4SMzo1M)GrsH9v*E z=;R_|3cDpKr!Bgjwe8)lwSOt60QLjlVC)ckW1dn}uG%?A>YyFlk#ACel6GSbxIx%b zuKn064?)Io*FHTh<;utOE-A_D41R47Yx88+=|B2EyuAy2l*RS`KhJJ}O~NIR1VX$d z0n{XFD-|KBST;m!KrD)=)Efb7Nx&Ok+A1mu!8Qg4*x*-~YZ|`+A;do-=1=&YU@O&Y3gwIP#>u?>cyO zH*oI)x0^hHeqG@@`dIBeu{SthK_^fw-H1 z+=FksbI>2Vhf_U<^D$JL8|!Nz6^@Vo8_O|)E1GcOFB^m~SEo*Z1 zg5>V&Eu1VxnE5+gzB|;>@7>X&7TA|}0#Clc-(6Vx&ne&GWtIJV;IiL`7J}R8!ga%Q z?*sQPaL)?vcU|#y@SJd_^g|Z>)8)@!bksAx>@$EX0PZ^QL2v9fUhAh5iSwLNd#%12U_$v+cYzWm$)eE+J2Wk8jy2&(x0k9Gj;@MvE^h z$XA!(d{;3r#?422yYhv`;j_TD0&ADy$=xq84&NtVX)m`7|MAOEeNy@}ZvbnLLvPOS zre6g+8rb)fEYrk&;Lomp&<$)h@5_LFBiZ?WO5iu$+dYebX=TtcywTgc>Kh$=s}gQlU}0 zbIyIcZPf**;LZZ>R^SShXLg}BMhD){ z8$YT~1Njn$INy+L*KNC*>!QHj16%`n0-o*L4O~hZ<9{gqAlZKm%{ugm0^pi(2=>-^ z_w4Qt+B(ZCZri>CY{RL1>y9}nFvjg$X#06>(|CW3e9;kn|4(H+^W|mATjU!(%K2_) zyR2=WKRWZBNoP(3*6t@y)^#(EMC&tvy`#`F72LP`{kqUleLoqv*MPf2H1uRzC!8I6 zx7s-$_>^<`_Lcg+_{+ER_vG6jv>ZF%ljohv>F|9-RmNE7TZ-*6-RAEOe_-!{&;poj z)!XxH9!FO_i+Ya+zIq(AL(dMJ+fnc1%|UAGt>g(#=Is97%CptcQ@=olD_=eNUM@w3 zE8ma)H~>#I=R zCIg>c!S_T2A9HC4&8KwLA08XK2%vp9_ zTo~leF|x$9A2nCLPo8@g(huV6-@WPJ?M7Cqtto@ywHoB4VE^pG9*19qI|zKi1GH7} z|2hWuBaXy$?CTqWE%`CyT(CR4!|jY$@y?^d`xDExVO-cQe5m?10QV?xwjBOrcQ`xq z_io_t2wC=7)`3G^>)RpUGcwU{A97{O=hRhwaz3zSwcxaQ%EkFP{HuEZfIRm+%D3u7 zi+jeAv3Ml<^k%&f;5= zKj1!ai<>V_j;J23z|05cHNki^=|pdb+M>y@lkl(n3i?StpZn$Np+3EaeAU0P3`3(M zUsoP?c|hZ65%3?aMz>TsGd`!BINw+Ud3L`*TNB9h$>-#W^B39eC*N7Ww+urN8olL= zzi!$u*kLDQm#%kZ#xfVSee8ASwM&34KzQCGyfw$iE1tOr*!Ny?zN;E|%7ukzoUz(5 zrk@7x>6iJQD|y;+w!?LlFPv`x`<++Ny##yv@o}~S*ZL~orx)Cn9reuhA|fA8>LH(& za1P5tPW;hw{1F~(7v2`!1mIE#&iK7>j_D{nTR5NS`FMbI(en=Ao(2x}8NaLx*Fn#A z-B~R>< z9K*iq;^_6CtlSA7^`0V}Ef$i@2QJs&wxAsWqAUOLo5qBJuroOla+`EG=J)Vlg z?Zhp$)8l+#tAQPMp>y7K;G%b&@#xhf7_T@z(f9F`B<)M>8j5Gfh`a%w>=Ng^$Ut!% zo({U&9hTH71l$RvDWD!HL#PY+ALBl7dR+cabx!hiL{K^BL`Y6fAa9hsCCWSKn7mG$ zO6TY)lMU>A^7S6acY>5}-`gE+Pf=*FGiK_684Zl&WMF4JjQ7nIUhvbHc?;Odz}o9J z+u~rm&_M8)@J|?uKmQ7!*(9Cv?{V;-8`G5Jzo*-vCW3*%IR%^r*YceJ*+x&r;cVj5 zDaUH7o^&Q6Jh68cFkJ(H6zC+ptH685y_~(tI$z+KIJ^&j0p9r>dI3y#ctwK%0-?5! zGtQxR;FdVNUl9BB2K&Lfi+vJ5Tm#P5-!Qg>|Li!N@o{_ll1yAUo$}R^?|c?U?R>qy ze7@Jn_n?>W?{V_r3(8A4g?Wg4qHo|=$H^DJd4bMQJx7zTm3%h+s*aPdtW)#se0Pv9 zWsRH9n@>9Aq%%&sbf?qeWNpWo z_vRD-ZfeyYTcifrG0zX?tWS-tPhFi|{ZjY&V~emvAUaXlj*%74QgEJs2Kxe8 zi|vH-jnC^d|633Nh)y_dA+5Hq2IuIJoN>c_yUnS3w#SNV--8!iOjpN0kY_aiR&X?& z=bXRNZp#Qq2ff^R;{7;cIrD_tRlo$dw#aWvMeiTQ(W)c==g|Au&0Z70xd@!CmpErr z1d8Ku9y=F%--)qLtB9Wny+doy2j6?(%jV3q3e_<=4qtb%q5enVicX=R61g_5hUGCQ zRyq6o1KV7mL$|bAdkn3D;P?*t1Dsi)xaGi8-R5tn)m!nAz%Fm9zv{v9)<);thQKY| z;y7-9iMCPlKg&4>&mu<*&56U; zSx@MYVvd?%&);{DcLI5B9plRQy!L)1b|d*J9L{(4E}cbr_2gMi9z=ZS`gibrNj#fN zKEFqvG|n+7Q=Y8&atKCpzrB{k4uH7n$8KBN>G;;j_mT4XkICoiZAm(D!_C@pR@Not zZP>(_Yoc>}U&qfu_8IH(r8+cSMBW0r$(Cn>5(1&w21N?6ut!W>lLUuUY^b5xq>`V zf3R>@uVC9NJdfphCuKSL(;|%XT4R}WJec1Dv&omXbsTV6R$|L^@DUFd`bR7_NM);@ z`+l@M^ScX7t?Io9_zipt7JT{pCtg51&!@e`;iGfLhG~CDG*VlC1f1Y2xE6s^`6_x^ zwMXat0lHXWwp)hSrW$GTTakVI@8#VN`#FtvrbhNpECT0C_1{`x(vHq~k#a2Z`RsfF z6D&-6IvC#P_eJ)TH~DDMwk*rsc20W5QsMiawQy-U_}Cf`{&esSwCT3f#yb{V!o9IR zoo&Zu;F<|8oXnNJs1oHp3#-znIsy};BZqM-1Vk5(bUzCi z#AcfMK)Aj8q1+3Hwz8-{a=D%ejl|1M{y-#azA0E=En5E}yf4|bseks7e(fTeyA9_B z0<`xY6V3{lwo+fkd1aKpFFDwzK2o2@=X3heg6>)LZ6LC0;g`b^t4e_0eZxoQn6`sg zra<@pj30mK3YYG?dRbrFL>tuSV_e$TFgBv_9h&~6m#8bWUuDdIQ2kR@V>9W0i^Z1M zv=8QodeY|TP%Gr)TEEjY))fQ8b2WXjiz6J=AAjMh;~dz+UCS{Q_Vr&}o4^I#(w6(q z--8=$@&V|^_}g<4{ZZY!V$$XV)o<3{7g&~e&9C+@cqpi9ci^-7boAp_!5iIZLKWZ%d2M+4q>$>`OnR6$RK{Wt=6vV$klNZr`Xi)?zy1s^ zwN>?N=J|4m*H-0x=NlnJW3Bj7V_*(=+uKaqMZi7Fd$pM({A!=%zlQ%$-D`yFGyH{j zMzyJZRQDTA=sVo24GVd0?rA3PpLi+#6{No|ffv7p3`l6_MUG8u=f5Z7?$IyW(^a;| zqk`wyM7zxKlru;7l&Ntber`i1>Dt=mddPjA9fxaP+-;#>bB`}o;5b6 zalO2URqK@%&)eeXZ2R*WUpywVj+n5;SKBo{VpTqPyC<@;7xE_nZzsarR%nXL+lP>4 zfkDBx9e#iQPJaS&Eg^CcJ{O-!#?(J$8YTDZtFA5B?(!gW=7?BNv!@0AS^)2Cfd_Zp zm$)n+9$W|yeh3f7d`T@sLRTE#NW1F%{}^57Gllj3sjcH^V+wSwXRfh&n8xT(@kv?) zIE|q<_@FDihWsKu5ngNJKE{;G2?u9b{~{S(L0wi4QSA-wQSc){1s z3(EgC`Mkc)Adh#?CoXI}2e^v<7Vi!uShcZY$%EeEBQyGdvoE;&LAMm<0DtJ)?Q}Dd zTix(9 z(yyvvOmP1;blyv$bt$@7s*ISM6@@&(q?yL}>XtDFW$`u_fEv-lE z3mW$U^Ym@YeSwyty(FtlXseTFw^{r3P;@6WCT$h&KYsCUGF;HJp`=k`wL*#^HO3y)o$7vpTX*rT#^zCQ>K=1NORZ^y zOW5i3Q#($Tkw7|2BKAAA?`-fW4`b6#yAF7@?{cp8_~&}L_zzg=bNeUyZ9UGL{}&?v zB8B4jZ{r-&9ahOHZVW!qOQ`v_D?JhH~SMKwyxy2 zLOXwm-1lqTS&>xu&gP?NvaK_SZl^=HbEu2(;rgwe#yA%aFTba2u{LeP@ab^7{=>PR zVviry-=d#<^p~G@B%sF_Tjm@>j!Cv?{7V0d_A+hKs}gCC>XYQ^RMIC0f=jyEOFmPx zGfVXy6d8>hJc_Qmi~g4GG%9_`(t)(Sp0u!d0GJ)Yw^)HcL zOT>E-w0qy~2kDntd{MIEA?brb^jh5dvzrDj&lDdv`Lob#9o(H` z`jYfI^=nr>$kT7r>ZGHZh{par+SCu4aprSK2>E4IjBO z->YxX!)=@4x0I8N#l)GGp<22Fnj2W!t)!8 z4<|lg!Yj#_k8H}btQtb{3KJ8ZdFtq#^|p?E=-;L`g3b`%cD03Z_PK2q4+&0kxXhT5 zrSQUFbmD5R6}|>rZ&g-IEn}r)-aw;y>f7nDip`68>i9s@}j9 z;ocj|U;U7?Xl&cq2gkO3$JwI znDm5Ob}!n~soX~?_pz~UDU>_cSSq+Lr`FwUVVEi?S^nv)g0v`O3dA)_X{zJw= zZ!1`MQ+lvqJoE2ztIsUt|76LkKo4vqWLp$jLEmgu-je>y|0X?MJedoP^RZbxyC)~T za_MwzmQ#P!S$~i2vz`;J$}WF+Z^`A&d&e*irc;N~u_lxT?E?OaNu@q?dT5Y~1>IGr z7fsdQf$U&m34N)u2?4-HaCpR)S8oirbp7CtQbmW`I zpX+^TEp^mk&2(%s=KhuN!>7>w4trdVC7*C_bnTnYc&28XUki^9`M#h2oI1tq*#xfY zyR$Cy>@)bO5FSmd-h1yu3zCA*1#(Pi%(Z4u0GX6dAFIqVpH*A$3qE(pJQEiG?Vs4W zT=hYrOAl`>q`7SnUr(aW)yR~xF0w>z*tgfzIzEXwe*`k(a^%WD`rg)+kR@t|bf(1Y zU^rkUZVmK#XDK>iC>J`H4+%17X)Kx0_3(|}`*?p2@0y^G#^G@45am8F&4i-R8hF~4 zijBsWJ!G-+PUXIyvcmSfnAl=5J_O_W5RT3%?&LpEo=IKuOoP74^CIoK3A%}&Jigup zUngRtGAFmS>@&|j6KUF;XC-Y-qMdntwk*wWo68(+wT!Qie2DxL9rH$4zSA6Q4GV^&zKVxt zLKDfSA(pi@5j-WmH!V%iOI#*?xQuq)N#BY0s^Rf!?&S|!0B@8dL)FjKdRBU0p3$)+ zTNiK@9Qy{^%3VI{;N5te-b6HhO2J)0ySy>83V(-pFS=L>&iHZ0P>0V(?NHrSW|oy| zj~_G5G*(dl|I9bFhZ#TdbnyCHIHl9P|7mQH`u5HF=%3g$tS5*5%)M;dKXI+AGL2jH z3~cdUT^;|;<40qIkdLc8A4=U$)A%;H>YlP1*9-o76M9kk(b;~d=b4dw#@D!CKldBq(s>o5Mbq{EtX79#I%p}mS(bWg zO5o`Bz$WCQ=!ZOg(3GAM^86x#xop6xDQeG2_(7UJi;ZBMKNy&HN=WZ2{0Fs#qBcmH zo~K_oB*j#ZZJRa(o4~Sam9Lb1yP&JhkH9qaJ2mBg(q4cco$+mj56|f0cPuux-$Xi| zY}U=T&S3M#gQKkJg)6|FcBE*V@b5t9(KtT&ho-g_-RLfCrZxP33vbE?NZlsa@16RC zrc{G&itfZb96P}jGS=^U20Z2HT!E>XC(MK=)+8b0(E0NG3zr5gGd{Pxpdh1{34Kic zH^u2K7UR_$`|=+ip5f|wRgRu_CwK!1=F+T0GrNRwpKZ*#tUl;zr!CJ^Kg5bQ+uK-_G0`6&|R!{+fX{axtSNoICAdQRTa(?dB{^-`7bFK91YaW1@8O*zcZ zX|_F>eQrp+{oG;fRjywJx2{8Z-{gO3bo7K^o9xoqTwi!Gb)jF{G)J%RX=ZPLZXQm} zMRPwhVU|LLcXv+48KK*N*I3L1r{*crAIu314zc!Tr>BuFyUB(Rq=vrhruQYiAAiwP z<9Bm>*An_B5D4fed`_^sFAThx4<9qTo6xi7uNo6nni zEzk7iacrX>-c5q%lc7&xI_J^;<5d2wOR9GJ{BMDx= zhNKNneL&VljU>cA9CctNO;udVkj1_43;%V0S)M?9S(ZWlezh8%^U5 z`rGz%z>~r~=EUIM2F9ZB#zvWS*bs&A#LA}Riu1%9g1uBU+o^kJPFA|+b9+2zK`Ug^ zz*7bWc{Ba4I*5Oos$Ll#9p#RJSdZ|Vz|f91(NDZt z0MA5`JJ*rN;mvPB?~9@P1n7Seyg5Ehf7o`D&3E9HA3^h;o;@B^c|)j=>iUdZzu`Q4 z<(%x6bH2-)mvBAb=1oUeUQeE!6N3*OowK3cU*4|Ib+Hq#?*+d?D~*vGx$kNx9-|wV zn$T=$E?Mr);fLpSwguFdZ`<_ifU)H$?U9e*^K8zGsC!wQ>|O!CceOdOanLm#zCLud z{P(@#={`13-$}VEEZIWAb>eBwNm)IRPw-ti?@IY+!v{Oz3Hgey;&~Kp_v9b@S#2NI z?xV3K@~e?<%ieF1UN;23q^}1dd*J8%siZyPHzSq40vh%wy@6-(H@;qnudAGQ2e~dq zceVKz8N7u13&<~@Ox-;2@_q-nuNtN_WQ6egg6l>AqqGE5>N4ot(p(5P&swgxIMs}?#azc%dFWnueh)TKe^2_FZZ$Y zYNRh9hb@vq`Ppa1=% z<-=V;-X`YyF`5^5!YjyX;YMG|_nlBN=?3U1T+xjQg__UmfZy<>6?)0%3vHb43%&f3 zFZ9X~f9M19wDJBX`pK5L+?PV*?4c&qgL(=#ad#utuGm}?j$vgZ;9ytiETLyO20gB_lcz#P+q zxuz#_u$S#WzS>>0D0B7F4-(Ak!>&BEvPHX|@NQ4${2u703FxN&NE`gT2|leDB3{aj z+&rJTVV>>>M`r1Mhwd{XWx5~h<9<-&2HiiY`+<2|$4f;a48EWhDzxE|i`Zqd$6oISE(?EM9rel$1jEAFj7|rycEaD)?h{{hm+#xwvH1e% zXyc5vb!eYW?3|Ho{C)TkWA@;c?z#fBm9IfMrw`vnBJf(vvgNns+p0CdK%dRTX82nO z{hzEldVXh5kAv^+^#$l_*SjO$Aw9yD-n26O0)xP%x8Y7gs;_&gYf#dYd@7eH2+P} zF?ZZ^d#i_S-dpleQzswjyOb}QV6%U&5Aj8!GaVnO?;qCOusoN)5qFccSJJN;ho3e6Zu{LBY6t^eF6|iH;whl zU6vV@m|#Nq)%IwuJ<8mk&-&--;GJqE&zgpuW8Jec2bet8o(|fwEh}HL%z$SwZNxuk zuhVzTkH^Yn$zsW1bXNTp=kkTJ_-~)8&pZ73ntL>VNC$~x*I+-_mN7=8XBg%a>5{A+ zcU~_6_PK8K%P+#i`rR>joO!DAdgJHG;Crygj=yf%(Jl)*s%>4@@D)R&_Iq+g^F=EC zBN>#IP}Dl(K1Uv%hJR!q^TlPc*qZpcQELLKbJV39ecTp=ZYR(-^`AFS3dZr<`@=_W zNx)v{f!z@2yWfb<&$f}EKRzw_?==s4etPLSYw3%-(PuU9Uqu~*{uxi0DcOBZ!C7B9 z(VTkn@?K_7J@ZFNFVlE}m9|j6j%@zReVHwvQuph5SDo~?r6)9IE>oYT-!-Q-olgd} z!86|`&l=M1WZk}=GP10+mJPtyjg?)U5%~tTy}_?LQ}`&SjCTQR@Iu*&SypC?a3?1I zZE5a`%w<{D2`zO;W0Tv6X^~Fm%p=&w*k?P@o4+fbV*PH~kl>42?>E-aCy4D>J=Q<; zi6Ma@CkL~$Prd=0%w*)8thS8%l=Vx0;6uf1wBSpg+RyA6KfGq?I4dy{FnLdmu>vhQ zmDR*Qr$&B>9U>YjpYlwjULGw(hv__v7RdCpcv=LYMO6F_kADcSkE0*84w=pszY+bL z8rj8jQlFyMeA0A36M7|bUvk&Et%9xRc@zCIj(`-E4s8}Mc>3rDTOUnlek?&Bb=DXT&$s}dlKsg#aLtajmIV0h-SGhruN;#k^{%uXM?a;Oi!(s`_avO=w{MS1ChIcFTKu$=Z*F;7{2^dU?;Wty<40JS}rsB0MZQN*~Yr=iLvr`V+U7&%3TQ-#@UX z#TTd;A2aEAPu(#yG6G(VtdR3ckFDH!r^H-ppmQBEfJT*M(M6%`9tR&G_(^ z#iOnCEmxnJxon<4Q1dM1mHDQ&dUeV1r(=(&MyB)bXT0;u&hT5mzTIbq4*5@vSVv;e zTlPR8@}Tmszj}+!+wh(EZ{TdobMrR%Qfn-0Ft&TgJ`Jt2di_xW9mfnadsNOw`Z0bE zOe8K$bKTjMQ(Mb@R!z>C88TvroD#n|sx9 z24yrQR=;WX{%mjQg@4+cea*hTImp*M)}E7?{}ap03+#M*rWTCLNsN3LS@h)-a~-D{TR=e6w(+?%uv8L}ntV#yXmn#NHX{hxRSYhm<%5;0aEQBIWp?@d`B zgQo-@xREkn7ruJ~%f_EFH+%-=e+=HQfHUvj-phJh$y;YxeYbo(?1yjO-1gkwMJr!h z{Nv=m?mhFa&5M6|N9*2at==`k;eD6Qymag0e3MiYpf4?aBdq=4SGQ`S{IRw&$9Z|7}Kxj$5!8(2f1JL@N0`3PkMcE%A0R2 ze%tVEY7E#MOI49t)%6hw0ZF&VEO^O(&}Gxk(FFC{@yK% z!|+@l>9eRKu`V?q(*9YrGl}+Z=G{!%o=*u>&lS(0`A9aY>``>bw6V;m-kiI>ui2wHSM%!%-;5`6 z&>?crA#%|n4D+ckujp_&xNZf1fV_bO#x-?d|I400|AxOs{ei8yIh&T6v#QynWm;TWiQmY+~E9pT$-`0Dj>t18$|2Y|r8A-F&|!-=Ef`En9CTwHRdj z&E)^{T{pGfLfw8${=Vo1N_(BO*LmhBC;RzNJhQK|?O(KK(_Lq_e(3KLiTZm-cJeG) zB%RsfeGpw_A?rlqque9+ZlexsvUlIRZCPu_Jh|imJ^{fS)e*C@}6w}U~Qcsu-e@$q(wRrccTw4R}|Je|pM=Ui_t=q}!F zVwXG`ccM*m)YOJr(P^+BvhBTKrp=g&^Tx2hWejxBI%0*h(o9=+#fH(QqH=U@8nzS9 zxf$>d@5XQ+$nat*I_wPjx_3gMlYEKdx3&Ts>J?`vUr{c; z_e5|h#%?EbBzn<*J;v@v$|!~RkyEQZe)>ygXWnwwixR?zPS<*XyH13U*ID~?)-j{> zq1MZ#a~{vnyB+_fuk)3fqUio*Om^#@asS){M<-Z-uRb|EV z%l?TMQaAbh-54H!$GVR4a8zKYJRZMX4!`t(UyhaiR=clNW9W5sSA4FvT#J`Wo(}B! zisSX&Df|BqJ{j9*Nh~@9#N%*%a)=2%O<%~y6Ra2aXy;RG&_(!Ds(dS(s{AXJ=B>|*XT+CE z6F$Cs$-dbh_{#f-k1WCcVB_y%e76k6Ka=k4rOC(dGsd#^ zG-*CUr&zRS>e@w#4OiV*p0_ryzX@sYpYnW2o-vkxPd4)GBH;Ay=HbD-n+k*LW}gyV z_mh(rrLMZ*fz;Qn{>h(=du@EmxBfK#qdpVH_sPF_{35Gw@*=BG@{L7io%SOopB2nY z%pIH8KUkF4ub?>Z-{6<-Up(_*%KMaa(4UyU!=IGj?C+OCM#jxxTarAf7^t!7BlU1c||J>W}EUc&XAue>yV zjV~kls!?x^f7kTjesJ<%O^>8^zWdVnM|~MdYkY%}ue|!L@frTXeA`Qj!yMeR;b~ zzvPIoFXJpl;=0R7r$=Wd+)Yg#qcpI|) zPua3`e2!^6J>3*Y1~v0u`F!jz`I*}5u4!uCwV>lAcgt3F`0~o$2hZ9*#Mr!_!P>)G zt94ZM-QQa~_>^}ubF22%*W~sKKIA=L&vV95bLra8Vk6%nu2;OT_WcxD ziY*2od=q+z4=#WY+%^1-&iWpD6*{-G#&!d`u=3TA&yn?Mk-vRlY8M`K_~Z!ljBHxX zYu=viBJF$MKRUFDe9I&U;Sc)o!I}JpH~XfI=M_!ccwRkuvihS-oP%!BgZM+g2|Wxv zv|UZiVx*4u=*jlpN6}U^dz5mS8}oJlQ|>P(&c~x4GB-_Smbz{9(q%&km*m{N&|5H9 z0)xIeIaVADDRx18bA1PrAISfZn9CdQwJtle$3^4k;(H(8 z!(3WpJ&*~#&zYGNsF59UQKDJ57a9s@=@Y|JHo0Z#dDr8rN3iWryq7QR$W$v#EFODt zjT@6?MZ7+ZLOc1>41KD;448@m(Oza<(@@9H=Jj2Z;>Jl^<&F#WS040Kyq@jT=lK9@ z0=cBUV>p|lS8!d_pPOIiGwY-a9w3HFW&L}fBe&3B^6kDV#)fiZ4vtKfkIC_`eQ*Fi z)tJSHruBLJ(YIxDHbmM%lO|u{5L0Kz8tPf+9C)!9;B1tPI64R47rd-@UE}Iw8wWRz zWB!8v`T6eL1;4HyV|;ry5_7wywTci__Q!v}Zg2h%8usSz+_~4=2Ra`9D7k@{_G``X zV&;mASW8KUb|opnb-jC7moB%g;Z=;six1ql?IZfPZ-oi{>M8a&SiNmq@Qv~t*X}Mq zV=d?2+d9*;Ro`Aq9(%v!1p0X;u+M(`M$&w<=j9X~-UVN%o_TgXr?!s4PTSB=I(;CY zc0fCPrZ%5DXLvX<_^;YNO6J1+e#pGO$e9!~Z@{m?T?vnAUR-YlW;L<*|7U0Za@j9v zQ_LjI^6~uhgy6buCM9`p&*1PXVjEApd&b&*_gu5>>>lQv-_XX};my^wc{OoQciy*R zna(}g=kJrh=4&SO2>*$d}%cW$-#A*KQfn2tun*?(5n!>7&F(*t_P;T zpOKtZ#ohqPubFqOtsP)O%Y8jt_A@Vp@w@kiZ$9Y*e;l~)p-(RP`LY-^j3R$;AMnd% zD|?v!AKdfvWjCOQ-H~a+ufgkTQ*_kw%r)G9NWGG2_eZq#0ovRbzMfi?+wvrBDjA-< zY&Y-wB?jB}^bfAf^7U_-N5AjEPJSPn_5Ws{WvBiyn0Fd+qOas^$Q)ClIM@5HIy?K- z%%17N@Xt)onpaJ)nt%Dv&fji&v`nY|sb$|?JECWB-CSdCyYtkyGH>_wLKm=-p7$jr zKkZAn=pF9o8!PGM0rNBGXTOy>-=yXzl%2YEUytCrmj^tY`Kn2+`8V}WLAL*$tN4j= zAKJ#f{LmF#a~Km&+<7AYziwjBi+%Rmi+tl6JJXP3_V}SMG`78cGulrZC<@}+H;q|E z!CyZKk7>`W^7UYRHs@$Q=^SHy&gKWgl&m1gy7dzLiJD}%SLYR5A zErv|dn#%{o`c$*GWZ}<=&8i}9pXAVo0tOj3fXY9y$|&&O*bRYrJj$$vkqK(F?mX5{Zkuj8tA6#4IaPVLR+S^F}Y63s}RcT~p} z8B|+AU7q8b&9nSL;>jD~xs`(HX-3s^4HC!Oz*XlVX+H(Az-!l6o!qMWEI>Bcv3V!q z6XaZkp8?m<(d=9dj@84_TON1(LHWk$>D*3O}*w!J+gWWsIi{_XuxWuTkIU z8idca#`|OLSI`b{*P=6w{GM|ib+cf2K6`I$$+vi~^ngDzsUAL6ziKUhE@^y=vNj)> z1fFxbmibRi3RoFQY?hdG8fnrM@h{kM=(08c2Y%H2E&E^^dA<9=k<>JR(y!Hk{R*#Z@kStiE_$L)p<^wy@PCGU7~P|HDcBZ zWWRKuY}!-iGsIdl*5Lbi`#Tdd52~$7Q~64J z1vRF6ACVR2;&3-43Y=a$u`2ee{vNHKWbQh{>%TlRdnfc-w37XbyUgq|dJC zv~l&OUE(jbF@gIc>g%*Q48M94x0E8e>P8CzSU9*J(Ddrbf=+$+`~vyc5}z zp4>41O||=U)38@<9!ovd-sQ-@DDYnUD!5ns67fk(W_^*p@7btY-`zy{YNz-#F}o<7 zZ-VG1=@EDcKSknJ>*xiGhrvHfrWb{O%D9QK$4=urif&@_OwX}L1dqR?rO9uO=JnB@ zyXvLbLv6cK&AN-pD_*Tnt z`7Z9cF34S-PqULY2!Azr)RywzMY4n10Bsh8Sm`4S^gWUGj|j zhnQdNz3D#UzrRj>ym2v}d%b^1x*277{^Clz&%O8y$9K@_J?#5YW!dnLto~|)a zj@{R>HsQ(D|EupdK);tVPrQo{#`8hM*UO@BEb85L?X$ahue#~P)PQddF$VG-X`Qg^ zT4PrqMR#Y&s7+<`-7fdMofXj2^GkR>iFD{MzA>!ZAum^BvMj{x`ogJUbpLF zpSMBX;@AJi0PC&)P368mZhTsdPiNk7VnMoF|C`h$k2fBhn_2&(ucDl*Q6Jf1@1fYl zc=q-_v>;0^LWbzPjfJ%L%bd4y%g0?~{*PS?JeT^odKz&?|M^;=MZ7|p)%m;)>Fn|6 zZG4}0-3ed(H_zKRiFQZ^Fh8}g1-`{PNmoAZkWbym%8UM+A`AC=eK3!4YoDKkzv!La z9c$um{+szD!I3Kg+y7;?Q1;}&aO6k8v>kEh#!h1gctyX9}*tB;%DZ`sD}>)Mr_`TBfdJidk}kIUEM=>tPK z2dm1Nf1LI(FLue}@%8EO_1|~nD`%W%-D8CVoNt<4(SV6vF*>(5a;Udq{hBpyu9_cm zkIdP|`rI1Pl$hwY5C1jiIq3$8N6%lZc{Pu-W#sD%nAk(vUCx*J4Sf<%@9x&VHP$_T z52U5lB&Jz4r9(`(?9?wlTSmHz*1XG@zw?Z5YdU@rt#ym`|84%hhcdeI_lf@t{uX`9 z3^5+jIopQB@$<>h?!UEGUMXE2{(A?0_rrJpf3KA4pVn^TajD%MaxZ_NDhf;^dQk`zyhspA$ox|19^&bk@6B zcLOFk)P!bo{!*;iIp5*nmC3B-_hwJCul;Oh`RFzw|M!DKa{xM=eU@a!k1IxdzT@RD zWfh>0t&|VBqAzCv4k2c%7qOt&cKDL_VE3<^fFF(XbKY3L@uUJXZsU1%{wGF9)wU|r z_~c^KIR7N%|Hf+z%6hQpeJK9g!JG+zU%m_+W#pGVTa>o{tS0LG&nUKv+RAN{yG6z%?*yAiaaWkOyKz_dwjc&ePgT;jb;xx=UV7Wd7h5+>TKyZ zon?h-GNA_QqQ3Zpu0uNDe+xW3XrCFSdIeH~g}L}FnClDk&~S7X50bJX?h*Ixn~?<10~lGWRxOA~a{dU65d72iXe#==~9Y3`{yE8VP9EKTXW zO8)csSB-9f4%N^D2bxQ>W6yMb!G&4n((LzKSJ^aUP1aqTY+twR6>QX5$xfdVbF|TK z$1i9cM!BC!U85U|3wKqaXW?IR)=-I`IUGki81{v3ushnh4b+>H>Cp?sI{H>#6oXev# zd76hNXguZn%)Cjj@LucsrB>?;(}@Syxe9uJ=WW#Wdd@oftk=ls+301Ibum6ajRUp+ z3i^R_R3?vP9BGV<;5vxwWUlF4iIq@1K>n|R{T_Zkz3)SMbSS(_8GMa($$r)qm-lu0 zu9-B+y;nbU(j_;(OFs%W51wj*ey4F2|6WWVY_RR9wES2vVg-n0>MmA5b=6px{M5az zhk`HN#h2>hD|Gwz0R`*Dx4sb?3>`FHqsS-K|0;YQ`QQ=EP_A9a zVzN6Hb6l8At{RJ{a?PadqjNZm#pSC`XR%ZT8g)jw_E1P(B;vQ%c`SL1qkP6uybpU< zZ^jY4AHPp#6Z8*Pv4&loSy1oKT$^24F*>`llK(vZRs83Vmdsx9!M?NpfbU-UCCk?r zrvjh4Ht^ZM-dyj`XsxeGZLRZvePx~h+?7qhJz4B;JU5ed8m=EpHzLiG-OqAQoEmf7 zpvb~o;isMcke+|WIB4YE4xY~-Zxh$;A4mh z4Z^|m3BtjZb0wDO-Q&RB#yjSf!Z3RH?cC?W+jhN}8-6I9^qKZ)d`CF1mptW+6L9KG zsU1A`6i%+|!1=u958>O%`Gi$g;||_A=gg!YC=B+m*y9ncd274_i6l3;GgNG)Apal z!$AYdza;+sJ(!*T&=2Ebs{EnLXuINYgj;i*# z{IsEj>uA~_epekA(2u4FUz3Fn)87;t#))B^7{-ZVoEXN*KNvfg69ajOYqjbC4s7qV*j(l*cu950q3kU78%r+?ScUnUI3G~w4&4mS zdh*=O|3vcK#d@GeyNLg~0?|+33NW;JD)(V%Y^5()dJ_05q_cwKT5v2Oe>wl}@z17D z%>I-4-iM>-7Sfhkq~F6|b$^a}Y9EKvY}g0SbYN-sSAZ=g-TsyZu)hHIGM*RmU(dbT z`~6$^4gmLI{^E&cTsdP2y>lXbq&T;4!ABRf2IZbX=6BBWm+t!w>M9v?J@^!3A{j7{ zzxu$F1=<^$&0k|UMl7Aj|H~=IyZ;LLy?fDHu>z7Q%5z%WyKCa^e?orEqm~P&wwzB} zBrm*qRafu*{lMSKUu~SuRpabtuG87;-&uDIL>?y%U-P=(an<NE^dzpXN*)VeUQ8r#9-0}bdJ>gIz6LL{gCS!w4;>RRHb`(7kxx~@iln1d(1C; zs#mzJSh1*yjN>H6adP^$wkmTsqhsrI2~{@AoG=lsOw z^Ehh}ej^?^as~Y+8FmNPY}WPzoL`rSzyEdg1kFi~tjmbJbTi-afQB|--QmbY*;Z{PP$`o?a~YX3`ng%xlj1{|8Cih28%$c^k<4 z7UfkVH}Ip4u;Fdk^oYvXjC}g7TgDa_Ei z>uAoLW6M~+zw;Z~vRpEUx=T;fIW0GRz&bK`HJ7Xt{w_At3ZFA3UT|SnaTOo`p6e== zzfot5C5HE1X81;c&;CY0^H6-X;Jc6VJl<>uma}2kNrokw2Pe(s{WF}sEq&ft#_lZBzyH~*eJdtulj|+HSaU6SIa!&a) z)^z_(U_E&#Jl_4}xcg^mhnM~j#l1kk1b9JKvP@=gXGU{2{)64L+_%#};qCtnuZ)s(?9V z$5zmP7BL&zv)ocNNHS_Pnlpj{dFdb?-{GLG|bRek<#e?^{`s5B1^9EouQDH| z%!l1FiTx>jAAXNsZ`o&Q{*}FxwhsaMGLO18P|xVjqQYIw3;n@U!kicN14}#KW?V)6 zygLoum3L8MU8D4+-c@Nls9&Lp{3fN01zSF34-cJ1UF7FcoJT3|zRI&LFK+xsD3AMH zl=mTcqm=m(xINqWV&c5YIOkh(@fm9oYebxrgPdr&5IE{ThV(`E6Ek|cYu`UMGPHp6 zbOiq)b&jMjTv~$P<#fIk5+Lq&0@s^}7r)s$;cM&5&Flxb-#WY|^T+P|gnWkQ0`~cQ@4IL7 zJ#^C-_w^O@kIsF~qrI}fPUGs?xLUi=TChDoR&iF<;n*N#m#wczztLRv)t$t7#o5!c z1%I}yLw3B$`bzUgXMQcm)=*i$-GMCINg6nsi}^MfIAT=+{EO$ido-MHVX5r+v(ok4 zzTSfVrDy3(&EN`w+ncjAmk7?TyIW2V%20Wq%yNC@`1$o79oNVg1f`7`pCZR&1U0MEMLiQ?`7DY1ah9%YB2~y~=5k?VPnMIn7#Q=soU(*wVE- z*z<_ISp7~Bu$-;W+Kx?EWX_e?RQRL|cOefRW2|qI9Yg*9L|$}^k?dJoQVTA{H`?~h ziNkE26ThUbJ2jy%w%aGr!y;IBo73D3K{4=|?eyhnIH)P(+~yj&Z3*TR3b%B4M9 zxL@J=fx6ox{Ra6IgD83SKfrqOP5PY1tUeq8AZ^sntN(jHO1BhVUs%7m(^WAv$DVU#@pw%qSsT#$(0cQom_{mts< zcbdlaRVTKt_n)*@-%D9vbrL#*X}p0sHpo7Mx~gl|YL4H~-%MUV_gv!VPvl#c=o`SS zD7G3O#eS4;Rp%97!TIC$Lw$|%sqRImJ`4@(tF9~v3@{@ZV-M!koeMwu8U;HJ-P}&= zja~7Slm8IcV$$pVR}_T(SGKMNRyvvdS(~A2^r^9hyQX^k!Imt2S!pVJhsAvFYrKf^ zqeI6Q%D=UVeF@R0q%ZgyS5RMlBc+Y!e*&ZWHFJMAWn~ZLOuJc5zFhV$)Y~yy$V1)> z=e)7L&`#~U0oF{`)4u3aMTN?zZ@o4Fr?Q4H#~$K&i&qx+Qz@%y2>QQU z)+Y8ohz?J&ZihZ)>+5>HUpR-d*2P#7|5g#N>9u>|lVfc=t@^rPTb*yg(gi>Fg`{KX z8zgr^i;6_oR(m!F-nT-P;ClpnV-oL}Z`Ro|kMtKQGYwuHMp?!1a+81KXv%cIL*ft5 zrcCYQIg|6O+UY@EDxtIJQ3YO)9`L=RAJ(26svGKS{6RBo=HS)6d|v9Gdnph8e#1P? zcYR53ChdLT%7M%0um1SMOnffDd-N?M-Qv&OZsX8?T~qlA-|_r*>-xFqw*JAyIaiL3 zraIqC*^}(_MU(%Jqop&(=JpEH-a^3;zuOkWMfrrMUQkEyP+*mhJ1D z`JEoQ0v)yfCC<%7Z;sWNb&mZDZep!V**g-g39bwCjSbm3&B{wTIwO1qvh70h+GCA0 z>8|3fhe>;nHk9FqDZwY%#Jyl{qK<2Jn9z1)N0Wa8wDJ1X3LhbEHwGP^r`^@liwif& z@2PTp{?H!Pk8v9IPiY;AJpu2n77bMY6~LUzd#%w)K4tgWe~I!*M#Pwl)z5ch-^-Sy zk5`%M!FC`2MRnrc?Y#T7Tfawu33LB2SM_-%_y>rtZrOt@c1ijp zeCH^!CcybsSudK|N3bI$Z# zZ12&}w7*f9pNJpFYB;Zkv;Z+tGZR_ICcZj<8v9$05)WsYwwcpRq2k#OA=hG@DOkt+ zC7<7W<(#)R&xuo7GBBg&>$Gcs3ul}_-#e+N#{I+iU=A>!i{}oXZrc7#o_FBYI`G*( zANuMxo2RZR$VIlPuO)Avq(65KweY3!9Wm(pAu#Bi_V|_!ZYsAhzOQ3^JA2veGb}hm zP&kX=Uwvcj+$7`$xSpfT=`N4`ihIen0$?=fjnvhTUdueV6ZjSEQ`tZ9YUa(Wm_NVG zJUWH>^h*20n(MyP z{oB-6W$IqEp8-zC=i@w!<|yhc`JFOXO%~pfeqfl&xSR6|e{8y;6VlW&(WU6dAJKg)v za5H$FcZuvR>WIOSF2A|V_EXs3gpc}VcO*vIm_Mc8du9Kzi8uhtP+6&5ry;ZA@qCJ% zt^Vup!X$AOEeCQ<5-sg@$==~Zv)Fs%tw;L(?pa3q)^Nvqq->LeoSAYK{ecfi{^18r zNhWnMq0i{|k>HcQq52)+`75+FdQouQRNz-A?xad>NRMnG2EuL+&)>zirJwC@qv+kW zto2GSx`KU|p3mkRNCQ@<(##+p7OO8!o0QH+^Y3-6MCKcs@|_K)^&ZfjCw)SKk%&n)0*3%S?r`g~l=`Uue$I92KTU0tapLvhqsT?@vf5ov`!_S!dFMFK#HT+V|I-fm zCyXo~S3bR940GN%&blbI*Sh3m&WfC%xI*lT)8V@>TCcv!^{I}-rjw1BNIjCe#FgYF zIsQ5Mtu=3tL{E6uR`5B3i*_G+!pGi3=ey7HJ!@}anZ3`>@%!scn61bMzReer z&wm5$_TW9cehO_B4V^KR#o1UHkzM$u#8dJM8SwIcwP^S%c4)Tzi+Y!gZ}ocCuIP8> zz;mLfyMEsOL3J;ycDq@C*6^JR|PpieF`2 z)&*vECG|~5AJAT0>BKNZqyz7u$V%|$_!%$YK8t#s2Y&7AK9%c~L%l{xE=yO{_jpR6 z^9iIEAupx^D;a$XSEZ+uUTNF3pT90Fx$E)Q5b}6(IEj1V@e4n7?#Iu|ZRr zGt-zm)0smDGM5g*2F+ltGa>XoXNGvOh|S5SP4Pxr@08z9XFm_7ekKDRxZVnd{Z^ye z|8?wvt761d!2{=VKPNVlJ!`?|TG?+Gwa-x*9NGOo^iDUcN0R?y{?K()-BVg0Jt6W5 z@~#w{`b5%YE1$|W3J>iZJkB zSe0&?O`ktj#hLH4w+HR5_Y?EszqYlR`icMay)i?+H=!H+Jwo5xldN3<&5?1e;e91E z0A72Y_qKfI9ZXt#3+)tdPJlk2@H~b4aolTPhkWj$;VIBBIxD#DW8NFulEwHal5dAI zJ+drob`5Ef&gH#fqQgQ5XVQn1Gkb?4>*RBwRTtB2v z!W*n?7(K4CsC6oOF=g26K;`J1Q!6dR4d)cQut9L8gG+RhAAA7!;++((m(jMnfeZMv zzJ_d{o&8~Kvh<_xl6DjSf!xoA=C(ZGeKTY0Nx?Hd&gEJZK)2+oy29XFqQ=^^_kPGMYL$@sj2ae6u9buv2cWt^wo)6sEh zv*r=erF~8zZToi}{ZO*RlM5*eR%Z~T(bYJzSV#F->eW1&=UDpK1CUA>RuX0U< zP8z=(x%cY+X)i~o+mAjI-HEKJl0UL1ZRkZ?0?3#|{9N9dicY+<=41IYZGV;gT-4|3 zD%O#uL(r!s@WAue-xt0UUfQmGzVN1WO3e+0(5i`h@r>y90p;4f$9R$6@fdZJEh8VK zw|=<`{F>9xfo_V?6zyzzOxk+R`{H}BZK^{C@9L<>XWMBDb^JBBS5Wr;iQ{NX32k!M zIIEmBKzqJ-Y4;`0LfW5}kCk5n9jWwn8uN)i z^geTlr&nk`lHPEL`Gjw&_I3<`z?T+d0fHqvz%>tM^-tg`^iU|40XELRYw)eA%1>wG7|sBv;KUu(%=4_X>1$&{3G$1Y@3s5xAK=_+xX*XtFb(Sce(K7Zz;n{ zF_S-|%rfdE`(`QjhaGRv+&B%`^*(F%GtAlc+{;|3as52-lAkNde?>=stTKU7_WNJ* z-tG^5|X@BP0L-ghBIB@MYBhqYG`HufiJ#G2|zk+AlpSF?fd%}xs~*K z(!I9a%(M8_Ztq%bOUj%<+VVJ<8+aD4;OwZ?yrnW`@%~)OR3BYRU7BtA3(wAy&2|Yq zFcBX97W_LQ41d`3Vt~BjG5Jj5=fJ7t_hj;^F3!=UiN{K~j;5@bU$U$(W1=5;Q^4OJ z9vc9Ur7~}I@R(xiRVR(VUBm=xeP(?OKNn{a)ep5ApN5xrVwWjyyuRw{g6E&I8cPCZ zMEIF|@z*7_Ajc=ylP0<^!ov{f# z&7{qMHw^Tc0{%ASRUhs%dT~Y@{jNNkSFh$>bSN_x`MXHXI_oZSSx5-q-#9v0rc- z)$7q2UeTfXxoPfqf`#Va(2gYjZSU^hTWaX6-^qqAslHRok(U??>D_#W2da(Fe861j z(H3odi@e8Ehd)u5%{(XaE&QhfU;T9)Z}A^LZCAf;CcSj`Urd=s|6c?hOobl43tdcs z)-FWP=<-VOxjw-1=xQUdz3&ip8$i94Ms~X1+tcOg9E{?=rYuiR^LXR3SU9xj`fHdY zNT)h~j?Ingyj!+6)cHO7S#|y?|3%A>k#|8Ww7ltW+a|6hZMi1{MD4BQ%VQ<^yV@V# zCRjyB`!h*ga@L>$IFcuA zc$Ushl7F}C7scyR!4>qLy%HGIsZo3=MEnWRi+JOil%L8yW5v7$=M2>co-f}o`$o!E z_{d$0`hSxC!;>U`Dvkato$h1o{ee3V_5_|s6K6%?4(F}D%H}=X;Md)^@1#9P-N8h= zJikq96LNt$$LYx|T31RZB;OBOGm4%vv3Cndc43Vfq731(iZiFI%^GjGmVQC!0`D*0 z%Q`Ad8@__(TN6yWY|k#RlQwkBi24~Xa^+ji1GCY?UC+EInDViwGPUOxiTHxW)a6|G zjOw7X(#$u-RpiNgh5csgaV0b%dA^x-KQio{qIKbX1$7erV;`$F{>dJHyigWC79zf(#7)Kbm@(uRuCn(L|33WwY<7_W1cOk#D;i=}D3suZ{Bkdcrn5 zKD>#wH~;ldHnX%By_sz2k{kcj&DF>9|BNQS8^CFh{(gh^LLT9&nQ$8V*BN{-;M-=u zZ7$yfIyh|onWTlNUy!%zk zmCSk>|3n}BTVq}ME%Wb+=s)RNlt%j7Mw=Bkj#;4`-O>98aH;M3;0{mK~vJbJb;mQ5j z5z)5TbNeps^aysIDgF^)&Wh4cjeB;%)#urVWfn7E;s;u1)=sBR>sc#Z!1n>_!M{c6 z-#yvrpwj5qZ2m)EM)ovx#JP)ho4GyXzM#*xwxqMR+=;w#Jn6KCqwdeuv#wIS^tRIL zl=3gt8q{txrz&4O`BMG(1jHs%`n-5IZ+0GaxPv`#wU55vZCx5?WW}+B6G`LAlRAfl zov=$gCk-@dP9{yym^5?-dUPu>+l95&`u&+E-DcwLm~^ibetdsqiS{+Xecfm`{cfvx zgh~G@|Mzir+bjEJ!l4@G64IObgZ62yy%n77gTH-?{ExBM0pB^p(C?2=Gjk7Xhq|2} zoD2)ysa5w9^O3QuAfNOTZP-_fmzE>{c>4{JRF|GtkY43k=q;{;w$vBpoZZ&k{{V2( z&3?utXuzfEju{h}kIuzb^oV|9Iq5u__FzdK(O6Tw>6lc{-D^A2=r!-Bf-~Pv*DL{cD$^f6f-~DS?01QI_TF7%LeNy376@;WYS< zgE#C2mRHtE;ujPDjH;*{=VLjnVgr$c!X#zfzrii$^W6~55R@$E6L3KQvaMiaY z3tUNiuAp6))4t1S=cV9zI_u}?-t!gt>pJte$DQ<4b1rd)FTgWo`>(k`Hbwc!`y=?zTyOL@!rw}8Hk)?{`agwy2FJOHlw-B*@>z9C zuYL#@S&rn_hYqn%;r7K=>+Ig9KIE+%bW}QDOAYDNADn?pY^b^9Mf@Sw(MQGWYA)@# ztXszm;<>`Z6vp9Y@SsozvLiU#2@evVI{2Yg*G8Ot1f^Pm_Lbl~4j9s}MCST*S9gwl z2A>H0k;JR47nslIf{R1c#}YqE%rotHk?@y1I?qB`V-&ufbbEQz-bL2D<4w9>65h=t zT`=*7@!de2;ETVrR?9x0wp~kpXu!pnb%M#ioUm{qJk2g;{1-*{RA~ot@)psI=;a0G zBFO-MCao=bH_E@&KQGblO8B?ds=|rdp2qxggm))3c6Px{Iy9?sqqUxR+fOX3?)jRF z$0__cX@5jn^~ed;UVYctroJ~5Zsfl^&a9T32tUa)lJBK&aSJwUlJ$h| zw@D*>TminN+c&uGaL2CBBt3Q*CTR$Zud6O|i7zE=(qDNpb?~)Jjl!A$EWwoC;Zoun zsDHixyM7dljSlx~?v&MDVFr)Zq~et~ zlE>qdUS7!%p6)^XU3onHyXv&R#N|KYyMm#(Bi|Os;@xAf;kmtCA!7JQEv zD}O6##HTQLGV8#Bv_B5~c)I1T{zkNZk^IR>h&mt@s zUG>PGzY(q1B-@(#Bm?=cOZrmg46P%+{gzetm%kjTXICA#C%ZILmS|XYzLs{aN1w-- zT4OviEOHeCUIFF4|VO6&R1 zl}N}f$n$3Dw@=~@H*y6M8Eu-xOo#}5>dH>x$=8&Dx`5x^R?j2nscmRf zW8f&iVA!h!0!Mrvy1dComWtW?DzeOZxdYIiXej_s+sSzmwOjss8LQ?Tq3lw$b~wDI zCak^s+oF5*b?ntw`;r&#`y=aK)~{`>S0%TMOI`bq&sV+&pkfTF+=xU79bP4boDdRckRa{ z>xv4 zW7O02jp&|rZ$hub`i^no$^}=U8)l4-)!p?hTo?~Yj0fq$3WM_Dsk=TkuH+BN>=91s z2cPgePo(4ogPZJ@M&z2Gr+z0m0zO_?e1e01d-W=GjW&L`cl5Paf0W`|{U53R)t=v6 z@si9c)=o7%tHyI5Pvzn^#$RCfcKn_Ch&wBo-#veI{rvH(L$pP0FJcU>1g{6Dc>7%L zK0&@|>yfp%*AG^{&dev8^I7Op823l~`~84$)$fj+31PiYzix2bNPDD{Jj@UM-dSH) zUnBnD>1@Io@TgVTszd8LAF+aAcDsV@LVc5q@<gIexP|FvGMMivWXhl_%qH_ zsl2Y|oPzk(5Z$YPYZ*(@2Wfmr=A8Ox@~%2^zC-65&L^%d108)vVNM!$8f`k`J~#eo zyZ&kJ{H67u#<{20Ok=(G=MS)T?^2G+4k7cm@=vtUHIJV%kE(mM)uBA~8NQasuEur+ z`g@f3+~_@D`d2c-avmm*^-tqpf$ObPe)2B%(03zt^YRjJ?GB6|9op-zFF(v?yk@MW$1g~V=hYN%tPxh_Iy`xHbm2l#z(yC!tTd- zLjn^?A_y5%MAap z_7HbgHATj-*O}7M?3?kT=Z~WNpnG=;z*7TZ!&Aujol^Ka z`aD1N)IB-)M^4P^O&^O^yXPOm5A3#HUq==Ptm~G?cjhnFJ>esfaV8zQ^&QT9x3{0T zV#tg`efR9*%^2GgUiH9`U8@!j>G}0%+wwSfJ~3&8sb^kREXv^K3Lxri+($bZ0D!svncN*-tS+|+{PKLdi+)?PW#?9 z@8Oe%I)BM{*!*}QI)2+R@-};5HuoopK8_=A^NvFFScBT}*Q$GI*5z^Dwt(`I3z(Y? z4J9|;1gvJxHSGtM8T;Wr&SLW3%A<8X?PPCpq#19kX{Y?n9q*-0JKdR2_a3{QlnWt|cEMHF^-kO0iM)(3x zHucwBkjtL5muCSwcKzFcU19@$CYu-e;abVJwZU@cP>*5MZ1b zi4RK$;5^QcGd|2woY{8o zcJb~$Ie4Qhn}_4hJE(cm=n$Gua)cY<;RDBH?dERDQD|RG_!bY#k_vHu0@C6#T$Wt_t$ zzs7M3`hAW0w|U_<%g>gB!c_fhSi!x7n(@=8x!j{H~qj7gPM$(^!Xn zxG-ln)*>I9zL-Z}AUjE~@+am5&F!L%9LB=Ml&f^!T7D&bL_R;w`5gZdebeMF>AZR@ zrXGdVBX$qY({+6pd{oZlUOS#k^u3X9o>h_!pw-xQfOjVRTIe@@SpMR}YXI>?pjoXe|D!%vx?><7c!{WedizE-*aY6@k0Ge-Q3mALnaH7u)t& zeOB!K9V4hi7W+48+*NP2n)^G<{V-X~#k!;3ySF|*$?BAk#Vp-Ze~-`j`Hvrb3(PJzg`EOmiNi;EFF>mYJfW+EIVWQOUOfo{*2`#0wad!r=<E1eY=PRcnaPz`U> zo{?z58$%nQU+FtM+Zfr!d^wA|T5t63S914Pg?q`idzd%OSOWIR{g;n9X zLv=rLIse2D4}cr*{$1$^)lZYDi}=`7%I>O*@_1mlN9AJOTX8+~IMK4VWcF@e8V|oo z%8Fm6`zx4h{@Znn7g0tD{Zt8Ucw_y0rOZdBP3~Pm?tKE?&^vcGwqMc@g~9!7GdHAm zv}G^`p0tB)^g$cC73o;AEZuwVYpTr}tL$rSixq=&Vq z7Cz97o_-K~QgY1-t?*t&8OyE}!zQ*6aukFGv zc|1OTD>(ZW_;>fZ{`>ETe1>(Bw=USow{W+UckCSVkFE>awjEg)Z0iEUYu|X<_sP31 zUBuoU#F~;d$sDK~&Es1gNMnCsqjl$8pD)KxcQSCzkLO~WhQ5WSr3+M z#4Z-yZV5WGhk2_!@eRqNDp#_pc#ULJbil|NuB__C2`}O&R{{&WsxG|7?a%&{=dIKG z=$kUNPVcF2$}Qyy@Lj$!|IW(C3G>uawx2lFg|oT$rc-23Ky(j3~scq)_S?ib4#d~)-Wz!gp*AT z1;PpCXbnENOZ!!}#>~3uF21Y4`Sbdw?NvO_>YK9UhiMJpwX|QbRZbPpQ^du#UF9hK zJH$)hW5U3BJ2p%iTVlfM^VbOrk9n_sbIe=yw$sFa!F^S4;JvIja8&PDez11B>o0Tv zbls=tLyl+N11+l_!fi6Nlf1#YGnRg>s{6TnVLnf7uG z?w~KwLq8|Hs6Xx}4RFSk_*mO^jZ*H}n6T5ba{r^+dg}(9rZ=fFTJn_x@p**~3UUJ8t>vvx57mrt4 zPp3Y$;9vptDBO<`9-v9lT@iGrb~nJsJwBkaHCN>FPcXhqe!;&DzOOk?x}&c4T&jn3 zp?WJ%5qWN+9m-cuz5?WMbQX2xzt@~6zvc141MKS_zNf6nXU;Tud@D>o_6xIhZd3aM zk_A-15}&oY1i#Dl*=j?_)%SB>(|n%V+_|UQce*%8Zal!etA5=;dlP6!xvwy1_PO|~ zKo>ayUCU_pRaooi%)Y-MC*F?F&O6S!S!=RzhN~yhImg!McN+B$@jZz+!7K&lK4cri z4`=#vv~Fp@W(>O{!z0zV<=6Rg)P^kD?ymLDr@gW*(j8(Gu>m>S_r2UFQa=}+;yyZG+P?`|coE5DkYQvUM|1bCr zJf1rBhIYl*)?FrDF@96xt*P4E%S11f%D(C59-sGnz_R*{oLxR{Qhd*a`F84iY4A*q zl|Vjw_1w|volDYLYVCK0&>iW&#eHAq-7Wu6fIHrpV~Q=_p;_*K&&PK><%X%F%3FV1 zTE9H@fNc6I@Uy~T0{b_zAInTjSmxcG&AlvU&K0kYXSH|f%|gW{cnIqC;# z{p8cHgm{ln{{q=!y=+)E+Qx=O>Gs~7)=xa~S@160bma}lnup!->FR49yZSfy4oqne z(N4FVG1l|E>M=VQLR(nBo0b-k~78~)g8!HM*Cf8ksG!r36|QV_H@NYCxv+x*RS zh>HtbdmxRE^*Oske(9P|z}^F2K=5I3B6(wA;|1U(4V-AdPI%EC_OWCF7gtGt7Z!WoUllXM0X%eXp=&Fxl%wX@E~CFo=Sh~G26UgrBiOpTMn3vPI~_A@=+(!($36%=)T@Ih z?`rQeE&iOlM5n4xTKu_pS?qzjco03ha#Y6*&0oK1(7D~+p*EHw6;6YZY(9*A$> zKpyGElDTUkh4Hh1|L>rRR^JFsw2sqGZ-eos6K zJ#@*}abweIS1o(3(w`)jGS|$sTJkJ>V=_n7vhJIJEw1o8oIHki5O#S@V&iX!W4w=9 zOSyIQvGk+B!Bwc0SbhSw4b)TPBw;W-X9K=fZL4Azd}FOIVT)|an(zs+58N@1vb(lh z_>ZMw@ejk>;L+atAb{V|Sh>d?&jmq^^OZw)pDH>&>v!z+k?s}d689b}f3OqV){Yt7 z>8I#@2%B8{)@JZz=BOx-X-;%$CF{dI{Y3Y%^rHS0y(rI0_;CSg*)v{~zPqQx+9$D+ zdoKgn0{HA%%kA|g!_(4c4*!U=n7`u@t<(<4&v}Eq@#M)`FviiHA-%9We(<3II-gZh z1)tTuAyxSEb=!3w^*xumORp8RZT0({w^=*rzwRE@eErzA!k}nb_GTK76X1WpG-D*G z@f9=HQ?u93QW_p1gr7N7@6CBNu;d9+;7$Y;@Viz#=ddp5OkC1r6~ia8SdTiVng`B8P7 zOqweEi&Yl7{8DY#{S(4NEDk=S&OYob0_EXt`OH1S`G{^ahi5_ioSWQQc19Y!9^VN% zlgE?DGZ~)A`J4)DI*L>4t%?qRk7qRY6Hl!h!g_#vswyvf(W?CXIs8hk#1<@Ty)`xK zIBP&&mQ~>guUW&a0a>f9sd;VKArWpHv3Y5pJ!qG3-Ub<+#;B+ytfZ|&)l7atmocIEs$rqPYft>Nw^nNB$RjfD`?{fjwOA{u&qH91{ND zJD$SdYP^m80sBPR*a_!h^ELq+;koQ(i8t3$<_5}En}x&M$*;Ljb$ONYMH?^R^GNmj zCH2}sAAT`qa&RBAo{{tXuAFD|`lS6uH0>(VmI7BWKi|MvL+}*422lEcL{~CGvMDgU zwL(|k=gl1nr29wo9q{Q+t&zIc0!0+)f z>=0$2=$?^w^#fu0M`s)g7$0HwS>rB&KSk{{#b@A~H;?9A>$2UPD+l+Q2X0%l=O$zG zwk6IP%oxadG|?Ht`|`LnXDGbk?q#Pt!{EU`%{bi=&#Z?gp8#fU8Zt6-wd92b$O&&; zUp_7?K4D=RJYRjKecTMu&`oy5dgSO8wcZ`I0~^hFtsC#iHeZ3#dq^XF9{nEb+R&J?hnaPn(wMS(r8pPBYsGIQFSvQT zWj9^y+LuV_0XZa`v(us8o=BzkU1g|87v7K*KRR)Rk8*JRs+1`N^A}) z84FeDnHeuf`CiZ(Cy#S7I~d!{)16v#YadziOzi-xGYH;RJZ|N@f~{-o=~nSCcz-eF z;$XNY|Iq&F#gspddR&D3HkExr*Pn6rieCq7*cylaS`>cTN{(36DqPh~0HO@vFc+rt{_CFb z)}8nT$5s~ILEVSZ{MFE1Pr?Pfy}X~(hbn&o`DIg|fE{!M-vy1#>7k*OV- zp1+;FqZxKe`v~ZyAU%0mJ!K>BIA!GDh1{t8lW5-;)K6*A+dii>t(1LMhvr4=xfJY{ z#IsdSHfPT8M~N@H)JEwhvdEK%UxS&zx9mX+m9Gwc+j{xh0yYYDN4DDgfZvvJu34*T zzYALI^vY3v&Y?a&$~d5N-Q@FNM||<^8|j1ah~#C$wS~;ByVqnBfWT6EW4C+@>_J_MVx0wW>^0PzKDzt4P8H8{+*^#j-k8P ztcr|3Mv9k_KKzGo(w(m@QhXc#HiaX_7)RID@?ObXcC_j{ts!>Pf9C^FV_^jRO=tWy z77`dYlcDXokJ%Nh8;d9LJtN7ku#xez>6`h$xq&h8He+Ch-M9U5`nWH7o5r-%`HF>CK z<@hkNcO=roz2}yBPBu@n=MV8cn!6T66SqJ=>f6%@*TUNwLsPZSdID{!d^1wq=WT4e z{RxdDI0G1_@Ava8CvE#q{Bz|^2$sVmRmTMCm;gQNJ8bSrKraDqzCiZo|GregN@#^t?aRg{9?lZn^a?XK`tr+)sv-D~07-ScxItKI%J-?U)^u-+uTiSLK#=TB*q#*ga$**l9aqD|S~ zrS7NTH&U>Cz*=GVo%101DrVm>?!j~V4SjHfHI;gpHSc!l4Nt#o4zN-mvno#Fop2fQAoAp#YxeZJ{<7;kdXjHW{2OK- z*i0VVo;loFbj|QgyKH$r{bmKqhbK`_c;P7qcf>ba9b*M!!IgXZrC(NlFfC9ryccQ0 z{=j6#Z@$8fw+59C7cM8<8^6pVUjRAPt~&3K6(}9<%P$>n!(UmuRFsRyLlYiKV}5Y{M(k~tN>2pe+Y*Q9}F zE4D|L=)ZW1;-m-D+odV1QRj8USH=NLH2rJz-lFMyDC-COYlUB2^`-7CK!?nj>a~8} zHpv7hS?fP%?TuY)e$L6P+kOQtH$j`9+7C1K`xu%yIo^!@n{Oa)HDj<(`kPCes;r7P z?OqQ&@ss>74G#fELclU(uj{%=`aXFnw z&3RYT1`ih{=pZ8G7mvIB*IMVj6#NR?sV`^wkoRKjApFP+$l8LTb+PA1{wZXAi+h>| zSI)agecds6hsTWhZ#>kO`Iy7{HZ3ho$o?H&@qNl$Ph8E6JLdBDgU3GF-3(}+(R zofQ+rxfyw#UB@89*X>V5`Z zmeB6z`+81Gy}_!u#Q&oq+rPFeCQ|-aeJclS|2nnegJf$|1Ndydug5g|+99X^ntVDl zaDe~K_w}3h{>eQ?HN*d!@9Ql^V$+Sqjuqk*~9+rxO8@4vN&Az8E)+-S{%t%MVATkF{S;trci z_L{4Bs(EU7=H)KszQBK_o!~5Z;s2JM;3wz@|1a1HUWE+w|B9VpTg$)JPVime9m`Je z{a38I`R1(pH}~gK{)UZtCh3o5C-^xus`2{w>;#{oF5$9V)UG4kEu9h|TVLv<4Hy^R9 zeShgy{FASG?`#SF=8-4$pf8v@%;KEc#$2niIyZ9|G7I56;`4Z?-qS6Zn#uou!088^ zO#TzjBR-F}>YF)n@wVFIgP%`W{F~1+C+bXIpglkvwf093*}3re1=}Vseggj^30o5A zgOL-reJ=Zyg~x|J&&J2k4E!BUs;V+;!2Hu{qxLGUEYrXFXc^7xx6}viV3n~Y^f#Uf zo+Q`FhhPBs3w(iP_-9&Ha)K3{Jwmo~NBom?wORD`gu=Zmzj>|)Sl+pwv6OKuv?hB* ztuJc%o=yLv2ke}kfL#~iT7?zQIWozDwZv;ppmgQfRL>@@>|{KjFJ1SQ9-s`#nw2r< zdTuK|`drV^dF)1HQ1(8K-t(GpjQ*&Gb}~bEzGL;bx7_}Q>6(Dh}r5Q`vDe>?dif65i~6*P&;6FVb*9HSW3JZ=7>L z&#C@sp#Q}8L|gG)&N*aK{_MYT&Vh61QGT4*_!?!4-U6jZ^Wq}Xc)YlbZp8mY~ocBKuT=h-<_te6Nw~c(M!R3FnxBc-g zF8@3E@AJPF=q#2_{x1Im{(p)8B^-nQ0qcK~|H03{!T-ig`{(&z`7!!K{4YRz((VZ? z8@u@k{~HBGHOdD7R~w{}gX4b$Qzm;B@6}(qqVGb^&t@dcaQn`8c!jeS87B zmAmBvPLqx_P3ZBYH~j^A{D;tgH(|Fl6I%MDd*uMx z6g>1pdukK*hl+bMp%S2t+e}Ob5G-F$xW(X z>PpMl{mRE^JoKqMtTF@n!AbZmlwD>Kehj34GyjoCj`e>L|J`$BzTg4&OAhnc8rSYa zbEEhwb_Jc0A}gncJr2pit;|DvdBjs>hbkLu`N8sJTiuO?FAuZ#9Adr?lUMvUcCK~j z=W{66)wdZx6X>lE%dzA;sY@P@e;#-d-?z;!eR}7jGGg417x>t)G3g5#5d^f4`q+)%1Y?s z4%5H8&iL2*vdcifulY-Mg#+Oi!igs@NzReXG?ex?A9Mb4?N4Iw-!SV4^7ZMG$HV^; z+Ik-N|G!8(<_%9`r_+H&n=!^`DK$BTQiLfwDWy*?6GpA_H#5o?<0-m z+x;&b4Z|C!`X{;VSZjQZ(=hq|6>I&e2mjU?+V_{vG4DO zJ(cYrbC|IU^K98xGLOkG>)wIbt!}amA3w{KfAskgZ{E;6BA+mxowPT9h`;K*g4TYw z@W@VDeX6zUH_wV3Sxb29@rO5I6HnVpc^=y9>Vv#CYHrbfg!uo;4~O^DJI()2Cb-B7b1!=q=Zf^NYh2#b7PgZ!-ZS=5*kYLclqJ9UizrX! zR$(Xo5Z|$O7T;$dQ)y)rA$(Zenu@Tx+U3C`kIOr$QN?e$a{m>oA zaPMPNx)~WD(#P5(TcR&d|M61!8{(`6>ty9gZp8MpPWD5W;~exg?9WOQpc2f&?d=tg5h7r-vNjd2z_ zk-Jf=k^5LvW=ZZ`KYv?R`nyZB*`vo6Vpl%(Pb0pT{=bfP%O?L==LZL3r_z+*uP8|< z{BkEaG38??pIYTJb|m@0_>6YV1s9S#rT5bQ^aSb&UbCfhEFjH;>?=0i@2_YjP5E&0 z&-S_YX@X_@DlF`CnjY|1Y@lA|KXGQ?cTnnpc%@nCs|Y7mUUYo_33rwEXYIs0eBz2B z`PR5GncNYjyoX_TkAZ!!&)8u$J?yVYkAb~`dZ1f0WtZ|@LLFbF-in*8`W^CBl&4UR zO*zL^UR3X+T+&$filxtzZrE=%SG*MbEw=iRqQsWiJ5yzgD%+sv*~2X7Ooh$_c{Y*< zGUA*GzzG-m)`?flPTqY}WMF*GzEyswV^f0D_K4ltcB0?uEbHZjeBD-aH`CU-5&G`F zy0!ZC+#S`;xgpKro&wzhoFQ>Ko?cB7Q2XigmwKJ!nbJy(!o#{0X z<>q8`bF4kIxu&4g8n}LZo8Q+N9?ApGlB>Eoky(^g#xsZKMxI-EZnrzrI2W<2uMa=Q zgzx65u{*C)ef-^>EY+v1JG`g6b3OG5&%!nfcx_K&w+FtOAMfS}Z<}{?b9QZEK8hrB zRv()%`X_MC2~K_W=G<^9b$GJ3gMGsIwx;|}Q^#38lRx}EHrk^oJCXv91_jfwo7|O0 z`pJqL9E`kgb+&$uf64~=tMxnGRUc?V-{(>v)g@H(R&Hwzbs5pa**vp{v+GgHpe%#8 zcjKtT#@z4}V4m35t;2kD{=hKxc#`!B^$)M09%U-CFZJlh(?6Itin`oLzPlAaAQ)L; zb(YKDC1sDD0Di#BHH6OsSEok%?=<2^@_ho2&Y3k;$L+CLvu?QlR_3#K=iOgNmQ3Q` zU<>-^%?7||@84VeYxX5Al%12Ej(x!a`M`*GWD67U!J&QSFabHhU!58x+M6L2-|Q?P)ug|}?f z4t{WPaD_j)Jp^y7^h?f4Yf_O0*ryDp=X7j zD}4hOzJ)KtTdM7rxijsjtC%Cdq^=Y2=XMYIR>~)6QKIu>zCD{6BeNIz9S3+m$~R%^ z@Sd-S^D?l!xJSw3eb>Z%i{Dg}SMYD=*+)IrqXT-MIy^=G2z|JpdAXFjAiEj(vj0^% z(tXJ8Pv<&JxzOlx^2kT0!efD_`ezchdGswD>EG4Se;Ltmdi1Taf_!1pOe2j~XZ7i& z)Kj*3o*my5{>v6#^)ThrZ?c2($`DOWj=_t{SN}|k_RsIwzv=@F(R~5wlIREVmGz9p z@6is%SxZy2AJl)dh*Mt-CQpIpNY3C~{Tt19?%ju9ObKv5fPL66=*m%6OEzh>AJbHn zI&RwMjH@EfbaAGT^`Lvk8#t{|`bdC=WD}|J)>U@>2>Wo!CV3kPFiKd@Ww@)~5>CX8(xx;u~5z z4^vN@c*7s4YlL=7mJzOX4=1`8^M0A~mF76`q`D?jzI0Hm=?8o<bN6+d0xW(vR!WnM+v=(ep{b}FzuSS+^;rr9i%o(d} z3pl^W|Ax+bH1mkwp5ToPyC~htaDL5qHgU^%GiPy^Z=yM0ay)U55+|4!1M_*lMW-Kc z06!j2q@RTEN_gU6*7I!{`8m=Rh=yfnjmL_XKkKU6yZQNkpt_I11E6q)%4dLC&-6lo#xn;!0bnTkVhSb zM>6NuQ(h~4V+V0h)2WAdE@W$&!2d~JK6seDf%{|07v|mp&0z_#bs%n$>cG25b>RO1>M$-^hUDxy(16N2 zlmEI~i@Sczcs!N9slt90TpIr|2c!ODv{p1@5SS;E=kS;Ww(6%dy_!Rn=GWBw@PFE< zu^?T$c(Q>3ZjOtE0giUj7B5d9>M7b09Vxx)**{v(Tch>t5v8F4yn7fLdMUVuawA2< zT69)YeJwpp7+N;_lV*JZUm9wyi`=5UYkPIb@8h{YH!_#8!@97cW>F6rxj&_-b6t_Y<>1Z%tU3BPT8I4U{vpQKM!ngCOwIm^u{hUn@6jH53H|`m zpf!z?f;o31TO~Dqip+F3_>fMRJ>4G!X$^mpTV zAUE=Iw{?-d14v&t7dnrw{3|9hEExEDKl`uuABIt9UH#&zr_ORMy)`eI>Hwa((+#oSlR}T9xGJOr-qG zW0R1rG}qJre2@7N=`P?={Ce(-*V&qozqiu|7$NY{%J-|EO7>+wAm6!PM3&%BH8BKF zwH6I^!oZfS)omiJMzJ;ZyeDI+yov}*ooJYJgX96v$j;qQWeyTWq7 zQkMIFVB>457x5#h*L3)^@=phzXcl{*y3(o4r}%_`rk&#nd-5DGw^}##bs9F=Ezk2T zQ(o3V)`SeFc_#9r!r*Bo|71J9^25lIA^ysX*87Gm>_=Ibm*K~_%rdfL7#u3?z#g2B zqKt3zJOSQ>+g9Y$owRubFy_%$7P9tW{{MTNY`5tj=DQ@qp;f({pTF$3rIqiCqy1A) z`5*3OZx7t2fr~SNb1Lt5!I$V;@mhbs2)scat|s)tPrbk8C7x^Db}h2<6P*d*rc`0z z>y81nYYy!iO1tv?Ll^$(Z0^;!C8x3mb9eA4?zIMt$|YfihO|?{yyG>{jAmAx?bj+ob#Ilen>l$D8$N zFQ=5U41SAxIgi7?LcG_Io;17De;?;O3Y-$|hw;Me3BSYR(e_`d`<;ZtR}`7G(MtHr zKJ|;}mNM#*2X>PtpMKfLyVbT9rPq%&@zu1WlK%OOctaZ{-*&wJ!cFc3cYKIOGN?PY zK8Z^y3qRa-&+y1r=yKi-$RGhb^>p2JlFqzvHE}=UF?BvE4cbX@RsrLqn*N4P{*80D z^|Yy&G{ScpI3Vqs^s`x`D=l#!F}_4oi%FZtm^c4V@;Ot;lTY{@-V5C_)}#|}_y)B2 z5#v*RHIICvzq3g5F8;qxqY5XPjOB@&z(npN178!qcMIdZ|)OMd7Zcs ze0wqh7a`Q0#&ZKt<@|G2hhH!3Yzlq<1IXI^StBZoa2(*2LGUr$OnYq=h;piIMR61oi;)485VA?WR20A@-E{E z6|w$*Nji%jPABO?=WQ6@I(ftR@G|Q0WFfmSzRuA6BzSpqZWtX+_}M~JhtTv5<0Fsy zI@{3IM3zz33Z7LwPm;a~T?_h}$gc=L#q(^m9+b6%GUii{_-NS)(53JZ+7#zJOFb&% z@>hp%r0ly3nQNGH&ZNxwh16jHIM|pQndR$T?_<7^F6lhVKo=6Zh39r2bRwZADSI06 zHH7E#%=d5*=j@<--RrD24>jwoIA>SUk#P|M2aBmgTFo1|=`~w&!%YR9ZCeVAPAxRQ z@9H%Fo4H~1W8wD;s6#FKuD5cfb8AbXJoIerD|JR*=h=c*pOJF%Ztxy{t$5AZ_iv?{2>1n4x#7%=nkGJ9PE{%Ul$PPh2{yTbDQh0M4f znQ=Wb<9cMq^~j9jTYBV}{szVlV18R=@D5SNOTa!nc509XDP!LzbT~ul?^OD`2%JwT zM4t!_;}TX|&|COf_RFp)?3B(jG?M;4lm2e9&@F=3^LQrnOd)+4ZQVlrbi&hku888C za&}Oco~j4$U#qReJ>=rt9h=l)Ge#&eJ7fEHF04#^3>t)XvIudUeG+@!Nqnuaa1&X3%&>?=d0Y2JX-5wHg=Rj zQk?Lnx9Yv#m$>jf{>Ro^_||`oORZCcV=vD;l&3iT5AyBd=uN)YQnu#$(bU7kv%-?= zJse9`dWrHBr@ZPb!IC_=bF1j3pVO*0bL4itkvaaNH?q|SdLvK%k+<@z?+)Vx+3+3W zz52X`en9m37IBghE2*E>(JjQk*2Esy@%h07+daRSWxM;1I>U&6--QZ6IriIUy}N&d%j$8^g*3`2bAEeSZC+v;)i-JcF~pW zoz}7s8oS5q+1cdTTx?>QuT$BKBv3g6)NFIh*Ffs@*VKQ0~8GB|q2%3U4r zqMyno{aW_FkA1&NO5@4w!Rzc>Ij~d)Z|)Bm_JHUs)gIg~xbg9S`}PsXxK~B>&*EN{yqJ4c^8S;1RqC5`8S+`09JlHhHDd3+P^zBfi?c zoOL!dScAOiuSn;&j9XVV8y}_UeqT;e`VEeF=wuiw&h6HSvTwhWCtjF4(U9< zPr8!J8JmUNrBbZBRG^~**A`Gdfy;@HJvs9M4v$89tOm`A6A( zA1Zp3aps6u%#OM5T7Je;_=j!;{*u(lyDrcxWnp|Td*K7%H^iOSZRju)erR>Xp2f)` zooAnOF5fEC!?AR-s`uYJi^IHiR6Y-7P+w%5EBvKmkIHM(C`HnZleMz&~nIc3*Fd$K=y3*Dl{`tt?)OnxF1 zca!SQH*3(wRO&9@^YXo?f3x}5z`WIsxppOS)$C!cq70=y1AjC0d$!U(OBowk{}VT+ z-v1$M!x)(@=)|%{ckqI^-PU0^>6r{CwPuKj*Dky#r2q|JtSUsZ>uo z(?8NL^c#;p%-15{8*}KFm3-e1?&^6@qYa9m%6l+z=xMX@KcA?wzeusx?Zl2w>pSV9 z+A{pyKb!QT_OW;I-SKr~(V!(K5I6kAOgsOX{gn0I;zai9lE&q8=RiJp4&>YS=Z4{1 z_5QO~N0_(1!Z(XG0v&p!S1@uTcQXM;G~wE1%NHDWWZ1c^AI_bUWwe$5tJv2&hq~>7 zZqzRM)oXbk`l`8{`(m%?_&#_qL^d;N&HvQKPxxOy*cu%qO*o?{r;&6cv1`))Pum=9 zGmY+bLNWTIm{{YEu_5L+whOWe)CiPt>+%3oCocm3m@#CpR)oVUN*jB7gFAwB+HRp zKZCOYpZa?@POf@rsmk9-S&z*A{9Qst9EjV7o+} zC{uoF+x>m;8PvO-oAHY00)w$${4s489v-6ozo1SlsM7{ue~&!v{$8ZVR|zmw-?xCh z{ag4zjJAUXDKHaA)2qD_yNWNktM*I!yaRt&%9jtE*glE}Mjmpw_^>7C;9sX{v)ay?V9GlR=G@-F$SkYV^CK1B36HF?oGEWJ zE*RIXgsqj9a~6B%A--$h#78{(^Yba|0Jay$f#bp8Z5ZLaN3g5hA1M~U)A$m;FQ6{8 z4P7Do2Aia3Olq9+BKxbf9lo6!EMMtl=|SU5TuOZ$$ZUI%2O|gT3y&+{%ZmbLDRteKz!}c{!>Med;W2lN`fVd;Nv7I|Y$ch5bx5|(BYEpzGv{O5C0U&@Fjlh8`a*o4v3G~B z&>Y5_k+;@Tj(FEB`gSmDvkj!tn7jadYA-bz`jm|rJgj(w_E6Sf_Xs^`ZY>3W&5{)^ z7-D1vGiDjTk`;_>MY?O(y0R5?RChIc=SL||XSh`7oB0;+md#A(NYTl8jLqnIWyYPx zq{h+xq*MCqc#9sC@46^G#OgFsExWy*|CyWx6Ml6T3|-YxxE9Q=x{zb#t2jnJouQh@ z{goNqUulDvWBGT`dQft^4>??C)6_p|L%{ZJkz5?$Y+9=AH}j$D_?)>rKy^)OJd^R% zN?OI&iocpR=q`uEM){%=UnxgUP63wc67JIstR&-ia)U2rq0SMYUo-1JH$2ef)tah} zv#KiFZznC3oPPk?^X^Bg`CR7=bcQWaKGqDrJfHYVr@H_iYWdfnk=Vys;I4OU>;+%m zntDIqUC&5Fz80_Hj0DduJpb7li2&!8{;B>V{om`aYh(KB5$!ui`|GK{*I&T?r}_)u z3V*A=e*O>i*WRuF`ukGcA?$eOF$tIe6BVNQ&mpG2_u) z!gI<9Hx&&Y*We%gVp`4p>{ZR@(Vo=;o(If16zQb1Nq_K0*OsZPFFo-I!|$GR&t=MP zbU5jQHAl{%gvwH!;hY^Z^33^ZhF_6qYx;Aiz#j)Tw&z)i=HJJIP57^bRqmP8PcmMD z>g$hp#wyIahpB&@GfH8~JB_#Idd=0Uk9SX?=b!E1jRir)OBa5EfzjRBmun@KfUnOjj6`e-Y0=h{1O`>)Ckh^!g4ts54zU z&vmeiuf&M|#1>CB3gnjzm)vsLW@%GDXY)Sh$xYwQX-h5a)ICVpX0J7M;*7)w`OOBO zty@0rw+8)12kF&jD}ytH|E_z(+&zy2Wqlnd*Qz^+ZB^u!zB%C!{OdyOU541Zj6L^Y z`liRHH~ky1!oUix>TPVzipd-H_c7rck%|5coE?PsQ$~n)@D21E*ot02KH9w|y}7sZ zAm0Z_H=6j)ns`U|P@!C_3lH?KL*5(^p4KZT4VxpKS4hKtDGi&WG;EGu6KwW_whK1z z#lWu5@VAswhta^;&VSi~g?JAKwpCCvJiIf(@pFDg_LDpK-m1L3Yod8CBJT<0QTwVk ze%kL^z8lo9*tNGZPlu?V=DpLg3xH1~-U$!A6&-=>Un7sk=Y-3A>(a1yO2ghsd;e+J zJEdXo#NqC`9prsZq}{j5??l*J4zmwkU%e3f#0PmE;(3_ok=)w_kG(=i z@c7;Z4CsDqdPka*1&po4VH=hcE+Rknul3l!*83jFovnQAJ=Q26?=jTl1Gjwqm-p5^ z0ZVRC-e>(e;dW~s27Pr`Di8I!O?h}{k>@S)oI|`Ra~}726#1Qc+AMn$(WB<{bFmk? zf;4OC&!$ZS$2HUpeDPkwhGt|>QkL#KN}j`Ef12|MVK;x8a~9==H;r(10Y@~^O8Omy zi&YQaanu9jTlR6&op;YPb=mFOo}@YV0*AV};Z?((J3Y93X9_NPe(%Ah+#Q5fo?z|4 zPXA`$9bkYfz z7x5^6G5KYK8`?S4naa0#Vk_Skc?>*YEb!oweh1+s!QAd@#H%asd09cd(nN z?z1{n-FLNtMcN&}TP|3pqB zN!#`YWi#e=Cg~)~5An`W9{zp)9hKpCcJrn$*QEb6(dh=i2+;@O2=*O43ypn7cp$oP zY)?Ymp%J1Fj133;Hbft^ao4P|UFbJ{hmJ2l6yA--w|dRqU9*gCuZgCx%!H2!7_g5kJx4@w4kfcZdc`|krw z##$B8dksP2jYU>NM@t0m0yzSusql7cPIB@?L;_i!v^-lNx(bwl}7)e(=~PR-d?=- z*1V!JWk+J_%-qzU@56Xxu9MB*@!o&t(D}eW+!xHH7x7QFBhs507|hZ4k>&!z(kHmM zNN}_VB7K?Qn>gf-ACRtquvd64^}_2}c=5L*s1lI&uXZ*25ECi;$t#zsfKM@8Qn zb0?eeFwl`riSpSRFZ3~TkqP@0rf;RM)c?5q$mk5%-?{GmcIst;W$}Y+*Eo7u~4jUU%Lc*!XtX z@U}JSe@=ELQJ-|4B+}Lw`CGm$!-gh@^U(vivyAkPB~XI(_jQzL)VlxWhe%)e=Jf8*Pm>IARU*3jZGEna6+Tly#f=uR9?2t?K9K zzDSJa>K5|I{-++C*#+2Nh^|Pdu;4HGlCwzAg2HC~Q1WTN#l)@GelBrwwCQ2O`GoH! zeJEp~^RZyoFqiVJIZ$-4gYb3w&-*I%2mel@tS2aI4D@ma558^^y>be!`m|puvQ8&7 zEL>be-h`i~Bk!Je{YY+WgZ1QgE>0C;ha0chu`F;fcYhV-JCfK|;16%)o z3=S`*y&n*-wz8>Hw-uQXTg{tAyA6{*?T2oBYZkh3yFdHMJNr0ee?^^&`a1f)!-Gxu zWx?kCj9~Mx3fP_7BTIG?uX6>FM|-m__OA=q^md*jZ0f`}cZ@*ey`3k>_X+8514oV2 zsV8+2zJ$BBvIHl*%(t!?oa%0m$Snyud3N`X^dDNQ(|-v5rr9q3NcR)Ltm7Zzk`{yrPmO+5j_U|7i_mDqGx_bXRxw|&`olLv1Bi-JRn|H4r4CPghpJ4ks+<9taZUcP2z^=;O zHOq4H?)3%pwZ4I`hnKKt3vbW6*B=Z`L8e}1b>`iRk6`*lZQTLfv8pHUlPUiOeG|We zx9BSKiD71MivAoErv2g@?5C_r&mZUH$ydcH+nkvzz2%demibULacQFg(%8Rq7`sPs&y)X)HZg z&mZS?&e_A-d#}Cr+H0@9_Szdoixt|7^*L|DBTd=-k5YNOhpX+x{Tn<9=KH+o5jR8e z)aN6DP ztW*6m!%N98KJ*fNWH$DvQz%EW!2Qf0UEG(3e00QGM0a0Ex9Xk!k~}7ujlUY3-UzHa zpea`dWIe^Vy9dlVggN;4v`@M$?eCg2$N?J&8<3BY2kxV--hV9Vu*|oJ`Yes-3u8DKpmL-9#5@F-#t4|HtIfTEdg7H69lv!eLx`eZYR7$@dC)6>XmF!GT|2mzW2>J&CxJ zy?E&Q1nTl^JYNCdZG3;ri-#AACQstg+Q_S`c&^qk<9SZtaoauIDNQU3J}w*C z@dwsM%u$m`E1o4hd20pzkBXy59Y|L(!ugnZ zjkz64-|X**@VBbB8ljZ>{f9C$zD0(Tvy-qLKZxWGoR@1_p$HVQhRKFq%70X z8Qp&)^G9CzPH~|hpAH%4wf1U_!Foq`p364D$f}$N%us)mr^)Z@NTSWs-Ji*LXyISs z_yY^?MdoXXuTkbQ=QLLMJDT7#H-I;-QDw)Li$27vwKyMRb8e^lZ0t5NH^fduPnFT% ziOo`^XM`!EnY=nrcMf{V;pdDrn0IYu~E?$SXm{(2s-RX{n9r51w z9Ont<0Ik~&{EM~o7_;tLn?LmwXAAjAYrdc6`$Zm8c0syxu2(kSzY%=icT@I#eD?y! zl(7!oUi)z8z_SVe{$b88){lD$Q=Pi_f08=kHzzE7X#e9g;<|W`qw0P z5#>|g@D1*w4->z)Vwf`@+Erg*FKW)XhJ4nnb>QXz?K5?m9&p~LEsw4GCOZ9L#fj&~ zuIeidwZ+es2_L;DGnYN;Z=L4P>FdOHt`Ru0IeC+^h3_8fBHS6=w2gIUYYsg**qUVp zQp2=;eY!QdgESk;Mj>~Mdbt|d)$IFJb8f1dGi%k@*mP3PpGnij`!Bpjdy9CtbeD#* zn3rEHwK_iVjp}2~GkbHK{$$SqEf$Bc1Na+joEFvqFG+r8A4N82JD^pIdm%+HT4VeZ zy`$`6n)vUCV2=iT?VtBD7lxXD8d6*P@mr|-Wjoo|x{UbPww&U~3xitA*?TEx@1-1F zU^#m)kLyhvI{l-a*QM_Sb{FZp`JX`_Dg2;p z-ATLUbgQA3^n!Ok??(UMr`GvO`+Li>ojvVY+>N$z>Z-etrC95>E&0>bLdjFL*Yr0o zsY7;pe`;i~AKB?TWUd<0`9iDiLWa7Rb#aD2y6UbZ_7s!*2g(QL1eMSGeagRH<^Fc# z*_D3EqpZ39jZ>?tC~J<&+GNU#++OCMaofQ8z<1FV*Oo<9R#lZRR8>V;Kc=i6U$m+! zDOBZ8?vFr!UBHsecsqFRG_x^86O|1?bOYXaG3N^FJQx z2*0*yLSvfmweX+U$s_x1{LzFX%|6yGsYX6)`01$h*n_FXwZvD=vvxLOi~P`nTlz(} zuMsAC&8Ge%Ss&lQ{|D%tM605IrFnrc;Z}9m_!&Z4;Z1lsPP9ikg0Y(aF!iV>-D)1$ zZr(Buf5vVfe4ao1c;_alt_wZ;iD@q`8ns zeCa;&U&8YpbP=p4mk08vIL2ly-8y3>;rO_CS@Lj8`-mxR>;bm12iV3QAhP=M{6^$i z>bjcptAVG!?E`R(H zx%3q0Ow!HeiEU%OwaadeaV|F!vkrCo)A7S`%$moD)0y14l&yWxOv)Ndn90w6ViDh6 z%wL`ULC#vrja0DC;CsvOV=L}gUjAp1w~23szfGDUZvM6Tjboi0+eQQQYrIcYt*{buj&tBG|P)#!hDVmx9rwy)^FX28QfFyZJv= zFnDhl4B}oO-3}MV^8ECCr=M?pK|7+46nKHT_m}@ifT6zcF0h?Lq|tqn+XRES)xh`% zVFwisP5;T%FVoq=TQW$9yL!bpn~r4-QI^XZ;;Zj7j|@KF>}kABf3F`$e?vbR=yvly zGi#dVfu<==8*R1TKgmf(r^uRSxz-!wt{Y|6G)+q^=c(s}->mQzyoZesx^tW2`i+;``S;5A!_4qxFQ+Kbr8CoTxC3^9I^>Z2W&LZ;gj5c|RBr zdz|-qJf2)B*vhN7^!o-5eCpc+-|0>n-{|o``{MuVhnuL2w@y-j zNIsU_JkCo)e^(}^q2IOMkd9vRwy6trH779*^rdx#$6$${O81RAYHX5 zude{c#qszH-;c$gKv$= zy}(nNx6rY4W2Xe)GCnklsH?Yb&^oB46uFIij(&iSrIPb_x-(mJ=%sHg^L3mmxV--i z9KoE-ywTXBvng5O%5JyJ)%-t4yuw>(-|hHKQQn_`ix72Sa5kjzjNmz!r>ouPyh3_c zuJAio#Nl)o_#OBIJkReu44lcNeT4i&cpo4v@2p`H`dH6y{~&hz zhZbKmV6QKl6HoAHS+;7TE9qZ6x>x9OWU*&=-4h-w(Mk=JA$3UlM|yY@2JfsVOuCn)yfwBbD&JT5_ELJu0vqc-xzzif%eVH2 zPauzUxOc?iPxs)1qt(>EE@2!gZzDKp1P6`a!1J|d!WqvQs=wQ=&t-pxS6B1D95}Rn zZGNR|k8kk6-tb_RT`=0Yin2|7W2S58iLJ_P!qo3Ipq{2`=xyn>^X!I+JIlQ%JaJ1N+z=CT+G;K$u5&{~}%DnE}DC z+cC!I-UFPi(7(n1&y)b{UIS<@38TrcC zu5L2-ENi?dP2+#Yqz^Lt3>o3Yt6X1=-hGbZU+9-+ZQ~5J(rfQfG=DpKp-TCbkWUBO zx;ZcF!Ig<;UJT!ZE`Dp^W`?f^uIpdnD|{=oTX8?0c9HV6Ku4PM&;=IDw}a>R(mRt_ z!ugTT0?QeK{RI9@!Z=ICHluo0t9!3_@ltdZUfLO?{lJ&f*NxBIW%0U3e9Km*&fh!b zYR)2tQ~J-CndZN2&TX9AgQu{U?#zMzEn(dk;9l^QENes{IcLO-jGT$`#~#T3)ygyN zM{*R-XAU1bVor_U=r!NsPH&a58edx}_Q?7P z$+~+wXG9hGI8$$6r1C$9{~FF%&Z+eqpYqdcd^h>|&!J8l+h=CwOgv7o+o?bN72V_P zEyFCQ3fP;de=4wN0Q;N3S_LevWoM=Yi!Y4-H__ezXXn!5Ws5)0X^)px;=8FC{yArx z>mPm^|2_Qc?)E~;9trGM@MU{3ebCIB;DWSam%lRte*jsT6X)NVe)$E|ZNERQh`LT> zFLF}l#^5o5J5w)jAx{Z;l$X0L8yd*F^H~cy<9gp0rQ1@i<+FNkY`!`5?bSE`XsG3I#&+FrJLbH6A39;3LH+#B zz?jeOOda#td6yP_c4x|%&+LqupI!9JjL%Yrr|q(br9JyaZ2kR%f)D&+?_CdUNVaw^ zw1?H_(e4c?=mcorhN0F@%`G>8r~A%Mzx+qw@4oz@MGNt{Qg5fttOWi7;7>nqdQkxQ z8Ne4UPJRvD7<79T`iL6(q0*N*bN)q-%nFoDpE>{awKvN@gwlup@X)LoSG;_)cRxem z^yxF@FXGVRG81o+o^fJ$o8(+?t~YZVbe!qbCG{$CR_;CX|ZG24>Nk@Hr z3|p5W)w6V0NJ?OHPGL=84EwOhS$UPmbzZqDCo;IytP^AHNyw!`*@GI!ImO|eiAv|( z;xU}*8-Xpmf56AC^eSp&CvvK>UH9kqkADvviJ{gR&wpWL(&+wAOGBFfTAHm6>FnNt zm%pX%q^?)$+xPBHW|%KFn^tG?nKU?b-M=lI9;1sSi6z$huRoZp;+oLPGv zdd=?{f36eYHy7Z`^%?qJX@2tQk@g4*`zc{M(|c6D5uEkBNq%4pu{#KJwnG0){mWJs z(Z4e}D?AffXU_HRSkgHJ`9tW09*Xd-a{=)ea>jQdX;yKbxqQOaKW*0-X?4#s7Ztp< za*jP}M>S#jNtUyd@^vOm_0d`Dc5qR#Jk$9u@>(PN1Ulc|M4Zk-T_!yP{j0N=+5>nB z*~dFKp*_Z1Tlnwc99%7DVB63O>6>}{jvlQ)EN9d2Vk=goTX+|pkzo81oT=RVd0$;& z8=D4&tGufT`;fY+FWWkh@!>g|_m1{8Ejc!VUMi3kjJ`;^x$vIdm&}4TE`vrcg;p+s zW-f+yFG5C6GBhjx?a8a&I?G#Is-C~1o_A2sHSv00PhC`w^6Bl#wW|B~i0j3+&a1;i z)Il_V_djAQmaJhuDI44odDS{}Xz?iQIY(2c9O^a(x*f||)Whp4jfKs$Rc#E>4==T| zj=?4^6FZX-_FX!=lZ$=NDs8 zpLf=$2EF+1ORfz?UJV}Fd*wB>>uTC}6?pz`h&H=&4|XZ`#`5C=UwrmgpZ&wf{oAdR ztw7~*d3p1Wvx3(jXJ5!T|Kr@_0(s@f^-_Oq({O9O=y4l63@dN$ahXj^Q56dBx#4zj ztg)zZ`>l`MI;pJ}gJ&bB*V@j9|HeK_?d1EP)O#K6*8K+Z3*Q-ynX@$Ij1yza4z8wo zc?&G(9WO86eW)T8Uzpuk?x=4gHHU=NfP)R)*-r(KVhv|BJ_jS!EZt*l0oK! z6X7Pp-jk8pTGE|*@TE2CkHLZJdk_D0@%_Foa8Nhz+yVQgk#iZ-wRXqTqllhihIGc3pi^d*bhxElNe>LA*4(d*MbfE9X zR#YghbeQa;>R&NAD($V3fsSbT+3rH^pIe^Zg3(3VZH#_Qc-7cG1ndR<`(;CVY3t z;hgHhDGNArJUD!}2oCR6z=;4y_mUZ$VUK@K!uQrVoLmnMYl@3JID9`WIK1x%&O5+4 z$<4bwAN%@m@~yU~lUH_eC-5)+c@O zwArK`O}Y%;27lPykKkMR_F@;Ia)$7)au&wR@zMnpUXbLR?3Kg!4^$5C>t1)u@zNe5 zJO;cdZ_3`9f9WveA3^23Pxx$LLwiL#iGQ0tHqw|r_>BLZ#P{*`=uAEa>aG@@jqGbg z))WnkX7>OK|D?seFIb!(`ttfq>D!>uOz2X&3XStm|E0S{nTLd@c-SK9qWEvp2Ho!_ z-&LkQ(0@7g=#>A|h1{8PJMwwekdU$IuSGV%reEhOWXInrn|}PK#^mFnYFKE%j$e0p z)LK!|q-^+efG0cIYv^m$VK`-K91cP^rL%90QFPt-)MD+j_sTN(q3-8?!>nO6{%Xj3 zq38{MP|R~O&pA9UFG+GV4;ArD;F-cRna9LI3%g(A>);j7KO>ou^NI(o z(4hkCr0{KX4>T6@rw!O{i>73wp>yP_gXk+-mgKyPZerhCKK$k6_O~?SYq9d&P^fnF z%U`8W+0Z_E$_5{M+R&Ukhh~Rg`wYLYjMYXzb`A45FADuN0%tY<(oMWa{bb*zbG(`( zpu6Syn|)660et)tuDbk^?$Hdcg5?1^z_ECWMW8lVEZ)fxU1J!r1 zbw(lI5zPbGuSC$L1=yR%Ugf45#%E+u@Q`T-dW)Ol_5By{EzUJsl#zZ2n*zrERm6?N zuR3+bPtqyQ5BT3fI`N=cd~f#l7(eJ*Ae%D*;%CBQ(itK6K`(Y1IuTK1{s~|(xd7;-LE?9-odQ!pUKPK@R#ZwNT_o@ zWtH>jE+SLsiTL`*76v(jJ;r<7Iz!V*q|w^!z^jJtGn=3>&Am^sA1D5BG+sVKep^Nx zW(amh93Ph_aenp7XF^#!Z_RHkcDfl4JFuhEK4KT;)LkK-oYhwz=drSL5^vR>p*!zq zhJ~L;2p3{j}Kvx@M)GSS<3@D6lRTcR z_S5&>@z7lhZy?VMV5%OQ_+G?YV|vq{frV{2Bz@>;q=Qx;7fc?I(6mM zol4@j;+^7+;+fiivw0LhgtvH{%9IXWJk#4}63_JZxin5MISG4D>T(Y-#g9GN(&LSt z^v7+KuRc*;uM964is%-8yD*>&NVbd~cw zdTIG>X@PbEO~p>5_F!K-&3S^g-FEB==E4i716yf5J>6dPf&+TGnPbw&vUa?f^txlu zlV`K3W2`;32wV|oU z*RkHSaUlM~e?n`7y~5M~P4=1mSHCRYRtTNzzAeE}o(=7pocGOqS@vIb;A$K1_2?nI z@xr>dA&2lFbp1Zx7VoAwAAgU3Q!elfEyEMuA}#ePdYiakVH4iQSg>w5+iB)s^k8r~ zx7gS}M6&R;CfwR*Iw`E@Q&WOb*7r9D@sA6kH9X2AynOyNYuQ|AzdhBZeIr*S(Ej2- z7})}Rtj%X{X&w1guU}Fx%@xn{FWZd>_Y`R!b9q;GcolU;hjfPN*X5@)J{FSC z!%GWrbRNPX?sUI3*|Z&duJ7^RPCdP{ffYG^rw?_+sPddBfG(x1owU>@MJ zf0XH@^4(bbx2a9FZ{yS4c69bBjP1M5_I3{TuL_4EW4p`PVY`2if$E(sWXH@i>ofiVsc%&l*eW zV^6*m{!b!YJl5o+zxJPac(46g>LK2(Jc_TPEdjoj-s80|6Xx-3rCG2(DueV?* zpC|ju=IakUnnRRV^MKO6O_&GQ@T+*Zc+%gfm*6PfYSL+p?|aJS*HL_O?rRZ$l#Vl< zyQJcJO@ERjTJ`P^Ye7C%{-f9ZJGu_jZ)2_1asBG}zwZBNxAFgl-nU2o;%sL%^F3Dzv~YTrt0uuJ%kR7j_1Im_d4y7}J*oKD_v z0q1|o|0f>Rbpvn7Q_{yBL?3r(@uf%T;FRZe@(lDJaD_khm+-2#s($Md(@@Wvgg$e&MUk&zTaR zb1wLD=O5056ZRH$>*ReWZIf--xDC|R%rV@X0-W7V#MioO-;>`<$V=F(#8-b&aLZAdZm0Cv(A;zSFL$BR88f~-m3`jVAM% zGZY=iJM<@aewuyyi@_z~^x<{j?pkno4gGjEYhzc(r+NP^(raw!`(5&BJg6V_R+!pZ z7Vks-ZzYZTOyApo=k}kz-;bAhYP|o>=6$Le7uvf|qWom~ZV-A?+t{N>zps2=ADVf! z#+_%UMZVh#EY2nLvULF@uwSv>y5i+*zeUcvt+=v1~U3LB-q z6wg8KK}Kxof=~U3{xM04)+jyP()_HGFXSbmW`Nxm^qcWh|lPIH^XB}|&@Q99#EV%h3=QaL6;1OQ4 z=!bkWPhAU-!3QdP8O8@{nakre4~Z9RU8nKq&GDKChraBt54|;+?72i2%2Pqv@AB{I zybdv*MU#qai;*6YRhi z`GoI==DK|SFU^;~aPW0H;osvNM8L|zwUb>B&4wc5?U2T@)@^?vPIdo~w_py~m9g)m z@wBHvzQeHIq0JggiD^XNg7G>up>ZrY+m3=Wm-Z;lF)NrGX;Y$|i1Kh|>ganmiUZtz zA%3QO;Ok5GZ2W({13>=&7gN9a3I6}H@%_kLuugn7lelHrhy*PB|HpAY$ldyw<_D*ow@n_l7#eFsTb9Y5!}WcNs86Ro~-We@Z>e~)F$E?qLmsr`IY zzsf_gZ0X7*lm7y^in|VY8pGYBeUbRCc2@`Vp~@}bj0`+yIdt2Jp8jYbZ7PqsV6Aja z;xmf`v&YvlT`=1@t3Vxt=%s?3mkA<&1UWAgE)zp19)Cz1E7{1nFglBLto6AeLMG?Z~Y3_d@c@#Hf;;rIBH@e7+2 zo{k*eANQ}Rev&MRZRR?SufQ671b{dBK)38^?@0H#!m`$13Y}upQ}ix+%=J7+rp-2K zbFGmJHzG?O8J9H>*M(g9kGp~o$_7EUTFtANW3V59hr_qM{kKu!PR4?}2QoS=+ga=y z9L_?7$MgNcAnPXV3rrc?Po}=7mc{Fv9sU>bDtkM$csM-E$Y-6+t~~#6BYQY^y_l-~ z)xGp}Ha1ko?;v~ak}0J#x_eyqqrJf399MMQqFeiI9?gq_(E|?ZfYC@Bx(JiZ+BOb( zs!-=UuA9=p{%PHz!IRa-7pafVyGXxg_Vp9u1kT*CbP0WSF@1LteHgz- zfc21QNAOn1^9F#oD_7&vcB(l0EL`2GGtl602laVU>v!l^YYNE|O23@E`j>xYz3+>^ z#hWG12$p2ag>iWEX`1WMDIeaJL!Q%~W4_OU2KFM8hl#Ed23PIZ-!U)k&Vi!~o@YALsJ@%O1S=&o(x*T6M*QYONMPp+$j$1laU zPO#Nh!KZyUkIQN>Hi2bdQ-(Y5_(DH4{V_s#yi>~$`j6Ly@Y5tK5E!J2q)GM%FJjb=;;NGJm<&!>7{pf~ghkpo9 zpdI{Ygf9_);QO`l#;!gZxK3?TewPp7E94l_e;{Fub(wko+q6k?^LdOh*>;rjPMlaE*3 zpXB*?748J%O!`W5=<~GE;}-+#zIL}CM#ATfZ5;h!_y&EF!W}`f0}2d%XQgDO|Io+t z<1&+IprRBV9q~Tm#pfgFqcxtYxqr>WkHS`Q4;y!+L<8V0QGVJ_9#4MCK`)ZnkM@B4 zPy|Bq&TT0}xdRG%%;oNo%(AqNLZZ>FBXJ~fAo8?b?Gq~@9S9|zxskJ(8VhttzS|<8djrZY3Kg`@pnAS7@ z%UXkeF8Y-E`oM?ubH6(Q`)by7htMH)nKg`i$Je37SmFC!8%=jkvm8J0G>*|LtoW~B z@ck#g1?O4d=>JXr_W?_DS`&O-XLQ9gHxlm6WhD!Dq`%Jp^S=A-8pb0l#A@SqoVSRd4u^)*nH!EVHk{IRN-we7pIj6HKYPH{UU22% zX$Espf3t1w`08C~wOYuOR{Xy2SOvT!h6VmvybFBAI@fqCbre3|q8`HM?|9c8XN}OF zP>=Ko1)Sj^ormA;!0rXdT?(hK_wt5@8eSrwLmQstQTn;y$||s&IlNWRdkDALKi9b1 zeaU6C>C(`_TaYW~T*COo?y%OCTMW%0A2k6_xc(pNx0t;8=KS-D`{Uo7Uo_>MAMcaz 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�K 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 XJw^{Xbhhdw)C{X=xsmn*J delta 123394 zcmceAV^^XbRa;H00q)EGfi=X2t}h-j8HIWk+e-qN6Ji`DH7aj zqC30BwQ4Zc^rIDn*0t)m2B{Ktl&Dp!&PRiGwQ6-Ej=NQ>Mz?C!YQL{@&w01EZ^`5F z`Rf}W$?LwK=f^$gb>8RZHj_y4tLNAL*p&6z{egyuuRfIen?sln(vS)bNTWO?{@eyt zptangLUFU!kWy+__N)5k;!a)A)oW*~gUf_&XhSSdSiV{}xNl2y&oQ2T*Z<=<#Cu<_Wb@=7kYl1d&+3e zx$WV6aiGzWYqB>Pt-06OuQR%HkK*;V+}ZX$#$D#?J?3k|d_88qPUIeTTxYyT9_NdF zy7`CAqK})epE6%RD_?Vw29L2|#^1`lvEe%7$GOMwx@wlW8$HH`Jj(m~(M@+_li|!g zuW^rYhWUDq`Fer*>NQ`lFki1VUxViBjX7i2b;j-Ham;+Z*L*!>z8*DSA2MIl=Ifiy z*LRq&kC?9?%KZ*KeA+zzy!rYS^YsVj>u=51hTKn`dyF&9*YnKRX7lxO^Yyyi^LFns z0_O3H&DVbObm!9MF0zRG!~_T#46b>0>F^=;JP^bS9wV>!F=^o{#5+ znmrGC3!beyCJE!dOYz);=Or?@=bpi2|2@M85A}~8eBF=?bDul&v}=xz9T^=uBp_pl9~cEb zI@UjU-#vpP_uVtxe{fXXKXm`#{RvYN59PdPU6@-v>*5sX{fA#WB#sPeIn;l@>~rYI zkwb^Y=)ptxiTm%1A3TBz{c)-2bxe#MM5V#e!$*fC8a3Gihs@fC6GMm0`uD5aN6^#Z zBVzc$BL~N1$FlsMb}?+W8Elq)i2LvF$H0z0bZAiA|G*&`49g(*pKT&6lIvIb#2vl8QuCsjvf##^pZ{VXtk_D@y)iiOMbi*{YGWGRrm zFrzxEGXB)JWvLJ=jIVr8@fzc!v#OtU#+ScNm**SQb&8yD{+H^=$@ut3R7Y;cUEfvp zJ&Yebp}3cE$46B^!5dOLPki9lhS?&-8aU5X4Z@7OG>6A_x-3Y zNKk?1Q<|q4U(j)eaXLYkaq54H@l;u#puh^MH!5dlIsYGJDb0|U%pX$qORRq5imG2` zT%Ru`XePX)3TyCdec~m?X&@_%JM{!=j92u8*G-Q6rxW|U`khfXB;Knzbx53M>}K_w z^o%`>lfynMm-{cQf!|sHP9qI6P6G`wP6MU>_&_79ehUU_?*Aw&pqY*^p3!lFaT;in zaT;ivaT+N4Gta-Z1Y~KJHQ1*uono8@T40>|nPohz`zda5b$*ed?AxuQkSLf3Mf!LQjO&epL7GWPJKcb>5Vl75J}E1s=xZ zEsFaXPw1GOq7%pUiG!>@O)R8%YHRIjW?|NVc!Y79S(NdpPYpE2c=~e1M|NOKI61li zZsz~ijP(gBbs8)&?mnajRArn_u)?@k*RO4Jwg1SX_3Z+4|Gny$Y8#RxTLabgoZ2bk z*XsIi#_5D!#uLv=DXV>~fUNe{;lVmQ#5g$-=3F}wN$~>x?i6L5td22GCm3OztWGfQ z*Ulw1-@5<*S8ZLIH6ZJ@|2(k#hnF$W>#W+^oUBjGUHT#fpKwE z)vqwF>LdS_SOHl_Go<7q>sDC(vUaYm@;%>$xUsBv#hx-}7^Z%;) zA;ufNX7ZGXumWljV?6#1)lq`+{zb*pjDJy2EX(*|-A}>f$bYi>__FG#$QoS#BgM;% zAN{)G6~^=0xhmuLXeVk`F85ztf6^hkcP_#A%jyJf#>3xK+{^f<^aT8j&zw^ALyUhL zfA-7siwGnT-GWDQ=SldjD8 zHTnb<#y_EFTxI+Y{lP_Ti<|Qo;x=umxMk-O(3-g!_k2eU)XR9M9+02$ALvh5LX6** z(kG6vf?IUb#TdW$XX?ZW#-FcGoMwDX*UvIOq#aEaSiv{+2Z}|;Q@W!v<1f_%tuX#+ zokLZ|sh^tS+xP#U)oUi6zjFzm)LHFj{DoD$1dKnV_qU(%J?erZLOVFt|9wAD9YuB& z2ys|j6l460dS(g6->*NSO*8&wAyPWKI!I(!M- z%zwH9QiCdMK<`>BjK{udH>Xx(oa(PLPVb6B4;bfF`;Qtpw+qaU>0Oa#NCTyJMGvbV z)$fX4#_3(r&v^O;`u#u13do629UiX3BaG9;qKvmbrJReUSOGN{VVtZ>Fi!Q8jFWR| z#>u&i=3DpwZ`RgjSp%}Jz`2fR87Jq8b$Dqf-@gAZ?=0B5|6gF-^<8CMg>iCXiE(nS z%DAeJ{99oKWL=GMvTmL6PiyByc;|(SoO3fC)3HaY%zw&ivd+sI5ck#Le#Xg(Amgix z%DIq*oBNL%ge?Vf|B-bO#;JakadM7kNE0LHMp*s-{*~VU306SXr5Ug2IKwzOl&!<3 z>hMCC-T%|fW?6&oC1qWaajIWpoSZ8&PW2av^ZS3AA-8Uc8aT`gqu9Huw8}U+S7SW% zLY18BjHk#tp)1gdi973XH{&!j595g+Dd)V5>-s6-V+CZLpK&@tka0>nnjuYqk}k~Z z_h{!LCP)5L1F|m08WeRr!Z?tOelfRb^e4ajHMXI5}5foa)aq zE}b*?Kh2PwAnQu3!GqemGUMc2g>mnTRMIUmPR>IiQSBob6#*W|EWPjSM;$4G;r=L*xHuvI^0u- zdl{#R`52%4i*ye8=Vt}fAjmjb7h;_1hZ!g5XoB3isH`vZp9=EYx)|$-tV=Ln)^UA$I@D=<#=XBj8wij1Sa+W#e1K-SR=$sxafw_ITL zzoDJ0Fiy@@8TY?LCvOkC|0gGEtO4=$I$Y=uX`tkUlk>kT=iJ2U{+}9ns6gN6lXYIk zslJbKa*k$510v^wtbXc`+PV-cAnPKG7jztDoE(bP;Umw?QzF3%Xl6;qmp7GlX~wC3 zhH-K(%Q)4aGCA^(EF$Y@hQ!IbS=Q0Dzma=Hh$7?UT$yqAtt#mjtX$?lSy!x3RLIYB3IGEUCX45>aj=VA4SwR7HE+5JCR=VuMl zIu0^U&V}mma2+1m;_CXdb)}1L708vvN3(h=F~-S>5yr{61mjdc$+$PAzX(7xhLn-G_wW9$IenoS7Ds$FDbr#|4-Ic>oizloRY4_ zI3?XW;}z|kc&YUU1nW=Mxpx!@5!11Uak9=^hx_VqKjWqoGXH|CpzmyDU5IhAF3dPN z7h#<0M;RyQ7?tWF5_rJGaE@->jXhGEUCbQmh~pQQ5i9I5{D7 zIh~levkrGNP80KJzIFef)E0SJgO+oZbw0)^>HLh-34)B1b2LFcfs~;A8fFb%udRzP zPS(X3FY0)NadIf}j2!uwe8vL&E`V{GS%&cm?NFBS@OjF)DaOgUg5)y)X)Wmlv#bGG zM>C}Q|B`mD%s4q$v2b($d0(cIZpl(0_a9kTt;1L9@EYSZv317F=Q-5=7kU6R zv*kU?Iw#{~ottqwfroK&jwVR`lXJe8vHSl`ZJnQWMAn5E&+0hLI5`)o!=uFM{+}Ae zs6ZL8bz?HZ_=ZPSNH9KezT!#7$+{7z4qvFlD~!{`mKZN+hpHw={?W{+!3t|Y*3}rN`s<97b9#nb{ZrC8)yx%_ z`TwA{&TTCKC+ob7Cv@y%oSgI5;lXF%=Kc$@0-9Nv@#sY=xgv~H{V3z)T#Rw5KXMzp z|0nBchO{(fU6OV5G3{KMadIxpcvZ(!TU=j%$hyK-fy!p$vvqipahh3)@!pG-b7jVT zDgAxl1y(@TRT!reEHO^b(G0nBE3EzxwR5Q&DIWGo=R%BA{qPQs^(X6ShBRYJy6BDu_*L@nRBpr=C+8B3mvx+E z+;mRnU;3E}z%zAtmT{Wd6yxs8lye2fss1dung29nvaVRCL5Xp4j%G;xkaG*H{sHY= zWt*%0N7hxh3rvswy($S;7^kGG)#2-PxX}I4Kz$eM`+p}Zn7B$==VqL&^Ds`%c^Rkr zKE}y8e~J~5bwS2Qv~?lI$+-yQ1sz8jC+A|CZ{7b>!APA33C3w=NyfWdm2+vvseWcB zPi=k4B)hX<>&|D2ak7qPNNY*X&9eF*)Xo(dSI!~-%B;YBhf2By#>t^d9llhDR~aWK zRwS4CPcz6|tE{WlX|T>XC7sX%rhe!IPR7YOnxL$2?mx25!y0@?Tjyn*lFrX~TE{`g z$+^%!a<%`%|5#w|{|MtWvnb=Me&t+@ajHMUI60TNgWdmAgCuJ}*3k^fYH}{a>No!0 z9KXmiPRhLn-G_eK7Q=(m+slp1V!4l(SU6paFzrr{< zM-yburNlaG(5bBxcka9aA?w_X$8_vroRZF4hx<&9{G$<313zm(GYc~A3@GP9j8pwE z#AD?X1lFF!D@_$UamS` zXPlf9x|~i-+{t-L|K!rm3TS2?#+^ZBotJT{?_->t^D|EMQ$bch*3k@U29$JRR{y8k zxd`KwbTP(@Iv!EFeg97uC0GOE$vQmEIL$1>cuSXZF1v$c{i(s!j)Lv)gcTU4`m>Ca zb2LNlT#41+^S{V3nSW(gK-N_l_eNFHEiq0GRqODTI=lvM=0A;;X12~6j2%$c2|WN_ z-^n;7otts$-?Pot{v+#XhI9f-I^T8!b60`>0# z(K+n(?UQV$Bn!y6&-d;6>3gc9NiE-MlRmQ3Q zisoDQ|1@KoA>{-m%R1}m?K)S)-8*kUDCyjc$8_x3$+v(1>)lzfwJm*hxSw&FS&;Fu zJCt)F##MdfUziopn?!_hIzg0ia*k%mof~2GKd+rjNG|iA8jy8q)}W%}4C9n^**bix z4lh`^x<pvz7w6|2!{O))g72`X$E6xiaHae}Qp0r}jV1ker~TTVf6Vq^+wmPR`XB z54}ny=Q`u$oOl(x|EB_{Zb$HeSoknmH1YQJ@35@ei` zj%G*$CFjDdzVDCb1yw{CPmy&oR#4RO2;<~Lq7F~i;c3RT6Dg5l1!uhd z=L(Ed{aMDzIWF{cRT%f)qmpikadNI|CzP1=PU9I9W$Cq!Uom`B?pTYUlinlXIba*!_Q2R}8ZTn8YCH~D_xp#s-Iz;oTCYDIY+<$onj5XsI4n7PSzC} z7crG|CB`RSsdiJD@pa8p3#=gCry5ikuV}u+c=O$=ewFbN%~u#F=W2>?-~W?!>#PA; zM>E{APX89nsb^07H`+Njx*xr`@Iy_j1hZv`cg&Cid4$1tBumWlj zWt^;wF;4YI7$@gwg5(fw*Cgtj`A-GjKdG!wvyRBREaO!jPccs0wNQu8ZgaK&s6lbN zz#Iw9ti<@tt8`W~PW2ZUr@P?_<6HOtORRvbqZyJzWZep@|1#}djd5~L^sDVj{X}%^ zOtAuT!d-`Z>ToaPlyo7+seYJoa*k$5GbZPvJL{*!ziI1Y zI}5h{={3Q4S;tAn$+>hLo_R)&{L8Wen%NZN^h<~W<5Yi^adNK6IMpvnF7ux(BI{^| z#L2n^*3p->a}~xZ>8gzT2UHTSSh%_W$hw-PK<+={>vg!$9nwH)W=_VV_o(gaW*qg^ z{`ar~vd+smoxsO9IY%?30g-b-R^M1RFE1iAklMK~$+`$@P|$IdadIeDhmX|Z3F37B zPcutWfpScQW9lHyIMvTEPR?Z+r}|Tjr^q^*A-8UpHE7q?6&WYz%8a`QmBR~+d;86a zr9_1lkflqE>jt9AIL&N@@m6&LQDZzfpd4K{Ir5KYOx6iKV&ZfHC*$NC&5-I-(s@|@ zL)tm7mCO7m>-^RN@U)JDjFUs5Iy_v5M;MnAGxuMV6$FNrMKQ+7i4n%hxdh`>Kgl>b zhY70v&#lX_1|QVcWf>>u3Y_bBmT_{fxW(1^x2|-htpas{pax~eX=V$IPrXh#S7Ds$ zFEQ?>-~U!w0X0}*oUE%cPD!`UcttxW;?I1cOSxG=OvfI^$q8>A?yJN7jMKz|if`Zl zJ72G?3)N{5W}K2P!Z@8E$~ZYk6I3~e^(X5_SOeqFDhCpbQ_`gwujn|#I9<51jGK*=r>%=HPS(X3FY0)NadIe8hbJ}Py8ovJY1V*dmSKGHpHq*F88?uU}jXQ^-Qzwc`6{FVZ_|H!%! z<5?Yt8K)eI)Zx*8SvSQx z+SJY!7$@h7jEj5Kg|0-L?*GZUG8L$aZrzwH)ZrDzX=Y1|&t-H{Grslv-xXFs*3}rN z6Ra~%&e06Hb51>T;vIjH8HW6KvjVct%XmV^KE}y8e;ppI!$T%V{?UkNW?|M~;w{R$ z2;)>g$~ZX}W1Q-bSh>u9vW{j*1EQo$S{tB#Tv;d5jFWR&#;ZD>Vq7|B?!Uq_6v)My zt;36q)67bYk4`A(%8XO}g?rikKUr6)(_o2la*k%mom*k`bK1Gu7FXAwtt*`vQTvlR zis;zMI3=CC4)@gIUdFe6|LbD~(YGt>{EU-zLB`3s5aU!o%s4rhq8ZYRDe0oD!AWgh zjB!f31mk5LCmAQ_(u!~2|5HJxPJ=AtG_xtjlkZT@6&R=bvpYD}pR6nHDA@k~Pl<7| zj%LW6TVVCYU*%rHf6k9_(>a-cRaW3XsFHAnar(QUR)?=MPL2xQ9}Se8aDtopPX&pG zm33~`;F#tf#>qJ^<8%Ta@K(r8>OII8AJ2Cr^puyOl+?odsLpd{}3ktP^^`~vJ~pi!s=S)r{~voq6)dp^WL=eUI>8F#%@IKZ$QX8594%V zZyoMqy!0hCF+by}4P6jq1*g8O8iW`>Ij?w_@v7z##%r2K85dtM`$>ryE2yagF~WG@ ztLnrF#zUGX8Sl|N&3IDtjLDJzl+|+!>I7NVVEv=&zX?6XcvbTP!eDlguT? zDe20L(|_4`fpPjzGcPe-y+IAMdOy4W&*&Mfum&`P8sjltf1UBrW9kIr&`#%qnmf0+ zIe+{&V4hGF-CG6biin6FsfTfL!pnG4*Y`1A&;#`|UP|eLh2qYURz9qfrNVenpJ0h` zT7qgFzQVZY(<--8HCEu&1?!A2eNr_L+Cthj8=5;ApPy6p-HdN&?ooXE{@}3sl zH1{!{eL|hU&-jAowAM7xxxH%FP;MwL*I)ktG0Zyp#aC4hBp5G#RxMSs4o`FbB~`zo zlT`k{@rG2_>is{Wlfs4qb*WLMQ481Rn*}ioS94PJM=V@rkKzdnS3^-e32x@UJaMC9 z=4;y0V3&nwEZk|~SqtB7;ZqiVnuQm(c}kpaDVW_Z5H1TZTKE|jUb65rExc^uXIc1y zg}Y6z_kYDwaJHqvl7&CZ!mAd3j)kvSc$03KTe!!4T6oODFSGCw3vaRTgoR(e#j*ZLOTn|Z3h>YG7Jh|=XDr-j;aLm6(!!@K{3;7C zNG|hFF2U87f>}$0Rtqm$_%#+@vhZsyylmmuS@?p5r~HC4GCNA&)pJyqEPyzT&79O?mn=L$M;oTNKV&S(~c*4S;53c;j zLDEw20!xFmg@-LXW8p8f@T`Tu$ik;A{KeZmB?^{;p6voLYvC`k@S=s^YT+ddf2oCU zZW_++rg0HPqHIo|Z8(CSQDf_E7V}4iJ46isf8S30N8ZwgYr!rijF1o~4G9(vTm@@} z>!e*Aw@Fa4x#sO+-sULT#YvlM&Mua0j-p*uZLZ^Xv1)V7+C|Maq0WH5eIkqFtkF`n ziL&8ZvY|`oqD?Ftcn4bDYd98c=zkh@P}wb3aIj)Hrfp)?=*hj|(LK4Jf8bCq_vj_L z$wx0f-z9L-_qhC`#cS5z?QfklL|;tgypLU-``QQBUU|2eM-TIcvv8U?WjJR}L+8%p zr(y7;bEk=tJqn(6$l*;lxKHjDiw*AMyTz%77X1GWHo9kbi>XE&o@jK>fHpd(cZ=0s zj>X-gwkw^x=xdjs?h^2$+0`w4{>R_+;kVcxzwo1fv8ATVhVfye$60Ey_qvM>_Bb|8 z!Ents*pC}{BQF`Q=?43}fw#mZ!!^}lKP4$#ooujgNSd{|vJLiW8|bXfF@a__Tv``x zj!c7n$%d87+FdIR_JZBD*kGTtyUGprMZ2qjj_mGZ4ff6nN7HdMayZr<_M&4|&aTlh z-za7pU8P1*Y(&49(y2z{WFv-e2;1w0h@R1qd*ysE<`O+Z^th9v57)xY={t-ALFc^9 z9&*pw>=E~j&7KTS+UzF`N7`mD8wK=T#klNZ#pc?ui8VRqwB0>xH?sDvzQuw|U09Ha zxf^GibC1tI%PW_62F+&-*C|63jXrY;PoFd#WkXCEy}9Rp;ytOG1#q8p+9^iYY{#9V zYEQy6hjYp)Rvg_kPI0^e+i2dU82<3S8$5ST_v0Vv$)&1CFI7i zdtF(ZJ?6&H6V5fmo@`n*>=QEcrj6z$!(KAR1X9D1wu?ENYt1HZEK9f7ZSHBik+CD$ zlvm-?x2A*c&2jG$A>7BhG4x2&oFU>aOf4~*`^?vGN{yIRrcWBiCygHWasvji)L@@9 zoLIJL!=08{f$Lk*aF-ph6lXzJEXb??T`}B9kPX8*-(Wu`-x@PEFLI-3bDe0gpOBzz zb0b44HssAIn@l-uI%Jd0z2dN^?X9?`9k)B@%sfG5yQku?FWGxBPlpSsS#q3!MKEx> zQEW80vW>`lEa7CMyVPjRG;U2@*rW+&e?E*y1bY+~HCVc=@wLXH;=7p@j3j8VBD7+inQS!lF}oT@bN~BIo`@r7ZM<2T~cg168?#5_ZU2zfYe!Tq)<%ZNX z#wD%Js$m4&i-ysSS;brnhB1n~FcQw9VI;?`e}`c%mqwh$264*nm}?MI4#!M`m^D|h z>Tn)!z_v`Nd~Y)@3ph6n16N;a;#AX~SKz8Ck<~+-A(;qG~hd4CjQ+ID!9B z<6P#e*X91{o6mpzr{8?P^Wm&BH??>$_vmlWaU47}d^q==|GYhS)AyRZ9YKHla6BIG z$D^}9u)n>(W52(DcyMU{z;IXF{*K%yzt@r*_+DG?d*6F+Zu0wA=GuSw?8g&J*BH4! z|M}T1!$bZ1g7MDbK>uhMZ0{c$=#TFU1Ooo{4u9v+zQN()p@CfYPcKjH zYv14B-sMMc!vkHxfnb+^zdtazzq4ci@cyBAeBVH4S6h3qP1fidLWO~jfq}06&VfK% z$Dn^`I5_AZ@CWy|4|n?a?d$09w++V!2L^(Jorv4_4Rpl)@!^iH!LGKh;r(5K;r4i2 zXWPKwK)kEJ4fF5pObxd0!+_!_3HEmd+dG4SeVzM6XM6i#JQyG7AMT3p@ACJz4|NQ+ zwGR(Q zkoU;Y(FaDy#O0%-@!Zl6U!VKet3L z@bvk)w^lFm3?DgszpQlU(2=7D4<8c!gJTC@i)rK@disseIrPBjsA!jY+&|Pl&>!sY z91QsVZ5^<7cu>R-#&0`3I*OIJPuxFrgSLbd$<;^Yp z{v+3R?DOv%_QwMQgZ)GM_hVyWk0QxB2a)6QA`uvBAA;Ndq5k+#|3Gf?f1jP(zvew> zD2Y`x4Y^aq4?lQl^l*Q!cde8=>ksGVuK&Y@=RWYj!MI4sgu&|rM~C9#`s=RCz4H%m z&-MNB;>Z8_kEa>AyVv*TKJ=%N+}VG=_{__%ZR@;OjEs#Xj>>x1{kbid{<8^fMHdGTy|#Z8sW5ajarn^D+?W3PncTNG zekHGFw+-SF^8d0)gM^Hp73R-a}rwsVH&! z2E^DyiJ?5+68<~?CcE*h{KxFZ73S%$Jaxvt_`qNn_HJ8OXJ8Q9xU(%j*ghB>=;~_U zAKbT396Z`R^jgsoXba+!-qx>oRQ`YL#_y$Jxx#ul_s!qoJFENp2XR5%-#OSOuLb*t zhX=cc0s{k`ZOE7X9qny0A8mbflF^kM?WsTo#;0(6c`*D3Uvhc?e7f4+p(3} zyM{Uj_YVYv{{4g4`Ca~C+Yrtk9PYx~OdBr7@c`bZa77%%#dv>byT5Bc-rHXG1D~Tm zI2g$P!eKlIiPW6CalP^E{zPK*A$fI*4~>rXi@vcZS{sa;jQsyJ8rNCgF9*7CF%EQe zcJ%v)@%Dht(G}-*Y=K~ z4$Kl}4B;KO9an7mE)x)ahp>Gw_Xm>sy-wp+xnlW`?>3t9_c@K8GY3bo%dt%c4<8yn zcyE4VxAEir=4nPte(H4NTWx_0t~fh%nhUo|hH;wR{J{3i#ycgn%nZa(#; z&CMJtEkX}|e{=JOuWoKOpW58K7|)?^Zf?Hqdz+h=;`r)sY;KOC{7dtjo9{;b4}EQO z^9A49+)RLFQRhQH-`uQxVRQ2%;FE~ogOj~&adY$7$<56%)cMqpHa98r5w=O{ETv_B_?4{ASV$6rYw#pgRXUV~0RH=qTm<6}b1K|Rm~ zXaMS*z+E|X8FAsb5Uq&a9|y;WyRFb(#J*49BLKvG&;!scv=5pp;vj(oQ551BDo#9x zLc}ef#O*&c20aO#hOR>Ap(WI7dK@P}rglSHph;*5nt}E~%g`ir9Xbhh&f#+c8AGdR z@Bb9~y%>M^jo@HKHh?<3LU=!oCo}+cz6}F=0>@A zd=AS4EkMPkSPtkc>ZMQMWY7Y%8Tg5d(sTp!3i$bP3u6U4>?# zJ!n^krp9nE{UwYVT7|k=Fsmg7!g|ptI05 zXc_9n$7Qb650H^K=z)fy8E6c;2wldBNzbQD^KPCyr+1?WlW9MttwObQx;)}Udi<7%vb90zH16n+{fyBec~ zwnAgj5OfsU3r#{tplRqhGy}~-C!sUY0<;7@4m}AiL95U*w21!wE4W5LXQ8f#;leL4 z$*ZycCvf0y6+-+HCx-Swd!T*L7<3dm2AzXWLYsdD2V@7(65=%UByVjsCB}wuWZGV}FD@(BprC6Db^w{uw!d3iHtBccSpG$V_NJ7$O7h!)M)A4yT>MueU5xZQ5IEHxj3`3-9 z5}auW$F&$GbPfgUXBonS_<-9G0caLl1#dpb5bjBg+=CN>FF_N~Rp4M&DHjfWIB0?%fHp%T&=zPM+6qlVgU|_RH?#naK)_}3Y08Rk)L;EC$9)tGoLlSl(2cX9hhxTLX5RX8+p&4il zItyJw`B*2M&BN-TA;!TSUC0q=6SN3zhNk9m(1L>{Xe-p8NAf)v$$~fy9YO595m^k~ zfX+k5o`;Tu7}!l1DB{{JNY*Jh7se7oS6+yvrXb`#q?S{IagIxn{fkvT0Xgr039vqB9W6(5o3|fR{p}t;2%s^w%60`zc zhDPqd5*)zpfO?_J&=9omPFM_`h8}~uUXFpl**@qL;_g>qApZxs^hyjEajJlWWgM(R z*P*R_u>85WQbB#tacCGi1C2vZLDSGx=p;07H?9NFD6|3{hps|PP}dE}4X6*=`YQAT zjYH#5XAJFc!2X}bK@cN4F@laz;5~>6h%?Xy#3kqiV*e-xiZ}z^fG$JbHzF7AM{YpF z&@$?^9Kr<*aRwTPu0Ye!z+qgtpk-(ox&~doF@+8im^nZgx{3~buYm(0SPu2P2TKAC zAfASXp^MNF=rVL1T7_nz%}21UpeLd8&{gOX)O8eBR`jy~T}A92Lwm$gs5A9EWGN1s zq2tgj3c?Q{YoU(U!Wr;B=nCRVXfNU!=m>NHdJMV_EkIol!jbplZ5et1x(7X6G&6+>L#wI(g8|^6@LnVbba@&{_XyVRedrLHegrdsP885FH1;TFh7-*|=MiT< zfa?X~A~XYCfKEdz&?0mRIuETvm!K=qRcH+=o-gnJanOVV@j>iUs1tes>V`(39%vlu zg(jgs=mgXcEkL`WbI=HM0UCuaL*vjIbQJ1%0WQH%4>S#Jg=V0k7hwNqanK7e4IP2b zLdT(V&@A)>bOyQrEkUc$lh7Kp3SEb;LFK>GJHwa&v>ED!`k_8(H`EV}LW9szXb74P zWB>QyU=pAgIt%TCo`A-o73e7R6f_B4ho+&f7h(obFEk4cKxd#eEXmY|Fo5^tPG}Yr zgQh=%6G5FHMgKDX7?uLseH_=5lx`3W(++BEkkFa%g{1(9l8K@y$F|L zs3^dx|G+>HuR;$%*P#)p_yqceI-yDE;%=L0Mg6|hY+?fO7&LVp2Med$#5!~h>UuFo zhz};i&?#siGk9R{$+sX%iLb z%vCnw`2dpDZxhbj;Yb^n5IPMV1us4aPD*~jCMKaZXa$x5EbKhK@m^% zVJYrFd^b9PI$mWHGtk&Q7zngChMC`q0S%yI=qYFkIys2%MnSvdHsO0Yata!Q&J4j} zXz#sn7U~$mQoS7e|2Pi3QOxL|O+=xtS0kCACFlZlF~f>WiU0zFt29Z7IP4=-LUCL&Ih4|4={P6mXD)X1<7h z2|e&7>;mW#)H{IW`!cQr(3W{54|M7qm?1R&O_UE}CKU_->VFCYfV#hn_R!iQ%Huc@ zzCjm-j{OJ@K~Me!?V%f0?ElswfTv+0wDvPpfG+$T6`-TP#9Dm_OY>`7NQPnIZ!r_- zu^Q?@TYrao(34Q_zvEK=XIOqO9D+umQ-8ryL67|n+Yj3McQ`hJCD_E0LuZVXU5v^D zyImBZMTcFCp`+$A@ijuk8_)&B$+PUja}ZZJw_QY_v9s;CLB%dP$1cjyrY3xY5IPR^ zzZ%JUu3f~TS?CP3=RADp5IPTaeHa7S1HDgR|DA6ay*S81k3nakQ_zhI?4k!1`!2#j z5J&Ng2j?i3=n}i=hWakGi*abvW%wExGLOThw)|-vWo*y$MZ0A=rVK?8oSvp zPC;w8*o8NNqh$ zF7yWtzQQh=$B^VuKQ#MFEID)onjTAGzxH890E^IBsP}HWH~}4nR-h$la284UDx45; z-#xJI0jy~Z&OuxHVLj9jor4}2u#0tQaM&(Fuf>wyYZpmq&;9rgDzxP_cHwwXUYJmx z!a?E)DnOf$q66pxv<5x)TAV0}fjx+FXgUcip$8tqmsX*x(3XdAjei{m0(HF}PC|R% zfD=KNpli_4H(>&=L;TNnk$`sp3nmC1gQhAtn8Cp*=pu9-+Vf^u|4*1XzA`xi&AtVe zLlYDD&MdU)ZLoYC7a(W^+V^%?4jq37b_2AMML*E7hwUQpdgKB$3~kN9a%dHL0_vW` z|CwY32bFihk~g5kcVnc`Q+ezLXv=%iAv8G+2e1U0N6-*jc)wk2Kzkm=jIo4K=nQlm z+Vc@y51<+7$Olj#Isq-Ad;?lT9RDB`aruKOyJ$+|gaE#eVjwdZG2-SAVgDkY`FFdB zA+A6Rh!;PM>jUD%$6+zl^9fiC4HmJMZ^VE*ugdp)u$>bR6nBhMa+V zp>xn4=sYwI9X)|0hps@Epo^bJy??>}U%^2D2OH2Z^uQM|LuedYfUZ4>ugyYFdfjsjp-ILX+RZNHe(5`~dqF+VUef1r7Wd1AYtEu!GFsB4~NEX7gm$6>3Fjx~*K4iTSJSA(E01pgBVAF^y*s=uq z{$=CBOJvnq8NOq?s%&xsPshXXA^(~?jXk?%3y&FIGH&eIyMeG3p%*G2vW45g?7<@W zhtcA4Kf<+l;!RSPCtZ8pkJ#Sl&{ca-mdGI+%+4D}5l-at--;O*?4H2!EQ);jhi^B| z*^NJPgkus{zx>OFjV31|52Ao7tRnOybmt$w!#JmL17XW#{_VFL=k3Owya=HvnD0l? z-T=ZrSyK)M4=w{j9>y~efc+LG05~Q4ae?LF!?#Da&I@Tnxcv@od4p#7<(=_ zj?f9yDQD!+%U~P%{M+w)C+2;-4sAKf|jCajkmU0Gf8fw{n}uF3y$ zl>F2$DEnZpji9U*WxeLplk|d&t&5Xl#RbrARJjn!D6zo7=?$Q?>M|kp)?OiIH zmBA+8zrCi?_(iZR7+)0GWesKX@6R8?s2bh75WioI(2wK3!q!&oK{$p`8#jV*93eMO z_IM0sqmPRG#pA~L*Ucc9c@!f?fqcjzmcUMe$;$E}^(5G$Zq&AlU=%4ROJS9Pv1*eTXkAJ`JBm(X27%!eH3F$D9Lw$e)cCnakug$d!HV)!a<9!JsG>s197bamq2n?2<{?hU z2p^*XU)qc?@X2S+>mbTTKB@9LisQ*os=Q9(SUkR!*OLf62vuG;77=zY>jiUzh16Ty7(zcnbsbA(5r$bWGhhe6$TDeT3C#Dz&YgM^ zOnylVBQ+nU-C!QZ*1+71InTt%pTOOqb@uq z)-{3EJ}>h3WR3H81>~8&Ao8iKu{RY(F!zO>a~cIZ4%TG$sd`FlM!m|!oCMo=5<`{s z*qu!gWxn$E^2>JfV4;)P@3KA~=))@sh&aW+taP7ZPK{mz% zp_q{`)$NhXnU=toekxbNyrwS7Do@MX7V{Qu4PoS$*aGI2u+e=sE^)t9_fxGnj-n{4 zRx^ySbydFSm^Yqrggpqo=uST5sgkkHv4P+uE_44=TWwR zvSaE3a0+4TZ)k_@6VJjF5Ly$x2`mUEy_64WwGS)=h7)V6&yd9tV8?Ie)kWS*UYI}_ zM@TKD9s?Wwt;j$5FUI*7&LB8$sbD$@HiruL95T*x;<$=(-2Lgho(6g5rkeh{K&|F9 zt)}e1=^VUQp&Sc=_p4L@VG*HqLSe8O#`?gf7)$7`tXY0cGi!fSU}>~<R>WOoau8cK- zc^UJ8$t+1(J3IjB0(4vKihw!6to7nxg4IicZ6Fhst*Va+UC%n>0@xbrX@>tdTJ$vs zxUvmRwg9%w*fLm!u^QMsV~+E1!C}k;CbyDxBCY3P{bv{qq2e@SylmF58sJfmu?ZE|Z4LPmu zEd2<@ALJF>ylD0ybRtClm=C#TG0mc?#W=#spUevjJ|@^Tg>Y?MD@)XnrC9~Fdtw6L0Y3e3lPUI%Mt%ymBUm9_PP$q~rW$cNMb zn1j^|gJCLH?Kp8C;2MTXgK0+N1lTef$J7dC5jOt~PxG^t-NzAz{w{qq{}fn2=-$NW zRd`BPM(9t)AD!!-Ef_0UI$L1*4)|nVuCsB#ubF)=jI<7 z#it~W3$Yu|#lO_6H?C?He-5>Ph0nvk=Bv+QD)<5Qt}s~XJR^VB!^U3oW+eeIy$9ct zz=`$E%GTSP7buHzj|bnLP@NPXM#pntGhpVY2j`?J2p14q4LSuj4`wxJ9jpXK+eFrP zVJRiEw)KL|F%|$Tf>8#@`eCqH556;FZQKVa$7ZdV0GkH04(1qG7R)-BDb1{HkAr1c ze`T=rR)4tU$pI_^j%`83R=`HUtOMKti?a^hd$H<_wSe`4SqB)@%sRlH6krdlD1SF2 znYH2=ST|!Cumg-ug9R8Xg0(U>57q+qGOMjifHGaICtd|}gIVi|i?H>O&+yLfKUkMe-p{>;@YL3z`Jm6dDC9fF;ZfJx!uDr))kp)m$N zaZ&z14&stNF6*e{-BUPTy@+BRj783|F7aRF+$BgqEk0Y45IRC8&jq`Rb;JB;{ zu3SN|j7Fx@md{9A1%`SF-Z@Z)Z}`B0OMYrxe21Jx0C@Bgd=Co8{8AzpuNUP4UsI0D zay%p(0V{#|O@ga3bR4Yb8hky;T!X#S2#+D`wNxpB&4cOpt_s2`LiLeFXAR-WYqw7% zlgn`_I=>DVb=ivzmstJzzC589WwGmw{NX$Cw`d5#1cG-U*d0T#j#m2PigAQ){6m5M zbY%*m$DhCUby)E^9J|``7mXViT(F4a?lyewNuCQ2sViVTs=Up487=`Rw{D1Luqc@I z9oi2T22*dzw?uTaqS>kPFAgT!4dJ!0B$&&>AcMV~1-_H6@Y=!_>Oa8IA(bS9R03vfCEuYKFoIt?62}Z zlqL7?^h*AiDh(D?TUP$2>O?2LCuhFXUsgo8fG~!h`AVKdnJcJQauq=kfw_{d%aQ0o zxsv9Ml@H-`m$_UwhjBdDg^9=x@sN4h2R098U7`e73Cv;EQp~prWvzQ{x@&L z8*&-P8>qbffqsJ%C~&qY(j5<20!;2Qai-h+|MHc?@q-@&mu||3 zWZhsH#-d;oVAf%ef~CP^8~Ko$ezyFlMnJm$N+^H%#o4T8))Sx59a^vP6|f{b@hPxT zFl)Vau(-)max7{zt}9?TtLO#m1+xw?@C+=hnRUi}VA5GDOMo4)_J=nmaZE!Rp`7Iu znCpi8PyWp~?vwHGJhE6bR&d47ey1=9(@*y>?>p4w=T`%Dz;KnO=uKO%ljj-XHyKn`8|BKs6CM#@!1-@wHZ+w@rZ+GxotQ9IW=g-a?7w4zn z_5bnpE^t*9+XMgJvk?x6dX$485-&*+P0mIz@^+Ov$$kBnc6XPed?729L# zhnAYxhD;`MiO8y@&|XF)=L7C_=**RDBi1c7T_O8{J^BH2WNl_#!OZBD1}YUS>vLvlv*;N58j}13Om7*JC=w41e5!gK?i=v zljfGYm!l8jcIr7rX2kW9b(SC0R4wy>4F)X$6IP0V^*u<%mYdhMFDH@)LNp2TCkTgu z^#b7_2t(v@CXqjwG`XnRG?SDrcQ%{iiKylNrr=HViTaio%{C#kfKBuhZ@ryHPd|N@4EKYL=q~0=f4*Sb--MerXI1v>=7bYI4g+c6X`L7$cj(7cmkIE315!_ za~@(1#)d$ea51n#VC!|_TJ$;SC9`X2HLxEK%akv1H?2ALC0aPPH(NZPi9NAfy$HOJ z*}y7}nEIoaC|#~&PW~@?|B{&zQcL7VB7;_%J+4VeMVs;8n$eo-cwp22dhF*)4#Uda0j|9b`Rt`%z(^q>$Ym5X|+)D7fX)Xf=h03+XXeZ~UebV6GPjs+cSzPG!5v}L_lJ7kv ztslmwHI>{gm3+kv3Z4n1wzb~A+Uyxx%)4CiD3=O%(g|zK*lT_Sc9?4(;XV$0`;q;b zl>)2wl{j~enbKnxxn+z<_X$C`MF0j$S9@VX1Gu!F^tuZ@>ZUdGV$*F(c z<5g59?G-b+ydhGa`KxA+;4Q%SBOT924qx3iHFc{@T))V>bF0)5-(Tz(05~kV&uhV(2Bi3F+7~Fv@dfgln znnz?kH&lKXt4pshGlRm)N$5Wl{cfhWucDXteFNbbLvcy};*)U;(G$5ix0w4W)w;)C zG<5wNW=co_u!O(D&l|!Ik&4+$uX>Z@o^K+ZdLXqADLv{ zM5=RXlpuXz8-3vHdb4xret>hI;hu)CGr2cVAIET5eU`@AKuZlHa+t`f4e*^uWbkwT zcGyCs>%Wz5^%f~-h!hbK3G3NQ5LMTkutxvkmXOljN>KEd-!fxDW&vEk#HMzgKJym# zXbq5rrP6@ryHv&XHjcU%IYjo9aS_Mo7wq4NCnHzaV!ddx44Z9r z<3=+vv=Z20VCw`HrQMsr3%VJX^-p+%ft>)h&4*Y1p4lqgUJ`nggjs8qKDY_)*Adyj zjyozo<8sYAW~b0PU~69Iu9J_GoOeuD`{Y{?-5ctvlg(IR^!35?zuuwS&n1$_HJ?kq z;!cn$q!ie&x0P-LA>;&+{+oO?4~-o~5u0cUUmtmXGx|0b*u-~~Ub`7`g+xa0;0~Sc zu?59YTg;9jM}dvpNgHf2GeZ*?zGv=+As^%CuE@zq#)FWFBn;l8^qX4{Y9SHVhur7# zli4c09$0w=y?!fAd5K8rUOUoRCnyyZnszJZ>0@r`=v$>Ltl5hFnFVakCyWC=ST(Sa zQea#!(v6U5e~idR?iht57Ws4YoB_733Z4DX%+Pt*$|L`<=t5v~ z4~ekN%@kMvF2$7ttN4ryLB6{6c+c!~ja;2uc;xp6NTY;UU_oEX2x6?b; zhL128U@eGSgfj-eZD!lf>wwMV-qbP4sWx6WxTSM&V2UGZvY+(iZG@ z+m7zk5h*`@ReTJXQK4POlj($F473N)m$swlbAb)!K3B94_8qWmwgGef4~NBO3x)_Q zfvpGjyMN-+3D}d|@Ck7JJ(u|W$WFopU~~Tj>jiB8pJ2m)C9`iqZ?^v=?_^+;{{)*2 ztf&dh4cQE6z^*@_$>pNfL~4nM?j;Io_eADpr~as*V}Z>Tn9p+c zEjL@~zrJg>Zq7(#EF-Z$hVpbdLv}ThGpA9O4=`cTx5N1_bmX1pkdPTf^3F4|@5C^# zArgJjXL0Q@tr|c90NWl%PlAXqwRgx^B00Y`K`jJO3E+FIbw~w~u~(?eZib%dJBT!b zx|XST%cYw`Vs!aWSxAhNa&uA?qF@<-Ul|oQGWk(%0lFhtfaq!HUk})A6Bc zlc~GKH45KwRV=U>!1$~--P4|#RRRkx0(LA&|FFj#7<`oXt`6Pp13XRVo$w#Zy$>0r zy6L<2nyrW0O$aR>xlJ{dRcRJ@M*$|k02)ZHaE86GLXNkuR$^F?y!u6$_V}QM`n7=Vcz?4 zv(P1V<*ye{tqs!yD=0A`2g4c3MC#nBzV10p=``VsLxzv`GlZ`r z;_P8*>l2xf6PeqC8%#>FV-k4kfN_sgkNCt)>>hhJ*}2pyVwFEzdcYPpfk|}+11lxd zi=R-PA|j)ETl&-|7?)}yebZt6fO$=5&^`2!bnc$%s)LO6vk%~eyMc}FV`+VW#iUt8 zCi)_Y_AVE(1_Gu*qbMI%r;-_@h6Qa2xm&;RoGoQ>X7R zhDPeY9W>j7_5xN3vB#xdqxCBX%?=@t0(11W^udE>X7_R;>-zrQJ%s4tKf#29S|3ag zs-zySnXm+f2Z)5aiEM*H;ngX8Grg_Sj14IuA?OB6e^Y5@45}cqxIeeY{2V6`9+RBF zY6YfBY2F03O#)|T8K?w)AJ%Gsr6*LG*JLLX89eZh%=Y!e0@9FhBrrF40hkn;1FZi* zOK+|+GqX#HoB$!fjUa9V<{I?JZc_;?ya_A?yc58J23fk_r%1wizb|s{r)KZ&BPEjg zM+!N>F1gWnldSv=&=hf%2hharRx5V zWHtg$184CVOFwhS+z?Vr4m_S{GRJYpscsY(ulbxc6}@OZm@ zlZnW=i9$lh*%poV-=9uSi_B&vP^RE~)uK_mkD4x*sJuWbNx6ia93PQVebh{F76YBz z0qF=7n%en=nc$L=4)e%P>S#hoqmNRw96mFgG_6<@$M-;szcZtq$>dJ%jENNJKA?q1 z&DdU}f$jmCAka7c{5IL_lTzk_Qx48Z!C4h3Wd%@KAh%0-CQu4%&-Uj7?NS~HlyU~> znr{A5M*Y8+!UB)|IX#=+pa8x8K+}5Ax1{V2K+8{>u}&u6Mz8DrWj7Dt76Kg%)W$s> z?#JB*RF*9V3x&^tB8_%@w1iKp(MbfjBfN3xajZ%8_hvKh_8X6+s}Zz8uY%T6l3XwMCu2r zE|g+qRkm5A-C(mwpM4*_Q7TVPyCl0Ot|4U&rXWRV+Im;*7nIz;TY(J_9dwB9{ocso zAxMEfJVTPANSa+|COh3gFa3=af%XYxu&eVZNyUGs&x?R=BdNTG1}z4fhs3L;>};S| zfmqo(H!4XP`xp%?DcxK6)Hdx~YRXRt8KsY-Nm2+s%+8~ooO*P2bs&IT=l2gjRzNJr`iviAk3iT6U`Hr81$1eP(@`3d-YPZ$HiazE~k0SY65E?(iU zZg-&W6ViQv?th7ipU{l}nsW{tolJ}$;4)HH5=zQ%R9LKQWChS_pz8!0u>1NKt^qsi zNUB)n?;qcg!ER)^?lub=wm=X0n$qqX(iumRSXV-5>t$1+E+obIDy2)!$fn(O3}eOT zk=jO*@?P_sKPw;#x#X5sN)MI{FlJjiyK{txWB_<4aH0ww@m&Y4B#0M5NQ9RTF6jwVJE^HKecGp&{pGgW%#Uzt&^X6eey!o2Tw36`@XD~4!kZ0qN+acgyYZmE zKnUo{|ItXLK$Gcfk4n7)6!fSoW=y1Qk&cm`cgQcnedI#(W1VszZR`=h;=K)2>}#)H zK;57F?W^{4(_~B#J(r7T6-WJ}%rkyHUI=jg7yhy90m#{-^E#j>fbJIwjS3Wf6zIY) z{RE_c9*3WxR%p&wegaVe0$qXD0ksK;TH3?ATqi4HJWLR>=$`%8giDdt2eLrTixPoB(Qy6BdqBlw_L@vDv2? zj79=|6zF78m=Qp2zee;wkCgiVv1=<0)sK|lZLD1V+H#U*0ZF=%q=cXRTKgj@>rbH{ zV(0E4MY@JDnN@)W5d@0 zJ#$@~2MY}B!*#9=s1w>wIb+yN+;q!A?5ou;$MU8;4LDN>-*f!eCn3+T2h;0tt$ z)7PFkg4)PBg79$%sLmgee*w^NgF^~Z&upN3sHbxa&{~u4irPFwpc>R_fJYUZ;R5tg zwVjzDXLtmpIc;AjDK|m{wqu=rfzA!)!*fDfI7YfD{pJ3Zi&OY^pX=xJsmkx&YlWjp z_gVpNJ-9Z@!ko?W9-tG$II|oOlFf1*(BkHNc~E!|T-$EPy69#t_^6&hyW17T?&riM+S&A>1Y4!lPn3TT%@EsO#v?s+~PldJsT?TZp6ui4lqn=f7_KlR=OGS^d zqbo(c*Tz4}l)S=R@<6@W*6Akw66q@>eKomeD;2H78_>+hNt!&5(<_qnFOt$4aE9b! zQ65mYKp*fEv}f`EQF2%@1^7{L)`OEKICqe9af2BfDOZRpfZA@$hyW`!OBwJg8%#%J zFVYtl@XrhYX6JoFsEGIneis4Xc+q)9e@@I zv~Lu&*oc=8E;zn#-Kc-OXbyHR23q?a-xL(S)&h0^Mi1QZWmovExRmxZjC>AGbfi1bV^_>H=S-lb4=E!@ifj~H zDn<5bG&?)DkyN{Yg?GvDo>=5Ye#uR)Y&ciJh-46_ro0hV8w>PNHdnAQ^r(PCBdWF} zNwswrkz9Y4J!a`}?fs=JnXcM7kCL+I91G%72vfBsi&dI4`WigU^XxYX5YxM4zj8Zg z0!*oAlSGPO92aeklKrVxXn|oprl@D@ygWf({w_`zSRo{?|{9BB@8+D6>rJ z!DocL)t2c!%X9owLqF6~^-xu}>sMQO8gsIm8Dr=3qdJKb(>#1nq^O1htF0*S zXcx9Vab${T2!Ko;te=m8+TI>F z@%}v7JbXXUV~g;8nNZp9v$K}NTie#%P?{fKU((FjSi0J%szVR>l9cz$mNaGloqbFF zO}7+EJ$veFaZgbz;+H9e0qVlO7ns94?@NQS2^i|GlBS~Zw=44(32+e z!W@34NPc}{c)Dk8Go$D=9?8pi<0`hk?q6z6sn3=6-jO@-YJ2an;_IcB(llR;r8xd3M%C@*Ls@m$bb1GC{u2ZdA z?v~VDhDTBl*r{r~hFe9lkY zg>m`=;iC{fHniaq>6hp2uno+Ac)xd&ek{$p%e-IzlB_nHhqR}M6|Hw(t2&vxbyW{5 z%B=i{ z(Qmq@j{Z}*%;M%Qvh89c|1R53)hr1M&< zSaYL(sWswzM8v}ZwvlRDqfYIl?pJ4S)7`qN2OU}Dzjy!|&IT2$Hec4=8dQIMrK<`K zs=W>HwiK14Dh^1Udn(6xz4AJhs!#l)oVx3ys%@BvG19wEFlTjF(KztbT+csmMQCTL za%pE973=JA;P={QUKUQesbN8nNU8lvH!F4Kcom`AP1LviUG;bPX#Ve#$TCicXDElR zyg`MBCR0iQtvXZx@|%hXTC|<~kKd*G2c1TOy6XfL-6IhhRMRI!`z_FGd3Q`@TwwI} z8Jh@i7jPa5b~i}B9-&*f)b+?R)1{&uL69tRodI}^{;~r$EgbZ z@lc1mAq)*Ub=G`q#JK8TJDXwq!h-eFF4a+GU)FIGRX61b)?d3+Po1z`HA`rg_ixj& zv-EzNYq(+VOKDNDJTy3OOl0TK7muTN>H3;q)pYY;dj7BIPyTovcf0DJ@e?wUk2B?X8^jXCZZ+onrd{gACniISqv$$}-zA`{1g@l4tw9C+q zXH}vT-ygHK)AsGIm+Y1OR9g6;OJ><1m(d`VwT(*iT|T37>>?=DJJf&?JB_NmFr(pP z02eU_P1{JcPlhxzRJ;_!bE2U!sw{sB*EK^KW2YPuSnpdUEyDZIHrl6huyR?$N5$*! z2dg2SB5sa%7Pm4`;s|d|bEE9(v5cn8-DS0SSH-Q|4Pv%Wj)>Q{4NM(^4X_tsh~C6&3# zRvD1a;bzJh`a+`~GgNg9+6kVnenYw19NcHP5>b>Sqj=15s2tq4`Q@SN2c<53te+pQ zrl}2A^smF!*X<8)xZr#k7jBcf*0WT8i1F2Ek0!kET)IAWlWNx{mEkj|Ee)1rT%HFnpw1}e zH{d0-G}1pO%rTAh8{m?uk*iuKj#RIiyY$|X>Z8$xq~ZZaEg&oh>k(hr=Rra@@y7^n zAzk1OD<-TI%6*}99@u!lypR_N`ws+hQ+DliAk!J`rA zudw$H43qfuLpqJtGWYm7Rtrd-lD+@_LLL505SHg$i? zsi=0qu+F)tkKCpPs^{u;+p#Jsn4dB%8eO3Wj#UTL{#$j+aq5leGan%~EP!FU8*-S# z8O@A_7g2LPdz=~>^f{^akf7b;l`H5mNjTO@MQ@%xUQINEH*PkHW__d&Oi;I|f(v3U zo>l`t@|kDH?U?6^(P9B^ah84fdw<`0j@~%it0FghZda|$;4AMKMels5U!A1xQTxy9 zmUpP1m2S+fJ;Rtnz~IBwOT%VjSZ1usc;>wWm&o${X~yJJn+K z#3&gT+UlIU)Wo1!=gBF zPCnpn_#rt3{y z$AmDUL65vrQd$0l`X8Wn3Q9ty7OK-k+2!SQy>HR+gb&bdI0?N; zhCH~tN#DQ1>Z$(6C59DNbT1!Op(d0hml#6z)%nqioW<1h)Np=-Dc|s{qGwc7uQrkT zhZRoLvhQz0i|8i%W{&K*6dbQNfcknm?CuU+aBYT{Ek=BJqgX+uP23;4|rsO z;VI3Jt}hNYUVk|(Z$Qq=h3AcVM44oasb9Yb z6PIM7PL^i@nKn@vbL&FK!(n!wOqdjD*?AU7p4j@;lILae0GB)%wxU{e$B`N3XAe z8jA-avrJYV%4$0>5AR@RSASIC9afX+)8so%dM^ zOtGRCv4Pc4oo#x4n{H-&4W1so%1TK5FK^Oq(od^~v+yevB9O@@iY`_Wi?sHvvLbRL z9*%R0+}$p|?m(FJMvPO|UHU^@dRhzGG|*)(;KE36WBivb|En{=jgUODKnDA^URnD1 zM!8Ng+Kgp!+nyQ9DuUz_Cf5mDyA97-F)HWl ztcJjBQTz)j!gwIg`S3_|xUsQUj!aS3BzekUCFg5pI}pbGan9|e|3a6L`beIf`{JC( zfr+g8Y{YZgWVO??N~~e+F6?G5+%js>%wRlB+FIiXUK%a^Sf z*XFa#n3}knMOa2%|FSjM+@uGrwr*tE>Z#RMd{D^Ej32W)tJoOvhz)e_*ImBZF>Vqn zG3Ty81=@lvEE9|S>`u*Cmqs@d#jN~VU_8NxF0Q`Qq6LuP@ws%SvLjdAwsr)ZU} zMR9S?fOjnMyrFTUv{d}r^>3|&wu9-Z2CrnbX`B5E#6tpPd7lZ{YRu0aHZUXr_1l5zImZWuRP4Zv86exVtVFN zz;Fi2>7+4X-ztSv1OJLbH$%5T*F2%3a#!%$h>^aI-JDWJ>`;a>L7k737Eu3u$OP(- zvbgMKSQG9k?x3nZIo+tgCcnHlCrzaLZ^)z3g~s4(J<_~2z%I}u4uj|65m84Xhpylw z&oH~~J|*F9J^xA7MUCT5;ghOk@CZPo!u6L=s+e44Y`0uT$&Z{*52eg}RrM30lz}Hx zRGe*ARij?zS%&9=t!Aub2N~&0Cb`RYsJ5r@LC2OJXR5YK9I#05czlu4UCd5(Ucbd7%wbuyc9DB)S-N{GQ?=#~)JSFu%1hEu=F-cuDR8DDb2FpYa4DS3dtheM zw}$5%aV@N!{cEu>z7=V+tP?nm5k|v1RJuTAXH?L@HocdX=NDlFy)UyS*0V0h;Ad>C zg%M%2gfcsl&IXuEdRapD1t$R&*PhJvzg#6;M$x`w5P{;ZehsjtURZD~h*MdI5Aop>YtH@E6Rx zXj=yH+SP`qkA(Q7j!1XIkmmZs1*+RvNlR>DjE(t4{7+w6FJSZWUtdB4#l(rZyBC<4 zI@2GQdbIm9jC#9(>0$XQw$0~5j3P0Nl3VoY&=CDdzRGITABTqL!9i2gXA8m_Bv z)UrrykF;Dev^jkg?g*?HSBHH8cMP5$#wdcr_>G85{B5FmW>T; zr-co$LcPxf(G zEW+;P`iG}fdai~UH=PMJJY!sHW|uV{*p1~r`y=H^yc?CS9pT>kG4CZo?8@W|Pm$h` zV5u1I#P7jBp{X+}?EQ&IZ#Vc8LpeT}jtRelUq*iEgDJsAuf<_v+u3MyluXE&X&QZ& z=+~cC@mU=yn{s>k@HlH1#CH!^who=WqKC;+&R=0dc4eZLk z1N=mIue!(Jwh&x+{ht0Ve2Y>zfayn{QL%0J`6(3Q#fYT-L%3sy_CBL-3Hv034ux;A zV6avNYJ57Avce++JKB@Azq9FaVONHyfSNtag4iL3ksZo$YG$bH!cc=|G8KyS?i`|@ zFHk+Zy@+WQNu6Xe^FFw6vQcWH*;q)x39-|dnAJ?DlX0`8KqZAgg87xbGrrtjq+_3D zJ-(3j;bQi#pWdMdKC4o1$pItLa&Hx^=fHZAr=D4B47HeeA*7FV0`dN4K5g_J#)?O4 zsU@C$1$fdOa|s3B9Qb^tS3bZ>_0eZlcoKuuRV}~5d{U4a2t`Df2vc_G;OA6U?i1kg z9fFOu(@jsAWmd5<<$Vm%OnR@CCz5&WJl^N6O7o=fULV4I#a<vFx}r`M}T7XzcfJtqRd0<}BXD_LWXIt4dZI`lIJmq}rFwhll9) zX(Td~Y^;Wv8Y|Y@{U_aKk-919H9Q!dvq<$fXX}?1srCaeu(XWOdtE1#^D*CWPIp+( z;ULckSh{aWm!VkH=4UcrixI=Ugr~r%;c-~;J!Q)`;fKbtV(2~rc9^Fv?+d6Di#=NZ4W!7j5!=2)xSvw+1msi?u0yQX6qHK&YAoD{8Mbz3J0~YJDf2u($ zJzM|qPcMGN@Svp;AStrtJfOrxLP@Vx4fv<3`u zprto2;r;KJZm}W`noX?A*(`njc@@`V9BDF`*jg_8j87V6Y*@1pVtCq-R-A8C-NxV} zR^l~EE73oNs`zxV=wb-QK{jB`B}=eBi`$=t*_N&InsE2~dVHbEQqc~*yimoAKgBxx z1u-UR&y6Xgg6MtWB++%F_;0<@_x`ylFP5TN{{Wsre~>kvYG%-%kuQOb-riD{t@G`? zul>LRVvx~i7~d}Ss2h@|V=gMtcO2zYH5mD};FemGaX#*>ui4F<#G&u;sJM>bpMrnV zXrH1ZwnG!`eFyp%X_xiDq~Vvpue}}>Ghid}k`QAoKdfQz+xi>2&8T4;(FA8BV*4f zomiwIa>uaLE?uUSfprXJx8adSkYXGZ)17!*DUL2>%VLGH`M)3oJi9Le3n+2f=Le_K zm=$c}{0!`yCO8t829>(oeyG*9mG?9V=SUFYKXOLzDpCWJ;$bS#;i~;4h< z+jP0qT`BrbKe1G`4_eKuUa?d~=dR#whtKBg%V2GyQc7)y>-So}pW{WQ2d~J$=hswj-Z@Pv5Xi^)w&Xe_6(y zI1dgBFzkaUeW%|QzC16xtV=M{H{cn-P2jRTCT_B%A?)>u$6`H=*`~*JvN3Af|KZZ` zK8@=sd73WKHSRV!#uHR_ju}(ks9;W2nEIP#s@*^cRwf>%TXZly|6(5qbxJLUC~D>v zb0DKw0xc>%_8AB-=J|-pYbgwdpVsLwsJKZ%!YHOnY-io5p)iV1-$howSXjN_vzr{z zy4sK%{Q1xS?CUuVgxCI`ia5OWSw3+m9=F=-Eib5%ZTn=h(j8=Mz-EkPZP?y7{NQID zvs~R{zNF_bS5fM|Ouc-$ifPZikD?f!y5sIL_67?#VXABwcx4)&nNpFRo0I6U{aByJ ze(-y4|A=j(L&-t1eZ&pox9S!zszKe7@as0vg-p7E3}V*|@ge zmn0v)qzKkFudpmOk@Q}d^mQxL^|`H6Fo8ib_b@#F71H7e8jNGpeWoHy+K%^P*yXrU zQ7Y_l4%OF_i+n@pC8-K5XhP{ub}!FkXaQ!=;Rt0fQ*gpbug>4yzqXUT?x4R}p_+Bv zL}FtngF}>@r{Y~Q&LfmM4IV@s`Go$g!(LJyCrWytpElADn+}$e?{3ls7U~T8%kKomV!~Ah$v2|Cyz`w;^I!-fohP`+r`jm;IO+LM7rD~m9<;#%z7Ye3x z^b$eaf}h*j9-~AKwcuPG$1EXYxZE<1?Z4K>nGDW1O>lk&N6xNI4A3e}vhhv;|Evif z@!X;_0A-eZGsrW288~?wZf7`3+TJD^Bwn5GI|pf71$&TkdC%h|oy&J;NP$b3In#f7 z!5}m9F1AJakbIj1bULuSKwMS;nj3s-Om&RgMqKC_%ntGf`V{72oK+bIvVYQFgQI>u ztAmSGYWlsT2NXwIViI_wB!LdO8d{Ps*{>L#2yfe}?;&sQaCquGzny0$>Ffgh?K~$G z#cXHNN~N6VV-u{KGl z$>96~?{>r0w(yj$R#o@H$3%afi+%B)MKYDO1=BUdM!E~6QB9B}F38tXNn!Fnoms;1 ztq=5^64l{`hrqPW9t#=AmC>e#9W2?Yyhs&BQO!%RRBgpzw%U1RzwH6=QP|XLx9S5W zoUFQ+xUhQPR{b;a+}nuv59L5YnBRBW-{0mc9xiHHxwi@@_#;KTY=0{{6&J!Ylst2R z1v*b-u2m+TL{@d(?1TQm4t)Z96VeH!W+l+*E#;yTcaqN*kvoWTj9aRj0XRWFtm*w;B44p7!G(@Kn_d@PxsE;IhTLTz z@-e{{G8tz1i9PHO#f8t+m)EFHy{7{b6fqi|Y~dK5Nx)=uh{55#!d9y+Wq05a=T%0I z7xe9~sOyz;fPVfJHqwH~7O*9f?E)T*OaKD5MB3=L{=Tj_2+}gp>}Ilhno}c9cSlne zy5+}oy$<&8p0SU>{L$?S>~+~~>+bCN`~Mm>`+@rSTGd95fQIP2M>)=MWPrXhj+WZH zSzme18mN1{%23v|PET57b!(O&tD*XrPAVe!``3)3Rh#w7SE)h-LMvy!7-%_h04nGM zTVc^2GyL&MuwB9UJC3j#w<(EgiqIRuyU0__g4{Ynd(L^gF((t8K2%xq3?t7@9J@y7 ziU)drLN?A_R#{JFWZRQWyYL@7*{hpnAeG1joPxc)%R%daJTv@E^db~l_92|USwH`p zis2)JWv_8W@_YU9YbvR2Q|Yo!WZx+`_0HxtrD~wbUhJ)9szY!u#PH)g`mr)~Lr@F6 zG@a9*2WfM5}?{9PAXK5))?PQKc{@q^@z7HNTvBNDN+L>rjIj|om zf2nN_8(R;vV||X5(7I7sh-!Cf^&rC~?*fy!?E21x;{8w*Y7VG&h|FIe1jXn9P~>#* zU={I>?zoN_!>uRu_;qS%NEYK)meZ+MuTwWC&w$UFw9Yyv&m{=+(x}lCHL9#JJILN> zKFXmWaexw3?jRlJWUWBfz>Yv%dd$Z+urQAKx$k0Ql-R)BC(W{%EIsAIxG*PaPygB& z73Qz*Fq1Pbz-9P9d@$kP3TE zxvZhtG?NU^nR_1+oLs(|vlcb>Ore~s^-qw2nqC%sF5&DjA8>1X8 zTy5eL-E-_ptX9Ayy%&H>C1t5w;4;6HP-Yl4`G)6><1%@NY1x+JxKxx(2ZmFih}=Nr zll<>Fy#G}g>7YD(6xG$ZJD7=IWZ9*^w&g}b6gLy1>BjB2!x2+5_dRC`k=2? zh<6x<{hSrd-Hm463s#V~-m<(6mZA^JVJdDt8IZE~m^gcQLAz;S8@` z`?x*E4mX?c6aAXIbrWE2A;rXeeZu6Tc+_laaHV zjR4|@Ya6wMa$<+ul12449m8YL#4qNu?YbX?1vWyImviDp24a7l_=mtHl=)jDjGgyq zEN6@PpXA$4x;Qbm$Vo3}+xbD_TY%3YtOb8#w!2{y$Hj~i zt@-@9v;i<8gPoXvX%6Y?caceIB8(YR4bNpik&cW$pW-zG_uBY^LtJ)mX0nBW%joF^ zy>uR7K=KvS%+cf%kGn15u&7+l-%-n{EV|c?nuIdlj#5oTxh9}wi-22d`9iP*u`Au407uSvYl2K;ZnguMn4a426Vk)GeR2+J@Mi76s`p z&su}@__wW=I_+I6NIMr>T~$$y9=O;V82)8nDaYlFCZEoESLHZ{Q!)z=4Z1v8#pWi8 zq+q`3B9V-AotgcJO_5RVB_z;vJQisf36|$IXyoyWaiT3$3kMhSqk~l=RK}6Db6BlAP`qOtvI?A>5rQel{Z>QVJ^&X*LxhfOWZt!d- z-UcQ7!mnJdct^RewVl*(BEnrg{Vbqd53c4nS~yAeLyh=3x2c)YS70NcO)YJ`W=EM& zSKp?3weExxN;`>;#Q`m_Jx51x$o%w{PZiqfor|na`sjEr1{E*1l7sR^aqUCKn@;!+ z8P_kiVx8|%P~dXQSG;SdOQkhCz?;t`n;#WD|U$#3*`mgMC*+JyD~- z+@Wq#_t)sJ>r~v*LCT>Lzt!76R2|2Qj@TAW#$~$B-#9QW4H13bw>2*?zteEl6z77o z6ALJ5AMh>;EMC|{>GDAjb{|9a_Bty@AN@$>^)E!Bau^I`Dl7da>>Jke=tmAhM}q8| zy1WW~z{qcSud;c~IUVHM||0C~Z;ym0q`o}%MqzIkB1lgeh8f&fKet!KsQ13sukOXC zEbr}Oc0Q%NFYlBfXMNt4cS?|RJezqhpcG+P&I8Zqc?($GpDq{){+Ga|cZnesT1T43 zDdW>7ailrIdjNp28&3269}O%vJs*-Tg!f1Y!CewUNEnyq2w1*tz4gC8R+H3s7xm8{ z<8$BnwT|1T($%8gdi*|hOHjYBk#5@ESc+ZyRMJfuWG{Gra9=q#A!JvxW?R6Xz6`vt z_VE>&^tG?S0`(RxSw)(3$_eBzrIW{=(B1Z{)R>!y&qkvK){;(>$*6Ka@d4}g-}ZAi z@Gs<>sj@Qe8|rSDaZ&%epSyt*`{)}!p-m1R*V8^xJ)OJgz><#xCq=)!Df-XF-W#4{ z;uz3_XXG7jMGqFrJAx5Cn9uv4_2BV3{VUX6F~#E7a*P>UB@R<;AG+q)yRC%S+wF_L zhL`DIrE#LOCxrn^meJ<1pQ3}-SP8jVyisd!4ad<+S{fV25N~L1G~CPzc6BRbYYoTN z>ZTi>Y?iC14|96jkp2{n<09nSJ$5>2mD3##Pjg9Iz)=`U3;9#tg-(wp=`S0eW|D5% z>A4Ly;qcBLIfwHUmA*nKh05yP)q?9{e3`#BJm>xC905tS^PWid z=_|T>z7yIA@q2&zZ@#=|rq||%dw!C{EcW{RiLyp_&R65g>0@1@$!*fv57MPwYe;QUN^+G^eDIEKreQC*$25UfPPHhb{Z@^S)DBY+tAtE1Jp`ya-MvY-g6&-ed!mYU2qst7_Xrm{- zX2pg|S2}dPQ534H)>=^=B`O1~4E#eNU|&_`Y-2d@ViyV-Xt%wg-+Il;nkt1yW!|14 z;@;J=&MC`~L)d59ZFqVJ=fp)aB0}l;Yh_=`;)*M2rP-=VX1{XO>sw&mS;D** z`rmODB z700^QxB5z{m73BISir6C%e$l{!>q`w6X9)dFtPeC-pAsNR`T67Yy?Vmpjw&e>VRZq z&d1dFD6N2vHclZ6!T5->^%e=NMBdS@#Yj?o$9^UP?CHd$sz@5(1-j|koR9e6Z9Vmh0)W5?cdOMZL-GXmU1)yX)3FFXE#V={pg$D z*p0Z7LvIJc6&#h3-5{GzG8X27zc&CMklk>vz<2TS1mL>^@XYLnsRBpMKQc-?1Wx*aZyFZb$+6BAFbqzKOHIPcnH{H)l88_q3TYoHUvX z=GuB6g1I#it)*hC2&d{fmlYqxf5(cAzCwWle;&A0_y?N#{nr^~(w+*64H2qbs^Yu< zR$}g()K|JXOWGT8yU)y9-460D?=%kcO4A%?`Q;OErg6F_n`UAErf3>QFnhP*xklc( z>YB|0Lk#Z))NdMHT=HkL7SJ*PLrHAdGnpBol82M4*{e-Yiy-3;UnUG&2fDc+nS#*4 z-7tXgI4c=%Na1XoX4vs4ua~%8P#1T@Zj7MhLC1Ws?%Y%J!%kcJ;$|yJk1n$YyQRwQ z0^Rq-S-Nk#)Mm_@xCN2!JKZ5&Hrm@qW>Ue%t?`k@96kUyR=RuVR_8|R$}-E9i=6Bw zbXha2q5i!QvjtnSX>%OeoUmBK1xHjq9MM0=xtIOKza|`lUdr;jU02C7vXC z2=4-alOI1{DbER*c+8K#0bE(g&jUvos_-x3$YM@S)T-$*vZC`XO1YE_dq8>x^Yv=P zl)fc?g2y4Y5TarRGYh9Du4+LSfjxb-2Od{+u?7-t*5|pW!`VI~VdU6`oCnrH4L5xN032LcU_FBfA-kkIu47i};=T zaw{_TJgbWrSX~rjTVn;G?iN!jW#2=!R~V0*Rq5Ef+TC(UN;Emj+mm?N>PO2?b-D2m z3yd7%99x=iR>?kDXUSuko?`OXkoRTa9!8*I!ZR50nzN#Jk?m58zhFwmNN4axnRA2O zTV*w}3?%KmM@8ngUf5qOXABhEgGev%igB-jvJ7>rL4-=`zey8=e+0_%DI*6PY0@uU zH4`#esb8P9tcypMu5dDX*(S}r%UsRd^Cl}t>Jh{VMqgF#0`Y3woXM69QrQAHHb537_<4!!4x*~=zWV!vH* zy!R7A5lbQBVS~lv!AGb@WUK$%7_CKqCDcmV=iE1qI*|iwm?iy9IU2MQigI6}1PWJG z4KaM~rzj^`WS?79P)d+JLn+?;lEM)0N?y{LqEU}@Jk>@okOccMj|JFJlRVHVKZ0`xYn&VvD zpkvXYax+T#wu0TG=1-I!RhYFW#p9yDUM-DYCs+G6^F#S~hshU50v#nOl1{QAt04kB z5kNu1~r%M|%v<%PTv}AXw8(CYm=^ou9VHclCvj1;b9hpcubQSCA?Ac; zybVh%6Ro>xVrly6grY*}tkRlTY}=YdHM=HP^jIvzISx*L_b3^Gzdfe{0wCf(zYB9`WH7Jo{;d|l!TCJ*-Y<2r4w zQyqee0P1CLS$Q4rVS%^6zh^2%+k?gm!&A;^B#ZB>*)DzBs|UVq^=xIQ?RUybN#XQx zeo^CXt8>RJ2+)oh_u1Fl{RB>Qze=D(EwAF9KsUZa z7dk{PS$>08Ke*9~^L>=by~T>{zZ)?|vHHG=B`#RDHD^08(Um{%YX{6uv+j(@*jT+{ zi?vK0Dbwj&t+=kEXo#kp%FO#W!dJt6>^R8sxdV+)WG7rG>$zL4NA<`yDm-@p_Km9k zVUhUe>zNw)t(NFfGPAOUYdYGqZ;6X)kE6LH6vyp5p8xS2;&QhP@KT&uSk8q;F>^0m zSmav;ki0Tgkg?V<Q%Q^LgR=4AaonDmBe7Pz-MLYafKHs+*Y=;E1! zoP4fMXCF`xDpz~`_5n2}wM~1cT%66Z4?6pb?*BO#Y}t?8#41(;l9qgDo$Yn^gX&g( z;dcH(b#J>nIrf%Exl?&$$0tv*pviMizF^qBMn;KSgFRV{~pmxE7d)r zI4O8O5#9%UA3Kb$QnA*357p^DRVpL;RYc%$vJb|?HueLu<9ik!^wCe*QaB%~FMg_G z+Dwf(J6eX!^Pe?ForBz8>vhLN>W1!nXVp2S7kK$xYM1+F_Pr^z`6Br$rW;{aUp$NZH+P<&!z{4!r7j)B;4y%rn7W#4J zF6Mn=CJ8h7G7LYg%sYz{ei}sx?zErA>u_8mTlwIYw8|UvBsg*qz0p#h`)Ok-XU$jo zOX&CJJHIaFzB|q_v-VSWLQb4A?_HS`G%c<(=U#2|Ro)WqMpw99sCZmFrMmZLe99&< zR4@IEgPI+v|Ma4PKC~dvc=BKsD`~f-UvPFOxeqfV#os9`VtC~OU4S*w=w~@5)M#1E zKtjvT-DYqkr#VL+l*i}Y$k8H)w~YK{ICY_HeGWH`+tVFOac5 z&R({#4*`iL$spD{vS|4yob!_YAU*hz>+5Du#Mnlk>f*VbFk{|8&m?9`liG)R?jSzz zY@DY*U17yDUGSLl8JgC(A>B*sb^A~DJHyb&& zy(bu579U3D1X6WonW^t}^_|a>cqR(9xvNh7l1~suzpC&0Qa#yk;j6v_ReKn*MUnn| zpz6#kC{ngnRXf7Ce0g3N`)aYgy^`3WP~#=D90Tb5q+m5LkJ3RC%$ zIVXWU@h_Nja&VjY4aqsxbi?>6LW%#yoRi7T^k>_+w+`aoCQA`xWg0cwN8+7iv z?5lAj;oDU21bkn?Yti=H9raE_ovwq^W9Fy5sVv{nL20 z<;@LmBD2kVOlk0Upl6f^T%=947k=rQQ8>pj{tDn9K;fZ?e-F6Vu2v4}wLlqIweV4x zSy@O6MfkQuEtYLfgYprwK+JnkLGZAv7^=XFf^8$uyDb@-y@4pmlqZiV1FIEmZ{Vpnv-reFz z@06+`&Y58Ia29Bta?qZ}cqU zBk|nfiYe{bxynH)=-qVmex#ia>zTb)avamu18zEx2;!g9q4Rp6{5Q}6)l9K&Qlb6s;T2vL-id`0sR@F6U}Zyv^Hjvhmyd7U*GA9PAv5A2SmLqnqv}B$TsEU0BrAOgQ0RIH4{+sGL{0m6F#(BVG6_KVK zHtXW|6}YqEPgE4!J1dAdp0`5|7bBf{!6SUv#Qb_U&JihyE52Mg$Ok7Zr@COXAN-~c zRs^Y6)bJi#fye@ACJTd@RtXRSGXZ1qeE;8e2EbVXE~VNEV}Z3;FUYf?at(!~s0~HW zYkb|0+Xc$LqI%Zdjld`sN&%y0>!kfThp`QOOl} zI5^~HOrJL#=+7%^h!cLuGN=N2^*<=!ceS=N{G|+vbONJup!na_@Gxix26fm6Kh6-4 zI;>aHVmVxD3sbQJ;Jr7k_+9mRDFju{!=@LEkSFyCp3qoX-OQ$D=K0P8`3gAboGC34 znh_vp{7YD7!a4r~967iyLO_O4xUpm zdidpQYHSPCZU>wWkyTw5E<-6XYEZdaxRxW*9p*D2D^=*#XjSBvT{ro2- zoMj+>-OOI3J@)~vYR&`UjTVlo>(H@3)nGS~`=b4x-?0DaX>{ZnuKW7cS9{f+2--Ti zEXEuDtj!5}4O@bhAqER+|SgCe_yEwR5_SJse(1pUv)d<(VRfc{F4J}t& zDv7%Dxn>VmnR0f(CY0@u?0-YJhuO)cXP|Z&s2QKO>@}+i6EJFZ7c*q+XkL{;K;z!9k^SRDW{6r^YIcssBAST9}xoFb2kW_i#eEW_%km z1XZcNS)VSoTrfTO5cfA0qP=JyWNLh04Hn68_(|ml)CT5aWAcdO51rx&wBUXjXq9 z~?rRNqb~+lsSK zkwKw6C0~5$&3j?D1HQUrItV%Xo_X*KU-v!X7*HJNI6Y(BpQ*Q4KaYWi{x_RXSbF?^ zU-!CJ%~4mVKMSY!iJG@t!aiSjON6nX%~(l$6SVz-k!KmH%4?Yh;Iq!$vbBD@Y`^t?&1-xanHp2TA+k4$JhT5?n!Xa;y_ZY2x zE&K8rbb>!$$rxZ#f}MH!P`@!+Y_z~OdeGI>A{=EwarNY{+wuGb$R5@JJwU&;fav|h z&u&(KcrCy+a`vM>u5@^e)C*ug7v(M5J&aLYT0~*he?;T zJ_?111?LSfzOV2uD94)6KVyJGmhbk0`Px4V109N091ppW-!J1GaRFGOd?~;RsXKUP zE%Q2t7SzzVpx5qSlD$y3h_M0wavc|yXE z<2cPl6<2BQIIT;siOBN~bfi(XJhy|)exbslT~g66GLXiOGp^yTfSre323;IPwu6wy z)@(u#{1F7pZx?u8B9~;XSNCqf-Hbn7BtWhmi|0~&G`RcfJh+PUy;%x6yRz*b>cEC=9`xVRvJ@>S*u{<#YO;2{q|3~k)j1Q zJOL98-d&&f1qwQXxMNjTHTOdp`r+|LDi{NX-haFn5j+>Q(vNbUiWOfN<&+&(dBx%i z!BnsSA*~+|Yr!A!^us9A8NRl)2@(ajkUjf(I0im;AESs|vMsO_WH%D_=jC|L!@+zq zLT+#FW%UeSt8pn?@XW3x*r5fV3Wfo6I)BXy2VcfBtOrrUbUl+-5vh0DRwwHvTPBzA z+#T_y2xp)|FXNft^(MhIu~U_2$Sit3JrU~pbTnH_?D^oiCE`mFCLtgG7Yx7a`SeS# zV{Q8UrH)=ZZdGf<=^0&iuzq3B2+v`NFGbiN88yQ*zw0gY^t3{v)cZIJA>Us?kcSxU zT`&N+;9V{B0_!VQZ;sEB);Wxyj_$gIm+H32wm{~YZ%1YW%oqTNdO?jfs{@|7o81v| zK>{Hc)ape+*%sij{bgyWu#U9hU&8YPTb#vKPA_|=caabZL=N@B>om3gfoCr0A;*CL z94qQNp3g#X(Qz;gm+|feIu6%O(grmfiSEk5gev8hCE;=ri=qpN`LnEYeDT7nVLo5# z8DMJu4nkTwN$cg8k0;J%4}ht^+Yc^y(BX!ck|%Ke*HKDO&6F-q7qpKhz>u)6V2!i#~uWn%u3*nJswd z8w_?9#U3zNh0ZQ_^&bA--5Q3t?f|Yw9fW#3Rsl?5a`KY2Goc9iG9$o9E3ueet+nvn z4)6%^I(|mbE7sh@+L=1Xz*G^wR5cMxv{ZAn1|Y2rVRfV_u_h}U{&EJKHZjPQ!={96 zzsFx`;n@0}sut8q9~hIg-h;T$^?HQR6qgWaLRL>8>hW>sHsZWtdZE_| ztNI$=U62(|$E$c=mxk|Qj@_k(X_~j^SBTINbAzn-M_i5r5b5PKEw~{cO-i>)D?Tvr zv}aX}IPMDWCQknxwPY_DcgpF#G_6hjji@b7Rk|6;F>J7QCd?-yg?0NVWh!|dyYHeLkeWq)HjhY}Ac0$1>AG>VU@K$^uI~taq|Ca5NLwsrDbj>$? zjm6iUx0|oAs|Ulw@KW@!*%Dxdd|htZSKnIqC{zW=lx5&JWW>03Rl0kKVV%iBT5OQ7 zdy5dW)9A5<`4)ySS(B& zia@X*z+H3bYg}~%!Gh$lGi~mw2Bhmvhpqi@K;UMaM96#u_E;DXpMu?}F*8E05lfU4 zQL9{R4f+^n*SBr}-epXG?lOB9Y)3xZHP6TU5Z7EYwbWbrWsr3?-pe!x>r_2772v`l z-B`{@T!Y9(fE0jUsi%R~i8`W#b*vtm3g%ZyTY~4AfL8%}0Y3#<6Y$IhyrPGuf>eZq ztEA7xvlkk3ZDZV?8xh0vgS=?`EEo{#jj3DPIuhx<(6#?&7WM6UoNtSNF4*eV2_|%4 z5gjq7u5A_i^G}fcx%^9b4+CB)!v5$$##V zU1#9nua7yd2%7 z{(ck%FmqxW#=nMl)`oGqogJM;9OrA_=Gp9x4$jen{R;7P2f$`|jKTK^oQs4(?;Oo9 z#1SQN?i}|e@OS|nKWPX9VfeuGF;R=TTATFwNYkefhBr6MbY>lzu8+Vq?6rp#>uS7j zzl{9^&k-2nJm74>`*hH;QC%)t$Z5a&!(EU)ug6ASS)k=hHI|Sv@N8WAz-Zl;&!r?& zZR;oyu~K#=G9py)G~W4CWj#sG^Ry5(^ENd^*nW1^u0(;mP@uglKLL&XIn_o#&DL0U zy|smC`5w{dHwShlXH7WS{*nF*D&FN@=B%(N)PdULS>GXUp#5Dl# zyo<3V&;zQNulclbw6&U}ZYxGB`8f`#++u!yhIr0Kv$fa(VN;?$3$&r3O!TEG8tBr^~9H0%?Q6l-du*>+IErF(omE2FsC8h zkF-lLl|Xt_&KYP3aKPqz`~=Magq%S*n9`#Ku*_YH)v{aVLcI!RKt4~*CB2Uf^DddJH(+1MxSs>J1S^g!pszR+@<5#3@RQ?tj&qob3Pjr~unQGnp|=R( z<#i_h_d@_}U#Qhe*P8+PX){u=$ypboL|!4ec%J~V*S=rPMm&qEI)1R5b&U;wD&l#E z&A~gmE89|ETHmy#O+XsgWCh-r!dFQ?-~@oZ%GU9XpWDedo;sv!VGeVdK%rmruuORj z6*&37Eh@?hoOFA~k*&5vsm)^mWrW`E#vBNWnv24hf_Cr; z`YT-v^iH+C_q65J2jSdx)G$MfSIchG)C{dnKfQ5S_Pv2yhFPy6)EfovZh|cQ5VJP{ z!-9^8G9hiZebYA8u*Tw@&j-A;VZuWJc&DcsT3v6PopNpfZURt3iKRT;T3?|ynOdKy zeTaJq_!bZb3YNBXp8J!h^*rL75yw4n^*DW;skI3?gCjo=uw5!E0uHyBhwN)SJ1XS1 zNb|16Wij2c<3`Wbf$HQD%;%^Ce_Nt{i?n9pZ^3fTZ1P&}WKx!+1M{y%DET}CSng6T z8hk4FA{8vs+SGW8g~21qyvq$~wr_czUx&!NmfW(mMS-6pyA)_5kww7+d?e-r4^UW3 zyLj66{gpn<(!$+$0cVkD3h8LkFuP<8{=|4AawL zfy(h&iq(fhjQn6fQ@UpYRE;cgjyerr3HZyeUN{nYh~YQQr!>jIS`7r(5HfRApgiT9 z>Tb|GbLO`XRr;{d&Us49BnJ3Sc_tMf;h~OvF;> zF8>qAEQNFfU(&U%ja%Y#e15|6EaYPfpGE_?h|Bn-L@&apz?a_-YiEM|$a%3AIPpi0 zN7iRKL^Hm#g`cCEocnfL?p)oUkj8oKLK=r=^b@=JXozNWS=_9@83#=$)^X7^UyMPP z=}ZMeN?i;#*HOS6$Swcrkm}IayTgJ*)6Id{(3xk`c1%zf#1B9f%X-SY_?Uqmh#KuT zop~9obnG|AGH096?QGqXHT1I}U&C>duG<9uMQGF(g8i3eDb~3Nc`+=&EKM)QI{X5_ z1#RRbGN1d=y8H0q^K?jG*n#wU?#Jgyj=`*^fY9I}rUH1NeE$3ao>}PNipQZ+cAViT za29H=hFDD-m<{%RHPTI9A?Yp+-=I52dGQ`0V^ zO8aoDSr?t}4L2oEYn$er$D-dtGuCNAVaM?U>SV>QT5`Q^)vh7w8zh-CHV#-CVT$JW zlX$n%@pW29S1$m&6VLLR9lSpFxUvDg7$7B`GGD2oK0i&ybh#Tn`KjnZAKc?IYI}HMJv-LIpN0 zG)C?+?br{48SFm0aLHih#_vNzdGX;EiEyc3lb?6ON`oa)J1^XUcYAi-kli9=$KNO| z#W+mjaWFbPiv0($E3Zn;a5S`@2cRQ=u2Uob>ByR}aq_?bc&5W*PUt^+zOM-!ur&Q= z&kya(#lBQ7!?zDAr!N&e=S7>&JiW;si2E5-h<8Oc!$(7&FPBVQRO_mLuozy)i+L17 z4Q9{mEK?uVbVOt!L#(OGNMvJP{_=CXrk>%mIZS0JP8GIT0L~~4V=iC zJ{fU+F$&kv3-!?p#wdDW*$qNV2cpFT&>Q`+^rUWDJJ>dgx#N)G&?;T37mAGSUHmNy zus?G_eX8I_qd->X>qCPIb*++i3A>>`hK76P7pzfs?NN(zNQ-rbHYkTjb~xOW4OCmW zcEt8>AKjj0S&tuC_0Phfuc7wqwWi8&N?Wf*Q{!Q(EA3nlA?leY?($CMtJP=|Jff+W zRvXUH&ac-z)rtQ4RdlUH)8nGx$LZe7SjjpPNC+PT71|p{3<5a5dssEdDsi_ZsK>&3R6QhWvsqRbH3Dis=HUy(J!?& zYQbt!k7&2m@vG_2BX}Q)8XVQWP`c5rqneK||CGkbbby(ThqA`69R+-) zbx`M7X~b7rNNtaMKDw!HG7KEq1VXTui+)5!QwL=5$0+;383~-8r$Vzv(RTDp}gB9e@NHe3F_*`B8;9EWI<8DF~ z8Snd-O!?nA!+d)e4H@jYJ$O zn)I4eM;eoXrDAtC-g{|6WIUobG7+YyH>?R{QPEUyGZpqGms6LQ`E< zP@8kwD0S6}qIb?|??GeC+WTt$(xRg`v=0?k+eb71z;Es% zmlnPEhjvJ*0hNkwU6+rKmfkPun4e8^Zfns>7VWsLg_N0L{oPoM3&!rx z*KVtqka|bU(o)LZY3UuU#K9S;WnfJ>8Hr{~P>bLw;aQYju6?bl-S@(DMGI7?y-o}7 zX$zGYs?$~ru3sd_qCP*7Rc>TP29{;y_cKt z*Fqj~?yORn-|r>Y2SDlf|ADfN5`p5JIr2^_f4+BjF>QIEO%7bO$Jp?y`mVHl*4w+7 z8a~wgS|5gl&v)aoSgi}tU~V{ffTBDuvM;#-C_2alJ1x}4ZiX^d|1-JT>vdZ9P;1&; zA2iQ))@{g+4;eUEKjEzG`eM#<2rqoBR43Q?$K8Q5kSZQ(cN8DG^+@|hIZgW?YbVvD z#YJyC(LU7F%B-R`721Z{s`~=^p_aIzo}FKG$XEC&>bm(wzxs)XN-g*Ktd7zpo3vwW z9Slz7RB_wA=nsGKjzW7gq#M|vsf4!={+7dhtIHEJz8*1~Oi!4tWY06WnrXDj+GZY2 z4i|pvx_R_QxCp7!5>uK5of8vaAsMQjwf6;=5u1M7y#%^CO6@0tX?BFjZunp>Zb(qf zF8zSzk1d^lw&__g9z}eDxD3s6IXghS1fJe|9<`_|LTV02+QnB)chO%M@LC1eV0Ttm zv{yUhAB}az>+1V+scEDLuj4qOFu`7K=uAy`U|1Bj-846^%S+JS(Nyo=3dm)qK6(v5olAd4iHGX!xpbqR zXdifM4l6ym_8z*sr(VFH0~HMO#=9DIsV~}yzMN(1TH!?j4aG=hJuPS`Le+Y6X-h+K zR9!d+=Jcq*-8u9|BN5`<1%d#dq%IfJr;S7pb=F+`#6T=kTh5`^8w0PVt)iR3&DM29 zGh&acf=U|;7d3Sb-EJ&Gly|8{6Y)(*>1?i>H|pkvZ&OVLLr~WP&wJ3j;Ic|;QxUIr zD5fq=#Y{RDE!qV?0TIvM1U19YJ{=EWM7jGWLXW8ywOLGxbv+nb%8Qr#IHvQUMG!?53atZ0e zmm0JX_0){n^hyiyddTHj#;(P4gL~HAJ?`2vcX#hpj}EdTbW`+zS2s(=7U{l?WFn4+5pQj=KWBXfWAbWf$; zv7%nTgU@hh73-$|k~gvBR=@7x_lPSorAFy-#oyfO*9mc@&nmM`*j(c9%nVk%XOk{N zA-3s~CsY||qCOAL#|n3uanaMgE!~Y39=->E^>mLzl@3By+GTQY!BtH_vO5LDiP|mf zbpU(uw3l6sdRYe5hWjDC>_StigVi2a194}!(d0PcCv$)HboZncal*&%;5kosUzCzt zwG<>@n?+y53Ge?|4A^hB<*A$X4maT$eu02u>~XB=`fwXL#|uA)gJ(V6pUk2tJp0+J z_!H7MZ8KO4w`ge|jffZF>E>K0St>3bhadEAN%pjU3YzN3Lw~Q+PXCSX%qn@9zx_uZv?NQ`AW!fE-;C(nskydV{E~p$ z;EsrEbe7^eig33QJ;yJjt;#ldN}(AYMW~M#*fUsvAr}5!ZWIhc+|6ZO=%bFJS^JV^ zKm-?s`#D4Sy^IN5$}{8LI=U3jye|a>;sja3nd$|2hT;6>QIJHIP9n5--dMOeNA0V& zmeHXfD383=&g!(y^dtjk2xi!KW|}-X{CDqg#WJ!z-P@71lkm~DKXRw{JBgS^dU3(Q z`caJO7_ZwycX#{V#rf#@@uc@RlTT+6SPOfbCyWUhCo{x!hW9v=DWm2->fKo+QL`>0 z)IFOQ>g6>~x@ldm*KHctMTD4dba|3>NLV|OwTo!$wsR%~CA`MM*!tm2`nih;jQas` zxo{6tics&Smz{Jmn~$SQ5Vr+snAFgt*0;uD)1QmDZA=5;dZtqAt|E$yS7F3&$~Jp- zeG*@*D^2Sv*49E}3r**vcvX~GQqB5m-Rg6zUr}&3F8+k3NNH2Fr>9u11pGK1 z69`{ZB;(i;r1;##QXHg6w(a&VC@D(pEqoN^jiNbyL`y|!SG2RQsI4gdi%#_u-zdsr zdV7Efid!%Zt(X$`B*^4@6H?EhIsD#{-)rgbGx)s-*_(Xy_cVU5$M4?y`y{FuAeJZ{ zY4Jc&&oL71i7ay|DjFyTD~@PHxn+77c?=f$$`#r_ShNg71G8-nEVoRop*Jw=?LSi0 zrWpgwILu$2ifdT%eMxjw*3zpliMEliBPO9w92_0uALH=Z&$Z0txDdpRa(W&&pNd}+ z9?C2_`x3fn3O(RF4i&W?g3VYtMXwGOk%~%7h6;=0G4!(8sd>>yL&ad#Av6t3Ig(EA z65bR%TsQ-(-f%nCktRh0hl~D-!{@2M@~6}DfOYUyQB!fE?_L$1y-p)O0VgLgsNF|c zNX4G?7Bv`wzE+F6kH9c-Ka^OWy3;QsMH2;okVz1;95j&SN?MsAyp@TRmmsDzsnRti z7WgbY46>x9uvx&k~HC~)`=!x{;A^)B36;<^91hGKzat1=eI%94H;hE>wS&Es2x%Cnaoh14?evOWM z^GqH3Xp-pe@ZLn|8RGvtzirK_!(@?&Urpsre$g1cs!eKGbLh+@^mzi+nDUPfDvXTu zreRaicd=xxlB9RSj0pN=idf5&CRGHH`)J`%r3(rpPleOxsUU?tdX;p07ZgUm9ZoIN zL~G?3rKP=~x2I=EX2uj1rHL6z^#TlHkBSH@RInuz&MDM(n#kt`m2Ii(Oe3cY3%N`e zy^NSa7LHlm63K;@LDlu&6Kw{^)zQuEtsz*Rws%-1L|FOi_WAq$aOyilgi-7aVRp=8 z&NaUo$22gO@0)IVa!?C5gGR^bRL{ycUC+zt=4|Bg#|)7m>*8MdC+a^_Y*xzY?o2Tc zOMlWVu%_chD`ttlifh1l+vrA`b~mN#v&9sn+ z2Zq*^j?XiOX`Zn7^2!1Sv?gPpNeNc+!)*-6k0R$|l4KQio-f{4lq0R> zd7b7g#B5OMmxZE%Ye6zTD+D}(t;~POD_#7JV|a}WF$)OGGC(zfwq<~e{X|VN_0??= zG$d1WuCWV!lyk$$x&>vkEPRD_WTIKysKUT2lN7p0M7o@V0QnOj90joOUW-x}iN?wY zl($HDI2}L=PwN@9e~}1P?o-(!(NoEz23ew|?1{u+in}IiI~uIbk(OtPILw+;Sz?Of zMKP~o=2oLIuZgw_OvqjnUE^{wQEVGbHgi!H7cPh0OjQSE4(Q4DK>tbym(8|w*^Wq0 zW5DvWp#g+&__f;lT8~7RPJr$VzF^q~+qOqI^R;R$_kITGy*;>ng$w)NXWY zg=nj88BGgwu{Y-lYP3>7Xv1|HH(HrfB?~{gwNf}bqIXKnW11QCe^O>PB%0n;(0pmr zDlA)lqZ#aKpdHoGq-8XiB7VdwEEd(8j}J}8Ls&AqD38tAv9n#a!{Zm4gMYG>OnA$S z24;%}%0`_~eVA0G?{%3W+uWe0k@pm8V#Ue7q*kbzrdY)($SN_bMI*00NiP^SJGJDQ z{TgMi#xlA^Z?6_TofOay47#vJ_=pa;8Kvy$GK>Op zuqHZDT#oQ?)H&he`ZO#@ELQxfEJrMnHIY*dnzUBjQ{JS+bs|i2u;$a<6yX6w1ktO> zc$`ylKES0>P=VKcQ$E&gVNQ5c>nCV4-!xQxXD{Rr$d^PBJ}o*!BFqtap}(BSnkCwb z9k}Sw&w>5ci*`yATCrZ#R>vpOvl%i(X-%irgYF(JehYl=4^lRO?O+@?U<+`d%Nrm~ zVcRH)v<6YNjbe;)k0KZLeM+(7G2ncK6{CtY(fuWhkhGtw8UK2p3>hI*S+@gnJ7eo5Nz5#(MGbE7Q6$J z+w|5uqF%r>V8m84?HZ{;WQH_pD1UtQKWLq&vUkK~84VN@eu61$J6&VIG$0AU|AI(Fm;e|F7(uOb!J#EB$VG+Vi&CYnxtQ3Qz@vj#pq@4738-gtsm2za zt0X#5%PrzMZfz`EMMKDCWll73s~GB)GSX1Ru^!B8g2Mli*$we_tC+?+L^khCZQm6Z z#g$%t7aPh7TJWx@C#_duOhG%|Mc1QKwuwluhWZCl%kYW5v#atL6R5C9MSY@eqPxxWxooLc_bmbeg zY`bVASHN&1rfC0mFh2Z{M;2gF6q@6jP{#rY2b1Ys0XMWqSxc%c0Q>AlY43}M&V`YQ zZLRo<&lA5)zlpq}=?J0)guK@D=zX!RNrP9hW^FR{blVU<9*#kS^XLwmw{6f^^HmET z*-QnAbm{{!P5GYUb{HMotZ3X0;i@>Z)BDpPJT;seW{DPZ7M?SBL-C=K4~0v$Q^Vl7 z%Y-uvhT`gnB2L_e(`_pJNHi!K`jK$d)NUWssE@_#%88=uA7fUEWk25NN3ZP?os>W6 z_%0E|vm@JF=1+Ha;f#a%wHw<52KZAn%ZVC%Dk6*#ds))3PsLEjOsoP+N&BMDKNbB| zrHownAdh4E3=5;^JrHnzrE`0bM>h4}iv{-NWtzNKtcFCae2!B?m!ViBy^K@CN3`~H zahP+2_m~Ly}v*y ze+{AWUx*e)Ufp8oKVM*SZK2~|hH;DF;eaqe9l+F!l@x-|0tluiM<9G0p`FJ>9dllw^*Y#JHM($2 z^i^Xn(&S^J5k(%w857+8D0(}Xl8#~+-7eDY!yV>mFU8u5^c5nm1YMCvtIZl+(4i9Qg+ zZym=vBdR!IWEVxlPGBtEjCd4p#OKwg(36NiPYX_pA>wqv4fmp-PJ(H-%;eeU8KsMC z*WsUs1=qUx1YM8!54ZC0T&@zYcWNiMqMoNjX9ate^)wWxB|UH!`~0+6>Jn-abe$;KS&RGq&0MtJqiJAEw`dXa(_RWb!tp=El6^$=u^#{Y~jw`>ZxjzWCY zKjTX*?>47KC1QoKem-bQhf72Qqd6Hd)vi8ygY z(1Gtoj55Ed;(MISc|QCg!onP^DLjoJX)qIZvc}GK26ZI<`{ZmfwSEJA*kg_xb^Q?& zA2a1g)X9bZ{!#2#SNu$UHc1Qp@{^e3@J)Yaf7FnqAM=Y6b7BpecTOyG*ouUM=6@#C z`&lfLvzUT1kBDH^;z7Usj3K;7mR}%N_t8@z64f@cD)XfDU$9Y~I0xpZ-;I?1B1+W9 z{pjQKkkq~D@_FnSsnp;C#OxE4asks1N`!{A>H?0m%Xni?@uH6|pzWBW7jWR&Nh^K@ zyGrVZ-7&ymS6`FmA|_=mns-s`M$n{GpTrhF8d53})rWm)Zz&ipczBsHyKB?9GU!*X z_eCDIy#T5oT>{ue#bw}c0d%1ZY#*%hl1`ErK(j81<~52zQvIh&I)LyCz<%0)Nqj2` zHzhgHn#0X6cMU@X0qL3uGkaQ(W8$4KQ(&-+SCVxadAN7g3f7_bucHPSwd-OlFYp^! z;DtRB8_?%BaOQ-%>?V?N=DaBeh{9S);S}@-_Brf*e~4k=Z(+B@!eEZKKEX<1({`s! z7}#j$*n{Ri5HoS^z575!)cL&!z8?ns2Dl8k1SkWP0xkl61yJfS5evoYf`_QM zKW%*|28)VsdF=T*()|%wa3=MBgqgdSQXYx_@Im`AN^C%HKNit0t&l}aKnu>QbUhBr z^`D7NP###H0O=G3{Ds~@mH$EpcWK36BGlzzcU<`az5o~`A5@ds&hzlfZ9eEpZhwkJ zIHBJC8?+s%^HU77A7|*Zu0l*#A9bU^zi>uP*dm)Lm*{u}MDAPN;GzU@6L14?9e~;W z4Dvlhf@i2oIt{KA1J&`}sLvx&3-@zd=}0A-(|YYBxm@vH`$R_9uz)HU5CjMW1W=43 z2XbQ+*;Bbd7Zf=XJ8g`LUbFlNN>5x1F)|dT3s$zhjpSZLN*N? z3*<3?(SRhtC_o}00Wh)?E!-zx;scz!tgVcve0SLaQ{uWiDy35Q8nUl?yCW^MV0{+W zkZe+T)3Qpo;PWMvr(3>?%XteL-6b-~;dm7`xDPFZnXIl2cx?DcFRiF=^hi zJG9uny=6<86^{lnyTnn~TZS?hY(j^GY6Exy#C<-z)3=_lCr(6@wi#|2{;abPl2bPRJM7Y5#@sK%?&gD zwUZ5_Mtd6k`jQreap+IIgJhVaS?}2ty=T?a$LRJt*@kZ#g3woxQk=;r7|7t?!B}IV zbR<{~!MQcSf>w2*))v`Oeb9lY+X!X~79@T}%|kFlK8E8E;M}BbAy}1IHT;I96e=Tp zH+R72rGU2qn*bXD8vt)n?@&3g$wWLd`vd4KA46NnJ~a=(Pt|yISC0l^R^A5U;CG7$ zESK=9bEq<<=m7_gqtKJ_89@{jCcTuu=}9=2`(M-`0_Ed;6$TWwC_hZLRlIfiJ-_N7Ifs41#3_!6E7%0Rn5$qzE}p@dZI)e1bs`2OlP7TQl1+k+VUB6C;-r zPu(Ly1jSAy*7#N09f<|_CB;R_X!%WyDF>RyD0x%qNn`5CaO{PN?Pyg!Ikd(rNMq9p z6a07_^fc|BOi$_|15b*pkHOIK#?_boYlH*uA^x%%47>~v)9yBOygu4^g(~XX^NVPJ z{O-5o{GbXzU*^^{?KYFO0q{PdBMt0$cYx=6NXLUyTi$Yz-w$|aRRL5|4Y9~w+tI>? zkU*wWaYMZxmiBbDAy~pFa%zO;o}{owvLhHoN+UhFtOKoTB*Ow)ayCJxT?gPZ!w|;| zE20OOp%}78a-c(Ppd9pLX(C9?Q>bTSIXK{HTg263vZ~r0Pu+ZH4DD|$vziEy@$%+7 zJ6~I6zX6%afkm0AeBb+g5F%ebpW=yrQq#E%;n36F3=wbihKu0sybfnOK>8 z<-pb&ucpwq(Q*__#+o`54hq2VZ6}CMus%V8t--|4*WrV9Aa`FY# z0{GD2*0O`ooz_NGOtw|sof)>Z86EftTg=z3rMvQg?zWMhbhouUiED_mwwS9>v$m0U zpkd=TF%DT6Vp8LF@&lg$TaE{16+V&gMEBas!9J=jG2=yX<&hVf)5sVZBP9|A<;BQV zKF3-a)ylB-kj`v+<3QJ5W~zHz;cx4*5zTRvLCQ+@9S!Y(W{;&k9pn_=7GtqS zvRcxKxv~Y$p9^AT2bUOR+78ebKpA~uT6O`^%s1oY5cN?@ni(e>I_jHCA2)hGPBvh9 zCr++mO>#W?77|%J3RzBJ9c5qKiO=XLSNTq83GXL>6hJay9AGS930Bq-QHCL*4p}<={WGy9+oocD1fp01!{R%Fr5{T7Vk^ zHUQoNtf&56v02CCQ9l;dc3jHJE{h9&)K$hQ2k35B75BuXhH|+lDhBDZmmG()$c|p1^RUt7 z`u;avFP%<(){I{1jlJx5+S42Dt4@C-a6Qt@sBbXDiJz%^A5;a0ls@3bw>?dUK2%3IOw zYET(~X|RtH2god3Y+M7)79L59v63B|1g3AI90KNe5vKmgY3&A>Gqqrex(-c}e1RK^|B+j8z9D}v} zGDN_-wDV=mDvzdXCqu~eA1Y_7cbZV32Uriw&+dOUft?%RG2juvxC*Hl3hwYVSzf^u zg?ix?xf$-V8Vr+teP#eB0b5UolQAq{426+vW9XL;z!-K7la2W-F$^8kiqeM5Xzz}| zj|ap7VgVfh?VHeK?74JdI7;y(aN(+TBH;DH&9z@%DCF(V-h!RliqI%Nnw7%4}p z9U9X+D!OB00#0J+_yi>R(1Qf|AHF$C#M@7FH&I3hTyBKJC!h>a3b+XP6>tG?9;P5N z9Y^irQF4jTBE)djf~!C8my*acUNkaEj^Wcz5=Ik4G8%htTN*Q3c5td^qc{kQLg}H5 zrsC093*Phufp0Jpg8+em0DwQBHo%X%kC8oV-)U%+28YB|JEE{hWH@~^M)rqhM;ZH% z<@p-_6@Ztg`bk52=P`~~?~Ro;oxVUu`vLo?cr2#Ls)jV;b?HZq$I0m;3-Q!%lk}%A z$H_sSeei0(tH1#49>vMojRcHllVz-5vnt7uiFS9xJIf`{8qkJh=@&V!f#H6X}7PVGjc?Ptm1ECNr&%8a4?6LIkR<2<=dlD{hXbZe5HM_DBKOqSbl zF}Q!SvBgKy<;h?y(3MQlBl7A|&na>ec9lI-Fm)Tyg(nhdS&RNol11Z-YaW^YN4{t9(XlXH}(ls6T~R~Y%*dImD@bXAZGd!$8E z=v27?A}Wx(DjVqfRLnx01*gg0P#zUclUAiBb)Sxjww_i@#}dFEGhOy|9E_E^J3f*c z&5*ub_5FMf{Ch!b>kjcoPgK3F&7dvi%OXAfjFX;jHr@*(<07r!1D{1Aec6`<(W)7k zY`Dgofn5ng#S9r57L1k5(pDVgb-T0`y6JLi$k~hm6`xen`6yG)FVuOa4042!lx>1F z*o2v=Qa9Akj4GAcs-!2HysZ{giOml6bX1Acxk`A?Hls?#wkqlGsM5)qvURQhRS9kF zG^gx9Qzc2){}s6=wM?C2A3iw(xwVkrzw&5m4QrjfHDs6^OF zY*LJ#IH>`hoGrUMRktOUk}^g%YUGR8S^yt_H-O!)j>IK^*(GBS8%kX7bj95=C;2fy zJ9W82^XACou!8737n@){?U;)$y+p_7f+=9zpC?;7y%UL37+@2{&4UP$O4fPUZy?#t zla0)yk&pyny_i3BnlI}}XS^PyxTB(W_3gNa=L?o)eE()?K{Q|X7K?T=Pf)M}Rm{g$ z{xp`oK>EbSj4BUsb3P^OEIk!UP1mvlMY zXD(j#&A}#~MS?d+VoOU;mziL}N(K&6*XdRUPO>pp)|YmtxKE@9?aLMQl`%y_GvzE5 zwozxYuz(<|y(Zhrff1&h0YxdVNlmH!B;3fpw91-@V)mb}(UQfIFJa0SW8b(2ysP2H zzR`Ua7Al0O*RdDwq+72;*)^4xEs^uo3E^~lh#a7_r=d&HjnI`WMaR34WtmL!xff;> z6^ld4^GUE%p+@h?K-#lRM*EzzVFbHZ!Fb!z*a+*q0aX+Pfj6*13n>2$xz#7rhJVo3 zH~P#mMAtydTrQIWrU7FrAPtZTm;#s#m;^xQy@_Ssj56PZOd1$QAI^pBHl&Ur$l@rw z0`tQIh&2HaIA~PYQFAG%U8*v z4)yAw(Y`cdoA7aiW_bg&;t(LS&3SHAJsa!Xg%Y!|YW288cUqDyck-mM%1@YyT2V$# za$1cZo=4kO+Yv1_>EvqJ+VlNTxNigG1GWPmzK_Yr_a8-#*U0S(u5-`j$a;?Y1Wojz zYHQ`IeEqT3o==_^ty(Luvq9B5)Zc?Puamu1)BCy(M13WMe!T|WYV2E5suo}BOR~E- zTYw_?DnPffKx_SU^~rUfbZylg_)E_U(MCFkpw*O_iwl}${chNwHm-*jWf9$4hgET& zEN{s$bD%z73rvQ7e+XIMf(*ElhHj8-bg^oK+@TDnn2p#DEHDaDHsNSAXCp+7h8DVg zM+6#KmszNCBiQtI>b*(Mioj>8CDT?*YmSYh;k|sCx5slPi}%85i!5|=6HY-t2lH(@ zs_8+oZ%a?d6x21+5<$b>mT_WGzOAM5BOOh@bT5Bfy4cFCOWHfKvl^LiG&u`MrF1s~ zUz~w2@IoAq+(ylqyJx0SRdB2n)!YX_Nv{PK4fgl$m98SP_a;wwT zAY*0b(%q*bi#0V{>yn1^AjWKSWPKr~4uJ`#OH}pot&Jc0S{61hJmadpSFDaSc{rbXNwDQy~T(N0dU$ zsOmOU+l<2DV<%?RkF~3mXw0Zuml1afPzERkTm<|IFif>Rmc97i;bUkRe)rQ4)#~or zhAGTQAlZ)O;4v^Z2&T55KvQ#mtG*!B7PZ+tPv;UPW^jp%pUBSQ(&E!OwXhwC*sT_!N@wAFwxqDizn6Md)S>co9x^(93=%yVf_MY?kvBa4k!} zaNYLv@atu%hrBWd3_#VJAkW5tMz&IFk^3GQ%%-h-WE5-d!f50kUA3LRM_yzV^j>IP zFqC_7491Pk=dy$IQeS-R19;t+ZXd)N-T67vAja>5blaO2?1P>F4nT|*i*9+LG&A%I z5RLX>4&h9_U#3eHmdA`754=d~d4to%!4nR9Jh?Ijk??N+k5L+3fgoB`k z6oi7kTGWDe1kel+4QL8zLbndeO645gDn=$a4jw|5kjFVdmWO3Mk80?f3HkokW^&mE z7d*e2=TF}ph5~6YtZh&Tv{YZp2&dsbP&EKvq3&N|TLGs#0@mV5iAUsCukXAu*`Q=$ z{b?)6y?lUrhkTEM9EzHw7@kFxe^f@qU_c6=4+t(NGz?HuxtDbdK1n|V#{q@N=p(@M z%LV*iFUaI^6Bqr1QpoiyIiYzABq!j%yLQ**c5UX5@8I8^os3V8o>dQg%pTW0O~&sj z2h)zPWV%BkP&#*_rpItnY6n!~9(Lj}>FKl%Nx7=u<9u3nOm1{uf>%diICP?h$6?R- z2lYQLhk1=bwDC#P3yzV)kN$3Bhzge0Zj{%sA z*0CNr22J5_p}5@OjAP3inHzq+uq)ZOX>HoWxQjL*Uj#puCNF` zC7ZNg`kL8&scPYVu4`V&?$i$uiY6hbvW@X?BlCA^)BpH{y%lIVD?Y zTI8FTiheAStySd_)%Z^O)O6TXsegd$hp$}|77c%_ZYpZ?o!p|x<{nr~RQA0br)^5U zL_N>SeAj^+89DaJIDDGamXsf4ltXrDc#l0_QC zDX3VniwXWjJqxZQEh8TPr9(Iqe_ez-t=;IoY$xB&Pg=lbeG1aNedUuD6`PM`Al_73 z*IEu-!ZVkYM;CsOwVRAZ6x4^i_!7VbUtjFlfj|81hxob>eU^d^Gdh343QyTj_EO}J zU;&+J!H=?@cR>xqjX8dVp5oxeW}N)*I-b>xW=FYO!{o`; zsXVNlc^1t!su=^C>BYvj#A?fa79IPfLuO=aq+9L9>Jufav|pJAyu<*PKY%{P-^zhK zS$4s-B;w<7roVx5kybzLn&W0aduDb2l^OSTl{8pkNlUsDIH~Gl?&FM*Ic)H$;uwyL z+g%)!bfh?@&T->Cwo~b3#)<1xHo0S`%ah|f{WiJI6(f!5!Y&lYbU9NTGp?*SCf>hJ zAI^jEll;T_2>Ke1h{KGoD}1)2Vq zK3@iy`?w+QsQ-WZL=+Uq+)$hgdVt2gqSJS-6}`U;_RsB8yr;OR@@H8`@z2I5Vg+U; z?&G@6rS8A%`Q&TfnL3=8zA>@mF>JVA?Pd&3+AZ*!=fj409<~6aWxz8CPlW+^W@Z+P zFAw_C+Ve0S$aY_pLMPA5`ubDR-{<97rO7~?E_hlf)k{;S;iFF#Qz3qOl(9nzCu;(} z`h4FVTMxqC2tOd@qTKkLEYJL$tUSJ4-`EP1UUSyTX4}a85Y8z&by1#EYS-6!LjHY6 zcQpjQG_YTCCCzm*x7R5uE|cG=^}wmi@l`HtFioByO)EQe@kP_Vci^UFC#|>Wb?o70 zH0=*p*wfJBYx0>vley%LV`|Z|a=A){5xnm` zIWPSqXK+reS@uJe+a#;z0E8HQy^G?KjjCU&s4S2FteH{u;Cgyw|D6z)q7l z_IY#vtBaCtG|mW93>u@4lrhhV{;Q9M*@!Ao(cu(#U-s1~t*mIxeR)<@_70jH&Rc~Wf4s7+PEV-0k|L}#M{(Ju%~O!M*OCn{Hr z&k#7{`Y-4TP4!i4nC-m`g;>tR!y9lf!8wQtxWAf>pKLy_AiN9f2o@(JGv61PWt;GO zNG2|`6;$&@CCW|p+gn+D;EeRvJayJ_xk45+h&_}(Pm68%JWW9wn-RA|ML6Aw3*ed! zr{ZU_VR|7iG>hBC)Et9{WXEHnT}DfIp=DsPybD4OyCB>KfUT)clj-t*)4=bgPSYCU z`YJSK&i|(A`J$jVY((`DF0Voq=k#x)7c>iOG(iX#7VW8&)s)D*%3pP^p)Uz=0KG9b z4$v5Q%mKc2#8G4SEr(B(SBuN5po_)^danR|1?>SEqYp#47l3JL^w*s#H2*5%0~<{r zggc5VR0j_=GVX$b4<7xG{^h&_Z1@Kpa5F`%r9%P-H-3**16Zd9pLBfUy4HuMjH z?uuvj^{U`kF!rj&o%<)P&NEAD>a-Q0;UYI9dqGb*aW0g@lk>#jCgh@~mpR9LbNs^0?UeahA5&!gK48UGc=9mA2=7T7m zW{8ajfBVeA=aOcJS=!IvmKL2fJH$DrCoX>z!W$ySwj{~699r6F&Vi;Co;O2s zwii~sunJwR2xAgvd+23F+UVGQeUwQ5C!w<7zX;LwAv$4g8zDQ$_y1Rcar6F3r_bE` z)pa^o8{J2sbHlT}l6iALlMc?MkF&j&CP$r!yPUgz3y72|M9SxXYPlEkc}B zo)?rj^Z%xC=PqZWr3kMA*p}Bqt!p|Y`NU3gaQ_}r$>1E24GQhD!6!8xX2{ZH{FaUy zcsN9xgU30zXEABNnhu^c-@_qTZJ^OR9{;bnvyY9U2*dc^wVhnKVs2aLm5hF^&wdoQZk zhb43t@6gUQV$6)(n1Ki8d~{TD zm4AGAhY>ofOo!uV7BKEvREzr^eC}N?i_IGppCF>KL)a9T_;ZEqnUzlVNZFyFlkF@2 z(Q)|*@d($guwsBT@!orJyfDZnvdkLi=Jtom^kyH?Zx_phR|(m>*TR=$#8z*DJ&9!! zlLnV8SN4?6%(kb?SKL#6WM_7sU4FJPO{{eoWm{pk*1OMqi{XKrED*6%P2d=j;cVIQ@Q&+T12ekErrQVWj zA~1Kc^U9H@%RHOIV1w{#aFp;Gu(jT_E3*sKK}ZsZ25^e74))e%{F}iV;mu&3@D{K^ zxD6a7yaQ~RMCgK$B-{f|5jMcy2QvXY4b})h57r6q0vm*121g0M1~x4s?1PXbd=Q)> z90hwH$^>v2tPwr})(O7@HV6-aqlDjWnKlC!5st$lN%(zmim(Ot)@K6v5UdeC4b};N z3^oXV3XT%~yv4*mAi@`LND}@EoFbeAdmAzVd=1tJUjXZbFM$ohSHMxi*Gvc&5xxT_ z2~U7igi~Pe!^Y2S*8;QxGg7`~^-Do&l#Mx8i0*#e2PgSFr=I z84q{@8eu#f3*cdxI7RJz%$goB2jxK2#-NfV=p>IDy2Qe2m7#najCKkt_w@s&&MGzOzf7J zsFt=7KloCHKgcWKA%hjEiIF?A&+~1D*zun@m8_Dsln)wgZC3v(L}{@4th^dTx05aZ E7uT`90{{R3 diff --git a/android/lib/armeabi-v7a/librnpushy.so b/android/lib/armeabi-v7a/librnpushy.so index e7e4a628c2b95d5d4a18f7e02b296ee9f1ff1aa6..ae8dcfbfc574b90d60f8d37d2514a6f4487c3b62 100755 GIT binary patch delta 125549 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 delta 78291 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 Ee*xGX^8f$< diff --git a/android/lib/x86/librnpushy.so b/android/lib/x86/librnpushy.so index e6b28b842986bbbb89cd0832b9afdacc0ad8a331..a364533e8ad454a9ad4c19096639b72b7e20817d 100755 GIT binary patch literal 400712 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 z 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⁢7STnfHsYgK#G? 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


5U)`_sc8#2A?I8>V?6Sq)V^J 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 delta 120045 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 a?JYRux7}&IJ8IBznYGiJlUg3e;eP@4afIFg diff --git a/android/lib/x86_64/librnpushy.so b/android/lib/x86_64/librnpushy.so index 5a7e2da0bb10d911b25714c7b8d9f862994c3955..ec7a5148e7b960280c2ed3a316afa8763ef2ac4d 100755 GIT binary patch literal 420928 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≪jrmoVD+uH9y+{FClC9`5D2B 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 delta 124359 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 Date: Mon, 12 Oct 2026 21:04:28 +0800 Subject: [PATCH 102/104] fix: address self-review findings on the shared-core round - Android: skip the per-chunk free-space plan (a JNI call) when the download length is known; nothing is ever probed then. - iOS: an undefined round decision fails the round as invalid_response, as on Android, instead of committing as 'none'. - archive_limits: CheckExtractedDirectory walks directories without counting them, matching iOS/Android which count only declared zip entries. Reserved directory names are still rejected. Co-Authored-By: Claude Opus 5.5 --- android/lib/arm64-v8a/librnpushy.so | Bin 418608 -> 418640 bytes android/lib/armeabi-v7a/librnpushy.so | Bin 261696 -> 261696 bytes android/lib/x86/librnpushy.so | Bin 400712 -> 400728 bytes android/lib/x86_64/librnpushy.so | Bin 420928 -> 420944 bytes .../modules/update/DownloadTask.java | 10 +++++++--- cpp/patch_core/archive_limits.cpp | 17 +++++++++++------ cpp/patch_core/tests/patch_core_test.cpp | 6 ++++-- ios/RCTPushy/RCTPushyOrchestrator.mm | 4 ++++ 8 files changed, 26 insertions(+), 11 deletions(-) diff --git a/android/lib/arm64-v8a/librnpushy.so b/android/lib/arm64-v8a/librnpushy.so index 5156a7b597a60f0c9621432b1f8f79e2a19cbff7..ef78569b6d73a87632fe78f0ef56d7bda012a9fa 100755 GIT binary patch delta 48490 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 T9nOtxdcl18l6K9H>hk{pFVAf) diff --git a/android/lib/armeabi-v7a/librnpushy.so b/android/lib/armeabi-v7a/librnpushy.so index ae8dcfbfc574b90d60f8d37d2514a6f4487c3b62..68c4cac56ef41f18be0ac75ae91f57ca23b55654 100755 GIT binary patch delta 47 zcmX@`hyTDI{tbT^MFRFdi{{yyIwg)d_}|sAR*BRyugqovrgi})Mj&R|F2KZW@CN_{ CMG`6i delta 47 zcmX@`hyTDI{tbT^MeMd5N_g}x_{qxO29lMxU*1@KP%olcfT>-8i4lmIwhJ&Z8~gzP DR0S0& diff --git a/android/lib/x86/librnpushy.so b/android/lib/x86/librnpushy.so index a364533e8ad454a9ad4c19096639b72b7e20817d..0bc213128e13f5f2dfdbcb96583f507e489eddc3 100755 GIT binary patch delta 10803 zcmaJ{33wD$y8UnULed84EJH%J&c+%HVHE)-0yBm!35e{3M1mv`NJGLRE*%5~g&`q@ z{0OLFnL&{!5q!fUBVkK@KH>iUWpFBTDeb5NQq71zA_d<$vIrV+_JEv~lbC+}f zd#k#-P8^Q=>~LIdifUS)I_w_r&o+`|BOFd51@PrL?Bqpiibr-QBD>*w<8SQL*biy_ zw%G8DXgg)3BdtFc+s#aAg;HuQ_}{9M@)Jb9q$~AH%!rNKZ|oZN_ueZXkL$nHb$I;C zNj28}PA$WxO856`)2EH{?tiA;DW%TplbtSgRe!|k((9c$s)znL&U5r@I8TX;Q$>2O zNS8hdtXeMvtBuT7d-OwK&5^lKr$)K-+^DW9LN5cWjVe&N`sY!uy68!2wLUi5r7w-n zRgL-~ux7BUdTI;=F}W&Re+H}}CO5LQot?rki1Gls_g{;-qEv~lV)Iq4J|fnoyJK_J z3H^1j&th{miqzxdlJ%UpTs1&n9_P|u26OBG0CRTCRWIvfJEC2%X8jOYGgzFS8jp74 zb5()<3|Ir$YW)IOT!Qp(a)L{*2D_>s1N#mvTkn?W(hCxERjIxn>>yaZehn-oDOY`_ z7bm&&)nHEjzreg;xq7c;Se2X$tDXmI1UslJmrL*J%2i&y46GKctNsz#Rj?xc?i80^ zl#;79>D$0gfHmpisV+S`HCKh{3&1vk<>;TMy6Vz$)l&VwG?!iq)}X%v)&%yQ9+~da z2c+k!6#Wsfdaz0Q*I;28x#C|2`~!o3V4r2czc@X<6a4Gs^RE;91A~8H&dxsnI>SFO z_y^Vu2LHOizb-!iz#72dA6Q&hpMPE99~k@t`wk5Lb%TH1eExwQ1cQHIDcybkb%%f5 zi&UciHCR|?w)mF`|1y34fi;4`zaH?fhtEH-S}^zrb`=c%-3kBh^!W#N0u26T!M`k@ ze_)%y;9r)juBXqxp70M0{(&`t!M|+ym+kWptR4*hfra(*`PU2nfx$np&w9Z>mmc36 z{`L0x*BkzU!9Or(AD@4H;2#+L18W9@e|_O!U!Q+q4PfvOEUur=zkcu!4E}+A2L}K8 z!@vGM|G*A{!9TE+yL|rL1^>Vhe_&oP_%{Il4OrT7Q<{H8Z4F?30C(#D81P|y40aS* zZ*c)k3}8yS{_)*eh@xwEqmh9{sU;cyf~5g051>1Mjrz8M6`t$Zt)x2Y??-C@!vdHX zz?1-H1TY)5*as3-1KVH|TPdg!troNptrf%uS1X7ubb}za%1wf8h_(ubk%#JqgcI!) zbP&BPcn8rQ!3d%T!L~$i2(}|SDA=Cph#XOLST=&O_8Jq$AN;LG0<@2__KzD42*nUoeU2l3+5?RY4cgb-@%k*9n+PWED&! z3KL8xiV)0@djt^oi5S7om^i^MM2Ui)u0$z9x)Egvb|>m8m`T(_um@4L;GIN$1+$0- z2=*izESOD{BiM^*xL|LhT){p>qXqlo_9EDiXuM#5qDg{x5lsVn+6*8nl8n2FN(2WH zl?o2VmM(Y?kz4RyqAI~5L`wy8h#nKX4>2G(6fqz;3^5=$95EpHTf~6i?+^om_ag=b za}fib?DtZe5k&QpF%mH#I0`W!I2th^I0i8wn1>h;%ts6e79a-pzjDt6<$0V=AOcUQ z#Ah9JRwb-(&@)=}zryKLE#i!W&S}ms9rWLpq*omDy(M9zgKDfXbq?BLO*!VEx2*}U zJBV#b8yxh8Eqc3yR@)-j&X=i`p_ujx~93!GedKX%A%g+tWo{a+VORM<4`E?T2q z|5Sgks1A3g$;J69ZvQLAD;)c?%isM{y~>wIsA0;(14pW!s)FYTyvmyew(v=S^~}W^ zH~H9aR18OsQgP~j?mkNOQR!SbN~Npz{NyN=sm}9jqfpIJ{z=YuaPnyA-{w4lqx^)x zDSl1h@BEcNv)vff!hBY~nY-XxXAOY?B$I6pB-CG*Za6{&XdpX724f0d_(X)}J}GX*NOT{ZTF z33CGt4Url;aA`h@cj0CEY7{DcFW*=9Wq@|-0=p)uBpzO%BDFU!aLsHqKEFU^YHI=~ z`*5PB@>c~A4dQlVRTnM&0-wo4xxBHsbdi^h#ifh9Ti_IbA#i{l<52rvFQ1>NQhC}q zbiIb>AWyx|n*|Q=Nr43Z8NmIKtS! z7jG8W&nE?H_-6qRcb|x{%>0Slk5QetdLqU$h}R(xy?;wCSuV;jv(UffNyt4e^VL)B zm8p_iUK6O`uLLG?yUBpN_}<9~rZ)Vfoc+N20NVQ(_~sbQ*0srq!Mz+c1($a4P=OV^ zP+%@^7nsN&3-n=aDj=473-sZK{AYDj)l|gHIXOGd$Iflw%QP{KLWJru>M#P7DT^%%tgU;jQKnlPDi+%;_ij2Hy=cnw&T3t zlON^e(eu*%B#tXYuuYXxL$oe|0=0#x;3mIV2-mmGfa}eLsw1ZE=R&NIEu1w2X3XV< zGtk#>c)P%H{#alyYej&g+*{y0KO~UJbpUI$7pr!x6`lONNX2Ov&-0dXSO5jM4^_=l zv_@9iDNZYf;sB5H!;=DMcppGJex5xgDrw_G=-V!C_YkhE;d=qxYbv@}{SX4Kt4}-7~9FQQgqHb8%|#+#n```fa-H*!}DKw z+-yYV*K={bezxkRUgD2uLwA6+Qot$h4dB!=mF{T7Ew|_&iux-~mKFbpQk4i>{~`r0 zJd9fZJXW=DUw|8O{CP#q;$)l4etWBt>GUevjOPvO4?M0+B+{h~Wr(6;VU1;2e;@L> zGPTC~=s$1V)Gu62;7xPT#Vx#h4!UxMznG&=VV&)pi?dJoe*}(lR5|L3EJqFF%T+g6 z@zQI+usKRX!%I&W$Pt@m~by@=pR2Ic))8 z2#;HU3SIn!z)ju_(9T@uip6l!y8wP2<*bD&o)Z^>za%^p+tHMTYAz=0y@jd*-u16k zTM#xMSE53lwJI#A3hrNpvwVJ7peJt>h~@VLuJC1nQ`~tG-~dk$V19ZLZVgy-i_pSe zzOhK%j}ZRdVyyR>Jby7VNAc#xD0!SuF2=RJtD=@DBn`=eWIYhI#P-(}9{C9$Jl-OW zn!0Or73rOIL~WK69bQKC)+VB)mxy{VCEEBRoI?JP)kHnrM2AW(RQf2<3|$ST%T5pj*PU>-%w#D^ckZqG#urt(5WGL7W!kLffWT7 zT8_?1Jy*wCsP7VV2>Ms=vrwN!DE~Mb{&6^N^f)pi?39vW#iP6xPuucR0?(X=JTLOD z-uO8eR$P>*l(R%`-m90cl-~kkn z%H-H-a=j#QUCJ(!$^vDZfUO%_h3nD@DZ3fG04Ut^CQeQVgx>+1I{O_ECnQl9xN)bI zoIR}6*v&th;SidYYnC$It(1)k(J}Pc z*I<31eCr1H8Fca{$i3c5%}51xa0bb#BQB6`={)GUH28HuSe&P&c@dPw4`^vSu^Nht zwZzKDfF)!-hlnknuXXTYRfKKuDx5EI(9y?%%Ob3DK6Bw)vMjMq@l z%nGu8BKeEM><2a@t|n+{>z*g_loZ=-by&|O5I%v1N@kJux2UM3+-|MGvMZ^yhCNmV ztg>oXRsa`kX^p60R=$?Demk&2i))WPVb%gIrWDI-_AD*xAR3%qr9~Y=e%Vrs_6r77 zjsY#&3dHptYk`$oyQ_=v!?zp-?bSr{MrdhUQQ^GNT3YzCz%ngDtpt{94(ZUmc}jcj z_eArHZ8j&$&!45mbVLL5XOmUR&95Ts^BB?mB{u8wdZLPPTAB;xDk`+(b#lH?i?=Ms zmXG>(&jXfg5zp77XYP5{upJP(-Ab#(2;7yZ2m*JN)v^m6TCgZQle<`9N#k#BWjBVD#c0EQ(37w`(h)i#8j<w zHrMSj=&DR~+22?_A?sd7_>Doh$4vOGd4o~=TAVp+qO1O)wGQiF8ob9KOpFShij@Cr zE%fWk4Z86rI_Eia-KL-p@keV-gth0*h3^N2(7j`#^IkO9-8AT~8+5+;_ie#f%!Qfz zTP*F1H~GPN7ZhE+)n+<{(+#?E6I~F<>X0(~Ey}7Q$pj+uN5jH293pW~sM@)1L$>zF~2HmAr zI*-gDrP`<(>DFB-Fcsnd*)qZ&_)Dkt+&4qxP7BoyGw4c8bWPB>(|tPs`p*tDxY{7x zY9@rJDI>JOcMZBvO>_;NY?R$ORA>1U*5693e>oyLgpRNXqSg;4zWw4)ExO?*y6W!c zx&;Q^W1{oG{-CYCWteF$tTzbXFlo@4Wv**7=zcWOdAz?O)DEUyKd8YC^+|J2H^%1Ve{R$32*5(8FU|*=$ZzZ>pt-5u>R$+#)Xcsaj?1YUxo$; zo9Nv4n(KxfY;m+Is0+@&%ze&%=E6#Ya8FPeh#S#m4>i~AGU(2l=u(E+$YU1WUmApo z9YdW-`JH)#?GCk!aJ-2wdqjxN-#7OUnhX0GggcGGfX@3XI=4Z0 z+C*1B!QY-IXX5Q6+-VS|B!rHzc9Oq*&Xn7AF>m@uD7)in)DZ)%9D>0OF8Ivgoa)!6 zOt-b{|5A9RNnt7sG}x-6;{y$f?udylXNF(b`lhzRpu3cS_20U8jYa0dKO2NS5<_(j z#eQAum^o+Am9*B${8v9@E_5Af37V~~h0y(4kWMn_KK(a3amKg)gVDXpAdE=DON0{K zGH2o00LhC~@UV>%hCw$Ry4H?5XPZ0v?GgO`3*LSoGZ8iigsp=#_RlTOykVmI zwf&;Np!?CM!}EWzx3Uc7X~t|ve8wQmNDdvLyVP8F+@PCgqRa7=nG3HPgc~4iMmg!I zpmfv=Y?>1~6@A}oapnWF2It@2o+mi>Wd@2(ufgB~gr-DHEVZ))fW>z^~%)wSsG{>MaEyU|?uUW<@+LMJg3j2qt+Zn9A= zQtO!cr$KibI+;TMbYT8P*svLMxiwUncC5vj7<_*cGKVjicXWzDHyXN>7lRG@+jHZ* zU>n+#_kXhC1eeou2H{E*VbOLQ73~b|=(_>kOLatxH~G`sFXG+8R@{Q0qg#ixC8_aU z>>o(QMeq50M#s@3q%56l)@Y-!4{wE-v zeAfu?`!_7ZhYm{=6(F5^L8BiyGTL6Ds(5*{eMqEyrIP?b7*ae^41W}D&-7%6VL5cN zlDq|XBl#Qw${Xzrq&@hPK@NGVEpNAvAPq(;KoXt29j`@_uXf}Mp4vh9S$~JWo-pLg zOX$-`@)=_AxcpBW*)Q9u!)4n6{0i@-%D=@|j?UL?GzCSj<8KjZJ($|oSm=JFk?So~ zgS1_@&}pQ+=PaZnjR$`esX7hbrdw&pkYH!S>mygl9K`vQ_b7dq)IzEokA04kOia5o>lgxTV;LYpy;W`0Dd{Fme9 zZ9?Vqs2IMGV2|bI1bc){j8ByFber#6OzxOyPq#;8*eN20hbG$7Y*pY@T!!rMMtoNy npK#{zbBXAf?@LZTge$genA?@VPPBJXT{%1nu!nmk*<=47UK7Kr delta 10828 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 z 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 delta 33846 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 VRpU>!6Y8bT;lI_quZ*Aj{{ZVs^BMpE 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 51d2d357..9df4ce1a 100644 --- a/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +++ b/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java @@ -350,11 +350,15 @@ private boolean transferArchive( new FileOutputStream(writePath, append), digest))) ) { while ((bytesRead = source.read(sink.buffer(), UpdateFileUtils.BUFFER_SIZE)) != -1) { - long[] probe = ArchiveLimits.planFreeSpaceProbe( + // A known length was reserved up front: no per-chunk plan + // (and no JNI round trip on this hot loop). + long[] probe = totalAll > 0 ? null : ArchiveLimits.planFreeSpaceProbe( totalAll, received, bytesRead, freeSpaceReservedUntil); received += bytesRead; - freeSpaceReservedUntil = probe[1]; - if (probe[0] > 0) { + if (probe != null) { + freeSpaceReservedUntil = probe[1]; + } + if (probe != null && probe[0] > 0) { try { ArchiveLimits.ensureFreeSpace(writePath, probe[0]); } catch (IOException e) { diff --git a/cpp/patch_core/archive_limits.cpp b/cpp/patch_core/archive_limits.cpp index ff98ac3e..2904b3c1 100644 --- a/cpp/patch_core/archive_limits.cpp +++ b/cpp/patch_core/archive_limits.cpp @@ -152,13 +152,18 @@ Status CheckExtractedDirectory(const std::string& dir, long long archive_bytes) status = Status::Error("cannot stat extracted entry " + child); break; } - const bool is_directory = S_ISDIR(st.st_mode); - status = accumulator.AcceptExtracted( - is_directory ? 0 : static_cast(st.st_size), - S_ISLNK(st.st_mode), child); - if (status.ok && is_directory) { - pending.push_back(child); + // Directories are walked but not counted: the zip may omit directory + // entries, and iOS/Android count only what the archive declares. + if (S_ISDIR(st.st_mode)) { + if (IsReservedEntryName(child)) { + status = Status::Error("archive contains reserved entry " + child); + } else { + pending.push_back(child); + } + continue; } + status = accumulator.AcceptExtracted(static_cast(st.st_size), + S_ISLNK(st.st_mode), child); } closedir(handle); if (!status.ok) { diff --git a/cpp/patch_core/tests/patch_core_test.cpp b/cpp/patch_core/tests/patch_core_test.cpp index eaa82351..b5b83300 100644 --- a/cpp/patch_core/tests/patch_core_test.cpp +++ b/cpp/patch_core/tests/patch_core_test.cpp @@ -1869,11 +1869,13 @@ void TestArchiveLimitsExtractedDirectory() { } { TempDir temp; - // 20001 entries: 200 directories of 99 files + their own entry, plus one. + // 20001 entries: 200 directories of 100 files (directories themselves + // are not counted, as iOS/Android count only declared zip entries), plus + // one. for (int d = 0; d < 200; d++) { const std::string dir = temp.path + "/d" + std::to_string(d); EnsureDirectory(dir); - for (int f = 0; f < 99; f++) { + for (int f = 0; f < 100; f++) { WriteFile(dir + "/f" + std::to_string(f), ""); } } diff --git a/ios/RCTPushy/RCTPushyOrchestrator.mm b/ios/RCTPushy/RCTPushyOrchestrator.mm index dc1e1539..67b0972a 100644 --- a/ios/RCTPushy/RCTPushyOrchestrator.mm +++ b/ios/RCTPushy/RCTPushyOrchestrator.mm @@ -732,6 +732,10 @@ + (void)runConfiguredRound:(NSString *)configJson flowjson::Value decision = flowcore::HandleRoundResponse( PushyToStdString(responseText), config, env, PushyToStdString(body), (long long)[[NSDate date] timeIntervalSince1970]); + if (!decision.IsObject()) { + pushyHostRoundResult = PushyHostResult(@"failed", @"invalid_response"); + return; + } NSString *cache = PushyFromStdString(decision.Get("cache").AsString()); NSDictionary *cancelledByReset = PushyHostResult(@"cancelled", @"reset"); // Commits only the response cache (nothing to install or activate) and From c8c77f78f61410771fbf39073794c328ead06af2 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 21:36:49 +0800 Subject: [PATCH 103/104] test(e2e): let the previous launch's native round settle before flipping forceBoot The relaunch right after the 'no directive' check starts its own delayed round (5s after launch). The test flipped forceBoot as soon as the base label showed, so that in-flight round could read the flipped server and activate one launch early (seen on CI: request at +5s, flip at +4s). Co-Authored-By: Claude Opus 5.5 --- Example/e2etest/e2e/native/native-check.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Example/e2etest/e2e/native/native-check.test.ts b/Example/e2etest/e2e/native/native-check.test.ts index 49d7e735..7f4e3967 100644 --- a/Example/e2etest/e2e/native/native-check.test.ts +++ b/Example/e2etest/e2e/native/native-check.test.ts @@ -97,6 +97,10 @@ describe('Native cold-start check', () => { await waitForReady(); await waitForBundleLabel(LOCAL_UPDATE_LABELS.base); await waitForHash(''); + // This launch runs its own delayed round (5s after start). Let it finish + // before flipping the directive: a round still in flight would read the + // flipped server and activate one launch early. + await new Promise((resolve) => setTimeout(resolve, NATIVE_CHECK_SETTLE_MS)); // Flip the directive before the launch whose round should honor it. await setForceBoot(true); From 9f35e68228541d69d5ca7add4661fa4638abc02f Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Mon, 12 Oct 2026 23:20:06 +0800 Subject: [PATCH 104/104] test(e2e): give the local-merge install hook its own 10min budget The beforeAll only uninstalls, reinstalls and launches the app; on slow macOS runners simctl alone took 5-9 minutes and the 300s testTimeout failed the suite before any step ran. Co-Authored-By: Claude Opus 5.5 --- Example/e2etest/e2e/local-merge.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Example/e2etest/e2e/local-merge.test.ts b/Example/e2etest/e2e/local-merge.test.ts index 4b1407d8..d1d9540a 100644 --- a/Example/e2etest/e2e/local-merge.test.ts +++ b/Example/e2etest/e2e/local-merge.test.ts @@ -175,7 +175,10 @@ describe('Local Update Merge E2E', () => { delete: true, ...getDetoxLaunchArgs(), }); - }); + // simctl uninstall + install alone has taken 5-9 min on slow macOS + // runners; the 300s testTimeout default would fail the suite before a + // single step of it ran. + }, 600000); beforeEach(async () => { await device.launchApp({