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 1673e4d9..eb7ed521 100644 --- a/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +++ b/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java @@ -136,7 +136,7 @@ private UpdateContext(Context context) { // Execute cleanUp before clearing SharedPreferences to avoid race condition cleanUp(DownloadTaskParams.DEFAULT_MAX_AGE_DAYS); SharedPreferences.Editor editor = this.sp.edit(); - removeVersionScopedKeys(editor); + removeKeys(editor, VersionScopedKeys.clearedOnUpgrade(sp.getAll().keySet())); applyState(editor, nextState); persistEditor(editor, "sync state with binary version"); clearNativeCheckCache(); @@ -441,8 +441,8 @@ private static void putNullableString( // Not editor.clear(): that also wiped the uuid (re-bucketing every gray // release on each app upgrade) and the native config (no crash hold and // no cold-start round until JS ran again). See VersionScopedKeys. - private void removeVersionScopedKeys(SharedPreferences.Editor editor) { - for (String key : VersionScopedKeys.select(sp.getAll().keySet())) { + private void removeKeys(SharedPreferences.Editor editor, java.util.List keys) { + for (String key : keys) { editor.remove(key); } } @@ -656,7 +656,7 @@ private void resetToPackagedBundleLocked() { throw new IllegalStateException("Failed to persist state for reset check cache"); } SharedPreferences.Editor editor = sp.edit(); - removeVersionScopedKeys(editor); + removeKeys(editor, VersionScopedKeys.clearedOnReset(sp.getAll().keySet())); applyState(editor, resetState); persistEditorOrThrow(editor, "reset to packaged bundle"); ignoreRollback = false; diff --git a/android/src/main/java/cn/reactnative/modules/update/VersionScopedKeys.java b/android/src/main/java/cn/reactnative/modules/update/VersionScopedKeys.java index 325be94b..95d34c69 100644 --- a/android/src/main/java/cn/reactnative/modules/update/VersionScopedKeys.java +++ b/android/src/main/java/cn/reactnative/modules/update/VersionScopedKeys.java @@ -6,31 +6,56 @@ /** * The keys of the main state file that describe installed versions rather - * than the install itself: per-version info (hash_*), the first-load marker, - * the native round's incomplete breadcrumb and the response cache's legacy - * location. A binary upgrade or a reset to the packaged bundle drops exactly - * these (plus the state fields applyState rewrites). Everything else — the - * client uuid that buckets gray releases, the persisted native config the - * cold-start round and crash hold run from, the bundle hash cache keyed by - * package version — belongs to the install and survives, as on iOS and - * Harmony. + * than the install itself. What a binary upgrade or a reset to the packaged + * bundle drops (besides the state fields applyState rewrites) is the same on + * every platform and generated from scripts/contract.ts (storedItems); the + * install's own keys — the client uuid that buckets gray releases, the + * persisted native config the cold-start round and crash hold run from, the + * round breadcrumb, the bundle hash cache keyed by package version — survive. */ final class VersionScopedKeys { static final String HASH_INFO_PREFIX = "hash_"; + // (scripts/contract.ts; bun scripts/generate-contract.ts) + // What a reset to the packaged bundle and a binary upgrade delete besides + // the state fields (scripts/contract.ts storedItems; every other item survives + // both: the uuid, the native config, the round breadcrumb, the bundle hash + // cache). + static final String[] RESET_KEYS = {UpdateContext.KEY_FIRST_LOAD_MARKED, SyncCoordinator.KEY_RESP_CACHE}; + static final String[] RESET_PREFIXES = {HASH_INFO_PREFIX}; + static final String[] UPGRADE_KEYS = {UpdateContext.KEY_FIRST_LOAD_MARKED, SyncCoordinator.KEY_RESP_CACHE}; + static final String[] UPGRADE_PREFIXES = {HASH_INFO_PREFIX}; + // + private VersionScopedKeys() { } - static List select(Collection keys) { + /** The keys a reset to the packaged bundle deletes. */ + static List clearedOnReset(Collection keys) { + return select(keys, RESET_KEYS, RESET_PREFIXES); + } + + /** The keys a binary upgrade deletes. */ + static List clearedOnUpgrade(Collection keys) { + return select(keys, UPGRADE_KEYS, UPGRADE_PREFIXES); + } + + private static List select( + Collection keys, String[] exact, String[] prefixes + ) { List doomed = new ArrayList<>(); for (String key : keys) { if (key == null) { continue; } - if (key.startsWith(HASH_INFO_PREFIX) - || key.equals(UpdateContext.KEY_FIRST_LOAD_MARKED) - || key.equals(SyncCoordinator.KEY_ROUND_INCOMPLETE) - || key.equals(SyncCoordinator.KEY_RESP_CACHE)) { + boolean match = false; + for (String name : exact) { + match |= key.equals(name); + } + for (String prefix : prefixes) { + match |= key.startsWith(prefix); + } + if (match) { doomed.add(key); } } diff --git a/android/src/test/java/cn/reactnative/modules/update/VersionScopedKeysTest.java b/android/src/test/java/cn/reactnative/modules/update/VersionScopedKeysTest.java index 64b5491d..a3f781ef 100644 --- a/android/src/test/java/cn/reactnative/modules/update/VersionScopedKeysTest.java +++ b/android/src/test/java/cn/reactnative/modules/update/VersionScopedKeysTest.java @@ -8,29 +8,33 @@ import org.junit.Test; public class VersionScopedKeysTest { + private static final List STORED = Arrays.asList( + "uuid", + "nativeConfig", + "bundleHashCache", + "packageVersion", + "currentVersion", + "hash_abc", + "hash_def", + "firstLoadMarked", + "nativeCheckIncomplete", + "nativeCheckResp", + null + ); + + // The round breadcrumb survives both: it only decides whether the next + // launch resumes the round without the start delay. + private static final HashSet VERSION_KEYS = new HashSet<>(Arrays.asList( + "hash_abc", "hash_def", "firstLoadMarked", "nativeCheckResp")); + + @Test + public void resetDropsVersionKeysAndKeepsInstallKeys() { + assertEquals(VERSION_KEYS, new HashSet<>(VersionScopedKeys.clearedOnReset(STORED))); + } + @Test - public void dropsVersionKeysAndKeepsInstallKeys() { - List doomed = VersionScopedKeys.select(Arrays.asList( - "uuid", - "nativeConfig", - "bundleHashCache", - "packageVersion", - "currentVersion", - "hash_abc", - "hash_def", - "firstLoadMarked", - "nativeCheckIncomplete", - "nativeCheckResp", - null - )); - assertEquals( - new HashSet<>(Arrays.asList( - "hash_abc", - "hash_def", - "firstLoadMarked", - "nativeCheckIncomplete", - "nativeCheckResp")), - new HashSet<>(doomed)); + public void upgradeDropsVersionKeysAndKeepsInstallKeys() { + assertEquals(VERSION_KEYS, new HashSet<>(VersionScopedKeys.clearedOnUpgrade(STORED))); } @Test diff --git a/harmony/pushy/src/main/ets/UpdateContext.ts b/harmony/pushy/src/main/ets/UpdateContext.ts index d575d09c..158802f2 100644 --- a/harmony/pushy/src/main/ets/UpdateContext.ts +++ b/harmony/pushy/src/main/ets/UpdateContext.ts @@ -56,11 +56,34 @@ function nativeConfigsEquivalent(a: string | undefined, b: string | undefined): return NativePatchCore.configsEquivalent(a, b); } +// Per-version info key prefix and the first-load marker key. +const HASH_INFO_PREFIX = 'hash_'; +const FIRST_LOAD_MARKED_KEY = 'firstLoadMarked'; + +interface ClearedKeys { + keys: string[]; + prefixes: string[]; +} + +// (scripts/contract.ts; bun scripts/generate-contract.ts) +// What a reset to the packaged bundle and a binary upgrade delete besides +// the state fields (scripts/contract.ts storedItems; every other item survives +// both: the uuid, the native config, the round breadcrumb, the bundle hash +// cache). +const RESET_CLEARED: ClearedKeys = { + keys: [FIRST_LOAD_MARKED_KEY, KEY_RESP_CACHE], + prefixes: [HASH_INFO_PREFIX], +}; +const UPGRADE_CLEARED: ClearedKeys = { + keys: [FIRST_LOAD_MARKED_KEY, KEY_RESP_CACHE], + prefixes: [HASH_INFO_PREFIX], +}; +// + interface PersistOptions { removeStaleHash?: boolean; cleanUp?: boolean; markFirstLoadMarker?: boolean; - clearFirstLoadMarker?: boolean; } export class UpdateContext { @@ -141,7 +164,7 @@ export class UpdateContext { ` cv=${snap.currentVersion} lv=${snap.lastVersion}` + ` ft=${snap.firstTime} fto=${snap.firstTimeOk}` + ` rb=${snap.rolledBackVersion}` + - ` flm=${this.readString('firstLoadMarked')}` + + ` flm=${this.readString(FIRST_LOAD_MARKED_KEY)}` + ` uuidSet=${!!this.readString('uuid')}`, ); } @@ -372,10 +395,7 @@ export class UpdateContext { this.preferences.deleteSync(`hash_${state.staleVersionToDelete}`); } if (options.markFirstLoadMarker) { - this.preferences.putSync('firstLoadMarked', 'true'); - } - if (options.clearFirstLoadMarker) { - this.preferences.deleteSync('firstLoadMarked'); + this.preferences.putSync(FIRST_LOAD_MARKED_KEY, 'true'); } if (options.cleanUp) { this.cleanUp(); @@ -499,6 +519,27 @@ export class UpdateContext { }); } + // Deletes `cleared` from the store (in memory; the caller flushes). A + // failed read leaves harmless orphans: none of them affects the state. + private removeKeys(cleared: ClearedKeys, event: string): void { + for (const key of cleared.keys) { + this.preferences.deleteSync(key); + } + if (cleared.prefixes.length === 0) { + return; + } + try { + const all = this.preferences.getAllSync(); + for (const key of Object.keys(all)) { + if (cleared.prefixes.some(prefix => key.startsWith(prefix))) { + this.preferences.deleteSync(key); + } + } + } catch (e) { + logger.error(TAG, `Failed to clear keys on ${event}: ${getErrorMessage(e)}`); + } + } + public syncStateWithBinaryVersion( packageVersion: string, buildTime: string, @@ -522,10 +563,11 @@ export class UpdateContext { ); UpdateContext.ignoreRollback = false; this.cleanUp(); - // 仅重置状态机字段(currentVersion / lastVersion / firstTime / firstTimeOk / - // rolledBackVersion)。不再 clearExisting,避免连带清除 uuid / firstLoadMarked / - // hash_* 等与 binary 版本无关的 KV —— 它们在多实例场景下本就脆弱,连带清除会 - // 让 getConstants() 永远读到空,从而 isFirstTime=false、markSuccess 永不执行。 + // 旧二进制的版本已不在状态里:删掉它们的元信息、首启标记与响应缓存,与 + // 各端一致(scripts/contract.ts storedItems)。uuid、原生配置等属于安装本身的 + // 键保留——不走 clearExisting(那会让 getConstants 读到空的 uuid)。升级后 + // 没有运行中的热更版本,首启标记不会在本次启动里先写后删。 + this.removeKeys(UPGRADE_CLEARED, 'binary upgrade'); this.writeState(nextState, {}); this.flushInBackground('sync binary version'); } @@ -629,25 +671,11 @@ export class UpdateContext { this.beginFlushBatch(); let flushed: Promise = Promise.resolve(); try { - // 删除已下载版本的 hash_* 元信息(不走 clear():它是异步的,且会连带清掉 - // 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)}`, - ); - } - this.writeState(resetState, { clearFirstLoadMarker: true }); - // 缓存里的响应仍在宣告本次 reset 刚删掉的版本,一并丢弃,避免 JS 侧复用。 - this.preferences.deleteSync(KEY_RESP_CACHE); + // 删除版本元信息、首启标记与响应缓存(缓存仍在宣告本次 reset 刚删掉的 + // 版本),与各端一致(scripts/contract.ts storedItems)。不走 clear(): + // 它是异步的,且会连带清掉 uuid。 + this.removeKeys(RESET_CLEARED, 'reset'); + this.writeState(resetState, {}); } finally { flushed = this.endFlushBatch('reset to packaged bundle'); } @@ -843,10 +871,10 @@ export class UpdateContext { } public consumeFirstLoadMarker(): boolean { - const marked = this.readString('firstLoadMarked') === 'true'; + const marked = this.readString(FIRST_LOAD_MARKED_KEY) === 'true'; this.trace(`consumeFirstLoadMarker:marked=${marked}`); if (marked) { - this.preferences.deleteSync('firstLoadMarked'); + this.preferences.deleteSync(FIRST_LOAD_MARKED_KEY); this.flushInBackground('clear first load marker'); } return marked; diff --git a/ios/RCTPushy/RCTPushy+Launch.mm b/ios/RCTPushy/RCTPushy+Launch.mm index 99c7299f..2bcf3992 100644 --- a/ios/RCTPushy/RCTPushy+Launch.mm +++ b/ios/RCTPushy/RCTPushy+Launch.mm @@ -65,6 +65,11 @@ + (NSURL *)resolveLaunchBundleURL:(NSString **)launchRolledBackVersionOut PushyToStdString(curBuildTime) ); if (sync.changed) { + // The old binary's versions are gone from the state: drop + // their info, the first-load marker and the response cache, + // as every platform does (scripts/contract.ts storedItems). + PushyRemoveKeys(defaults, PushyKeysClearedOnUpgrade(), + PushyKeyPrefixesClearedOnUpgrade()); [defaults setObject:@(YES) forKey:KeyPackageUpdatedMarked()]; state = sync.state; PushyApplyStateToDefaults(defaults, state); diff --git a/ios/RCTPushy/RCTPushy.mm b/ios/RCTPushy/RCTPushy.mm index 935bec8c..59afbc9c 100644 --- a/ios/RCTPushy/RCTPushy.mm +++ b/ios/RCTPushy/RCTPushy.mm @@ -445,16 +445,11 @@ - (NSDictionary *)constantsToExport state.build_time = PushyToStdString([RCTPushy buildTime]); PushyApplyStateToDefaults(defaults, state); - for (NSString *key in [defaults dictionaryRepresentation].allKeys) { - if ([key hasPrefix:keyHashInfo]) { - [defaults removeObjectForKey:key]; - } - } - [defaults removeObjectForKey:keyFirstLoadMarked]; - [defaults removeObjectForKey:KeyPackageUpdatedMarked()]; - // A cached response still advertises the version this reset just - // removed; dropping it stops the JS side from reusing that answer. - [defaults removeObjectForKey:keyNativeCheckCache]; + // Version info, the first-load and package-updated markers and the + // response cache (it still advertises the versions this reset just + // removed), the same on every platform (scripts/contract.ts). + PushyRemoveKeys(defaults, PushyKeysClearedOnReset(), + PushyKeyPrefixesClearedOnReset()); ignoreRollback = false; }); } @catch (...) { diff --git a/ios/RCTPushy/RCTPushyInternal.h b/ios/RCTPushy/RCTPushyInternal.h index 9d18d4c3..0f2001bc 100644 --- a/ios/RCTPushy/RCTPushyInternal.h +++ b/ios/RCTPushy/RCTPushyInternal.h @@ -55,6 +55,15 @@ extern PUSHY_HIDDEN std::atomic ignoreRollback; extern PUSHY_HIDDEN std::atomic pushyResetGeneration; PUSHY_HIDDEN NSString *KeyPackageUpdatedMarked(void); +// What a reset / a binary upgrade deletes besides the state fields +// (generated from scripts/contract.ts storedItems), and the removal itself: +// every key in `keys` and every stored key starting with one of `prefixes`. +PUSHY_HIDDEN NSArray *PushyKeysClearedOnReset(void); +PUSHY_HIDDEN NSArray *PushyKeyPrefixesClearedOnReset(void); +PUSHY_HIDDEN NSArray *PushyKeysClearedOnUpgrade(void); +PUSHY_HIDDEN NSArray *PushyKeyPrefixesClearedOnUpgrade(void); +PUSHY_HIDDEN void PushyRemoveKeys(NSUserDefaults *defaults, NSArray *keys, + NSArray *prefixes); PUSHY_HIDDEN NSString *PushyHashInfoKey(NSString *hash); // Immutable host-facing snapshot; activated always means NEXT launch. PUSHY_HIDDEN NSDictionary *PushyHostResult(NSString *status, NSString *reason, diff --git a/ios/RCTPushy/RCTPushyState.mm b/ios/RCTPushy/RCTPushyState.mm index 904928a6..d37e173e 100644 --- a/ios/RCTPushy/RCTPushyState.mm +++ b/ios/RCTPushy/RCTPushyState.mm @@ -44,6 +44,43 @@ // to reuse (§10.3), scoped to the request and config that produced it. PUSHY_HIDDEN NSString *const keyNativeCheckCache = @"REACTNATIVECN_PUSHY_NATIVE_CHECK_RESP_KEY"; +// (scripts/contract.ts; bun scripts/generate-contract.ts) +// What a reset to the packaged bundle and a binary upgrade delete besides +// the state fields (scripts/contract.ts storedItems; every other item survives +// both: the uuid, the native config, the round breadcrumb, the bundle hash +// cache). +PUSHY_HIDDEN NSArray *PushyKeysClearedOnReset(void) { + return @[keyFirstLoadMarked, KeyPackageUpdatedMarked(), keyNativeCheckCache]; +} +PUSHY_HIDDEN NSArray *PushyKeyPrefixesClearedOnReset(void) { + return @[keyHashInfo]; +} +PUSHY_HIDDEN NSArray *PushyKeysClearedOnUpgrade(void) { + return @[keyFirstLoadMarked, keyNativeCheckCache]; +} +PUSHY_HIDDEN NSArray *PushyKeyPrefixesClearedOnUpgrade(void) { + return @[keyHashInfo]; +} +// + +PUSHY_HIDDEN void PushyRemoveKeys(NSUserDefaults *defaults, NSArray *keys, + NSArray *prefixes) { + for (NSString *key in keys) { + [defaults removeObjectForKey:key]; + } + if (prefixes.count == 0) { + return; + } + for (NSString *key in [defaults dictionaryRepresentation].allKeys) { + for (NSString *prefix in prefixes) { + if ([key hasPrefix:prefix]) { + [defaults removeObjectForKey:key]; + break; + } + } + } +} + // 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"; diff --git a/scripts/contract.ts b/scripts/contract.ts index fafd0a2c..7fc9f290 100644 --- a/scripts/contract.ts +++ b/scripts/contract.ts @@ -412,3 +412,86 @@ export const servicePresets = { ], }, }; + +export type Lifecycle = 'reset' | 'binaryUpgrade'; + +export interface StoredItem { + name: string; + doc: string; + /** The key is a prefix (one entry per version). */ + prefix?: boolean; + /** The events that delete it; everything else survives both. */ + clearedOn: Lifecycle[]; + /** The key (or prefix) as an expression in each platform's own code. */ + keys: { objc?: string; java?: string; arkts?: string }; +} + +// The persisted items next to the state fields (which the shared state core +// rewrites itself) and what a reset to the packaged bundle or a binary +// upgrade does to them, the same on every platform. The physical keys stay +// as each platform has always stored them (no format migration). +export const storedItems: StoredItem[] = [ + { + name: 'HashInfo', + prefix: true, + doc: 'Per-version info: name, description, metaInfo and the rescue flags.', + clearedOn: ['reset', 'binaryUpgrade'], + keys: { objc: 'keyHashInfo', java: 'HASH_INFO_PREFIX', arkts: 'HASH_INFO_PREFIX' }, + }, + { + name: 'FirstLoadMarker', + doc: 'Set when a launch consumes first_time; getConstants reports and clears it.', + clearedOn: ['reset', 'binaryUpgrade'], + keys: { + objc: 'keyFirstLoadMarked', + java: 'UpdateContext.KEY_FIRST_LOAD_MARKED', + arkts: 'FIRST_LOAD_MARKED_KEY', + }, + }, + { + name: 'PackageUpdatedMarker', + doc: 'iOS: set by a binary upgrade, consumed by getConstants (old version cleanup).', + clearedOn: ['reset'], + keys: { objc: 'KeyPackageUpdatedMarked()' }, + }, + { + name: 'CheckCache', + doc: + "The native round's response cache: it describes versions the event just\n" + + 'removed (Android: its legacy location here; its own file is cleared too).', + clearedOn: ['reset', 'binaryUpgrade'], + keys: { + objc: 'keyNativeCheckCache', + java: 'SyncCoordinator.KEY_RESP_CACHE', + arkts: 'KEY_RESP_CACHE', + }, + }, + { + name: 'RoundBreadcrumb', + doc: 'Only decides whether the next launch resumes the round without the 5s delay.', + clearedOn: [], + keys: { + objc: 'keyNativeCheckIncomplete', + java: 'SyncCoordinator.KEY_ROUND_INCOMPLETE', + arkts: 'KEY_ROUND_INCOMPLETE', + }, + }, + { + name: 'Uuid', + doc: 'Identifies the install for gray-release bucketing: must never change.', + clearedOn: [], + keys: {}, + }, + { + name: 'NativeConfig', + doc: 'The host or JS config the cold-start round and crash hold run from.', + clearedOn: [], + keys: {}, + }, + { + name: 'BundleHashCache', + doc: 'Keyed by the binary identity, so it invalidates itself on upgrade.', + clearedOn: [], + keys: {}, + }, +]; diff --git a/scripts/generate-contract.ts b/scripts/generate-contract.ts index 4fbcd6c8..e9b28b48 100644 --- a/scripts/generate-contract.ts +++ b/scripts/generate-contract.ts @@ -19,7 +19,9 @@ import { roundStatuses, servicePresets, stateOperations, + storedItems, syncStorageKeys, + type Lifecycle, type TextHost, vendoredSources, } from './contract'; @@ -658,6 +660,66 @@ region('src/client.ts', 'service-presets', [ '};', ]); +// Stored-item lifecycle: what a reset / binary upgrade deletes. +const cleared = ( + event: Lifecycle, + platform: 'objc' | 'java' | 'arkts', + prefix: boolean +) => + storedItems + .filter( + (item) => + item.clearedOn.includes(event) && + !!item.prefix === prefix && + item.keys[platform] !== undefined + ) + .map((item) => item.keys[platform] as string); +const lifecycleDoc = [ + 'What a reset to the packaged bundle and a binary upgrade delete besides', + 'the state fields (scripts/contract.ts storedItems; every other item survives', + 'both: the uuid, the native config, the round breadcrumb, the bundle hash', + 'cache).', +]; +const events: [Lifecycle, string, string][] = [ + ['reset', 'Reset', 'RESET'], + ['binaryUpgrade', 'Upgrade', 'UPGRADE'], +]; +region( + `${javaDir}/VersionScopedKeys.java`, + 'lifecycle', + [ + ...lifecycleDoc.map((line) => `// ${line}`), + ...events.flatMap(([event, , upper]) => [ + `static final String[] ${upper}_KEYS = {${cleared(event, 'java', false).join(', ')}};`, + `static final String[] ${upper}_PREFIXES = {${cleared(event, 'java', true).join(', ')}};`, + ]), + ] +); +region( + 'ios/RCTPushy/RCTPushyState.mm', + 'lifecycle', + [ + ...lifecycleDoc.map((line) => `// ${line}`), + ...events.flatMap(([event, camel]) => [ + `PUSHY_HIDDEN NSArray *PushyKeysClearedOn${camel}(void) {`, + ` return @[${cleared(event, 'objc', false).join(', ')}];`, + '}', + `PUSHY_HIDDEN NSArray *PushyKeyPrefixesClearedOn${camel}(void) {`, + ` return @[${cleared(event, 'objc', true).join(', ')}];`, + '}', + ]), + ] +); +region(`${ets}/UpdateContext.ts`, 'lifecycle', [ + ...lifecycleDoc.map((line) => `// ${line}`), + ...events.flatMap(([event, , upper]) => [ + `const ${upper}_CLEARED: ClearedKeys = {`, + ` keys: [${cleared(event, 'arkts', false).join(', ')}],`, + ` prefixes: [${cleared(event, 'arkts', true).join(', ')}],`, + '};', + ]), +]); + // --- Apply ----------------------------------------------------------------- const check = process.argv.includes('--check'); diff --git a/scripts/tests/ios-purge-restore/extract.py b/scripts/tests/ios-purge-restore/extract.py index a1230aef..3bc9c3ea 100644 --- a/scripts/tests/ios-purge-restore/extract.py +++ b/scripts/tests/ios-purge-restore/extract.py @@ -95,6 +95,9 @@ def declaration(source: str, name: str) -> tuple[int, str]: # The exception boundary every entry point uses (CODE_AUDIT 4.4). 'PushyCaughtReason', 'PushyGuard', 'PushyWithStateLock', 'PushyToStdString', 'PushyFromStdString', 'KeyPackageUpdatedMarked', + # What reset / binary upgrade delete (generated from scripts/contract.ts). + 'PushyKeysClearedOnReset', 'PushyKeyPrefixesClearedOnReset', 'PushyKeysClearedOnUpgrade', + 'PushyKeyPrefixesClearedOnUpgrade', 'PushyRemoveKeys', 'PushyJsonDictionary', 'PushyJsonString', 'PushyRejectCaught', 'PushyFileQueue', 'PushySetNullableString', 'PushyHashInfoKey', 'PushyBinaryIdentityValue', 'PushyStateFromDefaults', 'PushyApplyStateToDefaults', 'PushySwitchVersionLocked', diff --git a/scripts/tests/ios-purge-restore/purge_restore_test.mm b/scripts/tests/ios-purge-restore/purge_restore_test.mm index 3436b9b3..878dc9f2 100644 --- a/scripts/tests/ios-purge-restore/purge_restore_test.mm +++ b/scripts/tests/ios-purge-restore/purge_restore_test.mm @@ -740,6 +740,38 @@ static void RoundCommitVerifies(void) { Expect([[NSFileManager defaultManager] removeItemAtPath:root error:NULL], "remove fixture files"); } +// A binary upgrade drops what belonged to the old binary's versions — their +// info, the first-load marker, the response cache — and keeps the install's +// own keys, the same on every platform (scripts/contract.ts storedItems). +static void BinaryUpgradeClearsVersionKeys(void) { + SetUp(Order::ResetFirst); + NSString *hashInfoKey = PushyHashInfoKey(@"B"); + NSString *breadcrumb = @"REACTNATIVECN_PUSHY_NATIVE_CHECK_INCOMPLETE_KEY"; + PushyWithStateLock(^{ + pushy::state::State state; + state.package_version = "0.9"; // the previous binary + state.build_time = "100"; + state.current_version = "B"; + PushyApplyStateToDefaults(testDefaults, state); + [testDefaults setObject:@"{\"name\":\"B\"}" forKey:hashInfoKey]; + [testDefaults setObject:@YES forKey:keyFirstLoadMarked]; + [testDefaults setObject:@"{}" forKey:keyNativeCheckCache]; + [testDefaults setObject:@YES forKey:breadcrumb]; + }); + NSString *rolledBack = nil; + NSString *purged = nil; + [RCTPushy resolveLaunchBundleURL:&rolledBack purgedVersion:&purged]; + Expect(State().package_version == "1.0", "the state takes the new binary identity"); + Expect(State().current_version.empty(), "the old binary's version is not launched"); + Expect(Value(hashInfoKey) == nil, "an upgrade drops the old versions' info"); + Expect(Value(keyFirstLoadMarked) == nil, "an upgrade drops the first-load marker"); + Expect(Value(keyNativeCheckCache) == nil, "an upgrade drops the response cache"); + Expect([Value(KeyPackageUpdatedMarked()) boolValue], "an upgrade is reported to getConstants"); + Expect([Value(keyUuid) isEqual:@"installation-uuid"], "an upgrade keeps the install identity"); + Expect([Value(breadcrumb) boolValue], "an upgrade keeps the round breadcrumb"); + Expect([[NSFileManager defaultManager] removeItemAtPath:root error:NULL], "remove fixture files"); +} + // CODE_AUDIT 4.4: a C++ exception from the shared cores is caught at the // entry point, with its reason, and never leaves the state lock held. The // launch falls back to the packaged bundle and still schedules the round. @@ -812,6 +844,7 @@ int main(int argc, char **argv) { {"install_record_activation", InstallRecordActivation}, {"switch_version_records", SwitchVersionRecords}, {"round_commit_verifies", RoundCommitVerifies}, + {"binary_upgrade_clears_version_keys", BinaryUpgradeClearsVersionKeys}, {"cpp_exception_boundary", CppExceptionBoundary}, }; int ran = 0; diff --git a/src/__tests__/harmonyStateLifecycle.test.ts b/src/__tests__/harmonyStateLifecycle.test.ts new file mode 100644 index 00000000..a22bcb54 --- /dev/null +++ b/src/__tests__/harmonyStateLifecycle.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from 'bun:test'; +import { storedItems } from '../../scripts/contract'; +import { evalHarmonyModule } from './nativeSources'; + +// What a reset to the packaged bundle and a binary upgrade delete on Harmony: +// the generated lists (scripts/contract.ts storedItems) through the actual +// UpdateContext.removeKeys, against a store holding every kind of key. + +interface ClearedKeys { + keys: string[]; + prefixes: string[]; +} + +function lifecycleHarness() { + const values = new Map([ + ['uuid', 'installation-id'], + ['nativeConfig', '{"appKey":"k"}'], + ['bundleHashCache', '1.0|1|sha'], + ['nativeCheckIncomplete', '1'], + ['nativeCheckResp', '{}'], + ['firstLoadMarked', 'true'], + ['hash_a', '{"name":"a"}'], + ['hash_b', '{"name":"b"}'], + ['currentVersion', 'a'], + ]); + const preferences = { + getSync: (key: string, fallback: string) => values.get(key) ?? fallback, + putSync: (key: string, value: string) => values.set(key, value), + deleteSync: (key: string) => values.delete(key), + getAllSync: () => Object.fromEntries(values), + }; + const module = evalHarmonyModule( + 'UpdateContext.ts', + `const store = Object.create(UpdateContext.prototype); + store.preferences = preferences; + ({ store, RESET_CLEARED, UPGRADE_CLEARED });`, + { + preferences, + KEY_CONFIG: 'nativeConfig', + KEY_RESP_CACHE: 'nativeCheckResp', + logger: { error() {} }, + getErrorMessage: (error: unknown) => String(error), + } + ) as { + store: { removeKeys: (cleared: ClearedKeys, event: string) => void }; + RESET_CLEARED: ClearedKeys; + UPGRADE_CLEARED: ClearedKeys; + }; + return { values, ...module }; +} + +// The install's own keys and the state fields (rewritten by the state core). +const survivors = [ + 'uuid', + 'nativeConfig', + 'bundleHashCache', + 'nativeCheckIncomplete', + 'currentVersion', +]; + +describe('harmony stored-item lifecycle', () => { + for (const event of ['reset', 'binaryUpgrade'] as const) { + test(`${event} drops the version keys and keeps the install's`, () => { + const h = lifecycleHarness(); + h.store.removeKeys( + event === 'reset' ? h.RESET_CLEARED : h.UPGRADE_CLEARED, + event + ); + expect([...h.values.keys()].sort()).toEqual([...survivors].sort()); + }); + } + + test('the contract keeps the breadcrumb, uuid and config on both events', () => { + for (const name of ['RoundBreadcrumb', 'Uuid', 'NativeConfig']) { + expect(storedItems.find((item) => item.name === name)?.clearedOn).toEqual( + [] + ); + } + }); +}); diff --git a/src/core.ts b/src/core.ts index 2540ab4f..4718c0fe 100644 --- a/src/core.ts +++ b/src/core.ts @@ -126,8 +126,10 @@ export const currentBundleSha256: string = ? PushyConstants.currentBundleSha256 : ''; -export const isFirstTime: boolean = PushyConstants.isFirstTime; -export const rolledBackVersion: string = PushyConstants.rolledBackVersion; +// The platforms report an absent value differently (iOS nil, Android +// false/null, Harmony false/''): normalize at the boundary. +export const isFirstTime: boolean = !!PushyConstants.isFirstTime; +export const rolledBackVersion: string = PushyConstants.rolledBackVersion || ''; export const isRolledBack: boolean = !!rolledBackVersion; export const buildTime: string = PushyConstants.buildTime;