diff --git a/Example/e2etest/e2e/bundle-hash.test.ts b/Example/e2etest/e2e/bundle-hash.test.ts index 22aedb6e..97c199a0 100644 --- a/Example/e2etest/e2e/bundle-hash.test.ts +++ b/Example/e2etest/e2e/bundle-hash.test.ts @@ -9,7 +9,8 @@ import { device } from 'detox'; // 就会确信 pdiff 可用而实际 hpatch 源校验失败,比 buildTime 启发式更糟。 // // 无需 app 配合:JS 层(core.ts)在模块加载时预取 getBundleHash,原生算完后 -// 缓存进 NSUserDefaults / SharedPreferences;宿主机直读缓存末段,与 Node 侧 +// 缓存进状态存储(三端同名的 pushy_state:iOS 独立 suite、Android +// SharedPreferences 文件,见 docs/STORED_STATE_LAYOUT.md);宿主机直读缓存末段,与 Node 侧 // 对包内 bundle 算的 sha256 比对。 // // Android 有独立的验证价值:它的哈希实现(MessageDigest)与被哈希字节的来源 @@ -23,8 +24,10 @@ const ANDROID_APK_PATH = path.resolve( __dirname, '../android/app/build/outputs/apk/release/app-release.apk' ); -const IOS_DEFAULTS_KEY = 'REACTNATIVECN_PUSHY_BUNDLEHASH_KEY'; -const ANDROID_PREFS_KEY = 'bundleHashCache'; +// scripts/contract.ts storeSchema: the state store and the cache's key, the +// same on every platform. +const STATE_STORE = 'pushy_state'; +const BUNDLE_HASH_KEY = 'bundleHashCache'; const POLL_TIMEOUT_MS = 60000; const POLL_INTERVAL_MS = 1000; @@ -52,7 +55,7 @@ function readIosCachedBundleHash( .toString() .trim(); const raw = execSync( - `plutil -extract ${IOS_DEFAULTS_KEY} raw -o - "${container}/Library/Preferences/${bundleId}.plist"`, + `plutil -extract ${BUNDLE_HASH_KEY} raw -o - "${container}/Library/Preferences/${STATE_STORE}.plist"`, { stdio: ['ignore', 'pipe', 'ignore'] } ) .toString() @@ -86,7 +89,7 @@ function readAndroidCachedBundleHash( } adbRooted = true; } - const prefsPath = `/data/data/${packageName}/shared_prefs/update.xml`; + const prefsPath = `/data/data/${packageName}/shared_prefs/${STATE_STORE}.xml`; for (const cmd of [ `adb -s ${adbName} shell cat ${prefsPath}`, `adb -s ${adbName} shell su 0 cat ${prefsPath}`, @@ -96,7 +99,7 @@ function readAndroidCachedBundleHash( stdio: ['ignore', 'pipe', 'ignore'], }).toString(); const match = xml.match( - new RegExp(`name="${ANDROID_PREFS_KEY}"[^>]*>([^<]+)<`) + new RegExp(`name="${BUNDLE_HASH_KEY}"[^>]*>([^<]+)<`) ); if (match) { return extractHash(match[1]); diff --git a/android/lib/arm64-v8a/librnpushy.so b/android/lib/arm64-v8a/librnpushy.so index c8a60737..64a7d3c6 100755 Binary files a/android/lib/arm64-v8a/librnpushy.so and b/android/lib/arm64-v8a/librnpushy.so differ diff --git a/android/lib/armeabi-v7a/librnpushy.so b/android/lib/armeabi-v7a/librnpushy.so index 43761aed..eef3fd7d 100755 Binary files a/android/lib/armeabi-v7a/librnpushy.so and b/android/lib/armeabi-v7a/librnpushy.so differ diff --git a/android/lib/x86/librnpushy.so b/android/lib/x86/librnpushy.so index a1685e4d..b6c617ce 100755 Binary files a/android/lib/x86/librnpushy.so and b/android/lib/x86/librnpushy.so differ diff --git a/android/lib/x86_64/librnpushy.so b/android/lib/x86_64/librnpushy.so index aadb9874..ab06e44d 100755 Binary files a/android/lib/x86_64/librnpushy.so and b/android/lib/x86_64/librnpushy.so differ diff --git a/android/src/main/java/cn/reactnative/modules/update/StoreMigration.java b/android/src/main/java/cn/reactnative/modules/update/StoreMigration.java new file mode 100644 index 00000000..32d51360 --- /dev/null +++ b/android/src/main/java/cn/reactnative/modules/update/StoreMigration.java @@ -0,0 +1,152 @@ +package cn.reactnative.modules.update; + +import android.content.SharedPreferences; +import android.util.Log; +import androidx.annotation.Nullable; +import java.util.HashMap; +import java.util.Map; +import org.json.JSONArray; +import org.json.JSONObject; + +/** + * The one-time move from the legacy layout (SDKs up to 10.61: the + * Texts.PREFERENCES_NAME file with native booleans, its "_check" file for + * the response cache) into StoreSchema's stores. The rules are the shared + * core's (cpp/patch_core/stored_items.h); this class only does the IO: + * snapshot both stores, write the plan's puts in one commit, and only once + * that commit reached disk delete the plan's removes from the legacy files. + * Every step is safe to repeat, so a process killed anywhere in between + * finishes the move on its next launch. + */ +final class StoreMigration { + static final String TAG = Texts.LOG_TAG; + + /** What run() did; for logs and tests. */ + enum Result { + /** The store already held the schema (legacy leftovers were removed). */ + CURRENT, + /** The legacy items were carried over and the schema written. */ + MIGRATED, + /** The puts did not reach disk: the legacy keys stay, the next launch retries. */ + RETRY, + /** No plan (the core failed): the uuid alone was carried, the next launch retries. */ + FALLBACK, + } + + interface Planner { + /** + * The plan as JSON (MigrateStoredItemsJson); "null" for an unknown + * layout, null when the core threw. + */ + @Nullable + String plan(int legacyLayout, String[] legacy, String[] current); + } + + static final Planner NATIVE = StoreMigration::migrateStoredItems; + + // librnpushy.so is loaded by UpdateContext's static initializer, which + // runs before the only caller (its constructor). + private static native String migrateStoredItems( + int legacyLayout, String[] legacy, String[] current); + + private StoreMigration() { + } + + /** + * Never throws: it runs on the launch path before anything reads the + * state store. A failure leaves the app on a fresh state store (as a new + * install) that still carries the install's uuid. + */ + static Result run( + SharedPreferences state, + SharedPreferences legacy, + SharedPreferences legacyCache, + Planner planner + ) { + try { + Map legacyItems = new HashMap<>(legacyCache.getAll()); + legacyItems.putAll(legacy.getAll()); + String json = planner.plan( + StoreSchema.LEGACY_LAYOUT, flatten(legacyItems), flatten(state.getAll())); + if (json == null || "null".equals(json)) { + throw new IllegalStateException("no migration plan"); + } + JSONObject plan = new JSONObject(json); + JSONArray puts = plan.getJSONArray("puts"); + if (puts.length() > 0) { + SharedPreferences.Editor editor = state.edit(); + for (int i = 0; i < puts.length(); i++) { + JSONArray put = puts.getJSONArray(i); + editor.putString(put.getString(0), put.getString(1)); + } + // One commit, so the schema never lands without the items. + if (!editor.commit()) { + Log.e(TAG, "Failed to persist the migrated state store"); + return Result.RETRY; + } + } + JSONArray removes = plan.getJSONArray("removes"); + if (removes.length() > 0) { + SharedPreferences.Editor legacyEditor = legacy.edit(); + SharedPreferences.Editor legacyCacheEditor = legacyCache.edit(); + for (int i = 0; i < removes.length(); i++) { + legacyEditor.remove(removes.getString(i)); + legacyCacheEditor.remove(removes.getString(i)); + } + // A failure only leaves keys nothing reads: the next launch + // removes them again. + boolean removed = legacyEditor.commit(); + if (!legacyCacheEditor.commit() || !removed) { + Log.w(TAG, "Failed to remove the legacy state keys"); + } + } + return plan.optBoolean("migrated") ? Result.MIGRATED : Result.CURRENT; + } catch (Throwable e) { + Log.e(TAG, "State store migration failed", e); + carryUuid(state, legacy); + return Result.FALLBACK; + } + } + + /** + * The migration could not run: start like a fresh install, but keep the + * install's identity (a new uuid would re-bucket every gray release). No + * schema is written, so the next launch runs the whole migration again. + */ + private static void carryUuid(SharedPreferences state, SharedPreferences legacy) { + try { + if (StoreSchema.readString(state, StoreSchema.KEY_UUID) != null) { + return; + } + Object uuid = legacy.getAll().get(StoreSchema.LEGACY_KEY_UUID); + if (uuid instanceof String && !((String) uuid).isEmpty() + && !state.edit().putString(StoreSchema.KEY_UUID, (String) uuid).commit()) { + Log.e(TAG, "Failed to persist the carried uuid"); + } + } catch (Throwable e) { + Log.e(TAG, "Failed to carry the uuid", e); + } + } + + /** + * [key, value, key, value, ...] with every value as text: a legacy + * boolean is "1" / "0" like the new layout's, anything without a text + * form (a string set) is "" — still a key to delete, never a value. + */ + static String[] flatten(Map items) { + String[] flat = new String[items.size() * 2]; + int i = 0; + for (Map.Entry entry : items.entrySet()) { + Object value = entry.getValue(); + flat[i++] = entry.getKey() == null ? "" : entry.getKey(); + if (value instanceof Boolean) { + flat[i++] = (Boolean) value ? StoreSchema.BOOLEAN_TRUE : StoreSchema.BOOLEAN_FALSE; + } else if (value instanceof String || value instanceof Number) { + flat[i++] = value.toString(); + } else { + flat[i++] = ""; + } + } + return flat; + } +} diff --git a/android/src/main/java/cn/reactnative/modules/update/StoreSchema.java b/android/src/main/java/cn/reactnative/modules/update/StoreSchema.java new file mode 100644 index 00000000..3254a117 --- /dev/null +++ b/android/src/main/java/cn/reactnative/modules/update/StoreSchema.java @@ -0,0 +1,129 @@ +package cn.reactnative.modules.update; + +import android.content.SharedPreferences; +import androidx.annotation.Nullable; + +/** + * The persisted layout every platform shares (scripts/contract.ts + * storeSchema): the keys, the two stores, and the State mapping, generated. + * Every value is a string; an empty one is never stored, a boolean is + * BOOLEAN_TRUE / BOOLEAN_FALSE, a marker is BOOLEAN_TRUE while set. The + * state store is written by StoreMigration's one-time move out of the + * legacy layout before anything reads it. + */ +final class StoreSchema { + // (scripts/contract.ts; bun scripts/generate-contract.ts) + static final int VERSION = 1; + static final String KEY_SCHEMA_VERSION = "schemaVersion"; + static final String STATE_STORE = "pushy_state"; + static final String CACHE_STORE = "pushy_cache"; + // A boolean is stored as one of these; anything else (absent) reads as the + // field's fallback. + static final String BOOLEAN_TRUE = "1"; + static final String BOOLEAN_FALSE = "0"; + static final int LEGACY_LAYOUT = 1; + // Carried by hand when the migration itself cannot run. + static final String LEGACY_KEY_UUID = "uuid"; + // The state fields (pushy::state::State). + static final String KEY_PACKAGE_VERSION = "packageVersion"; + static final String KEY_BUILD_TIME = "buildTime"; + static final String KEY_CURRENT_VERSION = "currentVersion"; + static final String KEY_LAST_VERSION = "lastVersion"; + static final String KEY_FIRST_TIME = "firstTime"; + static final String KEY_FIRST_TIME_OK = "firstTimeOk"; + static final String KEY_ROLLED_BACK_VERSION = "rolledBackVersion"; + // Per-version info: name, description, metaInfo and the rescue flags. + static final String KEY_HASH_INFO = "hash_"; + // Set when a launch consumes first_time; getConstants reports and clears + // it. + static final String KEY_FIRST_LOAD_MARKER = "firstLoadMarked"; + // The native round's raw response for the JS side to reuse (§10.3), scoped + // to the exact logical request and native config that produced it. It + // describes versions a reset or an upgrade removes. + static final String KEY_CHECK_CACHE = "nativeCheckResp"; + // Set when a round starts, cleared when it ends (§11.4). Residue on the + // next launch means the previous process died mid-round: that launch + // resumes immediately instead of waiting 5s. + static final String KEY_ROUND_BREADCRUMB = "nativeCheckIncomplete"; + // Identifies the install for gray-release bucketing: must never change. + static final String KEY_UUID = "uuid"; + // The persisted native check config (JS syncNativeConfig or the host) the + // cold-start round and the crash hold run from. + static final String KEY_NATIVE_CONFIG = "nativeConfig"; + // "|" of the embedded bundle: keyed by the binary, + // so it invalidates itself on upgrade. + static final String KEY_BUNDLE_HASH_CACHE = "bundleHashCache"; + // + + private StoreSchema() { + } + + static StateCoreResult readState(SharedPreferences store) { + StateCoreResult state = new StateCoreResult(); + // (scripts/contract.ts; bun scripts/generate-contract.ts) + state.packageVersion = readString(store, KEY_PACKAGE_VERSION); + state.buildTime = readString(store, KEY_BUILD_TIME); + state.currentVersion = readString(store, KEY_CURRENT_VERSION); + state.lastVersion = readString(store, KEY_LAST_VERSION); + state.firstTime = readBoolean(store, KEY_FIRST_TIME, false); + state.firstTimeOk = readBoolean(store, KEY_FIRST_TIME_OK, true); + state.rolledBackVersion = readString(store, KEY_ROLLED_BACK_VERSION); + // + return state; + } + + static void writeState(SharedPreferences.Editor editor, StateCoreResult state) { + // (scripts/contract.ts; bun scripts/generate-contract.ts) + writeString(editor, KEY_PACKAGE_VERSION, state.packageVersion); + writeString(editor, KEY_BUILD_TIME, state.buildTime); + writeString(editor, KEY_CURRENT_VERSION, state.currentVersion); + writeString(editor, KEY_LAST_VERSION, state.lastVersion); + writeBoolean(editor, KEY_FIRST_TIME, state.firstTime); + writeBoolean(editor, KEY_FIRST_TIME_OK, state.firstTimeOk); + writeString(editor, KEY_ROLLED_BACK_VERSION, state.rolledBackVersion); + // + } + + /** The value, or null when absent or empty (the two read the same). */ + @Nullable + static String readString(SharedPreferences store, String key) { + String value = store.getString(key, null); + return value == null || value.isEmpty() ? null : value; + } + + static boolean readBoolean(SharedPreferences store, String key, boolean fallback) { + String value = store.getString(key, null); + if (BOOLEAN_TRUE.equals(value)) { + return true; + } + if (BOOLEAN_FALSE.equals(value)) { + return false; + } + return fallback; + } + + static boolean readMarker(SharedPreferences store, String key) { + return readBoolean(store, key, false); + } + + /** Removes the key for a null or empty value. */ + static void writeString(SharedPreferences.Editor editor, String key, @Nullable String value) { + if (value == null || value.isEmpty()) { + editor.remove(key); + } else { + editor.putString(key, value); + } + } + + static void writeBoolean(SharedPreferences.Editor editor, String key, boolean value) { + editor.putString(key, value ? BOOLEAN_TRUE : BOOLEAN_FALSE); + } + + static void writeMarker(SharedPreferences.Editor editor, String key, boolean set) { + if (set) { + editor.putString(key, BOOLEAN_TRUE); + } else { + editor.remove(key); + } + } +} 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 62025033..c90d291f 100644 --- a/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java +++ b/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java @@ -34,19 +34,6 @@ final class SyncCoordinator { // Version-info flag read by JS (metadata.ts), stored encoded. private static final String INFO_CRASH_HOLD = Texts.INFO_CRASH_HOLD; - // Shared with Harmony. The response cache lives in its own - // SharedPreferences file (UpdateContext.getNativeCheckCache). - // (scripts/contract.ts; bun scripts/generate-contract.ts) - // The persisted native check config (JS syncNativeConfig or the host). - static final String KEY_CONFIG = "nativeConfig"; - // Raw response cache for the JS side to reuse (§10.3), scoped to the exact - // logical request and native config that produced it. - static final String KEY_RESP_CACHE = "nativeCheckResp"; - // Set when a round starts, cleared when it ends (§11.4). Residue on the - // next launch means the previous process died mid-round: that launch - // resumes immediately instead of waiting 5s. - static final String KEY_ROUND_INCOMPLETE = "nativeCheckIncomplete"; - // // The check response (and a remote queryUrls list) is a small JSON // document; anything bigger is a broken or hijacked endpoint. private static final long MAX_QUERY_RESPONSE_BYTES = 1024 * 1024; @@ -104,7 +91,7 @@ static BundlePreparationResult prepareBundle(UpdateContext context, long timeout if (!nativeReady || sContext != context || !context.getIsUsingBundleUrl()) { return BundlePreparationResult.of(BundlePreparationResult.SKIPPED, "not_initialized"); } - String configJson = context.getKv(KEY_CONFIG); + String configJson = context.getKv(StoreSchema.KEY_NATIVE_CONFIG); BundlePreparationResult rejection = configRejection(configJson); if (rejection != null) { return rejection; @@ -122,7 +109,7 @@ static BundlePreparationResult prepareBundle(UpdateContext context, long timeout } if (roundConfigGeneration != UpdateContext.getNativeConfigGeneration() || !FlowBridge.configsEquivalent(configJson, roundConfigJson) - || !FlowBridge.configsEquivalent(configJson, context.getKv(KEY_CONFIG))) { + || !FlowBridge.configsEquivalent(configJson, context.getKv(StoreSchema.KEY_NATIVE_CONFIG))) { return BundlePreparationResult.of(BundlePreparationResult.CANCELLED, "config_changed"); } if (roundGeneration != UpdateContext.getResetGeneration()) { @@ -148,7 +135,7 @@ static void recordJsRound(String config) { private static boolean hasJsRound(UpdateContext context) { String jsConfig = sJsCompletedConfig; return jsConfig != null - && FlowBridge.configsEquivalent(jsConfig, context.getKv(KEY_CONFIG)); + && FlowBridge.configsEquivalent(jsConfig, context.getKv(StoreSchema.KEY_NATIVE_CONFIG)); } /** @@ -190,7 +177,7 @@ static void schedule(final UpdateContext context, final String launchRolledBackV nativeReady = true; // The crash-hold rescue shares the orchestrator's rollout gate: no // persisted config, no handler (§11.3). - if (context.getKv(KEY_CONFIG) != null) { + if (context.getKv(StoreSchema.KEY_NATIVE_CONFIG) != null) { CrashHold.install(); } Threads.startDaemon("pushy-native-sync", Thread.MIN_PRIORITY + 1, () -> { @@ -198,7 +185,7 @@ static void schedule(final UpdateContext context, final String launchRolledBackV // 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) { + if (context.getKv(StoreSchema.KEY_ROUND_BREADCRUMB) == null) { Thread.sleep(5000); } if (hasJsRound(context)) { @@ -221,7 +208,7 @@ static boolean isRoundInFlight() { } private static boolean hasRunnableConfig(UpdateContext context) { - return context != null && configRejection(context.getKv(KEY_CONFIG)) == null; + return context != null && configRejection(context.getKv(StoreSchema.KEY_NATIVE_CONFIG)) == null; } static void onConfigured(UpdateContext context) { @@ -298,7 +285,7 @@ private static void activatePendingVersion(UpdateContext context) { return; } String hashInfoJson = null; - String existingInfo = context.getKv("hash_" + hash); + String existingInfo = context.getKv(StoreSchema.KEY_HASH_INFO + hash); if (existingInfo != null) { try { JSONObject info = new JSONObject(existingInfo); @@ -488,7 +475,7 @@ static void runOnce(RoundHost host, long deadlineNanos) throws JSONException { roundGeneration = resetGeneration; roundConfigGeneration = configGeneration; roundResult = BundlePreparationResult.of(BundlePreparationResult.FAILED, "check_failed"); - String configJson = host.getKv(KEY_CONFIG); + String configJson = host.getKv(StoreSchema.KEY_NATIVE_CONFIG); roundConfigJson = configJson; JSONObject event = new JSONObject() @@ -520,7 +507,7 @@ static void runOnce(RoundHost host, long deadlineNanos) throws JSONException { if ("markIncomplete".equals(type)) { marked = true; try { - host.setKv(KEY_ROUND_INCOMPLETE, "1"); + host.setKv(StoreSchema.KEY_ROUND_BREADCRUMB, StoreSchema.BOOLEAN_TRUE); } catch (IllegalStateException ignored) { } } else if ("finish".equals(type)) { @@ -537,8 +524,8 @@ static void runOnce(RoundHost host, long deadlineNanos) throws JSONException { // without committing need the extra write. if (marked) { try { - if (host.getKv(KEY_ROUND_INCOMPLETE) != null) { - host.removeKv(KEY_ROUND_INCOMPLETE); + if (host.getKv(StoreSchema.KEY_ROUND_BREADCRUMB) != null) { + host.removeKv(StoreSchema.KEY_ROUND_BREADCRUMB); } } catch (IllegalStateException ignored) { } @@ -591,7 +578,7 @@ private static JSONObject launchEnv(UpdateContext context, String launchRolledBa 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")); + env.put("uuid", context.getKv(StoreSchema.KEY_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 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 eb7ed521..14a3c2c4 100644 --- a/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +++ b/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java @@ -23,10 +23,10 @@ public class UpdateContext { private final Context context; private final File rootDir; private final Executor executor; + // The state store and the cache store (StoreSchema): the response cache + // is a whole server response, and keeping it in sp would make every small + // state commit rewrite it. private final SharedPreferences sp; - // The native check's raw response cache (SyncCoordinator.KEY_RESP_CACHE) - // lives in its own file: it is a whole server response, and keeping it in - // sp made every small state commit rewrite it. private final SharedPreferences checkCacheSp; // Our own PackageInfo, looked up once: every getPackageInfo call is a // Binder IPC, and the constructor, the bundle-hash cache and the native @@ -65,7 +65,6 @@ public class UpdateContext { private static final int STATE_OP_CLEAR_ROLLBACK_MARK = 5; private static final int STATE_OP_RESOLVE_LAUNCH = 6; // - static final String KEY_FIRST_LOAD_MARKED = "firstLoadMarked"; // 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 @@ -118,9 +117,19 @@ private UpdateContext(Context context) { throw new IllegalStateException("Failed to create storage root dir: " + rootDir); } - this.sp = this.context.getSharedPreferences(Texts.PREFERENCES_NAME, Context.MODE_PRIVATE); + this.sp = this.context.getSharedPreferences(StoreSchema.STATE_STORE, Context.MODE_PRIVATE); this.checkCacheSp = this.context.getSharedPreferences( - Texts.PREFERENCES_NAME + "_check", Context.MODE_PRIVATE); + StoreSchema.CACHE_STORE, Context.MODE_PRIVATE); + // Before anything reads the state store (the one-time move out of the + // legacy files; a no-op once done). + StoreMigration.Result migration = StoreMigration.run( + sp, + this.context.getSharedPreferences(Texts.PREFERENCES_NAME, Context.MODE_PRIVATE), + this.context.getSharedPreferences(Texts.PREFERENCES_NAME + "_check", Context.MODE_PRIVATE), + StoreMigration.NATIVE); + if (migration != StoreMigration.Result.CURRENT) { + Log.i(TAG, "State store migration: " + migration); + } this.packageInfo = lookupPackageInfo(this.context); this.reactInstanceManager = pendingReactInstanceManager; @@ -140,25 +149,10 @@ private UpdateContext(Context context) { applyState(editor, nextState); persistEditor(editor, "sync state with binary version"); clearNativeCheckCache(); - } else { - migrateNativeCheckCache(); } startBundleHashComputation(); } - // One-time move of the response cache out of the main state file. - private void migrateNativeCheckCache() { - String legacy = sp.getString(SyncCoordinator.KEY_RESP_CACHE, null); - if (legacy == null) { - return; - } - if (checkCacheSp.edit().putString(SyncCoordinator.KEY_RESP_CACHE, legacy).commit()) { - SharedPreferences.Editor editor = sp.edit(); - editor.remove(SyncCoordinator.KEY_RESP_CACHE); - persistEditor(editor, "migrate check cache"); - } - } - public String getRootDir() { return rootDir.toString(); } @@ -190,8 +184,6 @@ public boolean getIsUsingBundleUrl() { // bundleHash cache: "||". The // key identifies the installed binary; every (re)install changes // lastUpdateTime, so the hash is recomputed once per install. - private static final String KEY_BUNDLE_HASH_CACHE = "bundleHashCache"; - public interface BundleHashListener { void onBundleHash(String hash); } @@ -283,14 +275,14 @@ private void publishBundleHash(String hash) { private String loadOrHashBundledAsset() { String cachePrefix = getPackageVersion() + "|" + getPackageLastUpdateTime() + "|"; - String cached = sp.getString(KEY_BUNDLE_HASH_CACHE, null); + String cached = StoreSchema.readString(sp, StoreSchema.KEY_BUNDLE_HASH_CACHE); if (cached != null && cached.startsWith(cachePrefix)) { return cached.substring(cachePrefix.length()); } String hash = sha256OfBundledAsset("index.android.bundle"); if (!hash.isEmpty()) { SharedPreferences.Editor editor = sp.edit(); - editor.putString(KEY_BUNDLE_HASH_CACHE, cachePrefix + hash); + editor.putString(StoreSchema.KEY_BUNDLE_HASH_CACHE, cachePrefix + hash); persistEditor(editor, "cache bundle hash"); } return hash; @@ -415,27 +407,7 @@ DownloadTaskParams downloadPatchFromPpk( } private StateCoreResult getStateSnapshot() { - StateCoreResult state = new StateCoreResult(); - state.packageVersion = sp.getString("packageVersion", null); - state.buildTime = sp.getString("buildTime", null); - state.currentVersion = sp.getString("currentVersion", null); - state.lastVersion = sp.getString("lastVersion", null); - state.firstTime = sp.getBoolean("firstTime", false); - state.firstTimeOk = sp.getBoolean("firstTimeOk", true); - state.rolledBackVersion = sp.getString("rolledBackVersion", null); - return state; - } - - private static void putNullableString( - SharedPreferences.Editor editor, - String key, - String value - ) { - if (value == null) { - editor.remove(key); - } else { - editor.putString(key, value); - } + return StoreSchema.readState(sp); } // Not editor.clear(): that also wiped the uuid (re-bucketing every gray @@ -448,13 +420,7 @@ private void removeKeys(SharedPreferences.Editor editor, java.util.List } private void applyState(SharedPreferences.Editor editor, StateCoreResult state) { - putNullableString(editor, "packageVersion", state.packageVersion); - putNullableString(editor, "buildTime", state.buildTime); - putNullableString(editor, "currentVersion", state.currentVersion); - putNullableString(editor, "lastVersion", state.lastVersion); - editor.putBoolean("firstTime", state.firstTime); - editor.putBoolean("firstTimeOk", state.firstTimeOk); - putNullableString(editor, "rolledBackVersion", state.rolledBackVersion); + StoreSchema.writeState(editor, state); } /** @@ -543,8 +509,9 @@ public void setKv(String key, String value) { persistEditorOrThrow(editor, "set key " + key); } + @Nullable public String getKv(String key) { - return sp.getString(key, null); + return StoreSchema.readString(sp, key); } void removeKv(String key) { @@ -554,7 +521,7 @@ void removeKv(String key) { } public String getCurrentVersion() { - return sp.getString("currentVersion", null); + return StoreSchema.readString(sp, StoreSchema.KEY_CURRENT_VERSION); } /** The one-shot launch markers getConstants reports, consumed together. */ @@ -578,13 +545,13 @@ static final class LaunchMarkers { LaunchMarkers consumeLaunchMarkers() { LaunchMarkers markers; synchronized (commitLock) { - boolean isFirstLoadMarked = sp.getBoolean(KEY_FIRST_LOAD_MARKED, false); + boolean isFirstLoadMarked = StoreSchema.readMarker(sp, StoreSchema.KEY_FIRST_LOAD_MARKER); String rolledBackVersion = rolledBackVersion(); markers = new LaunchMarkers(isFirstLoadMarked, rolledBackVersion); if (isFirstLoadMarked || rolledBackVersion != null) { SharedPreferences.Editor editor = sp.edit(); if (isFirstLoadMarked) { - editor.remove(KEY_FIRST_LOAD_MARKED); + StoreSchema.writeMarker(editor, StoreSchema.KEY_FIRST_LOAD_MARKER, false); } if (rolledBackVersion != null) { applyState(editor, runStateOp(STATE_OP_CLEAR_ROLLBACK_MARK, null)); @@ -604,7 +571,7 @@ LaunchMarkers consumeLaunchMarkers() { } public String rolledBackVersion() { - return sp.getString("rolledBackVersion", null); + return StoreSchema.readString(sp, StoreSchema.KEY_ROLLED_BACK_VERSION); } public void markSuccess() { @@ -616,7 +583,7 @@ public void markSuccess() { SharedPreferences.Editor editor = sp.edit(); applyState(editor, nextState); if (nextState.staleVersionToDelete != null) { - editor.remove("hash_" + nextState.staleVersionToDelete); + editor.remove(StoreSchema.KEY_HASH_INFO + nextState.staleVersionToDelete); } persistEditorOrThrow(editor, "mark success"); } @@ -731,7 +698,7 @@ public String getBundleUrl(String defaultAssetsUrl) { SharedPreferences.Editor editor = sp.edit(); applyState(editor, launchState); if (launchState.consumedFirstTime) { - editor.putBoolean(KEY_FIRST_LOAD_MARKED, true); + StoreSchema.writeMarker(editor, StoreSchema.KEY_FIRST_LOAD_MARKER, true); } persistEditor(editor, "resolve launch"); } @@ -789,7 +756,7 @@ void setNativeConfig(String config) { // 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)); + config, StoreSchema.readString(sp, StoreSchema.KEY_NATIVE_CONFIG)); SharedPreferences.Editor editor = sp.edit(); if (changed) { // Also invalidate on a failed persistence attempt: an older @@ -804,11 +771,10 @@ void setNativeConfig(String config) { } // A native-only first launch needs a stable gray-release identity // before JS initializes. Never replace an existing installation ID. - String uuid = sp.getString("uuid", null); - if (uuid == null || uuid.isEmpty()) { - editor.putString("uuid", java.util.UUID.randomUUID().toString()); + if (StoreSchema.readString(sp, StoreSchema.KEY_UUID) == null) { + editor.putString(StoreSchema.KEY_UUID, java.util.UUID.randomUUID().toString()); } - editor.putString(SyncCoordinator.KEY_CONFIG, config); + editor.putString(StoreSchema.KEY_NATIVE_CONFIG, config); // Persist even an equal value: a previous commit may have updated // SharedPreferences memory but failed to write its file. persistEditorOrThrow(editor, "persist configuration"); @@ -864,14 +830,14 @@ private boolean commitSyncResultState( } SharedPreferences.Editor editor = sp.edit(); if (hash != null && hashInfoJson != null) { - editor.putString("hash_" + hash, hashInfoJson); + editor.putString(StoreSchema.KEY_HASH_INFO + hash, hashInfoJson); } if (switching) { applyState(editor, computeSwitchState(hash)); } // The round is over once its result lands: clear the // interrupted-round breadcrumb in the same commit. - editor.remove(SyncCoordinator.KEY_ROUND_INCOMPLETE); + editor.remove(StoreSchema.KEY_ROUND_BREADCRUMB); persistEditorOrThrow(editor, "commit sync result"); if (switching) { ignoreRollback = false; @@ -880,7 +846,7 @@ private boolean commitSyncResultState( // describes, still under commitLock so a reset cannot interleave. if (responseCacheJson != null && !checkCacheSp.edit() - .putString(SyncCoordinator.KEY_RESP_CACHE, responseCacheJson).commit()) { + .putString(StoreSchema.KEY_CHECK_CACHE, responseCacheJson).commit()) { Log.w(TAG, "Failed to persist native sync response cache"); } return true; @@ -910,14 +876,14 @@ private String readBundleSha256(String hash) { /** Raw native check response for JS (§10.3); null when absent. */ @Nullable String getNativeCheckCache() { - return checkCacheSp.getString(SyncCoordinator.KEY_RESP_CACHE, null); + return StoreSchema.readString(checkCacheSp, StoreSchema.KEY_CHECK_CACHE); } private boolean clearNativeCheckCache() { - if (!checkCacheSp.contains(SyncCoordinator.KEY_RESP_CACHE)) { + if (!checkCacheSp.contains(StoreSchema.KEY_CHECK_CACHE)) { return true; } - if (!checkCacheSp.edit().remove(SyncCoordinator.KEY_RESP_CACHE).commit()) { + if (!checkCacheSp.edit().remove(StoreSchema.KEY_CHECK_CACHE).commit()) { Log.e(TAG, "Failed to clear native sync response cache"); return false; } @@ -970,8 +936,8 @@ void runCleanupWithLatestState(CleanupAction action) { final String[] keepNames; synchronized (commitLock) { keepNames = CleanupKeepNames.select( - sp.getString("currentVersion", null), - sp.getString("lastVersion", null), + StoreSchema.readString(sp, StoreSchema.KEY_CURRENT_VERSION), + StoreSchema.readString(sp, StoreSchema.KEY_LAST_VERSION), launchVersion ); } 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 c0f98055..79294aab 100644 --- a/android/src/main/java/cn/reactnative/modules/update/UpdateModule.java +++ b/android/src/main/java/cn/reactnative/modules/update/UpdateModule.java @@ -49,7 +49,7 @@ protected Map getTypedExportedConstants() { String currentVersion = updateContext.getCurrentVersion(); constants.put("currentVersion", currentVersion); - constants.put("currentVersionInfo", updateContext.getKv("hash_" + currentVersion)); + constants.put("currentVersionInfo", updateContext.getKv(StoreSchema.KEY_HASH_INFO + currentVersion)); // Served from the value cached at launch resolution (no file read on // the JS thread) when currentVersion is the launched version. constants.put("currentBundleSha256", updateContext.currentBundleSha256(currentVersion)); @@ -62,7 +62,7 @@ protected Map getTypedExportedConstants() { constants.put("isFirstTime", markers.isFirstTime); constants.put("rolledBackVersion", markers.rolledBackVersion); - constants.put("uuid", updateContext.getKv("uuid")); + constants.put("uuid", updateContext.getKv(StoreSchema.KEY_UUID)); int supportedDiffVersion = 0; try { supportedDiffVersion = NativeCore.supportedDiffVersion(); @@ -363,7 +363,7 @@ public void setUuid(final String uuid, final Promise promise) { return; } StateSerialRunner.run(STATE_SERIAL, promise, ErrorCodes.FILE_OPERATION_FAILED, "setUuid", () -> { - updateContext.setKv("uuid", uuid); + updateContext.setKv(StoreSchema.KEY_UUID, uuid); promise.resolve(true); }); } @@ -381,7 +381,7 @@ 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(STATE_SERIAL, promise, ErrorCodes.FILE_OPERATION_FAILED, "setLocalHashInfo", () -> { - updateContext.setKv("hash_" + hash, info); + updateContext.setKv(StoreSchema.KEY_HASH_INFO + hash, info); promise.resolve(true); }); } @@ -392,7 +392,7 @@ public void getLocalHashInfo(final String hash, final Promise promise) { if (requireNonEmpty(hash, "hash", promise) == null) { return; } - String value = updateContext.getKv("hash_" + hash); + String value = updateContext.getKv(StoreSchema.KEY_HASH_INFO + hash); if (value == null || value.isEmpty()) { // No record (iOS resolves nil the same way): JS treats it as {}. // Only a record that exists and is not a JSON object is invalid. 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 95d34c69..002390a8 100644 --- a/android/src/main/java/cn/reactnative/modules/update/VersionScopedKeys.java +++ b/android/src/main/java/cn/reactnative/modules/update/VersionScopedKeys.java @@ -14,17 +14,15 @@ * 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}; + // What a reset to the packaged bundle and a binary upgrade delete from the + // state store besides the state fields (scripts/contract.ts storedItems; + // the cache store is cleared on both, every other item survives both: the + // uuid, the native config, the round breadcrumb, the bundle hash cache). + static final String[] RESET_KEYS = {StoreSchema.KEY_FIRST_LOAD_MARKER}; + static final String[] RESET_PREFIXES = {StoreSchema.KEY_HASH_INFO}; + static final String[] UPGRADE_KEYS = {StoreSchema.KEY_FIRST_LOAD_MARKER}; + static final String[] UPGRADE_PREFIXES = {StoreSchema.KEY_HASH_INFO}; // private VersionScopedKeys() { diff --git a/android/src/test/java/cn/reactnative/modules/update/StoreMigrationTest.java b/android/src/test/java/cn/reactnative/modules/update/StoreMigrationTest.java new file mode 100644 index 00000000..b9817861 --- /dev/null +++ b/android/src/test/java/cn/reactnative/modules/update/StoreMigrationTest.java @@ -0,0 +1,358 @@ +package cn.reactnative.modules.update; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import android.content.SharedPreferences; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.Test; + +// The Android side of the persisted layout: StoreSchema's State mapping and +// StoreMigration's IO around the shared core's plan, against +// cpp/patch_core/tests/stored-items-vectors.json. The vectors' plans stand in +// for the core (the .so does not load on the JVM); the legacy values are +// typed as SharedPreferences returns them. +public class StoreMigrationTest { + /** An in-memory SharedPreferences whose commit can be made to fail. */ + static final class FakePrefs implements SharedPreferences { + final Map values = new HashMap<>(); + boolean failCommit; + int commits; + + FakePrefs(Map initial) { + values.putAll(initial); + } + + @Override + public Map getAll() { + return new HashMap<>(values); + } + + @Override + public String getString(String key, String fallback) { + // ClassCastException like the platform for a non-string value. + return values.containsKey(key) ? (String) values.get(key) : fallback; + } + + @Override + public Set getStringSet(String key, Set fallback) { + throw new UnsupportedOperationException(); + } + + @Override + public int getInt(String key, int fallback) { + throw new UnsupportedOperationException(); + } + + @Override + public long getLong(String key, long fallback) { + throw new UnsupportedOperationException(); + } + + @Override + public float getFloat(String key, float fallback) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean getBoolean(String key, boolean fallback) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean contains(String key) { + return values.containsKey(key); + } + + @Override + public Editor edit() { + return new FakeEditor(); + } + + @Override + public void registerOnSharedPreferenceChangeListener( + OnSharedPreferenceChangeListener listener) { + } + + @Override + public void unregisterOnSharedPreferenceChangeListener( + OnSharedPreferenceChangeListener listener) { + } + + final class FakeEditor implements Editor { + private final Map puts = new HashMap<>(); + private final Set removes = new HashSet<>(); + + @Override + public Editor putString(String key, String value) { + puts.put(key, value); + return this; + } + + @Override + public Editor putStringSet(String key, Set value) { + throw new UnsupportedOperationException(); + } + + @Override + public Editor putInt(String key, int value) { + throw new UnsupportedOperationException(); + } + + @Override + public Editor putLong(String key, long value) { + throw new UnsupportedOperationException(); + } + + @Override + public Editor putFloat(String key, float value) { + throw new UnsupportedOperationException(); + } + + @Override + public Editor putBoolean(String key, boolean value) { + throw new UnsupportedOperationException("the layout stores text only"); + } + + @Override + public Editor remove(String key) { + removes.add(key); + return this; + } + + @Override + public Editor clear() { + throw new UnsupportedOperationException("never clear a whole store"); + } + + // Like the platform: memory changes even when the disk write + // fails, and removes apply before puts. + @Override + public boolean commit() { + apply(); + commits++; + return !failCommit; + } + + @Override + public void apply() { + for (String key : removes) { + values.remove(key); + } + values.putAll(puts); + } + } + } + + private static JSONArray vectors() throws IOException, JSONException { + String path = "cpp/patch_core/tests/stored-items-vectors.json"; + File dir = new File(System.getProperty("user.dir")).getAbsoluteFile(); + while (dir != null && !new File(dir, path).isFile()) { + dir = dir.getParentFile(); + } + if (dir == null) { + throw new IOException(path + " not found above " + System.getProperty("user.dir")); + } + return new JSONObject(new String( + Files.readAllBytes(new File(dir, path).toPath()), StandardCharsets.UTF_8)) + .getJSONArray("cases"); + } + + // A vector's legacy values as SharedPreferences holds them. + private static Map typed(JSONObject object) throws JSONException { + Map values = new HashMap<>(); + for (Iterator keys = object.keys(); keys.hasNext(); ) { + String key = keys.next(); + Object value = object.get(key); + if (value instanceof JSONObject || value instanceof JSONArray) { + Set set = new HashSet<>(); + set.add(value.toString()); + value = set; + } + values.put(key, value); + } + return values; + } + + // How the core reads a legacy value (see the vectors' doc). + private static Map text(Map values) { + Map text = new HashMap<>(); + for (Map.Entry entry : values.entrySet()) { + Object value = entry.getValue(); + text.put(entry.getKey(), value instanceof Boolean + ? ((Boolean) value ? "1" : "0") + : value instanceof String || value instanceof Number ? value.toString() : ""); + } + return text; + } + + private static Map pairs(String[] flat) { + Map pairs = new HashMap<>(); + for (int i = 0; i + 1 < flat.length; i += 2) { + pairs.put(flat[i], flat[i + 1]); + } + return pairs; + } + + @Test + public void replaysTheSharedVectors() throws Exception { + JSONArray cases = vectors(); + int replayed = 0; + for (int i = 0; i < cases.length(); i++) { + JSONObject vector = cases.getJSONObject(i); + if (vector.isNull("plan")) { + continue; + } + String name = vector.getString("name"); + Map legacyValues = typed(vector.getJSONObject("legacy")); + // The response cache is the legacy "_check" file's. + Map cacheValues = new HashMap<>(); + Object cache = legacyValues.remove(StoreSchema.KEY_CHECK_CACHE); + if (cache != null) { + cacheValues.put(StoreSchema.KEY_CHECK_CACHE, cache); + } + FakePrefs legacy = new FakePrefs(legacyValues); + FakePrefs legacyCache = new FakePrefs(cacheValues); + FakePrefs state = new FakePrefs(typed(vector.getJSONObject("current"))); + JSONObject plan = vector.getJSONObject("plan"); + final Map wantLegacy = text(typed(vector.getJSONObject("legacy"))); + final Map wantCurrent = text(typed(vector.getJSONObject("current"))); + + StoreMigration.Result result = StoreMigration.run( + state, legacy, legacyCache, (layout, legacyFlat, currentFlat) -> { + assertEquals(name, StoreSchema.LEGACY_LAYOUT, layout); + assertEquals(name, wantLegacy, pairs(legacyFlat)); + assertEquals(name, wantCurrent, pairs(currentFlat)); + return plan.toString(); + }); + + assertEquals(name, plan.getBoolean("migrated") + ? StoreMigration.Result.MIGRATED : StoreMigration.Result.CURRENT, result); + JSONObject after = vector.getJSONObject("after"); + assertEquals(name, typed(after.getJSONObject("current")), state.values); + Map legacyAfter = new HashMap<>(legacy.values); + legacyAfter.putAll(legacyCache.values); + assertEquals(name, text(typed(after.getJSONObject("legacy"))), text(legacyAfter)); + // The puts land in one commit. + assertTrue(name, state.commits <= 1); + replayed++; + } + assertTrue(replayed >= 10); + } + + private static JSONObject firstVector() throws Exception { + return vectors().getJSONObject(0); + } + + @Test + public void theLegacyKeysStayUntilTheStateStoreIsOnDisk() throws Exception { + JSONObject vector = firstVector(); + FakePrefs legacy = new FakePrefs(typed(vector.getJSONObject("legacy"))); + FakePrefs legacyCache = new FakePrefs(new HashMap<>()); + FakePrefs state = new FakePrefs(new HashMap<>()); + state.failCommit = true; + String plan = vector.getJSONObject("plan").toString(); + + assertEquals(StoreMigration.Result.RETRY, + StoreMigration.run(state, legacy, legacyCache, (l, a, b) -> plan)); + assertEquals(typed(vector.getJSONObject("legacy")), legacy.values); + // In memory already: this launch reads the migrated items. + assertEquals("install-uuid", StoreSchema.readString(state, StoreSchema.KEY_UUID)); + } + + @Test + public void aFailedCoreKeepsTheUuidAndRetriesNextLaunch() throws Exception { + JSONObject vector = firstVector(); + FakePrefs legacy = new FakePrefs(typed(vector.getJSONObject("legacy"))); + FakePrefs legacyCache = new FakePrefs(new HashMap<>()); + FakePrefs state = new FakePrefs(new HashMap<>()); + List failures = new ArrayList<>(); + failures.add((l, a, b) -> null); + failures.add((l, a, b) -> "null"); + failures.add((l, a, b) -> { + throw new UnsatisfiedLinkError("no native"); + }); + failures.add((l, a, b) -> "{not json"); + for (StoreMigration.Planner planner : failures) { + state.values.clear(); + assertEquals(StoreMigration.Result.FALLBACK, + StoreMigration.run(state, legacy, legacyCache, planner)); + Map uuidOnly = new HashMap<>(); + uuidOnly.put(StoreSchema.KEY_UUID, "install-uuid"); + assertEquals(uuidOnly, state.values); + assertEquals(typed(vector.getJSONObject("legacy")), legacy.values); + } + String plan = vector.getJSONObject("plan").toString(); + // The core's plan never overwrites what the store holds, so the + // carried uuid is simply kept (the vector's plan is for an empty store). + state.values.clear(); + assertEquals(StoreMigration.Result.MIGRATED, + StoreMigration.run(state, legacy, legacyCache, (l, a, b) -> plan)); + assertEquals(typed(vector.getJSONObject("after").getJSONObject("current")), state.values); + } + + @Test + public void flattenReadsLegacyValuesAsText() { + Map values = new HashMap<>(); + values.put("on", true); + values.put("off", false); + values.put("count", 3); + values.put("text", "x"); + values.put("set", new HashSet()); + Map want = new HashMap<>(); + want.put("on", "1"); + want.put("off", "0"); + want.put("count", "3"); + want.put("text", "x"); + want.put("set", ""); + assertEquals(want, pairs(StoreMigration.flatten(values))); + } + + @Test + public void theStateMappingWritesTextAndReadsItBack() { + FakePrefs store = new FakePrefs(new HashMap<>()); + StateCoreResult empty = StoreSchema.readState(store); + assertNull(empty.currentVersion); + assertFalse(empty.firstTime); + assertTrue(empty.firstTimeOk); + + StateCoreResult state = new StateCoreResult(); + state.packageVersion = "1.0"; + state.buildTime = "28000000"; + state.currentVersion = "b"; + state.lastVersion = ""; + state.firstTime = true; + state.firstTimeOk = false; + SharedPreferences.Editor editor = store.edit(); + StoreSchema.writeState(editor, state); + editor.commit(); + Map want = new HashMap<>(); + want.put("packageVersion", "1.0"); + want.put("buildTime", "28000000"); + want.put("currentVersion", "b"); + want.put("firstTime", "1"); + want.put("firstTimeOk", "0"); + assertEquals(want, store.values); + + StateCoreResult read = StoreSchema.readState(store); + assertEquals("b", read.currentVersion); + assertNull(read.lastVersion); + assertTrue(read.firstTime); + assertFalse(read.firstTimeOk); + } +} diff --git a/android/src/test/java/cn/reactnative/modules/update/SyncRoundReplayTest.java b/android/src/test/java/cn/reactnative/modules/update/SyncRoundReplayTest.java index 4b672fd7..9101c1c5 100644 --- a/android/src/test/java/cn/reactnative/modules/update/SyncRoundReplayTest.java +++ b/android/src/test/java/cn/reactnative/modules/update/SyncRoundReplayTest.java @@ -340,7 +340,7 @@ public void replaysTheRound() throws Exception { JSONObject start = round.start(); String config = start.getString("config"); if (!config.isEmpty()) { - round.kv.put(SyncCoordinator.KEY_CONFIG, config); + round.kv.put(StoreSchema.KEY_NATIVE_CONFIG, config); } double deadline = start.getDouble("deadline"); @@ -407,7 +407,7 @@ public void replaysTheRound() throws Exception { assertFalse(result.isActivated()); } // However the round ends, the breadcrumb does not survive it. - assertFalse(round.kv.containsKey(SyncCoordinator.KEY_ROUND_INCOMPLETE)); + assertFalse(round.kv.containsKey(StoreSchema.KEY_ROUND_BREADCRUMB)); } @Test 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 a3f781ef..aa2337da 100644 --- a/android/src/test/java/cn/reactnative/modules/update/VersionScopedKeysTest.java +++ b/android/src/test/java/cn/reactnative/modules/update/VersionScopedKeysTest.java @@ -9,6 +9,7 @@ public class VersionScopedKeysTest { private static final List STORED = Arrays.asList( + "schemaVersion", "uuid", "nativeConfig", "bundleHashCache", @@ -18,14 +19,14 @@ public class VersionScopedKeysTest { "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. + // launch resumes the round without the start delay. The response cache + // lives in the cache store, which both events clear as a whole. private static final HashSet VERSION_KEYS = new HashSet<>(Arrays.asList( - "hash_abc", "hash_def", "firstLoadMarked", "nativeCheckResp")); + "hash_abc", "hash_def", "firstLoadMarked")); @Test public void resetDropsVersionKeysAndKeepsInstallKeys() { @@ -39,9 +40,9 @@ public void upgradeDropsVersionKeysAndKeepsInstallKeys() { @Test public void keyNamesMatchTheirOwners() { - assertEquals("firstLoadMarked", UpdateContext.KEY_FIRST_LOAD_MARKED); - assertEquals("nativeCheckIncomplete", SyncCoordinator.KEY_ROUND_INCOMPLETE); - assertEquals("nativeCheckResp", SyncCoordinator.KEY_RESP_CACHE); - assertEquals("nativeConfig", SyncCoordinator.KEY_CONFIG); + assertEquals("firstLoadMarked", StoreSchema.KEY_FIRST_LOAD_MARKER); + assertEquals("nativeCheckIncomplete", StoreSchema.KEY_ROUND_BREADCRUMB); + assertEquals("nativeCheckResp", StoreSchema.KEY_CHECK_CACHE); + assertEquals("nativeConfig", StoreSchema.KEY_NATIVE_CONFIG); } } diff --git a/cpp/android_jni/jni_natives.h b/cpp/android_jni/jni_natives.h index 809ae508..f0ae9d31 100644 --- a/cpp/android_jni/jni_natives.h +++ b/cpp/android_jni/jni_natives.h @@ -38,6 +38,7 @@ jlongArray ArchivePlanFreeSpaceProbe(JNIEnv* env, jclass, jlong known_total, jlo jstring ResumePlanRequest(JNIEnv* env, jclass, jstring sidecar, jstring url, jlong partial_size); jstring ResumeClassifyResponse(JNIEnv* env, jclass, jint status, jlong resume_offset, jstring content_range, jstring content_encoding, jlong content_length, jstring sidecar, jstring url, jlong partial_size); jstring ResumeSidecar(JNIEnv* env, jclass, jstring url, jstring etag, jstring last_modified, jstring previous_sidecar, jlong total); +jstring StoredItemsMigrate(JNIEnv* env, jclass, jint legacy_layout, jobjectArray legacy, jobjectArray current); // } // namespace jni_natives diff --git a/cpp/android_jni/jni_registration.cpp b/cpp/android_jni/jni_registration.cpp index e16ec399..ecef9e7d 100644 --- a/cpp/android_jni/jni_registration.cpp +++ b/cpp/android_jni/jni_registration.cpp @@ -94,6 +94,11 @@ const std::vector& NativeClasses() { {"291ef0d4ad8a7a", "723bfed0b88a274923311bb6e5a7826444205fcdf4daae943d432d07e18c93a9887e5a3655c7c2a4949e3355371df78299937648503c43d9d8ae9a68092f0113fd9887857c422602b9d595959c72462c45ebc5afb9d44b412006e2cefd", reinterpret_cast(&pushy::jni_natives::ResumeSidecar)}, }}, + {"3919bbc3ab8a6b512c3e08f0c0b6df60452311edfbc8f780624b2d1de38c93a99565511c07ecdaa496967357", // StoreMigration + { + {"371ef3c3af9f6d7636300efcd29a84684734", "723ecffda48a7e446d331df7d1fca379582e0ae6a5e0949f73592d46eac2aebad544402307e5cffecbb376582012bfc1ab89630e6d2f0afcdca8d7", + reinterpret_cast(&pushy::jni_natives::StoredItemsMigrate)}, + }}, // }; return classes; diff --git a/cpp/android_jni/stored_items_jni.cpp b/cpp/android_jni/stored_items_jni.cpp new file mode 100644 index 00000000..526737a7 --- /dev/null +++ b/cpp/android_jni/stored_items_jni.cpp @@ -0,0 +1,28 @@ +// JNI surface of the persisted-layout migration (cpp/patch_core/ +// stored_items.h) for StoreMigration.java. Flat key/value arrays in, JSON +// text out; a null return means the core threw (memory ran out) and the +// caller falls back to carrying the uuid alone — a C++ exception must never +// unwind through the JNI boundary. +#include + +#include "jni_natives.h" + +#include +#include + +#include "jni_util.h" +#include "../patch_core/stored_items.h" + +using pushy::jni_util::JArrayToVector; +using pushy::jni_util::ToJString; + +jstring pushy::jni_natives::StoredItemsMigrate(JNIEnv* env, jclass, jint legacy_layout, + jobjectArray legacy, jobjectArray current) { + try { + return ToJString(env, pushy::stored::MigrateStoredItemsJson( + static_cast(legacy_layout), JArrayToVector(env, legacy), + JArrayToVector(env, current))); + } catch (...) { + return nullptr; + } +} diff --git a/cpp/patch_core/native_texts.h b/cpp/patch_core/native_texts.h index f8912392..89930549 100644 --- a/cpp/patch_core/native_texts.h +++ b/cpp/patch_core/native_texts.h @@ -63,10 +63,6 @@ inline const std::string& PackageDeltaSuffix() { static const std::string value = text::Reveal("741ee4d0e09b69512137"); return value; } -inline const std::string& PackageUpdatedMarkedKey() { - static const std::string value = text::Reveal("0832d5f29aa549710b0939daf88ca058790f3dded7e888b451640d2ec3f69099bb43711523cafa8ea7bb4372132a"); - return value; -} inline const std::string& LogTag() { static const std::string value = text::Reveal("2812f5d2bac6664436360afc9ba680694b3301"); return value; diff --git a/cpp/patch_core/stored_items.cpp b/cpp/patch_core/stored_items.cpp new file mode 100644 index 00000000..a859d20a --- /dev/null +++ b/cpp/patch_core/stored_items.cpp @@ -0,0 +1,253 @@ +#include "stored_items.h" + +#include + +#include "../update_flow_core/flow_json.h" +#include "obscured_text.h" + +namespace pushy { +namespace stored { +namespace { + +struct Carried { + std::string to; + std::string from; + // A flag: any legacy value means set, stored as "1". + bool marker; +}; + +struct LegacyLayout { + int id; + std::vector carried; + std::vector dropped; + std::vector dropped_prefixes; +}; + +// Built on first use: the keys with update wording are encoded (see +// scripts/encode-native-text.ts) and revealed at run time. +const std::vector& LegacyLayouts() { + static const std::vector* layouts = new std::vector{ + // (scripts/contract.ts; bun scripts/generate-contract.ts) + // Android: SharedPreferences Texts.PREFERENCES_NAME, and its "_check" + // file for the response cache; native booleans. + {kLegacyAndroid, + // Carried: {key, legacy key, marker}. + { + {"uuid", "uuid", false}, + {"nativeConfig", "nativeConfig", false}, + {"nativeCheckIncomplete", "nativeCheckIncomplete", true}, + {"bundleHashCache", "bundleHashCache", false}, + {"packageVersion", "packageVersion", false}, + {"buildTime", "buildTime", false}, + }, + // Dropped. + { + "currentVersion", + "lastVersion", + "firstTime", + "firstTimeOk", + "rolledBackVersion", + "firstLoadMarked", + "nativeCheckResp", + "rolledBack", + "shouldRollback", + "blockUntil", + "blockReason", + }, + {"hash_"}}, + // iOS: standardUserDefaults, shared with the host app: only these keys + // are ours (the unprefixed packageVersion/buildTime that SDKs before + // 10.5x wrote are left alone, they may be the host's). + {kLegacyIos, + // Carried: {key, legacy key, marker}. + { + {"uuid", "REACTNATIVECN_PUSHY_UUID", false}, + {"nativeConfig", "REACTNATIVECN_PUSHY_NATIVE_CONFIG_KEY", false}, + {"nativeCheckIncomplete", "REACTNATIVECN_PUSHY_NATIVE_CHECK_INCOMPLETE_KEY", true}, + {"bundleHashCache", "REACTNATIVECN_PUSHY_BUNDLEHASH_KEY", false}, + {"packageVersion", "REACTNATIVECN_PUSHY_PACKAGEVERSION", false}, + {"buildTime", "REACTNATIVECN_PUSHY_BUILDTIME", false}, + }, + // Dropped. + { + "REACTNATIVECN_PUSHY_INFO_KEY", + "REACTNATIVECN_PUSHY_FIRSTLOADMARKED_KEY", + "REACTNATIVECN_PUSHY_ROLLEDBACKMARKED_KEY", + text::Reveal("0832d5f29aa549710b0939daf88ca058790f3dded7e888b451640d2ec3f69099bb43711523cafa8ea7bb4372132a"), + "REACTNATIVECN_PUSHY_NATIVE_CHECK_RESP_KEY", + text::Reveal("0832d5f29aa549710b0939daf88ca058790f3ddedcf797b6597a1c2dc7f785"), + "REACTNATIVECN_PUSHY_BUNDLEHASH_IDENTITY", + "REACTNATIVECN_PUSHY_BUNDLEHASH_VALUE", + }, + {"REACTNATIVECN_PUSHY_HASH_"}}, + // Harmony: preferences Texts.PREFERENCES_NAME; booleans native or "true". + {kLegacyHarmony, + // Carried: {key, legacy key, marker}. + { + {"uuid", "uuid", false}, + {"nativeConfig", "nativeConfig", false}, + {"nativeCheckIncomplete", "nativeCheckIncomplete", true}, + {"bundleHashCache", "bundleHashCache", false}, + {"packageVersion", "packageVersion", false}, + {"buildTime", "buildTime", false}, + }, + // Dropped. + { + "currentVersion", + "lastVersion", + "firstTime", + "firstTimeOk", + "rolledBackVersion", + "firstLoadMarked", + "nativeCheckResp", + "rolledBack", + "shouldRollback", + "blockUntil", + "blockReason", + }, + {"hash_"}}, + // + }; + return *layouts; +} + +const LegacyLayout* FindLayout(int id) { + for (const LegacyLayout& layout : LegacyLayouts()) { + if (layout.id == id) { + return &layout; + } + } + return nullptr; +} + +bool Owns(const LegacyLayout& layout, const std::string& key) { + for (const Carried& item : layout.carried) { + if (item.from == key) { + return true; + } + } + if (std::find(layout.dropped.begin(), layout.dropped.end(), key) != + layout.dropped.end()) { + return true; + } + for (const std::string& prefix : layout.dropped_prefixes) { + if (key.compare(0, prefix.size(), prefix) == 0) { + return true; + } + } + return false; +} + +// The value of `key`, or "" when absent. +const std::string& Lookup(const Entries& entries, const std::string& key) { + static const std::string* const empty = new std::string(); + for (const auto& entry : entries) { + if (entry.first == key) { + return entry.second; + } + } + return *empty; +} + +Entries Pairs(const std::vector& flat) { + Entries entries; + for (size_t i = 0; i + 1 < flat.size(); i += 2) { + entries.emplace_back(flat[i], flat[i + 1]); + } + return entries; +} + +} // namespace + +int ParseSchemaVersion(const std::string& value) { + // Nine digits at most: no overflow, and no schema will ever get there. + if (value.empty() || value.size() > 9 || value[0] == '0') { + return 0; + } + int version = 0; + for (char c : value) { + if (c < '0' || c > '9') { + return 0; + } + version = version * 10 + (c - '0'); + } + return version; +} + +MigrationPlan MigrateStoredItems(int legacy_layout, const Entries& legacy, + const Entries& current) { + MigrationPlan plan; + const LegacyLayout* layout = FindLayout(legacy_layout); + if (layout == nullptr) { + plan.valid = false; + return plan; + } + const int stored = ParseSchemaVersion(Lookup(current, kSchemaVersionKey)); + if (stored > kSchemaVersion) { + // A later SDK owns the store (the user went back to an older binary): + // read what this one knows, write nothing, delete nothing. + plan.schema_version = stored; + return plan; + } + + // Every legacy key goes, whether this run migrates or an earlier one did: + // a migration killed before its removes, or an older binary the user went + // back to in between, leaves keys that must never be read again. + for (const auto& entry : legacy) { + if (Owns(*layout, entry.first)) { + plan.removes.push_back(entry.first); + } + } + std::sort(plan.removes.begin(), plan.removes.end()); + plan.removes.erase(std::unique(plan.removes.begin(), plan.removes.end()), + plan.removes.end()); + + plan.schema_version = kSchemaVersion; + if (stored == kSchemaVersion) { + return plan; + } + + plan.migrated = true; + for (const Carried& item : layout->carried) { + const std::string& value = Lookup(legacy, item.from); + // The store wins: a value it holds without a schema was written by this + // SDK after an earlier migration attempt that did not reach its schema + // write (or is that attempt's own copy). + if (value.empty() || !Lookup(current, item.to).empty()) { + continue; + } + plan.puts.emplace_back(item.to, item.marker ? std::string("1") : value); + } + plan.puts.emplace_back(kSchemaVersionKey, std::to_string(kSchemaVersion)); + return plan; +} + +std::string MigrateStoredItemsJson(int legacy_layout, + const std::vector& legacy, + const std::vector& current) { + const MigrationPlan plan = + MigrateStoredItems(legacy_layout, Pairs(legacy), Pairs(current)); + if (!plan.valid) { + return "null"; + } + flowjson::Value puts = flowjson::Value::Array(); + for (const auto& put : plan.puts) { + flowjson::Value pair = flowjson::Value::Array(); + pair.Push(flowjson::Value::String(put.first)); + pair.Push(flowjson::Value::String(put.second)); + puts.Push(pair); + } + flowjson::Value removes = flowjson::Value::Array(); + for (const std::string& key : plan.removes) { + removes.Push(flowjson::Value::String(key)); + } + flowjson::Value out = flowjson::Value::Object(); + out.Set("schemaVersion", flowjson::Value::Number(plan.schema_version)); + out.Set("migrated", flowjson::Value::Bool(plan.migrated)); + out.Set("puts", puts); + out.Set("removes", removes); + return flowjson::Stringify(out); +} + +} // namespace stored +} // namespace pushy diff --git a/cpp/patch_core/stored_items.h b/cpp/patch_core/stored_items.h new file mode 100644 index 00000000..135784e9 --- /dev/null +++ b/cpp/patch_core/stored_items.h @@ -0,0 +1,80 @@ +#pragma once + +#include +#include +#include + +// The persisted layout every platform shares (scripts/contract.ts +// storeSchema, stateFields, storedItems) and the one-time move out of the +// layouts SDKs up to 10.61 wrote (legacyLayouts). The hosts keep the IO: +// they snapshot both stores as strings, write the plan's puts, and only once +// those are durable delete the plan's removes from the legacy store. +// tests/stored-items-vectors.json pins this layer and the hosts' adapters. +// +// What is carried over is what a binary upgrade keeps (the uuid, the native +// config, the round breadcrumb, the bundle hash cache) plus the binary +// identity the upgrade is detected from. The new layout only ships in a new +// binary, whose first launch resets every other state field anyway +// (state::SyncBinaryVersion), so the installed version, its info and the +// launch markers are dropped instead of translated. +// +// Keep the core internal to whichever binary links it (see podspec Core). +#pragma GCC visibility push(hidden) + +namespace pushy { +namespace stored { + +// (scripts/contract.ts; bun scripts/generate-contract.ts) +constexpr int kSchemaVersion = 1; +constexpr const char* kSchemaVersionKey = "schemaVersion"; +// The legacy layouts, by the id the hosts pass. +constexpr int kLegacyAndroid = 1; +constexpr int kLegacyIos = 2; +constexpr int kLegacyHarmony = 3; +// + +// Key/value pairs in store order (the hosts' snapshots are unordered maps; +// keys are unique). A value the host cannot read as text (an iOS dictionary) +// is passed as "": it is still a key to delete, never a value to carry. +using Entries = std::vector>; + +struct MigrationPlan { + // False only for an unknown legacy layout id: the host then migrates + // nothing (see the hosts' uuid-only fallback). + bool valid = true; + // The schema the store holds once the plan is applied: kSchemaVersion, or + // a newer one a later SDK wrote, which this SDK leaves alone. + int schema_version = 0; + // The plan carries the one-time migration (the store had no schema). + bool migrated = false; + // Into the state store, in order; schemaVersion is always the last, so a + // host whose writes are not atomic (iOS) never marks a partial migration + // as done. A key the store already holds is never overwritten. + Entries puts; + // From the legacy store, sorted: only keys the legacy layout owns (iOS + // shares its legacy store with the host app). + std::vector removes; +}; + +// The store's own schemaVersion value as a number; 0 when absent or not a +// plain positive decimal. +int ParseSchemaVersion(const std::string& value); + +// Idempotent: re-running it after any prefix of its plan was applied yields +// the rest of that plan. legacy: the legacy store; current: the state store. +MigrationPlan MigrateStoredItems(int legacy_layout, const Entries& legacy, + const Entries& current); + +// For the bindings: flat [key, value, key, value, ...] arrays in (an odd +// trailing key is ignored), JSON out: +// {"schemaVersion":1,"migrated":true,"puts":[["uuid","..."],...], +// "removes":["..."]} +// or "null" for an unknown layout. +std::string MigrateStoredItemsJson(int legacy_layout, + const std::vector& legacy, + const std::vector& current); + +} // namespace stored +} // namespace pushy + +#pragma GCC visibility pop diff --git a/cpp/patch_core/tests/stored-items-vectors.json b/cpp/patch_core/tests/stored-items-vectors.json new file mode 100644 index 00000000..4bd9ef7f --- /dev/null +++ b/cpp/patch_core/tests/stored-items-vectors.json @@ -0,0 +1,653 @@ +{ + "doc": [ + "cpp/patch_core/stored_items.h MigrateStoredItems, and the hosts that apply its plan.", + "layout: the legacy layout (android 1, ios 2, harmony 3; a number is passed as is).", + "legacy values are typed as the legacy store held them: a boolean reads as \"1\"/\"0\",", + "a number as its text, anything else (a dictionary, an array) as \"\" (a key to delete,", + "never a value to carry). plan null: no plan (unknown layout). after: both stores once", + "the puts and then the removes are applied; re-planning there is a no-op." + ], + "cases": [ + { + "name": "android: an installed SDK moves its install items and drops its version state", + "layout": "android", + "legacy": { + "uuid": "install-uuid", + "nativeConfig": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example/\"]}", + "bundleHashCache": "1.0|1700|abc", + "packageVersion": "1.0", + "buildTime": "28000000", + "currentVersion": "v2", + "lastVersion": "v1", + "firstTime": false, + "firstTimeOk": true, + "rolledBackVersion": "v0", + "firstLoadMarked": true, + "nativeCheckIncomplete": "1", + "nativeCheckResp": "{\"ts\":1,\"body\":\"{}\"}", + "hash_v1": "{\"name\":\"v1\"}", + "hash_v2": "{\"name\":\"v2\"}", + "blockUntil": 0, + "blockReason": "x" + }, + "current": {}, + "plan": { + "schemaVersion": 1, + "migrated": true, + "puts": [ + [ + "uuid", + "install-uuid" + ], + [ + "nativeConfig", + "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example/\"]}" + ], + [ + "nativeCheckIncomplete", + "1" + ], + [ + "bundleHashCache", + "1.0|1700|abc" + ], + [ + "packageVersion", + "1.0" + ], + [ + "buildTime", + "28000000" + ], + [ + "schemaVersion", + "1" + ] + ], + "removes": [ + "blockReason", + "blockUntil", + "buildTime", + "bundleHashCache", + "currentVersion", + "firstLoadMarked", + "firstTime", + "firstTimeOk", + "hash_v1", + "hash_v2", + "lastVersion", + "nativeCheckIncomplete", + "nativeCheckResp", + "nativeConfig", + "packageVersion", + "rolledBackVersion", + "uuid" + ] + }, + "after": { + "legacy": {}, + "current": { + "uuid": "install-uuid", + "nativeConfig": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example/\"]}", + "nativeCheckIncomplete": "1", + "bundleHashCache": "1.0|1700|abc", + "packageVersion": "1.0", + "buildTime": "28000000", + "schemaVersion": "1" + } + } + }, + { + "name": "android: a fresh install only writes the schema", + "layout": "android", + "legacy": {}, + "current": {}, + "plan": { + "schemaVersion": 1, + "migrated": true, + "puts": [ + [ + "schemaVersion", + "1" + ] + ], + "removes": [] + }, + "after": { + "legacy": {}, + "current": { + "schemaVersion": "1" + } + } + }, + { + "name": "android: keys the legacy layout never wrote are not ours to delete", + "layout": "android", + "legacy": { + "uuid": "install-uuid", + "hostSetting": "kept", + "hash": "not a prefix match" + }, + "current": {}, + "plan": { + "schemaVersion": 1, + "migrated": true, + "puts": [ + [ + "uuid", + "install-uuid" + ], + [ + "schemaVersion", + "1" + ] + ], + "removes": [ + "uuid" + ] + }, + "after": { + "legacy": { + "hostSetting": "kept", + "hash": "not a prefix match" + }, + "current": { + "uuid": "install-uuid", + "schemaVersion": "1" + } + } + }, + { + "name": "android: empty legacy values are deleted, never carried", + "layout": "android", + "legacy": { + "uuid": "", + "nativeConfig": "", + "nativeCheckIncomplete": "", + "packageVersion": "1.0" + }, + "current": {}, + "plan": { + "schemaVersion": 1, + "migrated": true, + "puts": [ + [ + "packageVersion", + "1.0" + ], + [ + "schemaVersion", + "1" + ] + ], + "removes": [ + "nativeCheckIncomplete", + "nativeConfig", + "packageVersion", + "uuid" + ] + }, + "after": { + "legacy": {}, + "current": { + "packageVersion": "1.0", + "schemaVersion": "1" + } + } + }, + { + "name": "android: killed after the puts, the next launch only removes the legacy keys", + "layout": "android", + "legacy": { + "uuid": "install-uuid", + "nativeConfig": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example/\"]}", + "bundleHashCache": "1.0|1700|abc", + "packageVersion": "1.0", + "buildTime": "28000000", + "currentVersion": "v2", + "lastVersion": "v1", + "firstTime": false, + "firstTimeOk": true, + "rolledBackVersion": "v0", + "firstLoadMarked": true, + "nativeCheckIncomplete": "1", + "nativeCheckResp": "{\"ts\":1,\"body\":\"{}\"}", + "hash_v1": "{\"name\":\"v1\"}", + "hash_v2": "{\"name\":\"v2\"}", + "blockUntil": 0, + "blockReason": "x" + }, + "current": { + "uuid": "install-uuid", + "nativeConfig": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example/\"]}", + "nativeCheckIncomplete": "1", + "bundleHashCache": "1.0|1700|abc", + "packageVersion": "1.0", + "buildTime": "28000000", + "schemaVersion": "1" + }, + "plan": { + "schemaVersion": 1, + "migrated": false, + "puts": [], + "removes": [ + "blockReason", + "blockUntil", + "buildTime", + "bundleHashCache", + "currentVersion", + "firstLoadMarked", + "firstTime", + "firstTimeOk", + "hash_v1", + "hash_v2", + "lastVersion", + "nativeCheckIncomplete", + "nativeCheckResp", + "nativeConfig", + "packageVersion", + "rolledBackVersion", + "uuid" + ] + }, + "after": { + "legacy": {}, + "current": { + "uuid": "install-uuid", + "nativeConfig": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example/\"]}", + "nativeCheckIncomplete": "1", + "bundleHashCache": "1.0|1700|abc", + "packageVersion": "1.0", + "buildTime": "28000000", + "schemaVersion": "1" + } + } + }, + { + "name": "android: what an older binary wrote after a downgrade is dropped, the store keeps its uuid", + "layout": "android", + "legacy": { + "uuid": "downgraded-uuid", + "nativeConfig": "{\"appKey\":\"old\"}", + "packageVersion": "0.9", + "buildTime": "27000000", + "currentVersion": "v9", + "firstTime": true, + "firstTimeOk": false, + "hash_v9": "{}" + }, + "current": { + "schemaVersion": "1", + "uuid": "install-uuid", + "nativeConfig": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example/\"]}", + "packageVersion": "1.0", + "buildTime": "28000000", + "currentVersion": "v2" + }, + "plan": { + "schemaVersion": 1, + "migrated": false, + "puts": [], + "removes": [ + "buildTime", + "currentVersion", + "firstTime", + "firstTimeOk", + "hash_v9", + "nativeConfig", + "packageVersion", + "uuid" + ] + }, + "after": { + "legacy": {}, + "current": { + "schemaVersion": "1", + "uuid": "install-uuid", + "nativeConfig": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example/\"]}", + "packageVersion": "1.0", + "buildTime": "28000000", + "currentVersion": "v2" + } + } + }, + { + "name": "android: a store a later SDK wrote is left alone, and so is the legacy store", + "layout": "android", + "legacy": { + "uuid": "install-uuid", + "currentVersion": "v2" + }, + "current": { + "schemaVersion": "2", + "uuid": "install-uuid" + }, + "plan": { + "schemaVersion": 2, + "migrated": false, + "puts": [], + "removes": [] + }, + "after": { + "legacy": { + "uuid": "install-uuid", + "currentVersion": "v2" + }, + "current": { + "schemaVersion": "2", + "uuid": "install-uuid" + } + } + }, + { + "name": "ios: the shared defaults lose only the keys an SDK wrote", + "layout": "ios", + "legacy": { + "REACTNATIVECN_PUSHY_UUID": "install-uuid", + "REACTNATIVECN_PUSHY_NATIVE_CONFIG_KEY": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example/\"]}", + "REACTNATIVECN_PUSHY_NATIVE_CHECK_INCOMPLETE_KEY": true, + "REACTNATIVECN_PUSHY_BUNDLEHASH_KEY": "1.0|9|1700|abc", + "REACTNATIVECN_PUSHY_PACKAGEVERSION": "1.0", + "REACTNATIVECN_PUSHY_BUILDTIME": "1700000000", + "REACTNATIVECN_PUSHY_INFO_KEY": { + "currentVersion": "v2", + "isFirstTime": false, + "isFirstLoadOK": true + }, + "REACTNATIVECN_PUSHY_ROLLEDBACKMARKED_KEY": "v0", + "REACTNATIVECN_PUSHY_FIRSTLOADMARKED_KEY": true, + "REACTNATIVECN_PUSHY_ISPACKAGEUPDATEDMARKED_KEY": true, + "REACTNATIVECN_PUSHY_NATIVE_CHECK_RESP_KEY": "{\"ts\":1,\"body\":\"{}\"}", + "REACTNATIVECN_PUSHY_HASH_v1": "{\"name\":\"v1\"}", + "REACTNATIVECN_PUSHY_HASH_v2": "{\"name\":\"v2\"}", + "packageVersion": "host-owned", + "buildTime": "host-owned", + "AppleLanguages": [ + "en" + ], + "REACTNATIVECN_PUSHY_SOMETHING_ELSE": "not a key any SDK wrote" + }, + "current": {}, + "plan": { + "schemaVersion": 1, + "migrated": true, + "puts": [ + [ + "uuid", + "install-uuid" + ], + [ + "nativeConfig", + "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example/\"]}" + ], + [ + "nativeCheckIncomplete", + "1" + ], + [ + "bundleHashCache", + "1.0|9|1700|abc" + ], + [ + "packageVersion", + "1.0" + ], + [ + "buildTime", + "1700000000" + ], + [ + "schemaVersion", + "1" + ] + ], + "removes": [ + "REACTNATIVECN_PUSHY_BUILDTIME", + "REACTNATIVECN_PUSHY_BUNDLEHASH_KEY", + "REACTNATIVECN_PUSHY_FIRSTLOADMARKED_KEY", + "REACTNATIVECN_PUSHY_HASH_v1", + "REACTNATIVECN_PUSHY_HASH_v2", + "REACTNATIVECN_PUSHY_INFO_KEY", + "REACTNATIVECN_PUSHY_ISPACKAGEUPDATEDMARKED_KEY", + "REACTNATIVECN_PUSHY_NATIVE_CHECK_INCOMPLETE_KEY", + "REACTNATIVECN_PUSHY_NATIVE_CHECK_RESP_KEY", + "REACTNATIVECN_PUSHY_NATIVE_CONFIG_KEY", + "REACTNATIVECN_PUSHY_PACKAGEVERSION", + "REACTNATIVECN_PUSHY_ROLLEDBACKMARKED_KEY", + "REACTNATIVECN_PUSHY_UUID" + ] + }, + "after": { + "legacy": { + "packageVersion": "host-owned", + "buildTime": "host-owned", + "AppleLanguages": [ + "en" + ], + "REACTNATIVECN_PUSHY_SOMETHING_ELSE": "not a key any SDK wrote" + }, + "current": { + "uuid": "install-uuid", + "nativeConfig": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example/\"]}", + "nativeCheckIncomplete": "1", + "bundleHashCache": "1.0|9|1700|abc", + "packageVersion": "1.0", + "buildTime": "1700000000", + "schemaVersion": "1" + } + } + }, + { + "name": "ios: killed between two puts, the next launch keeps what landed and writes the rest", + "layout": "ios", + "legacy": { + "REACTNATIVECN_PUSHY_UUID": "install-uuid", + "REACTNATIVECN_PUSHY_NATIVE_CONFIG_KEY": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example/\"]}", + "REACTNATIVECN_PUSHY_NATIVE_CHECK_INCOMPLETE_KEY": true, + "REACTNATIVECN_PUSHY_BUNDLEHASH_KEY": "1.0|9|1700|abc", + "REACTNATIVECN_PUSHY_PACKAGEVERSION": "1.0", + "REACTNATIVECN_PUSHY_BUILDTIME": "1700000000", + "REACTNATIVECN_PUSHY_INFO_KEY": { + "currentVersion": "v2", + "isFirstTime": false, + "isFirstLoadOK": true + }, + "REACTNATIVECN_PUSHY_ROLLEDBACKMARKED_KEY": "v0", + "REACTNATIVECN_PUSHY_FIRSTLOADMARKED_KEY": true, + "REACTNATIVECN_PUSHY_ISPACKAGEUPDATEDMARKED_KEY": true, + "REACTNATIVECN_PUSHY_NATIVE_CHECK_RESP_KEY": "{\"ts\":1,\"body\":\"{}\"}", + "REACTNATIVECN_PUSHY_HASH_v1": "{\"name\":\"v1\"}", + "REACTNATIVECN_PUSHY_HASH_v2": "{\"name\":\"v2\"}" + }, + "current": { + "uuid": "install-uuid", + "nativeConfig": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example/\"]}" + }, + "plan": { + "schemaVersion": 1, + "migrated": true, + "puts": [ + [ + "nativeCheckIncomplete", + "1" + ], + [ + "bundleHashCache", + "1.0|9|1700|abc" + ], + [ + "packageVersion", + "1.0" + ], + [ + "buildTime", + "1700000000" + ], + [ + "schemaVersion", + "1" + ] + ], + "removes": [ + "REACTNATIVECN_PUSHY_BUILDTIME", + "REACTNATIVECN_PUSHY_BUNDLEHASH_KEY", + "REACTNATIVECN_PUSHY_FIRSTLOADMARKED_KEY", + "REACTNATIVECN_PUSHY_HASH_v1", + "REACTNATIVECN_PUSHY_HASH_v2", + "REACTNATIVECN_PUSHY_INFO_KEY", + "REACTNATIVECN_PUSHY_ISPACKAGEUPDATEDMARKED_KEY", + "REACTNATIVECN_PUSHY_NATIVE_CHECK_INCOMPLETE_KEY", + "REACTNATIVECN_PUSHY_NATIVE_CHECK_RESP_KEY", + "REACTNATIVECN_PUSHY_NATIVE_CONFIG_KEY", + "REACTNATIVECN_PUSHY_PACKAGEVERSION", + "REACTNATIVECN_PUSHY_ROLLEDBACKMARKED_KEY", + "REACTNATIVECN_PUSHY_UUID" + ] + }, + "after": { + "legacy": {}, + "current": { + "uuid": "install-uuid", + "nativeConfig": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example/\"]}", + "nativeCheckIncomplete": "1", + "bundleHashCache": "1.0|9|1700|abc", + "packageVersion": "1.0", + "buildTime": "1700000000", + "schemaVersion": "1" + } + } + }, + { + "name": "ios: a value this SDK wrote without a schema is newer than the legacy one", + "layout": "ios", + "legacy": { + "REACTNATIVECN_PUSHY_UUID": "install-uuid", + "REACTNATIVECN_PUSHY_NATIVE_CONFIG_KEY": "{\"appKey\":\"old\"}" + }, + "current": { + "schemaVersion": "garbage", + "nativeConfig": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example/\"]}" + }, + "plan": { + "schemaVersion": 1, + "migrated": true, + "puts": [ + [ + "uuid", + "install-uuid" + ], + [ + "schemaVersion", + "1" + ] + ], + "removes": [ + "REACTNATIVECN_PUSHY_NATIVE_CONFIG_KEY", + "REACTNATIVECN_PUSHY_UUID" + ] + }, + "after": { + "legacy": {}, + "current": { + "schemaVersion": "1", + "nativeConfig": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example/\"]}", + "uuid": "install-uuid" + } + } + }, + { + "name": "harmony: native and \"true\" booleans are dropped, the install items carried", + "layout": "harmony", + "legacy": { + "uuid": "install-uuid", + "nativeConfig": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example/\"]}", + "bundleHashCache": "1.0|1700|abc", + "packageVersion": "1.0.0", + "buildTime": "1700000000000", + "currentVersion": "v2", + "firstTime": true, + "firstTimeOk": false, + "firstLoadMarked": "true", + "nativeCheckIncomplete": "1", + "nativeCheckResp": "{}", + "hash_v2": "{\"name\":\"v2\"}" + }, + "current": {}, + "plan": { + "schemaVersion": 1, + "migrated": true, + "puts": [ + [ + "uuid", + "install-uuid" + ], + [ + "nativeConfig", + "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example/\"]}" + ], + [ + "nativeCheckIncomplete", + "1" + ], + [ + "bundleHashCache", + "1.0|1700|abc" + ], + [ + "packageVersion", + "1.0.0" + ], + [ + "buildTime", + "1700000000000" + ], + [ + "schemaVersion", + "1" + ] + ], + "removes": [ + "buildTime", + "bundleHashCache", + "currentVersion", + "firstLoadMarked", + "firstTime", + "firstTimeOk", + "hash_v2", + "nativeCheckIncomplete", + "nativeCheckResp", + "nativeConfig", + "packageVersion", + "uuid" + ] + }, + "after": { + "legacy": {}, + "current": { + "uuid": "install-uuid", + "nativeConfig": "{\"appKey\":\"k\",\"endpoints\":[\"https://a.example/\"]}", + "nativeCheckIncomplete": "1", + "bundleHashCache": "1.0|1700|abc", + "packageVersion": "1.0.0", + "buildTime": "1700000000000", + "schemaVersion": "1" + } + } + }, + { + "name": "an unknown legacy layout has no plan", + "layout": 9, + "legacy": { + "uuid": "install-uuid" + }, + "current": {}, + "plan": null, + "after": { + "legacy": { + "uuid": "install-uuid" + }, + "current": {} + } + } + ] +} diff --git a/cpp/patch_core/tests/stored_items_test.cpp b/cpp/patch_core/tests/stored_items_test.cpp new file mode 100644 index 00000000..c0c56af6 --- /dev/null +++ b/cpp/patch_core/tests/stored_items_test.cpp @@ -0,0 +1,216 @@ +// Replays tests/stored-items-vectors.json through cpp/patch_core/ +// stored_items.h: the plan, the stores once it is applied, and the +// re-entry property every host relies on (a process killed after any prefix +// of the plan finishes the same migration on its next launch). +#include +#include +#include +#include +#include +#include +#include + +#include "../../update_flow_core/flow_json.h" +#include "../stored_items.h" + +namespace { + +using pushy::stored::Entries; +using pushy::stored::MigrationPlan; +using Store = std::map; + +int failures = 0; + +void Check(bool ok, const std::string& what) { + if (!ok) { + std::fprintf(stderr, "[FAIL] %s\n", what.c_str()); + ++failures; + } +} + +int Layout(const flowjson::Value& value) { + if (value.IsNumber()) { + return static_cast(value.AsNumber()); + } + if (value.AsString() == "android") { + return pushy::stored::kLegacyAndroid; + } + if (value.AsString() == "ios") { + return pushy::stored::kLegacyIos; + } + return pushy::stored::kLegacyHarmony; +} + +// A legacy value as the hosts pass it (see the vectors' doc). +std::string Text(const flowjson::Value& value) { + if (value.IsString()) { + return value.AsString(); + } + if (value.IsBool()) { + return value.AsBool() ? "1" : "0"; + } + if (value.IsNumber()) { + return flowjson::Stringify(value); + } + return ""; +} + +Store ToStore(const flowjson::Value& object) { + Store store; + for (const auto& member : object.members()) { + store[member.first] = Text(member.second); + } + return store; +} + +Entries ToEntries(const Store& store) { + return Entries(store.begin(), store.end()); +} + +std::string Describe(const Store& store) { + flowjson::Value object = flowjson::Value::Object(); + for (const auto& entry : store) { + object.Set(entry.first, flowjson::Value::String(entry.second)); + } + return flowjson::Stringify(object); +} + +flowjson::Value PlanJson(const MigrationPlan& plan) { + flowjson::Value puts = flowjson::Value::Array(); + for (const auto& put : plan.puts) { + flowjson::Value pair = flowjson::Value::Array(); + pair.Push(flowjson::Value::String(put.first)); + pair.Push(flowjson::Value::String(put.second)); + puts.Push(pair); + } + flowjson::Value removes = flowjson::Value::Array(); + for (const auto& key : plan.removes) { + removes.Push(flowjson::Value::String(key)); + } + flowjson::Value out = flowjson::Value::Object(); + out.Set("schemaVersion", flowjson::Value::Number(plan.schema_version)); + out.Set("migrated", flowjson::Value::Bool(plan.migrated)); + out.Set("puts", puts); + out.Set("removes", removes); + return out; +} + +// Applies the first `puts` puts and the first `removes` removes. +void Apply(const MigrationPlan& plan, size_t puts, size_t removes, Store* legacy, + Store* current) { + for (size_t i = 0; i < puts && i < plan.puts.size(); ++i) { + (*current)[plan.puts[i].first] = plan.puts[i].second; + } + for (size_t i = 0; i < removes && i < plan.removes.size(); ++i) { + legacy->erase(plan.removes[i]); + } +} + +// Runs the migration to completion from (legacy, current), the way a host +// does across launches. +void Finish(int layout, Store* legacy, Store* current) { + const MigrationPlan plan = + pushy::stored::MigrateStoredItems(layout, ToEntries(*legacy), ToEntries(*current)); + Apply(plan, plan.puts.size(), plan.removes.size(), legacy, current); +} + +std::vector Flat(const Store& store) { + std::vector flat; + for (const auto& entry : store) { + flat.push_back(entry.first); + flat.push_back(entry.second); + } + return flat; +} + +} // namespace + +int main(int argc, char** argv) { + if (argc < 2) { + std::fprintf(stderr, "usage: stored_items_test \n"); + return 2; + } + std::ifstream in(argv[1]); + std::stringstream buffer; + buffer << in.rdbuf(); + bool ok = false; + const flowjson::Value file = flowjson::Parse(buffer.str(), &ok); + if (!ok || !file.IsObject()) { + std::fprintf(stderr, "cannot read %s\n", argv[1]); + return 2; + } + + Check(pushy::stored::ParseSchemaVersion("1") == 1, "schema \"1\""); + Check(pushy::stored::ParseSchemaVersion("12") == 12, "schema \"12\""); + for (const char* bad : {"", "0", "01", "-1", "1.0", " 1", "x", "1234567890"}) { + Check(pushy::stored::ParseSchemaVersion(bad) == 0, + std::string("schema \"") + bad + "\" is not a schema"); + } + + int cases = 0; + for (const auto& c : file.Get("cases").elements()) { + const std::string name = c.Get("name").AsString(); + const int layout = Layout(c.Get("layout")); + const Store legacy = ToStore(c.Get("legacy")); + const Store current = ToStore(c.Get("current")); + const MigrationPlan plan = + pushy::stored::MigrateStoredItems(layout, ToEntries(legacy), ToEntries(current)); + const std::string json = + pushy::stored::MigrateStoredItemsJson(layout, Flat(legacy), Flat(current)); + ++cases; + + if (c.Get("plan").kind() == flowjson::Value::Kind::Null) { + Check(!plan.valid, name + ": expected no plan"); + Check(json == "null", name + ": binding returns " + json); + continue; + } + Check(plan.valid, name + ": expected a plan"); + const std::string got = flowjson::Stringify(PlanJson(plan)); + Check(flowjson::Value::DeepEquals(PlanJson(plan), c.Get("plan")), + name + ": plan " + got); + bool parsed = false; + Check(flowjson::Value::DeepEquals(flowjson::Parse(json, &parsed), c.Get("plan")) && + parsed, + name + ": binding " + json); + if (!plan.puts.empty()) { + Check(plan.puts.back().first == pushy::stored::kSchemaVersionKey, + name + ": the schema is the last put"); + } + + Store after_legacy = legacy; + Store after_current = current; + Apply(plan, plan.puts.size(), plan.removes.size(), &after_legacy, &after_current); + const Store want_legacy = ToStore(c.Get("after").Get("legacy")); + const Store want_current = ToStore(c.Get("after").Get("current")); + Check(after_legacy == want_legacy, name + ": legacy after " + Describe(after_legacy)); + Check(after_current == want_current, + name + ": current after " + Describe(after_current)); + + // Done means done: planning again changes nothing. + const MigrationPlan again = pushy::stored::MigrateStoredItems( + layout, ToEntries(after_legacy), ToEntries(after_current)); + Check(again.puts.empty() && again.removes.empty() && !again.migrated, + name + ": planning again after the plan is not a no-op"); + + // Killed after any prefix of the plan (puts in order, then removes): + // the next launch reaches the same stores. + const size_t steps = plan.puts.size() + plan.removes.size(); + for (size_t step = 0; step < steps; ++step) { + Store l = legacy; + Store s = current; + const size_t puts = std::min(step, plan.puts.size()); + Apply(plan, puts, step - puts, &l, &s); + Finish(layout, &l, &s); + Check(l == want_legacy && s == want_current, + name + ": killed after step " + std::to_string(step) + " ends at " + + Describe(l) + " / " + Describe(s)); + } + } + + if (failures > 0) { + std::fprintf(stderr, "%d stored items check(s) failed\n", failures); + return 1; + } + std::printf("%d stored items vectors passed\n", cases); + return 0; +} diff --git a/docs/STORED_STATE_LAYOUT.md b/docs/STORED_STATE_LAYOUT.md new file mode 100644 index 00000000..e8ab36cd --- /dev/null +++ b/docs/STORED_STATE_LAYOUT.md @@ -0,0 +1,141 @@ +# 持久化落盘布局(schema 1)与一次性迁移 + +> 适用版本:10.61.0 之后的下一个版本起。单一来源是 `scripts/contract.ts` 的 +> `storeSchema` / `stateFields` / `storedItems` / `legacyLayouts`, +> `bun scripts/generate-contract.ts` 生成三端的 key 表、State ↔ 存储映射与 +> C++ 旧布局表;迁移规则是共享内核 `cpp/patch_core/stored_items.h`, +> 向量 `cpp/patch_core/tests/stored-items-vectors.json`。 + +## 1. 统一后的布局 + +三端各有两个**自己的**键值存储,名字相同: + +| 存储 | Android | iOS | 鸿蒙 | 内容 | +| --- | --- | --- | --- | --- | +| `pushy_state` | SharedPreferences 文件 | `NSUserDefaults` suite | preferences | 状态字段与其余各项、`schemaVersion` | +| `pushy_cache` | SharedPreferences 文件 | `NSUserDefaults` suite | preferences | 只有响应缓存 `nativeCheckResp` | + +响应缓存单独放,是因为三端的存储每次落盘都会重写整个文件,整份服务端响应不该 +跟着每一次小的状态提交一起重写(Android 早就这么做,这次三端对齐)。 + +**值的编码**:一律是字符串。布尔值存 `"1"` / `"0"`,缺失时取字段默认值 +(`firstTime` 为 false,`firstTimeOk` 为 true);标记(`firstLoadMarked`、 +`binaryUpgraded`、`nativeCheckIncomplete`)置位时存 `"1"`,清除即删键;空串从不 +落盘(写空串等于删键),所以“缺失”和“空”读起来一样。 + +**键**(三端同名): + +- 状态字段(`pushy::state::State`,由共享状态内核整体改写):`packageVersion`、 + `buildTime`、`currentVersion`、`lastVersion`、`firstTime`、`firstTimeOk`、 + `rolledBackVersion`。 +- `hash_<版本>`:版本元信息;`firstLoadMarked`;`binaryUpgraded`(仅 iOS 使用); + `nativeCheckResp`(缓存存储);`nativeCheckIncomplete`;`uuid`;`nativeConfig`; + `bundleHashCache`;`schemaVersion`(当前为 `"1"`)。 + +reset / 二进制升级各删什么仍由 `storedItems.clearedOn` 决定(与 #687 相同), +缓存存储在两种事件里都整体清掉。 + +## 2. 迁移只搬“升级后本来就保留”的东西 + +新布局只会随新的原生二进制上线(SDK 原生代码变了,必须重新打包)。而三端的 +release 构建每次都会写入新的 buildTime: + +- Android:`android/build.gradle` 的 `pushy_build_time` resValue,release 为构建时的分钟数; +- iOS:podspec 的 “Generate build time” 脚本阶段每次构建 `date +%s`(源码只读时退回 + 安装包里的值,再不行用 Info.plist 的 mtime); +- 鸿蒙:`hvigor-plugin.ts` 在 release 构建时写入当前时间。 + +所以新布局的第一次启动一定会被 `SyncBinaryVersion` 判为二进制升级:当前/上一 +版本、首次加载标记、回滚标记清空,旧热更目录随后被清理。迁移因此只需要搬升级后 +**保留**的项,外加判定升级用的二进制身份: + +| 搬过去 | 为什么 | +| --- | --- | +| `uuid` | 标识安装,灰度分桶依据;变了会重洗分桶 | +| `nativeConfig` | 原生冷启动检测与 crash hold 在 JS 再次运行之前就要用 | +| `nativeCheckIncomplete` | 上次轮次中断时,本次启动不等 5 秒直接续传 | +| `bundleHashCache` | 以二进制身份为键,升级后自然失效,搬过去无害 | +| `packageVersion`、`buildTime` | 让升级照常由 `SyncBinaryVersion` 判定,而不是由迁移强行触发 | + +热更状态(当前版本、版本元信息、首启/回滚标记、响应缓存)**不搬、直接删**。 + +**例外:debug 构建**三端的 buildTime 都固定为 `"0"`,同一台设备上换新 SDK 重新 +debug 构建不会被判为升级;此时迁移后的状态里没有当前版本,启动内置包(热更版本 +需要重新下载)。只影响开发期,且方向是安全的(回到内置包)。iOS 的 +`legacy_migration_same_binary` 用例锁定这一行为。 + +## 3. 迁移规则(`MigrateStoredItems`) + +输入:旧存储与新状态存储的快照(字符串键值;旧存储里的原生布尔读作 `"1"`/`"0"`, +数字读作文本,读不成文本的值——iOS 的旧 info 字典——读作 `""`:要删的键,绝不搬运)。 +输出:`puts`(写入新状态存储,`schemaVersion` 永远最后一个)与 `removes` +(从旧存储删除,已排序)。 + +- 新存储 `schemaVersion` 为当前版本(1):不写入;`removes` 为旧存储里仍然存在的、 + 属于旧布局的键(上次迁移在删键前被杀,或降级期间旧二进制写回的键)。 +- 新存储 `schemaVersion` 更大(未来的 SDK 写的,用户装回了本版本):什么都不做, + 两个存储都不碰,按本版本认识的键读取。 +- 否则(缺失或不是规范的正整数):迁移。旧值非空、且新存储里还没有这个键时才写入 + (**新存储优先**:没有 schema 却已有值,要么是上次未完成迁移写进去的同一个值, + 要么是之后本 SDK 写的更新的值),最后写 `schemaVersion`。 +- 只删旧布局**拥有**的键(`legacyLayouts` 列出的每个 SDK 写过的键与 `hash_` 前缀)。 + iOS 的旧存储是与宿主共用的 `standardUserDefaults`;Android/鸿蒙的旧文件名 + `update` 也很通用,宿主可能自己在用,所以同样只删认识的键。iOS 早期 SDK 写过的 + 无前缀 `packageVersion`/`buildTime` 可能是宿主的,既不读也不删。 + +## 4. 宿主侧的 IO 与崩溃安全 + +三端都在**任何读取状态存储之前**执行迁移:Android 在 `UpdateContext` 构造函数里, +iOS 在 `PushyDefaults()` 的 `dispatch_once` 里,鸿蒙在 `UpdateContext` 构造函数里。 + +| 步骤 | Android | iOS | 鸿蒙 | +| --- | --- | --- | --- | +| 写 puts | 一次 `commit()`(整文件原子写) | 逐个 `setObject:`,schema 最后 | `putSync` 后一次 flush | +| 确认落盘 | `commit()` 返回 true | 写入在返回前已交给 cfprefsd,进程被杀不丢 | flush 成功 | +| 删 removes | 旧主文件与旧 `_check` 文件 | `standardUserDefaults` | 旧 preferences,再 flush | + +- **幂等、可在任一步被杀后重入**:puts 落盘前被杀,旧键都还在,下次重做;iOS 在 + 两个 put 之间被杀,已写的值按“新存储优先”保留、其余补写;puts 落盘后、删键前被杀, + 下次看到 schema 只删旧键。C++ 测试对每个向量的每一个前缀都验证了这一点。 +- **落盘失败**:旧键保留,下次启动重试;本次进程内存里已有迁移结果,照常运行 + (Android 内存里的 schema 会随之后任意一次成功提交一并落盘,下次只剩删旧键)。 +- **迁移本身失败**(内核抛异常、JNI/NAPI 不可用、计划无法解析):按全新安装启动, + 但宿主单独把旧 `uuid` 搬过去;不写 schema,下次启动重做完整迁移。删旧键永远在 + 写入之后,所以任何失败都不会丢掉 uuid(iOS 的 `remove-first` 负向对照验证)。 + +## 5. 降级(用户装回旧二进制) + +- 旧二进制只认旧布局,而旧布局的键已被删除:它表现为**全新安装**——没有热更版本、 + 没有原生配置(JS 运行后 `syncNativeConfig` 会重新写入),`uuid` 由 JS 重新生成, + 降级期间灰度分桶会变。它下载的热更目录与新二进制共用存储根目录,可能被它的清理删掉。 +- 再升级回新二进制:新存储的 schema 还在,不再迁移;降级期间旧二进制写的键全部删除, + `uuid` 恢复为降级前的值(分桶回到原样)。若重新安装的是同一个构建(身份相同), + 状态里的当前版本若目录已被旧二进制清掉,启动解析会回滚到内置包;否则照常判为升级。 +- App Store / 应用市场不允许降级,降级只出现在侧载、`adb install -d`、企业分发等场景; + 为此保留旧 `uuid` 键会违背“迁移完成后删除旧 key”,权衡后不保留。 + +## 6. iOS 改用独立 suite 的取舍 + +改为 `initWithSuiteName:@"pushy_state"` / `@"pushy_cache"`,不再与宿主共用 +`standardUserDefaults`: + +- **避免键冲突**:无前缀的 `packageVersion`/`buildTime` 曾与宿主/三方 SDK 的键撞上, + 每次启动都像换了二进制、把已装热更清掉;独立命名空间后三端可以用同一组无前缀键名。 +- **避免被宿主连带清空**:常见的“退出登录”实现是 + `removePersistentDomainForName:bundleIdentifier`,会把 uuid 与热更状态一起抹掉。 +- **前缀扫描只扫自己的键**:reset / 升级删 `hash_*` 时不再遍历宿主的全部 defaults。 +- 代价:多一个 plist 文件(同在 `Library/Preferences`,备份行为与之前相同); + 隐私清单的 `CA92.1`(仅本应用读写)照样覆盖,不涉及 App Group。迁移需要处理旧键, + 即上面的一次性迁移。 + +## 7. 验证 + +- C++:`scripts/test-patch-core.sh` 的 `stored_items_test`(计划、应用后的两个存储、 + 再次规划为空操作、任一前缀被杀后重入结果一致)。 +- Android:`StoreMigrationTest`(JVM 单测,按向量回放迁移 IO、提交失败保留旧键、 + 内核失败只搬 uuid、State 映射往返)。 +- iOS:`scripts/test-ios-purge-restore.sh` 的 `stored_items_vectors`(真实内核 + + 真实 `PushyMigrateStores`)、`legacy_migration_upgrade` / `_same_binary` / + `_write_failure` / `_read_failure`,负向对照 `skip-carry`、`remove-first`。 +- 鸿蒙:`src/__tests__/harmonyStateLifecycle.test.ts`(按向量回放 `migrateStore`、 + 落盘失败、内核失败、State 映射往返)。 diff --git a/harmony/pushy/src/main/cpp/pushy.cpp b/harmony/pushy/src/main/cpp/pushy.cpp index 8c5d28f9..89369de1 100644 --- a/harmony/pushy/src/main/cpp/pushy.cpp +++ b/harmony/pushy/src/main/cpp/pushy.cpp @@ -24,6 +24,7 @@ #include "resume_plan.h" #include "state_core.h" #include "state_ops.h" +#include "stored_items.h" namespace { @@ -1496,6 +1497,26 @@ static napi_value ResumeSidecar(napi_env env, napi_callback_info info) { static_cast(total))); } +// migrateStoredItems(legacyLayout, legacy[], current[]) -> plan JSON +// (cpp/patch_core/stored_items.h); the flat [key, value, ...] arrays are the +// two stores' snapshots. +static napi_value MigrateStoredItems(napi_env env, napi_callback_info info) { + napi_value args[3] = {nullptr, nullptr, nullptr}; + size_t argc = 3; + if (!GetArgCount(env, info, &argc, args) || argc < 3) { + ThrowError(env, "migrateStoredItems: wrong number of arguments"); + return nullptr; + } + int32_t layout = 0; + std::vector legacy; + std::vector current; + if (!GetInt32(env, args[0], &layout) || !GetStringArray(env, args[1], &legacy) || + !GetStringArray(env, args[2], ¤t)) { + return nullptr; + } + return NewString(env, pushy::stored::MigrateStoredItemsJson(layout, legacy, current)); +} + napi_value Init(napi_env env, napi_value exports) { // (scripts/contract.ts; bun scripts/generate-contract.ts) if (!ExportFunction(env, exports, "syncStateWithBinaryVersion", Guarded) || @@ -1523,7 +1544,8 @@ napi_value Init(napi_env env, napi_value exports) { !ExportFunction(env, exports, "normalizeConfiguration", Guarded) || !ExportFunction(env, exports, "resumePlanRequest", Guarded) || !ExportFunction(env, exports, "resumeClassifyResponse", Guarded) || - !ExportFunction(env, exports, "resumeSidecar", Guarded)) { + !ExportFunction(env, exports, "resumeSidecar", Guarded) || + !ExportFunction(env, exports, "migrateStoredItems", Guarded)) { // return nullptr; } diff --git a/harmony/pushy/src/main/ets/NativePatchCore.ts b/harmony/pushy/src/main/ets/NativePatchCore.ts index fce94758..ccce05c5 100644 --- a/harmony/pushy/src/main/ets/NativePatchCore.ts +++ b/harmony/pushy/src/main/ets/NativePatchCore.ts @@ -221,6 +221,11 @@ interface NativePatchCoreBindings { previousSidecar: string, total: number, ): string; + + // 落盘布局迁移(cpp/patch_core/stored_items.h,与 Android/iOS 共用): + // 旧/新存储的扁平键值数组 [k, v, k, v, ...],返回计划 JSON + // {schemaVersion, migrated, puts, removes};未知布局返回 'null'。IO 在 StoreSchema.ts。 + migrateStoredItems(legacyLayout: number, legacy: string[], current: string[]): string; } export default NativeCore as unknown as NativePatchCoreBindings; diff --git a/harmony/pushy/src/main/ets/PushyTurboModule.ts b/harmony/pushy/src/main/ets/PushyTurboModule.ts index 6380440c..84e671b6 100644 --- a/harmony/pushy/src/main/ets/PushyTurboModule.ts +++ b/harmony/pushy/src/main/ets/PushyTurboModule.ts @@ -8,11 +8,8 @@ import logger from './Logger'; import NativePatchCore from './NativePatchCore'; import { UpdateContext } from './UpdateContext'; import { EventHub } from './EventHub'; -import { - KEY_CONFIG, - recordJsRound, - KEY_RESP_CACHE, -} from './SyncCoordinator'; +import { recordJsRound } from './SyncCoordinator'; +import { KEY_HASH_INFO, KEY_UUID } from './StoreSchema'; import { ERROR_FILE_OPERATION_FAILED, ERROR_INVALID_HASH_INFO, @@ -173,12 +170,12 @@ export class PushyTurboModule extends UITurboModule { const currentVersion = this.context.getCurrentVersion(); const currentVersionInfo = currentVersion - ? this.context.getKv(`hash_${currentVersion}`) + ? this.context.getKv(`${KEY_HASH_INFO}${currentVersion}`) : ''; const currentBundleSha256 = this.context.currentBundleSha256(currentVersion); const isFirstTime = this.context.consumeFirstLoadMarker(); const rolledBackVersion = this.context.rolledBackVersion(); - const uuid = this.context.getKv('uuid'); + const uuid = this.context.getKv(KEY_UUID); const isUsingBundleUrl = this.context.getIsUsingBundleUrl(); if (rolledBackVersion) { @@ -217,7 +214,7 @@ export class PushyTurboModule extends UITurboModule { this.requireHash(hash, 'setLocalHashInfo'); validateHashInfo(info); try { - await this.context.setKv(`hash_${hash}`, info); + await this.context.setKv(`${KEY_HASH_INFO}${hash}`, info); } catch (error) { throw toPushyError(error, ERROR_FILE_OPERATION_FAILED); } @@ -227,7 +224,7 @@ export class PushyTurboModule extends UITurboModule { // 但不是 JSON 对象的记录才是 INVALID_HASH_INFO。 async getLocalHashInfo(hash: string): Promise { this.requireHash(hash, 'getLocalHashInfo'); - const value = this.context.getKv(`hash_${hash}`); + const value = this.context.getKv(`${KEY_HASH_INFO}${hash}`); if (!value) { return ''; } @@ -238,7 +235,7 @@ export class PushyTurboModule extends UITurboModule { async setUuid(uuid: string): Promise { logger.debug(TAG, ',call setUuid'); try { - await this.context.setKv('uuid', uuid); + await this.context.setKv(KEY_UUID, uuid); } catch (error) { throw toPushyError(error, ERROR_FILE_OPERATION_FAILED); } @@ -282,7 +279,7 @@ export class PushyTurboModule extends UITurboModule { // (NATIVE_CHECKUPDATE_DESIGN §10.3)。缺省空串,永不 reject。 async getNativeCheckCache(): Promise { logger.debug(TAG, ',call getNativeCheckCache'); - return this.context.getKv(KEY_RESP_CACHE) ?? ''; + return this.context.getNativeCheckCache(); } async reloadUpdate(options: { hash: string }): Promise { diff --git a/harmony/pushy/src/main/ets/StoreSchema.ts b/harmony/pushy/src/main/ets/StoreSchema.ts new file mode 100644 index 00000000..aba830e2 --- /dev/null +++ b/harmony/pushy/src/main/ets/StoreSchema.ts @@ -0,0 +1,262 @@ +import type { StateCoreResult } from './NativePatchCore'; +import logger from './Logger'; + +// The persisted layout every platform shares (scripts/contract.ts +// storeSchema): the keys, the two stores and the State mapping, generated. +// Every value is a string; an empty one is never stored, a boolean is +// BOOLEAN_TRUE / BOOLEAN_FALSE, a marker is BOOLEAN_TRUE while set. + +// (scripts/contract.ts; bun scripts/generate-contract.ts) +export const SCHEMA_VERSION = 1; +export const KEY_SCHEMA_VERSION = 'schemaVersion'; +export const STATE_STORE = 'pushy_state'; +export const CACHE_STORE = 'pushy_cache'; +// A boolean is stored as one of these; anything else (absent) reads as the +// field's fallback. +export const BOOLEAN_TRUE = '1'; +export const BOOLEAN_FALSE = '0'; +export const LEGACY_LAYOUT = 3; +// Carried by hand when the migration itself cannot run. +export const LEGACY_KEY_UUID = 'uuid'; +// The state fields (pushy::state::State). +export const KEY_PACKAGE_VERSION = 'packageVersion'; +export const KEY_BUILD_TIME = 'buildTime'; +export const KEY_CURRENT_VERSION = 'currentVersion'; +export const KEY_LAST_VERSION = 'lastVersion'; +export const KEY_FIRST_TIME = 'firstTime'; +export const KEY_FIRST_TIME_OK = 'firstTimeOk'; +export const KEY_ROLLED_BACK_VERSION = 'rolledBackVersion'; +// Per-version info: name, description, metaInfo and the rescue flags. +export const KEY_HASH_INFO = 'hash_'; +// Set when a launch consumes first_time; getConstants reports and clears it. +export const KEY_FIRST_LOAD_MARKER = 'firstLoadMarked'; +// The native round's raw response for the JS side to reuse (§10.3), scoped +// to the exact logical request and native config that produced it. It +// describes versions a reset or an upgrade removes. +export const KEY_CHECK_CACHE = 'nativeCheckResp'; +// Set when a round starts, cleared when it ends (§11.4). Residue on the +// next launch means the previous process died mid-round: that launch +// resumes immediately instead of waiting 5s. +export const KEY_ROUND_BREADCRUMB = 'nativeCheckIncomplete'; +// Identifies the install for gray-release bucketing: must never change. +export const KEY_UUID = 'uuid'; +// The persisted native check config (JS syncNativeConfig or the host) the +// cold-start round and the crash hold run from. +export const KEY_NATIVE_CONFIG = 'nativeConfig'; +// "|" of the embedded bundle: keyed by the binary, +// so it invalidates itself on upgrade. +export const KEY_BUNDLE_HASH_CACHE = 'bundleHashCache'; +// + +const TAG = 'PushyStore'; + +/** One store, as text. read returns '' for an absent key. */ +export interface StoreAccess { + read(key: string): string; + write(key: string, value: string): void; + remove(key: string): void; +} + +export function readBoolean( + store: StoreAccess, + key: string, + fallback: boolean, +): boolean { + const value = store.read(key); + if (value === BOOLEAN_TRUE) { + return true; + } + if (value === BOOLEAN_FALSE) { + return false; + } + return fallback; +} + +export function readMarker(store: StoreAccess, key: string): boolean { + return readBoolean(store, key, false); +} + +/** Removes the key for an absent or empty value. */ +export function writeString( + store: StoreAccess, + key: string, + value: string | undefined, +): void { + if (value) { + store.write(key, value); + } else { + store.remove(key); + } +} + +export function writeBoolean( + store: StoreAccess, + key: string, + value: boolean | undefined, +): void { + store.write(key, value ? BOOLEAN_TRUE : BOOLEAN_FALSE); +} + +export function writeMarker(store: StoreAccess, key: string, set: boolean): void { + if (set) { + store.write(key, BOOLEAN_TRUE); + } else { + store.remove(key); + } +} + +export function readState(store: StoreAccess): StateCoreResult { + return { + // (scripts/contract.ts; bun scripts/generate-contract.ts) + packageVersion: store.read(KEY_PACKAGE_VERSION), + buildTime: store.read(KEY_BUILD_TIME), + currentVersion: store.read(KEY_CURRENT_VERSION), + lastVersion: store.read(KEY_LAST_VERSION), + firstTime: readBoolean(store, KEY_FIRST_TIME, false), + firstTimeOk: readBoolean(store, KEY_FIRST_TIME_OK, true), + rolledBackVersion: store.read(KEY_ROLLED_BACK_VERSION), + // + }; +} + +export function writeState(store: StoreAccess, state: StateCoreResult): void { + // (scripts/contract.ts; bun scripts/generate-contract.ts) + writeString(store, KEY_PACKAGE_VERSION, state.packageVersion); + writeString(store, KEY_BUILD_TIME, state.buildTime); + writeString(store, KEY_CURRENT_VERSION, state.currentVersion); + writeString(store, KEY_LAST_VERSION, state.lastVersion); + writeBoolean(store, KEY_FIRST_TIME, state.firstTime); + writeBoolean(store, KEY_FIRST_TIME_OK, state.firstTimeOk); + writeString(store, KEY_ROLLED_BACK_VERSION, state.rolledBackVersion); + // +} + +/** A store's IO for the migration: a snapshot, in-memory writes, a flush. */ +export interface MigrationStore { + snapshot(): Record; + put(key: string, value: string): void; + remove(key: string): void; + /** Resolves once the store is on disk; rejects when it is not. */ + flush(): Promise; +} + +/** NativePatchCore.migrateStoredItems: the plan as JSON, 'null' for an unknown layout. */ +export type MigrationPlanner = ( + legacyLayout: number, + legacy: string[], + current: string[], +) => string; + +interface MigrationPlan { + migrated?: boolean; + puts?: string[][]; + removes?: string[]; +} + +/** + * [key, value, key, value, ...] with every value as text: a legacy boolean + * is '1' / '0' like the new layout's, anything without a text form is '' — + * still a key to delete, never a value. + */ +export function flattenStore(items: Record): string[] { + const flat: string[] = []; + for (const key of Object.keys(items)) { + const value: Object | null | undefined = items[key]; + flat.push(key); + if (typeof value === 'boolean') { + flat.push(value ? BOOLEAN_TRUE : BOOLEAN_FALSE); + } else if (typeof value === 'string' || typeof value === 'number') { + flat.push(String(value)); + } else { + flat.push(''); + } + } + return flat; +} + +/** + * The one-time move from the legacy layout (SDKs up to 10.61: preferences + * Texts.PREFERENCES_NAME, booleans native or 'true') into the state store, + * by the shared core's plan (cpp/patch_core/stored_items.h). The puts land + * in memory at once (everything after this reads them); the legacy keys are + * deleted only once the state store's flush succeeded. Every step is safe to + * repeat, so a process killed anywhere in between finishes the move on its + * next launch. Never throws: a failure leaves a fresh state store (as a new + * install) that still carries the install's uuid. Resolves with what it did + * ('current' | 'migrated' | 'retry' | 'fallback'), for logs and tests. + */ +export function migrateStore( + state: MigrationStore, + legacy: MigrationStore, + planner: MigrationPlanner, +): Promise { + let plan: MigrationPlan; + try { + const text = planner( + LEGACY_LAYOUT, + flattenStore(legacy.snapshot()), + flattenStore(state.snapshot()), + ); + const parsed = JSON.parse(text) as MigrationPlan | null; + if (!parsed || !Array.isArray(parsed.puts) || !Array.isArray(parsed.removes)) { + throw new Error('no migration plan'); + } + plan = parsed; + for (const put of plan.puts ?? []) { + state.put(put[0], put[1]); + } + } catch (error) { + logger.error(TAG, `state store migration failed: ${String(error)}`); + return carryUuid(state, legacy).then(() => 'fallback'); + } + const migrated = plan.migrated === true; + const written = (plan.puts ?? []).length > 0 ? state.flush() : Promise.resolve(); + return written.then( + () => { + const removes = plan.removes ?? []; + if (removes.length === 0) { + return migrated ? 'migrated' : 'current'; + } + for (const key of removes) { + legacy.remove(key); + } + // A failure only leaves keys nothing reads: the next launch removes + // them again. + return legacy.flush().then( + () => (migrated ? 'migrated' : 'current'), + (error: Object) => { + logger.warn(TAG, `failed to remove the legacy keys: ${String(error)}`); + return migrated ? 'migrated' : 'current'; + }, + ); + }, + (error: Object) => { + // The legacy keys stay until the puts are on disk. + logger.error(TAG, `failed to persist the migrated store: ${String(error)}`); + return 'retry'; + }, + ); +} + +// The migration could not run: start like a fresh install, but keep the +// install's identity (a new uuid would re-bucket every gray release). No +// schema is written, so the next launch runs the whole migration again. +function carryUuid(state: MigrationStore, legacy: MigrationStore): Promise { + try { + const current = state.snapshot()[KEY_UUID]; + const uuid = legacy.snapshot()[LEGACY_KEY_UUID]; + if (typeof current === 'string' && current.length > 0) { + return Promise.resolve(); + } + if (typeof uuid !== 'string' || uuid.length === 0) { + return Promise.resolve(); + } + state.put(KEY_UUID, uuid); + return state.flush().catch((error: Object) => { + logger.error(TAG, `failed to persist the carried uuid: ${String(error)}`); + }); + } catch (error) { + logger.error(TAG, `failed to carry the uuid: ${String(error)}`); + return Promise.resolve(); + } +} diff --git a/harmony/pushy/src/main/ets/SyncCoordinator.ts b/harmony/pushy/src/main/ets/SyncCoordinator.ts index cf1ac752..12cbdb59 100644 --- a/harmony/pushy/src/main/ets/SyncCoordinator.ts +++ b/harmony/pushy/src/main/ets/SyncCoordinator.ts @@ -7,6 +7,12 @@ import { BundlePreparationRound, bundlePreparationResult } from './BundlePrepara import type { BundlePreparationResult } from './BundlePreparationResult'; import { monotonicNowMs } from './MonotonicClock'; import { PushyError, getErrorMessage } from './ErrorCodes'; +import { + BOOLEAN_TRUE, + KEY_NATIVE_CONFIG, + KEY_ROUND_BREADCRUMB, + KEY_UUID, +} from './StoreSchema'; // 原生冷启动检测(NATIVE_CHECKUPDATE_DESIGN §10):每进程一次,getBundleUrl // 后延迟数秒运行,完全不依赖 app bundle——坏热更把 JS 砸挂后,下次启动仍能 @@ -17,19 +23,8 @@ import { PushyError, getErrorMessage } from './ErrorCodes'; // 鸿蒙的 debug 门控由触发点天然承担:dev 走 MetroJSBundleProvider, // PushyFileJSBundleProvider.getBundleUrl 不会被调用。 const TAG = 'NativeSync'; -// 与 Android 共用的存储键。鸿蒙不做 crash-hold(errorManager 的同步回调驱动 -// 不了 ArkTS 异步网络 IO),续传 + 零延迟标记是收敛砖机的全部机制。 -// (scripts/contract.ts; bun scripts/generate-contract.ts) -// The persisted native check config (JS syncNativeConfig or the host). -export const KEY_CONFIG = 'nativeConfig'; -// Raw response cache for the JS side to reuse (§10.3), scoped to the exact -// logical request and native config that produced it. -export const KEY_RESP_CACHE = 'nativeCheckResp'; -// Set when a round starts, cleared when it ends (§11.4). Residue on the -// next launch means the previous process died mid-round: that launch -// resumes immediately instead of waiting 5s. -export const KEY_ROUND_INCOMPLETE = 'nativeCheckIncomplete'; -// +// 鸿蒙不做 crash-hold(errorManager 的同步回调驱动不了 ArkTS 异步网络 IO), +// 续传 + 零延迟标记(KEY_ROUND_BREADCRUMB)是收敛砖机的全部机制。 const START_DELAY_MS = 5000; // 本次启动的事实,交给共享内核(cpp/update_flow_core/native_round.h)。 @@ -133,7 +128,7 @@ interface RunnableVerdict { } function readNativeConfig(context: UpdateContext): ConfigRead { - const json = context.getKv(KEY_CONFIG); + const json = context.getKv(KEY_NATIVE_CONFIG); const verdict = JSON.parse(NativePatchCore.runnableConfig(json)) as RunnableVerdict; if (verdict.status !== undefined) { return { @@ -222,7 +217,7 @@ export function scheduleNativeSync( scheduledRollback = launchRolledBackVersion; // 结果本来就是"下次启动生效",延迟几秒让开冷启动关键路径(§7 R5)—— // 除非上个进程死于轮中(残留标记),那时每一秒启动时间都要用来续传。 - const delayMs = context.getKv(KEY_ROUND_INCOMPLETE) ? 0 : START_DELAY_MS; + const delayMs = context.getKv(KEY_ROUND_BREADCRUMB) ? 0 : START_DELAY_MS; setTimeout(() => { if (hasJsRound(context)) { logger.info( @@ -249,7 +244,7 @@ async function runOnce( roundGeneration = resetGeneration; roundConfigGeneration = configGeneration; roundResult = bundlePreparationResult('failed', 'check_failed'); - const configJson = context.getKv(KEY_CONFIG); + const configJson = context.getKv(KEY_NATIVE_CONFIG); roundConfigJson = configJson; let event: RoundEvent = { @@ -276,11 +271,11 @@ async function runOnce( marked = true; logger.info( TAG, - `round started (resume=${!!context.getKv(KEY_ROUND_INCOMPLETE)})`, + `round started (resume=${!!context.getKv(KEY_ROUND_BREADCRUMB)})`, ); // 面包屑只是"下次零延迟"的优化:落盘失败不能阻止救援轮次本身。 try { - await context.setKv(KEY_ROUND_INCOMPLETE, '1'); + await context.setKv(KEY_ROUND_BREADCRUMB, BOOLEAN_TRUE); } catch (e) { logger.warn(TAG, `cannot persist round marker: ${getErrorMessage(e)}`); } @@ -295,7 +290,7 @@ async function runOnce( } finally { if (marked) { try { - await context.removeKv(KEY_ROUND_INCOMPLETE); + await context.removeKv(KEY_ROUND_BREADCRUMB); } catch (e) { // 残留标记只意味着下次启动少等 5 秒。 logger.warn(TAG, `cannot clear round marker: ${getErrorMessage(e)}`); @@ -345,7 +340,7 @@ async function launchEnv( packageVersion: context.getPackageVersion(), // 与 JS 常量同形(无版本时为 ''),JS 才能复用这次请求的响应缓存。 currentVersion: context.getCurrentVersion(), - uuid: context.getKv('uuid') ?? '', + uuid: context.getKv(KEY_UUID) ?? '', // getConstants consumes the persisted rollback marker during startup; use // the launch-path snapshot captured before that happens. rolledBackVersion: launchRolledBackVersion, diff --git a/harmony/pushy/src/main/ets/UpdateContext.ts b/harmony/pushy/src/main/ets/UpdateContext.ts index 158802f2..c1372e3d 100644 --- a/harmony/pushy/src/main/ets/UpdateContext.ts +++ b/harmony/pushy/src/main/ets/UpdateContext.ts @@ -12,12 +12,26 @@ import { DownloadTaskParams } from './DownloadTaskParams'; import { bundleManager } from '@kit.AbilityKit'; import { util } from '@kit.ArkTS'; import logger from './Logger'; +import { recordJsRound, scheduleNativeSync } from './SyncCoordinator'; import { - KEY_CONFIG, - KEY_RESP_CACHE, - recordJsRound, - scheduleNativeSync, -} from './SyncCoordinator'; + CACHE_STORE, + KEY_BUNDLE_HASH_CACHE, + KEY_CHECK_CACHE, + KEY_CURRENT_VERSION, + KEY_FIRST_LOAD_MARKER, + KEY_HASH_INFO, + KEY_LAST_VERSION, + KEY_NATIVE_CONFIG, + KEY_UUID, + MigrationStore, + migrateStore, + readMarker, + readState, + STATE_STORE, + StoreAccess, + writeMarker, + writeState, +} from './StoreSchema'; import NativePatchCore, { STATE_OP_CLEAR_ROLLBACK_MARK, STATE_OP_MARK_SUCCESS, @@ -56,27 +70,23 @@ 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). +// What a reset to the packaged bundle and a binary upgrade delete from the +// state store besides the state fields (scripts/contract.ts storedItems; +// the cache store is cleared on both, 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], + keys: [KEY_FIRST_LOAD_MARKER], + prefixes: [KEY_HASH_INFO], }; const UPGRADE_CLEARED: ClearedKeys = { - keys: [FIRST_LOAD_MARKED_KEY, KEY_RESP_CACHE], - prefixes: [HASH_INFO_PREFIX], + keys: [KEY_FIRST_LOAD_MARKER], + prefixes: [KEY_HASH_INFO], }; // @@ -89,7 +99,11 @@ interface PersistOptions { export class UpdateContext { private context: common.UIAbilityContext; private rootDir: string; + // The state store and the cache store (StoreSchema): the response cache is + // a whole server response, and keeping it with the state would make every + // small state flush rewrite it. private preferences!: preferences.Preferences; + private cachePreferences!: preferences.Preferences; // 宿主传入的调试标志(RNOH isDebugModeEnabled)。debug 下 markSuccess 空转、 // 不算内嵌 bundle 摘要,与 Android 的 debug 库对齐。HAR 自身没有构建期 // DEBUG 信号(hvigor 不为 HAR 生成 BuildProfile),所以只看宿主。 @@ -144,6 +158,7 @@ export class UpdateContext { logger.error(TAG, `Failed to create root directory: ${getErrorMessage(e)}`); } this.initPreferences(); + this.migrateLegacyStore(); this.trace('ctor'); this.syncStateWithBinaryVersion( this.getPackageVersion(), @@ -164,8 +179,8 @@ export class UpdateContext { ` cv=${snap.currentVersion} lv=${snap.lastVersion}` + ` ft=${snap.firstTime} fto=${snap.firstTimeOk}` + ` rb=${snap.rolledBackVersion}` + - ` flm=${this.readString(FIRST_LOAD_MARKED_KEY)}` + - ` uuidSet=${!!this.readString('uuid')}`, + ` flm=${this.readString(KEY_FIRST_LOAD_MARKER)}` + + ` uuidSet=${!!this.readString(KEY_UUID)}`, ); } @@ -177,7 +192,10 @@ export class UpdateContext { private initPreferences() { try { this.preferences = preferences.getPreferencesSync(this.context, { - name: PREFERENCES_NAME, + name: STATE_STORE, + }); + this.cachePreferences = preferences.getPreferencesSync(this.context, { + name: CACHE_STORE, }); } catch (e) { // Fail fast: a missing preferences store means no state can be persisted, @@ -189,8 +207,53 @@ export class UpdateContext { } } - private optionalPreferencesApi(): OptionalPreferencesApi { - return this.preferences as unknown as OptionalPreferencesApi; + /** + * 一次性把旧布局(PREFERENCES_NAME)迁入状态存储,在任何读取之前执行;完成后 + * 为空操作。puts 立即写入内存(之后的读取都能看到),旧键在状态存储落盘成功后 + * 才删除;失败时按全新安装启动,但保住 uuid(见 StoreSchema.migrateStore)。 + */ + private migrateLegacyStore(): void { + let legacy: preferences.Preferences; + try { + legacy = preferences.getPreferencesSync(this.context, { + name: PREFERENCES_NAME, + }); + } catch (e) { + // 读不到旧存储就当作全新安装。 + logger.error(TAG, `Failed to open the legacy store: ${getErrorMessage(e)}`); + return; + } + const state = this.migrationStore(this.preferences, 'state store'); + migrateStore( + state, + this.migrationStore(legacy, 'legacy store'), + NativePatchCore.migrateStoredItems, + ).then(result => { + if (result !== 'current') { + logger.info(TAG, `state store migration: ${result}`); + } + }); + } + + private migrationStore( + store: preferences.Preferences, + name: string, + ): MigrationStore { + return { + snapshot: () => store.getAllSync() as Record, + put: (key: string, value: string) => store.putSync(key, value), + remove: (key: string) => store.deleteSync(key), + flush: () => this.flushStore(store, `migrate ${name}`), + }; + } + + /** 状态存储的文本视图(StoreSchema 的读写映射)。 */ + private stateAccess(): StoreAccess { + return { + read: (key: string) => this.readString(key), + write: (key: string, value: string) => this.preferences.putSync(key, value), + remove: (key: string) => this.preferences.deleteSync(key), + }; } private getBundleFlags(): bundleManager.BundleFlag { @@ -267,30 +330,6 @@ export class UpdateContext { return ''; } - private readBoolean(key: string, defaultValue: boolean): boolean { - const value = this.preferences.getSync(key, defaultValue) as - | string - | boolean - | number - | null - | undefined; - if (typeof value === 'boolean') { - return value; - } - if (typeof value === 'string') { - if (value === 'true') { - return true; - } - if (value === 'false') { - return false; - } - } - if (typeof value === 'number') { - return value !== 0; - } - return defaultValue; - } - // >0 while a multi-write commit is open: putSync/deleteSync still apply in // memory, the single flush happens when the outermost batch closes. The // cold-start round's three writes (hash info, activation, response cache) @@ -322,7 +361,12 @@ export class UpdateContext { if (this.flushBatchDepth > 0) { return Promise.resolve(); } - const api = this.optionalPreferencesApi(); + return this.flushStore(this.preferences, reason); + } + + /** 落盘一个存储(见 flushPreferences),不受批次影响。 */ + private flushStore(store: preferences.Preferences, reason: string): Promise { + const api = store as unknown as OptionalPreferencesApi; if (typeof api.flushSync === 'function') { try { api.flushSync(); @@ -336,7 +380,7 @@ export class UpdateContext { // fall through to the async flush rather than failing outright } } - return this.preferences.flush().then( + return store.flush().then( () => undefined, (error: Object) => { logger.error( @@ -354,48 +398,26 @@ export class UpdateContext { }); } - private putNullableString(key: string, value?: string): void { - if (value) { - this.preferences.putSync(key, value); - return; - } - this.preferences.deleteSync(key); - } - private getBundlePath(hash: string): string { return `${this.rootDir}/${hash}/${HARMONY_BUNDLE_FILE_NAME}`; } private getStateSnapshot(): StateCoreResult { - return { - packageVersion: this.readString('packageVersion'), - buildTime: this.readString('buildTime'), - currentVersion: this.readString('currentVersion'), - lastVersion: this.readString('lastVersion'), - firstTime: this.readBoolean('firstTime', false), - firstTimeOk: this.readBoolean('firstTimeOk', true), - rolledBackVersion: this.readString('rolledBackVersion'), - }; + return readState(this.stateAccess()); } private applyState(state: StateCoreResult): void { - this.putNullableString('packageVersion', state.packageVersion); - this.putNullableString('buildTime', state.buildTime); - this.putNullableString('currentVersion', state.currentVersion); - this.putNullableString('lastVersion', state.lastVersion); - this.preferences.putSync('firstTime', !!state.firstTime); - this.preferences.putSync('firstTimeOk', state.firstTimeOk !== false); - this.putNullableString('rolledBackVersion', state.rolledBackVersion); + writeState(this.stateAccess(), state); } /** 内存写入(不落盘):状态字段 + 可选的陈旧 hash / 首载标记 / 清理。 */ private writeState(state: StateCoreResult, options: PersistOptions): void { this.applyState(state); if (options.removeStaleHash && state.staleVersionToDelete) { - this.preferences.deleteSync(`hash_${state.staleVersionToDelete}`); + this.preferences.deleteSync(`${KEY_HASH_INFO}${state.staleVersionToDelete}`); } if (options.markFirstLoadMarker) { - this.preferences.putSync(FIRST_LOAD_MARKED_KEY, 'true'); + writeMarker(this.stateAccess(), KEY_FIRST_LOAD_MARKER, true); } if (options.cleanUp) { this.cleanUp(); @@ -570,6 +592,22 @@ export class UpdateContext { this.removeKeys(UPGRADE_CLEARED, 'binary upgrade'); this.writeState(nextState, {}); this.flushInBackground('sync binary version'); + this.clearCheckCache('binary upgrade').catch(() => { + // 已在 flushStore 里记录。 + }); + } + + /** 原生轮次的原始响应缓存(§10.3);没有时为 ''。 */ + public getNativeCheckCache(): string { + const value = this.cachePreferences.getSync(KEY_CHECK_CACHE, ''); + return typeof value === 'string' ? value : ''; + } + + // 删除响应缓存:它描述的版本刚被 reset / 升级 / 配置更换作废。内存里立即 + // 生效,返回落盘的 promise。 + private clearCheckCache(reason: string): Promise { + this.cachePreferences.deleteSync(KEY_CHECK_CACHE); + return this.flushStore(this.cachePreferences, `clear check cache (${reason})`); } private static nativeConfigGeneration: number = 0; @@ -580,7 +618,7 @@ export class UpdateContext { /** 与持久化的配置是否等价(C1)。 */ public isStoredNativeConfig(config: string | undefined): boolean { - return nativeConfigsEquivalent(config, this.getKv(KEY_CONFIG)); + return nativeConfigsEquivalent(config, this.getKv(KEY_NATIVE_CONFIG)); } /** @@ -589,18 +627,22 @@ export class UpdateContext { * response cache and generations stay as they are. */ public setNativeConfig(config: string): Promise { - if (!this.getKv('uuid')) { - this.preferences.putSync('uuid', util.generateRandomUUID()); + if (!this.getKv(KEY_UUID)) { + this.preferences.putSync(KEY_UUID, util.generateRandomUUID()); } - const current = this.getKv(KEY_CONFIG); + const current = this.getKv(KEY_NATIVE_CONFIG); // Byte-identical is trivially no change, even for a value C1 cannot parse. if (!current || (current !== config && !nativeConfigsEquivalent(current, config))) { // Also guards A -> B -> A replacements and late download commits. UpdateContext.resetGeneration += 1; UpdateContext.nativeConfigGeneration += 1; - this.preferences.putSync(KEY_CONFIG, config); - this.preferences.deleteSync(KEY_RESP_CACHE); + this.preferences.putSync(KEY_NATIVE_CONFIG, config); recordJsRound(''); + // Flush even an equal value so a retry after a storage error can succeed. + return Promise.all([ + this.flushPreferences('persist configuration'), + this.clearCheckCache('configuration'), + ]).then(() => undefined); } // Flush even an equal value so a retry after a storage error can succeed. return this.flushPreferences('persist configuration'); @@ -679,8 +721,9 @@ export class UpdateContext { } finally { flushed = this.endFlushBatch('reset to packaged bundle'); } + const cacheCleared = this.clearCheckCache('reset'); UpdateContext.ignoreRollback = false; - await flushed; + await Promise.all([flushed, cacheCleared]); // maxAgeDays=0:删除下载目录内容,仅保留当前运行版本的目录(残留目录由 // 下次常规清理回收)。挂到串行任务链尾,避免与在飞的解压/打补丁并发。 @@ -732,18 +775,23 @@ export class UpdateContext { let flushed: Promise = Promise.resolve(); try { if (hash && hashInfoJson) { - this.preferences.putSync(`hash_${hash}`, hashInfoJson); + this.preferences.putSync(`${KEY_HASH_INFO}${hash}`, hashInfoJson); } if (shouldSwitch) { this.applySwitch(hash); } - if (responseCacheJson) { - this.preferences.putSync(KEY_RESP_CACHE, responseCacheJson); - } } finally { flushed = this.endFlushBatch('commit sync result'); } await flushed; + // Advisory cache in its own store, written after the state it describes + // (as on Android): a lost write only costs JS a request. + if (responseCacheJson) { + this.cachePreferences.putSync(KEY_CHECK_CACHE, responseCacheJson); + this.flushStore(this.cachePreferences, 'commit sync cache').catch(() => { + // 已在 flushStore 里记录。 + }); + } return true; } @@ -835,8 +883,8 @@ export class UpdateContext { await verifyInstallForActivation( `${this.rootDir}/${safeHash}`, safeHash, - this.readString('currentVersion') === safeHash || - this.readString('lastVersion') === safeHash, + this.readString(KEY_CURRENT_VERSION) === safeHash || + this.readString(KEY_LAST_VERSION) === safeHash, ); } @@ -871,10 +919,10 @@ export class UpdateContext { } public consumeFirstLoadMarker(): boolean { - const marked = this.readString(FIRST_LOAD_MARKED_KEY) === 'true'; + const marked = readMarker(this.stateAccess(), KEY_FIRST_LOAD_MARKER); this.trace(`consumeFirstLoadMarker:marked=${marked}`); if (marked) { - this.preferences.deleteSync(FIRST_LOAD_MARKED_KEY); + writeMarker(this.stateAccess(), KEY_FIRST_LOAD_MARKER, false); this.flushInBackground('clear first load marker'); } return marked; @@ -999,9 +1047,8 @@ export class UpdateContext { return UpdateContext.isUsingBundleUrl; } - // bundleHash 缓存:"||"。键标识当前 - // 安装的二进制,每次(覆盖)安装 updateTime 都会变,每个安装只算一次。 - private static readonly KEY_BUNDLE_HASH_CACHE = 'bundleHashCache'; + // bundleHash 缓存(KEY_BUNDLE_HASH_CACHE):"||"。 + // 键标识当前安装的二进制,每次(覆盖)安装 updateTime 都会变,每个安装只算一次。 private getBundleModifiedTime(): number { try { @@ -1033,7 +1080,7 @@ export class UpdateContext { return Promise.resolve(''); } const cachePrefix = `${this.getPackageVersion()}|${this.getBundleModifiedTime()}|`; - const cached = this.readString(UpdateContext.KEY_BUNDLE_HASH_CACHE); + const cached = this.readString(KEY_BUNDLE_HASH_CACHE); if (cached.startsWith(cachePrefix)) { return Promise.resolve(cached.slice(cachePrefix.length)); } @@ -1075,7 +1122,7 @@ export class UpdateContext { } if (hash) { this.preferences.putSync( - UpdateContext.KEY_BUNDLE_HASH_CACHE, + KEY_BUNDLE_HASH_CACHE, cachePrefix + hash, ); // 缓存落盘失败不影响返回值(下次重算即可)。 diff --git a/ios/RCTPushy/RCTPushy+Launch.mm b/ios/RCTPushy/RCTPushy+Launch.mm index 2bcf3992..f6e90293 100644 --- a/ios/RCTPushy/RCTPushy+Launch.mm +++ b/ios/RCTPushy/RCTPushy+Launch.mm @@ -70,7 +70,8 @@ + (NSURL *)resolveLaunchBundleURL:(NSString **)launchRolledBackVersionOut // as every platform does (scripts/contract.ts storedItems). PushyRemoveKeys(defaults, PushyKeysClearedOnUpgrade(), PushyKeyPrefixesClearedOnUpgrade()); - [defaults setObject:@(YES) forKey:KeyPackageUpdatedMarked()]; + [PushyCacheDefaults() removeObjectForKey:keyCheckCache]; + PushyWriteMarker(defaults, keyBinaryUpgradedMarker, YES); state = sync.state; PushyApplyStateToDefaults(defaults, state); } @@ -97,7 +98,7 @@ + (NSURL *)resolveLaunchBundleURL:(NSString **)launchRolledBackVersionOut if (decision.consumed_first_time) { // bundleURL may be called many times, ignore rollbacks before process restarted again. ignoreRollback = true; - [defaults setObject:@(YES) forKey:keyFirstLoadMarked]; + PushyWriteMarker(defaults, keyFirstLoadMarker, YES); } NSString *loadVersion = PushyFromStdString(decision.load_version); @@ -127,7 +128,7 @@ + (NSURL *)resolveLaunchBundleURL:(NSString **)launchRolledBackVersionOut // The first load being consumed never happens: the // packaged bundle must not report isFirstTime. ignoreRollback = false; - [defaults removeObjectForKey:keyFirstLoadMarked]; + PushyWriteMarker(defaults, keyFirstLoadMarker, NO); } break; #else diff --git a/ios/RCTPushy/RCTPushy.mm b/ios/RCTPushy/RCTPushy.mm index 95ecea7d..52153208 100644 --- a/ios/RCTPushy/RCTPushy.mm +++ b/ios/RCTPushy/RCTPushy.mm @@ -177,34 +177,38 @@ - (NSDictionary *)constantsToExport ret[@"downloadRootDir"] = [RCTPushy downloadDir]; ret[@"packageVersion"] = [RCTPushy packageVersion]; ret[@"buildTime"] = [RCTPushy buildTime]; - ret[@"rolledBackVersion"] = [defaults objectForKey:keyRolledBackMarked]; - ret[@"isFirstTime"] = [defaults objectForKey:keyFirstLoadMarked]; - ret[@"uuid"] = [defaults objectForKey:keyUuid]; + pushy::state::State state = PushyStateFromDefaults(defaults); + NSString *rolledBackVersion = PushyFromStdString(state.rolled_back_version); + BOOL isFirstTime = PushyReadMarker(defaults, keyFirstLoadMarker); + ret[@"rolledBackVersion"] = rolledBackVersion; + if (isFirstTime) { + ret[@"isFirstTime"] = @YES; + } + ret[@"uuid"] = PushyReadString(defaults, keyUuid); ret[@"isUsingBundleUrl"] = @(pushyIsUsingBundleUrl.load()); // 原生 patch 内核可消费的 diff 轨道版本(2 = hdiffv2 轨道), // JS 随 checkUpdate 以 diffV 上报,服务端按能力门控下发 ret[@"supportedDiffVersion"] = @(pushy::hbc::kSupportedDiffVersion); - NSDictionary *pushyInfo = [defaults dictionaryForKey:keyPushyInfo]; - NSString *currentVersion = [pushyInfo objectForKey:paramCurrentVersion]; + NSString *currentVersion = PushyFromStdString(state.current_version); ret[@"currentVersion"] = currentVersion; if (currentVersion != nil) { - ret[@"currentVersionInfo"] = [defaults objectForKey:PushyHashInfoKey(currentVersion)]; + ret[@"currentVersionInfo"] = PushyReadString(defaults, PushyHashInfoKey(currentVersion)); // bundleSha256 from the install record, for crash-report attribution. ret[@"currentBundleSha256"] = PushyIsSafePathComponent(currentVersion) ? PushyRecordedBundleSha256(PushyVersionDir(currentVersion)) : @""; } - if (ret[@"isFirstTime"]) { - [defaults removeObjectForKey:keyFirstLoadMarked]; + if (isFirstTime) { + PushyWriteMarker(defaults, keyFirstLoadMarker, NO); } - if (ret[@"rolledBackVersion"] != nil) { - [defaults removeObjectForKey:keyRolledBackMarked]; + if (rolledBackVersion != nil) { + PushyApplyStateToDefaults(defaults, pushy::state::ClearRollbackMark(state)); [RCTPushyInstaller clearInvalidFiles]; } - if ([[defaults objectForKey:KeyPackageUpdatedMarked()] boolValue]) { - [defaults removeObjectForKey:KeyPackageUpdatedMarked()]; + if (PushyReadMarker(defaults, keyBinaryUpgradedMarker)) { + PushyWriteMarker(defaults, keyBinaryUpgradedMarker, NO); [RCTPushyInstaller clearInvalidFiles]; } }); }); @@ -220,8 +224,7 @@ - (NSDictionary *)constantsToExport return; } - NSUserDefaults *defaults = PushyDefaults(); - [defaults setObject:uuid forKey:keyUuid]; + PushyWriteString(PushyDefaults(), keyUuid, uuid); resolve(@true); } @@ -255,7 +258,7 @@ - (NSDictionary *)constantsToExport RCT_EXPORT_METHOD(getNativeCheckCache:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { - resolve([PushyDefaults() stringForKey:keyNativeCheckCache] ?: @""); + resolve(PushyReadString(PushyCacheDefaults(), keyCheckCache) ?: @""); } RCT_EXPORT_METHOD(markJsCheckCompleted:(NSString *)config @@ -281,7 +284,7 @@ - (NSDictionary *)constantsToExport NSError *error = nil; if (PushyJsonDictionary(value, &error) != nil) { - [PushyDefaults() setObject:value forKey:PushyHashInfoKey(hash)]; + PushyWriteString(PushyDefaults(), PushyHashInfoKey(hash), value); resolve(@true); } else { PushyRejectError(reject, PushyErrorWithCode( @@ -297,7 +300,7 @@ - (NSDictionary *)constantsToExport { NSUserDefaults *defaults = PushyDefaults(); - resolve([defaults stringForKey:PushyHashInfoKey(hash)]); + resolve(PushyReadString(defaults, PushyHashInfoKey(hash))); } RCT_EXPORT_METHOD(downloadFullUpdate:(NSDictionary *)options @@ -450,6 +453,7 @@ - (NSDictionary *)constantsToExport // removed), the same on every platform (scripts/contract.ts). PushyRemoveKeys(defaults, PushyKeysClearedOnReset(), PushyKeyPrefixesClearedOnReset()); + [PushyCacheDefaults() removeObjectForKey:keyCheckCache]; ignoreRollback = false; }); } @catch (...) { diff --git a/ios/RCTPushy/RCTPushyInternal.h b/ios/RCTPushy/RCTPushyInternal.h index 0f2001bc..7e67014c 100644 --- a/ios/RCTPushy/RCTPushyInternal.h +++ b/ios/RCTPushy/RCTPushyInternal.h @@ -41,20 +41,31 @@ typedef NS_ENUM(NSInteger, PushyType) { #pragma mark - RCTPushyState.mm -extern PUSHY_HIDDEN NSString *const keyPushyInfo; -extern PUSHY_HIDDEN NSString *const paramCurrentVersion; -extern PUSHY_HIDDEN NSString *const keyUuid; +// The persisted keys (scripts/contract.ts storeSchema). +// (scripts/contract.ts; bun scripts/generate-contract.ts) +extern PUSHY_HIDDEN NSString *const keySchemaVersion; +extern PUSHY_HIDDEN NSString *const keyPackageVersion; +extern PUSHY_HIDDEN NSString *const keyBuildTime; +extern PUSHY_HIDDEN NSString *const keyCurrentVersion; +extern PUSHY_HIDDEN NSString *const keyLastVersion; +extern PUSHY_HIDDEN NSString *const keyFirstTime; +extern PUSHY_HIDDEN NSString *const keyFirstTimeOk; +extern PUSHY_HIDDEN NSString *const keyRolledBackVersion; 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 keyFirstLoadMarker; +extern PUSHY_HIDDEN NSString *const keyBinaryUpgradedMarker; +extern PUSHY_HIDDEN NSString *const keyCheckCache; +extern PUSHY_HIDDEN NSString *const keyRoundBreadcrumb; +extern PUSHY_HIDDEN NSString *const keyUuid; +extern PUSHY_HIDDEN NSString *const keyNativeConfig; +extern PUSHY_HIDDEN NSString *const keyBundleHashCache; +// 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); // 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`. @@ -69,7 +80,21 @@ PUSHY_HIDDEN NSString *PushyHashInfoKey(NSString *hash); PUSHY_HIDDEN NSDictionary *PushyHostResult(NSString *status, NSString *reason, NSString *hash = nil, BOOL activated = NO); PUSHY_HIDDEN NSTimeInterval PushyMonotonicNow(void); +// The state store (every key but keyCheckCache), migrated out of the legacy +// layout on first use; and the cache store (keyCheckCache). PUSHY_HIDDEN NSUserDefaults *PushyDefaults(void); +PUSHY_HIDDEN NSUserDefaults *PushyCacheDefaults(void); +// Values as the layout stores them: text, never empty (writing nil or "" +// removes the key); a marker is a boolean that is only stored while set. +PUSHY_HIDDEN NSString *PushyReadString(NSUserDefaults *defaults, NSString *key); +PUSHY_HIDDEN void PushyWriteString(NSUserDefaults *defaults, NSString *key, NSString *value); +PUSHY_HIDDEN BOOL PushyReadBool(NSUserDefaults *defaults, NSString *key, BOOL fallback); +PUSHY_HIDDEN void PushyWriteBool(NSUserDefaults *defaults, NSString *key, BOOL value); +PUSHY_HIDDEN BOOL PushyReadMarker(NSUserDefaults *defaults, NSString *key); +PUSHY_HIDDEN void PushyWriteMarker(NSUserDefaults *defaults, NSString *key, BOOL set); +// The one-time move from the legacy layout (standardUserDefaults) into +// `state`; see RCTPushyState.mm. Returns what it did, for logs and tests. +PUSHY_HIDDEN NSString *PushyMigrateStores(NSUserDefaults *state, NSUserDefaults *legacy); PUSHY_HIDDEN void PushyWithStateLock(void (NS_NOESCAPE ^block)(void)); // Inside `@catch (...)`: the reason of what was caught — an NSException's // reason or a C++ exception's what() — or `fallback` when it has none. The diff --git a/ios/RCTPushy/RCTPushyOrchestrator.mm b/ios/RCTPushy/RCTPushyOrchestrator.mm index 68383fff..667ea880 100644 --- a/ios/RCTPushy/RCTPushyOrchestrator.mm +++ b/ios/RCTPushy/RCTPushyOrchestrator.mm @@ -11,13 +11,12 @@ #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 +// keyNativeConfig: the raw JSON persisted by JS (syncNativeConfig) or the +// host (configure) for the native round, parsed on read +// (PushyLoadRunnableConfig); absent = no round. keyRoundBreadcrumb: 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"; +// rescue was truncated), so that launch resumes immediately instead of +// waiting. Both in RCTPushyState.mm (scripts/contract.ts storedItems). // The crash-hold version-info flag read by JS, stored encoded // (cpp/patch_core/native_texts.h) so it does not appear as a plain binary @@ -324,7 +323,7 @@ static BOOL PushyConfigsEquivalent(NSString *a, NSString *b) { // 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]; + NSString *text = PushyReadString(PushyDefaults(), keyNativeConfig); if (json != NULL) { *json = [text copy]; } @@ -341,10 +340,10 @@ @implementation RCTPushyOrchestrator + (void)persistConfiguration:(NSString *)config { PushyWithStateLock(^{ NSUserDefaults *defaults = PushyDefaults(); - if ([defaults stringForKey:keyUuid].length == 0) { + if (PushyReadString(defaults, keyUuid) == nil) { [defaults setObject:[NSUUID UUID].UUIDString forKey:keyUuid]; } - if (PushyConfigsEquivalent([defaults stringForKey:keyNativeConfig], config)) { + if (PushyConfigsEquivalent(PushyReadString(defaults, keyNativeConfig), config)) { // The same config in another serialization (key order, trailing // slashes, defaulted keys). Not a change: the stored text, the // response cache and any in-flight round all stay valid. @@ -355,8 +354,8 @@ + (void)persistConfiguration:(NSString *)config { // commitRoundWithGeneration next to the reset generation), and the // cached response and JS-completed marker belong to the old one. pushyNativeConfigGeneration.fetch_add(1); - [defaults setObject:config forKey:keyNativeConfig]; - [defaults removeObjectForKey:keyNativeCheckCache]; + PushyWriteString(defaults, keyNativeConfig, config); + [PushyCacheDefaults() removeObjectForKey:keyCheckCache]; [self recordJsRound:nil]; }); if (pushySyncReady.load() && [self hasRunnableConfig]) { @@ -401,7 +400,7 @@ + (void)scheduleFromColdStart:(NSString *)launchRolledBackVersion { [RCTPushyOrchestrator prepareProcess:launchRolledBackVersion]; NSUserDefaults *defaults = PushyDefaults(); int64_t delaySeconds = - [defaults objectForKey:keyNativeCheckIncomplete] != nil ? 0 : 5; + PushyReadMarker(defaults, keyRoundBreadcrumb) ? 0 : 5; dispatch_after(dispatch_time(DISPATCH_TIME_NOW, delaySeconds * NSEC_PER_SEC), dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{ PushyGuard(@"native sync", ^{ if ([RCTPushyOrchestrator hasJsRound]) { @@ -522,7 +521,7 @@ + (NSDictionary *)prepareBundleWithin:(NSTimeInterval)timeout { } NSString *cancellation = PushyFromStdString(flowcore::HostCancellation( PushyToStdString(configJson), PushyToStdString(pushyHostRoundConfig), - PushyToStdString([PushyDefaults() stringForKey:keyNativeConfig]), + PushyToStdString(PushyReadString(PushyDefaults(), keyNativeConfig)), pushyHostRoundConfigGeneration, pushyNativeConfigGeneration.load(), pushyHostRoundGeneration, pushyResetGeneration.load())); if (cancellation != nil) { @@ -550,7 +549,7 @@ + (BOOL)hasJsRound { if (jsConfig == nil) { return NO; } - return PushyConfigsEquivalent([PushyDefaults() stringForKey:keyNativeConfig], jsConfig); + return PushyConfigsEquivalent(PushyReadString(PushyDefaults(), keyNativeConfig), jsConfig); } // Runs the process's single round on the calling thread if nobody has @@ -628,7 +627,7 @@ + (void)activatePendingVersion { } NSDictionary *hashInfoEntry = nil; NSDictionary *parsed = - PushyJsonDictionary([PushyDefaults() stringForKey:PushyHashInfoKey(hash)], NULL); + PushyJsonDictionary(PushyReadString(PushyDefaults(), PushyHashInfoKey(hash)), NULL); if (parsed != nil) { NSMutableDictionary *merged = [parsed mutableCopy]; merged[PushyInfoCrashHold()] = @YES; @@ -668,7 +667,7 @@ + (void)runOnce:(NSString *)launchRolledBackVersion deadline:(NSTimeInterval)dea pushyHostRoundConfigGeneration = configGeneration; pushyHostRoundResult = PushyHostResult(@"failed", @"check_failed"); NSUserDefaults *defaults = PushyDefaults(); - NSString *configJson = [defaults stringForKey:keyNativeConfig]; + NSString *configJson = PushyReadString(defaults, keyNativeConfig); pushyHostRoundConfig = configJson; flowjson::Value event = flowjson::Value::Object(); @@ -691,7 +690,7 @@ + (void)runOnce:(NSString *)launchRolledBackVersion deadline:(NSTimeInterval)dea for (const flowjson::Value &command : step.Get("commands").elements()) { const std::string &type = command.Get("type").AsString(); if (type == "markIncomplete") { - [defaults setObject:@YES forKey:keyNativeCheckIncomplete]; + PushyWriteMarker(defaults, keyRoundBreadcrumb, YES); marked = YES; } else if (type == "finish") { [self finishRound:command @@ -708,7 +707,7 @@ + (void)runOnce:(NSString *)launchRolledBackVersion deadline:(NSTimeInterval)dea } } @finally { if (marked) { - [defaults removeObjectForKey:keyNativeCheckIncomplete]; + PushyWriteMarker(defaults, keyRoundBreadcrumb, NO); } } } @@ -764,7 +763,7 @@ + (void)runOnce:(NSString *)launchRolledBackVersion deadline:(NSTimeInterval)dea }; put("packageVersion", [RCTPushy packageVersion]); put("currentVersion", currentVersion); - put("uuid", [PushyDefaults() stringForKey:keyUuid]); + put("uuid", PushyReadString(PushyDefaults(), keyUuid)); put("rolledBackVersion", launchRolledBackVersion); put("buildTime", [RCTPushy buildTime]); put("os", [NSString stringWithFormat:@"%@ %@", PushyOsName, PushyOsVersion()]); @@ -953,7 +952,7 @@ + (BOOL)commitRoundWithGeneration:(uint64_t)generation activated = YES; } if (cache != nil) { - [defaults setObject:cache forKey:keyNativeCheckCache]; + [PushyCacheDefaults() setObject:cache forKey:keyCheckCache]; } committed = YES; }); diff --git a/ios/RCTPushy/RCTPushyState.mm b/ios/RCTPushy/RCTPushyState.mm index d37e173e..a4a63722 100644 --- a/ios/RCTPushy/RCTPushyState.mm +++ b/ios/RCTPushy/RCTPushyState.mm @@ -6,57 +6,74 @@ #include #include "../../cpp/patch_core/digest.h" #include "../../cpp/patch_core/native_texts.h" +#include "../../cpp/patch_core/stored_items.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 (native_texts.h) are decoded on first use, not by -// static initializers at image load. -PUSHY_HIDDEN NSString *KeyPackageUpdatedMarked(void) { - static NSString *value = PushyFromStdString(pushy::texts::PackageUpdatedMarkedKey()); - 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"; +// The persisted layout every platform shares (scripts/contract.ts +// storeSchema): two NSUserDefaults suites of our own. Not +// standardUserDefaults: the host app shares that namespace, so a host key +// could collide with ours (the unprefixed packageVersion/buildTime once did, +// and wiped installed versions), and a host that clears its defaults domain +// (a common "log out" reset) would take the uuid and the update state along. +// (scripts/contract.ts; bun scripts/generate-contract.ts) +static NSString *const PushyStateStoreName = @"pushy_state"; +static NSString *const PushyCacheStoreName = @"pushy_cache"; +// A boolean is stored as one of these; anything else (absent) reads as the +// field's fallback. +static NSString *const PushyBooleanTrue = @"1"; +static NSString *const PushyBooleanFalse = @"0"; +// Carried by hand when the migration itself cannot run. +static NSString *const legacyKeyUuid = @"REACTNATIVECN_PUSHY_UUID"; +// Every legacy key starts with it: only those are snapshotted (the host app +// shares that store). +static NSString *const legacyKeyPrefix = @"REACTNATIVECN_PUSHY_"; +PUSHY_HIDDEN NSString *const keySchemaVersion = @"schemaVersion"; +PUSHY_HIDDEN NSString *const keyPackageVersion = @"packageVersion"; +PUSHY_HIDDEN NSString *const keyBuildTime = @"buildTime"; +PUSHY_HIDDEN NSString *const keyCurrentVersion = @"currentVersion"; +PUSHY_HIDDEN NSString *const keyLastVersion = @"lastVersion"; +PUSHY_HIDDEN NSString *const keyFirstTime = @"firstTime"; +PUSHY_HIDDEN NSString *const keyFirstTimeOk = @"firstTimeOk"; +PUSHY_HIDDEN NSString *const keyRolledBackVersion = @"rolledBackVersion"; +// Per-version info: name, description, metaInfo and the rescue flags. +PUSHY_HIDDEN NSString *const keyHashInfo = @"hash_"; +// Set when a launch consumes first_time; getConstants reports and clears it. +PUSHY_HIDDEN NSString *const keyFirstLoadMarker = @"firstLoadMarked"; +// Set by a binary upgrade, consumed by getConstants (old version cleanup). +PUSHY_HIDDEN NSString *const keyBinaryUpgradedMarker = @"binaryUpgraded"; +// The native round's raw response for the JS side to reuse (§10.3), scoped +// to the exact logical request and native config that produced it. It +// describes versions a reset or an upgrade removes. +PUSHY_HIDDEN NSString *const keyCheckCache = @"nativeCheckResp"; +// Set when a round starts, cleared when it ends (§11.4). Residue on the +// next launch means the previous process died mid-round: that launch +// resumes immediately instead of waiting 5s. +PUSHY_HIDDEN NSString *const keyRoundBreadcrumb = @"nativeCheckIncomplete"; +// Identifies the install for gray-release bucketing: must never change. +PUSHY_HIDDEN NSString *const keyUuid = @"uuid"; +// The persisted native check config (JS syncNativeConfig or the host) the +// cold-start round and the crash hold run from. +PUSHY_HIDDEN NSString *const keyNativeConfig = @"nativeConfig"; +// "|" of the embedded bundle: keyed by the binary, +// so it invalidates itself on upgrade. +PUSHY_HIDDEN NSString *const keyBundleHashCache = @"bundleHashCache"; +// // (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). +// What a reset to the packaged bundle and a binary upgrade delete from the +// state store besides the state fields (scripts/contract.ts storedItems; +// the cache store is cleared on both, 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]; + return @[keyFirstLoadMarker, keyBinaryUpgradedMarker]; } PUSHY_HIDDEN NSArray *PushyKeyPrefixesClearedOnReset(void) { return @[keyHashInfo]; } PUSHY_HIDDEN NSArray *PushyKeysClearedOnUpgrade(void) { - return @[keyFirstLoadMarked, keyNativeCheckCache]; + return @[keyFirstLoadMarker]; } PUSHY_HIDDEN NSArray *PushyKeyPrefixesClearedOnUpgrade(void) { return @[keyHashInfo]; @@ -160,10 +177,6 @@ PUSHY_HIDDEN BOOL PushyGuard(NSString *where, void (NS_NOESCAPE ^block)(void)) { 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; @@ -171,14 +184,132 @@ PUSHY_HIDDEN BOOL PushyGuard(NSString *where, void (NS_NOESCAPE ^block)(void)) { return [NSString stringWithUTF8String:value.c_str()]; } -static void PushySetNullableString(NSUserDefaults *defaults, NSString *key, NSString *value) { - if (value != nil) { +PUSHY_HIDDEN NSString *PushyReadString(NSUserDefaults *defaults, NSString *key) { + NSString *value = [defaults stringForKey:key]; + return value.length > 0 ? value : nil; +} + +PUSHY_HIDDEN void PushyWriteString(NSUserDefaults *defaults, NSString *key, NSString *value) { + if (value.length > 0) { [defaults setObject:value forKey:key]; } else { [defaults removeObjectForKey:key]; } } +PUSHY_HIDDEN BOOL PushyReadBool(NSUserDefaults *defaults, NSString *key, BOOL fallback) { + NSString *value = [defaults stringForKey:key]; + if ([value isEqualToString:PushyBooleanTrue]) { + return YES; + } + if ([value isEqualToString:PushyBooleanFalse]) { + return NO; + } + return fallback; +} + +PUSHY_HIDDEN void PushyWriteBool(NSUserDefaults *defaults, NSString *key, BOOL value) { + [defaults setObject:value ? PushyBooleanTrue : PushyBooleanFalse forKey:key]; +} + +PUSHY_HIDDEN BOOL PushyReadMarker(NSUserDefaults *defaults, NSString *key) { + return PushyReadBool(defaults, key, NO); +} + +PUSHY_HIDDEN void PushyWriteMarker(NSUserDefaults *defaults, NSString *key, BOOL set) { + if (set) { + [defaults setObject:PushyBooleanTrue forKey:key]; + } else { + [defaults removeObjectForKey:key]; + } +} + +// A stored value as the migration core reads it: text as is, a number or a +// boolean as its text ("1" for YES, like the layout's own booleans), anything +// else (the legacy info dictionary) as "" — a key to delete, never a value. +static std::string PushyDefaultsText(id value) { + if ([value isKindOfClass:[NSString class]]) { + return PushyToStdString(value); + } + if ([value isKindOfClass:[NSNumber class]]) { + return PushyToStdString([(NSNumber *)value stringValue]); + } + return std::string(); +} + +// The one-time move from the legacy layout (SDKs up to 10.61: +// standardUserDefaults, REACTNATIVECN_PUSHY_* keys) into the state suite, by +// the shared core's plan (cpp/patch_core/stored_items.h). The puts end with +// the schema, so a process killed between two of them migrates again on its +// next launch (the core never overwrites what the suite already holds); the +// legacy keys are deleted only after every put. A write reaches cfprefsd +// before setObject:forKey: returns, so dying here loses nothing already put. +// Never throws: a failure leaves a fresh suite (as a new install) that still +// carries the install's uuid, and writes no schema, so the next launch tries +// again. +PUSHY_HIDDEN NSString *PushyMigrateStores(NSUserDefaults *state, NSUserDefaults *legacy) { + @try { + pushy::stored::Entries legacyItems; + NSDictionary *legacyAll = [legacy dictionaryRepresentation]; + for (NSString *key in legacyAll) { + // The host app shares that store: only our prefix is looked at. + if ([key hasPrefix:legacyKeyPrefix]) { + legacyItems.emplace_back(PushyToStdString(key), PushyDefaultsText(legacyAll[key])); + } + } + pushy::stored::Entries currentItems; + NSDictionary *currentAll = [state dictionaryRepresentation]; + for (NSString *key in currentAll) { + currentItems.emplace_back(PushyToStdString(key), PushyDefaultsText(currentAll[key])); + } + pushy::stored::MigrationPlan plan = pushy::stored::MigrateStoredItems( + pushy::stored::kLegacyIos, legacyItems, currentItems); + if (!plan.valid) { + [NSException raise:NSInternalInconsistencyException format:@"no migration plan"]; + } + for (const auto &put : plan.puts) { + [state setObject:PushyFromStdString(put.second) forKey:PushyFromStdString(put.first)]; + } + for (const std::string &key : plan.removes) { + [legacy removeObjectForKey:PushyFromStdString(key)]; + } + return plan.migrated ? @"migrated" : @"current"; + } @catch (...) { + NSLog(@"RCTPushy -- state store migration failed: %@", + PushyCaughtReason(@"unknown exception")); + } + PushyGuard(@"uuid carry", ^{ + NSString *uuid = [legacy stringForKey:legacyKeyUuid]; + if (PushyReadString(state, keyUuid) == nil && uuid.length > 0) { + [state setObject:uuid forKey:keyUuid]; + } + }); + return @"fallback"; +} + +PUSHY_HIDDEN NSUserDefaults *PushyDefaults(void) { + static NSUserDefaults *defaults; + static dispatch_once_t once; + dispatch_once(&once, ^{ + defaults = [[NSUserDefaults alloc] initWithSuiteName:PushyStateStoreName]; + // Before anything reads the suite; a no-op once done. + NSString *migration = PushyMigrateStores(defaults, [NSUserDefaults standardUserDefaults]); + if (![migration isEqualToString:@"current"]) { + NSLog(@"RCTPushy -- state store migration: %@", migration); + } + }); + return defaults; +} + +PUSHY_HIDDEN NSUserDefaults *PushyCacheDefaults(void) { + static NSUserDefaults *defaults; + static dispatch_once_t once; + dispatch_once(&once, ^{ + defaults = [[NSUserDefaults alloc] initWithSuiteName:PushyCacheStoreName]; + }); + return defaults; +} + PUSHY_HIDDEN NSString *PushyHashInfoKey(NSString *hash) { return [keyHashInfo stringByAppendingString:hash ?: @""]; } @@ -247,60 +378,30 @@ PUSHY_HIDDEN void PushyRejectError(RCTPromiseRejectBlock reject, NSError *error) 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]); + // (scripts/contract.ts; bun scripts/generate-contract.ts) + state.package_version = PushyToStdString(PushyReadString(defaults, keyPackageVersion)); + state.build_time = PushyToStdString(PushyReadString(defaults, keyBuildTime)); + state.current_version = PushyToStdString(PushyReadString(defaults, keyCurrentVersion)); + state.last_version = PushyToStdString(PushyReadString(defaults, keyLastVersion)); + state.first_time = PushyReadBool(defaults, keyFirstTime, false); + state.first_time_ok = PushyReadBool(defaults, keyFirstTimeOk, true); + state.rolled_back_version = PushyToStdString(PushyReadString(defaults, keyRolledBackVersion)); + // 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)); + // (scripts/contract.ts; bun scripts/generate-contract.ts) + PushyWriteString(defaults, keyPackageVersion, PushyFromStdString(state.package_version)); + PushyWriteString(defaults, keyBuildTime, PushyFromStdString(state.build_time)); + PushyWriteString(defaults, keyCurrentVersion, PushyFromStdString(state.current_version)); + PushyWriteString(defaults, keyLastVersion, PushyFromStdString(state.last_version)); + PushyWriteBool(defaults, keyFirstTime, state.first_time); + PushyWriteBool(defaults, keyFirstTimeOk, state.first_time_ok); + PushyWriteString(defaults, keyRolledBackVersion, PushyFromStdString(state.rolled_back_version)); + // } // Version switch without acquiring the state lock: the caller must already @@ -319,7 +420,7 @@ PUSHY_HIDDEN void PushySwitchVersionLocked(NSString *hash) { } static NSString *PushyCachedBundleHash(NSUserDefaults *defaults, NSString *cachedPrefix) { - NSString *cached = [defaults stringForKey:keyBundleHashCache]; + NSString *cached = PushyReadString(defaults, keyBundleHashCache); if ([cached hasPrefix:cachedPrefix]) { return [cached substringFromIndex:cachedPrefix.length]; } diff --git a/scripts/contract.ts b/scripts/contract.ts index 341c97a4..2f148b17 100644 --- a/scripts/contract.ts +++ b/scripts/contract.ts @@ -212,31 +212,6 @@ export const archivePatchTypes = [ }, ]; -// Storage keys the Android and Harmony orchestrators share (iOS uses its own -// REACTNATIVECN_PUSHY_* names). -export const syncStorageKeys = [ - { - name: 'Config', - value: 'nativeConfig', - doc: 'The persisted native check config (JS syncNativeConfig or the host).', - }, - { - name: 'RespCache', - value: 'nativeCheckResp', - doc: - 'Raw response cache for the JS side to reuse (§10.3), scoped to the exact\n' + - 'logical request and native config that produced it.', - }, - { - name: 'RoundIncomplete', - value: 'nativeCheckIncomplete', - doc: - 'Set when a round starts, cleared when it ends (§11.4). Residue on the\n' + - 'next launch means the previous process died mid-round: that launch\n' + - 'resumes immediately instead of waiting 5s.', - }, -]; - // The host-facing result of a native round (prepareBundle). export const roundStatuses = [ { name: 'Skipped', value: 'skipped' }, @@ -283,6 +258,7 @@ export const jniFunctions: Record = { 'ResumePlan.planRequest': 'ResumePlanRequest', 'ResumePlan.classifyResponse': 'ResumeClassifyResponse', 'ResumePlan.sidecar': 'ResumeSidecar', + 'StoreMigration.migrateStoredItems': 'StoredItemsMigrate', }; // NativePatchCore.ts binding -> the NAPI callback in pushy.cpp. @@ -313,6 +289,7 @@ export const napiFunctions: Record = { resumePlanRequest: 'ResumePlanRequest', resumeClassifyResponse: 'ResumeClassifyResponse', resumeSidecar: 'ResumeSidecar', + migrateStoredItems: 'MigrateStoredItems', }; export type TextHost = 'cpp' | 'java' | 'arkts'; @@ -376,11 +353,6 @@ export const nativeTexts: NativeText[] = [ values: { java: '.apk.patch', arkts: '.app.patch' }, doc: 'Archive suffix of a patch against the installed package.', }, - { - name: 'PackageUpdatedMarkedKey', - value: 'REACTNATIVECN_PUSHY_ISPACKAGEUPDATEDMARKED_KEY', - hosts: ['cpp'], - }, { name: 'LogTag', value: 'react-native-update', hosts: ['cpp', 'java'] }, { name: 'LibraryName', value: 'rnpushy', hosts: ['java'] }, { @@ -421,85 +393,228 @@ export const servicePresets = { }, }; + +// --- Persisted layout (schema 1) -------------------------------------------- +// +// Every platform keeps the same keys, with the same value encoding, in two +// key-value stores of its own (Android SharedPreferences files, iOS +// NSUserDefaults suites, Harmony preferences). The generator turns this into +// each platform's key table and its State <-> store mapping; the one-time +// move out of the layouts SDKs up to 10.61 wrote is the shared +// cpp/patch_core/stored_items.h (vectors: stored-items-vectors.json). +// +// Every value is a string. A boolean is "1" or "0"; a marker is "1" while set +// and absent otherwise; an empty string is never stored (writing one removes +// the key), so absent and empty read the same. + +export type Platform = 'android' | 'ios' | 'harmony'; + +export const storeSchema = { + version: 1, + versionKey: 'schemaVersion', + /** The main store: the state fields and every item without `store`. */ + stateStore: 'pushy_state', + /** + * Whole server responses: kept apart so the small state commits do not + * rewrite them (every platform rewrites a store's whole file). + */ + cacheStore: 'pushy_cache', + booleans: { true: '1', false: '0' }, +}; + +export interface StateField { + /** The key, and the field's name in Java/ArkTS (C++ uses its snake_case). */ + name: string; + type: 'string' | 'boolean'; + /** What an absent boolean reads as. */ + fallback?: boolean; +} + +// pushy::state::State (cpp/patch_core/state_core.h), which the shared state +// core rewrites as a whole. +export const stateFields: StateField[] = [ + { name: 'packageVersion', type: 'string' }, + { name: 'buildTime', type: 'string' }, + { name: 'currentVersion', type: 'string' }, + { name: 'lastVersion', type: 'string' }, + { name: 'firstTime', type: 'boolean', fallback: false }, + { name: 'firstTimeOk', type: 'boolean', fallback: true }, + { name: 'rolledBackVersion', type: 'string' }, +]; + export type Lifecycle = 'reset' | 'binaryUpgrade'; export interface StoredItem { name: string; doc: string; + key: string; /** The key is a prefix (one entry per version). */ prefix?: boolean; + /** Kept in the cache store instead of the state store. */ + cache?: boolean; + /** A flag: "1" while set, absent otherwise (any legacy value counts as set). */ + marker?: 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 platforms that use it (all when absent). */ + platforms?: Platform[]; } -// 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). +// The persisted items next to the state fields and what a reset to the +// packaged bundle or a binary upgrade does to them, the same on every +// platform. export const storedItems: StoredItem[] = [ { name: 'HashInfo', + key: 'hash_', 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', + key: 'firstLoadMarked', + marker: true, 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).', + name: 'BinaryUpgradedMarker', + key: 'binaryUpgraded', + marker: true, + doc: 'Set by a binary upgrade, consumed by getConstants (old version cleanup).', clearedOn: ['reset'], - keys: { objc: 'KeyPackageUpdatedMarked()' }, + platforms: ['ios'], }, { name: 'CheckCache', + key: 'nativeCheckResp', + cache: true, 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).', + "The native round's raw response for the JS side to reuse (§10.3), scoped\n" + + 'to the exact logical request and native config that produced it. It\n' + + 'describes versions a reset or an upgrade removes.', 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.', + key: 'nativeCheckIncomplete', + marker: true, + doc: + 'Set when a round starts, cleared when it ends (§11.4). Residue on the\n' + + 'next launch means the previous process died mid-round: that launch\n' + + 'resumes immediately instead of waiting 5s.', clearedOn: [], - keys: { - objc: 'keyNativeCheckIncomplete', - java: 'SyncCoordinator.KEY_ROUND_INCOMPLETE', - arkts: 'KEY_ROUND_INCOMPLETE', - }, }, { name: 'Uuid', + key: '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.', + key: 'nativeConfig', + doc: + 'The persisted native check config (JS syncNativeConfig or the host) the\n' + + 'cold-start round and the crash hold run from.', clearedOn: [], - keys: {}, }, { name: 'BundleHashCache', - doc: 'Keyed by the binary identity, so it invalidates itself on upgrade.', + key: 'bundleHashCache', + doc: + '"|" of the embedded bundle: keyed by the binary,\n' + + 'so it invalidates itself on upgrade.', clearedOn: [], - keys: {}, }, ]; + +export interface LegacyLayout { + platform: Platform; + /** Crosses JNI/NAPI as this number. */ + id: number; + doc: string; + /** + * What the migration carries over: new key <- legacy key. Only what a + * binary upgrade keeps (the uuid, the native config, the breadcrumb, the + * bundle hash cache) and the binary identity that upgrade is detected + * from: the new layout only ships in a new binary, whose first launch + * resets every other state field anyway (SyncBinaryVersion). + */ + carried: Record; + /** Legacy keys deleted without carrying them, every one an SDK ever wrote. */ + dropped: string[]; + droppedPrefixes: string[]; +} + +const androidLegacy = { + carried: { + uuid: 'uuid', + nativeConfig: 'nativeConfig', + nativeCheckIncomplete: 'nativeCheckIncomplete', + bundleHashCache: 'bundleHashCache', + packageVersion: 'packageVersion', + buildTime: 'buildTime', + }, + dropped: [ + 'currentVersion', + 'lastVersion', + 'firstTime', + 'firstTimeOk', + 'rolledBackVersion', + 'firstLoadMarked', + 'nativeCheckResp', + 'rolledBack', + 'shouldRollback', + 'blockUntil', + 'blockReason', + ], + droppedPrefixes: ['hash_'], +}; + +// The layouts SDKs up to 10.61 wrote. +export const legacyLayouts: LegacyLayout[] = [ + { + platform: 'android', + id: 1, + doc: + 'SharedPreferences Texts.PREFERENCES_NAME, and its "_check" file for the\n' + + 'response cache; native booleans.', + ...androidLegacy, + }, + { + platform: 'ios', + id: 2, + doc: + 'standardUserDefaults, shared with the host app: only these keys are\n' + + "ours (the unprefixed packageVersion/buildTime that SDKs before 10.5x\n" + + 'wrote are left alone, they may be the host\'s).', + carried: { + uuid: 'REACTNATIVECN_PUSHY_UUID', + nativeConfig: 'REACTNATIVECN_PUSHY_NATIVE_CONFIG_KEY', + nativeCheckIncomplete: 'REACTNATIVECN_PUSHY_NATIVE_CHECK_INCOMPLETE_KEY', + bundleHashCache: 'REACTNATIVECN_PUSHY_BUNDLEHASH_KEY', + packageVersion: 'REACTNATIVECN_PUSHY_PACKAGEVERSION', + buildTime: 'REACTNATIVECN_PUSHY_BUILDTIME', + }, + dropped: [ + 'REACTNATIVECN_PUSHY_INFO_KEY', + 'REACTNATIVECN_PUSHY_FIRSTLOADMARKED_KEY', + 'REACTNATIVECN_PUSHY_ROLLEDBACKMARKED_KEY', + 'REACTNATIVECN_PUSHY_ISPACKAGEUPDATEDMARKED_KEY', + 'REACTNATIVECN_PUSHY_NATIVE_CHECK_RESP_KEY', + 'REACTNATIVECN_PUSHY_BLOCKUPDATE', + 'REACTNATIVECN_PUSHY_BUNDLEHASH_IDENTITY', + 'REACTNATIVECN_PUSHY_BUNDLEHASH_VALUE', + ], + droppedPrefixes: ['REACTNATIVECN_PUSHY_HASH_'], + }, + { + platform: 'harmony', + id: 3, + doc: 'preferences Texts.PREFERENCES_NAME; booleans native or "true".', + ...androidLegacy, + }, +]; + diff --git a/scripts/generate-contract.ts b/scripts/generate-contract.ts index e9b28b48..6eebd273 100644 --- a/scripts/generate-contract.ts +++ b/scripts/generate-contract.ts @@ -19,8 +19,11 @@ import { roundStatuses, servicePresets, stateOperations, + legacyLayouts, + type Platform, + stateFields, storedItems, - syncStorageKeys, + storeSchema, type Lifecycle, type TextHost, vendoredSources, @@ -399,24 +402,6 @@ region( archivePatchTypes.map(({ objc, value }) => `PushyType${objc} = ${value},`) ); -// Sync storage keys. -region( - `${javaDir}/SyncCoordinator.java`, - 'storage-keys', - syncStorageKeys.flatMap(({ name, value, doc }) => [ - ...comment(doc, 4), - `static final String KEY_${snake(name)} = "${value}";`, - ]) -); -region( - `${ets}/SyncCoordinator.ts`, - 'storage-keys', - syncStorageKeys.flatMap(({ name, value, doc }) => [ - ...comment(doc), - `export const KEY_${snake(name)} = '${value}';`, - ]) -); - // Round statuses. region( `${javaDir}/BundlePreparationResult.java`, @@ -660,62 +645,280 @@ region('src/client.ts', 'service-presets', [ '};', ]); -// Stored-item lifecycle: what a reset / binary upgrade deletes. +// --- Persisted layout (contract.ts storeSchema) ---------------------------- + +const capitalize = (name: string) => name[0].toUpperCase() + name.slice(1); +const platformLabels: Record = { + android: 'Android', + ios: 'iOS', + harmony: 'Harmony', +}; +// Stored encoded in native code: the binaries carry no update wording. +const nativeWording = /update|patch|rescue|reload|hotfix/i; +const cppText = (value: string) => + nativeWording.test(value) + ? `text::Reveal("${encodeNativeText(value)}")` + : `"${value}"`; +const itemsOn = (platform: Platform) => + storedItems.filter((item) => !item.platforms || item.platforms.includes(platform)); +const layoutOf = (platform: Platform) => { + const layout = legacyLayouts.find((candidate) => candidate.platform === platform); + if (!layout) { + throw new Error(`scripts/contract.ts legacyLayouts: no ${platform} layout`); + } + return layout; +}; +// Every key the schema defines, for the legacy tables' targets. +const schemaKeys = new Map([ + ...stateFields.map((field) => [field.name, { marker: false }] as const), + ...storedItems.map((item) => [item.key, { marker: !!item.marker }] as const), +]); +for (const layout of legacyLayouts) { + for (const target of Object.keys(layout.carried)) { + if (!schemaKeys.has(target) || target.endsWith('_')) { + throw new Error(`legacyLayouts ${layout.platform}: ${target} is not a schema key`); + } + } +} +for (const key of [storeSchema.versionKey, ...schemaKeys.keys()]) { + if (nativeWording.test(key)) { + throw new Error(`storeSchema: key ${key} has update wording (stores are plain text)`); + } +} +const booleanDoc = [ + 'A boolean is stored as one of these; anything else (absent) reads as the', + "field's fallback.", +]; + +// The shared core: the schema and the legacy layouts. +region('cpp/patch_core/stored_items.h', 'schema', [ + `constexpr int kSchemaVersion = ${storeSchema.version};`, + `constexpr const char* kSchemaVersionKey = "${storeSchema.versionKey}";`, + '// The legacy layouts, by the id the hosts pass.', + ...legacyLayouts.map( + ({ platform, id }) => `constexpr int kLegacy${capitalize(platform)} = ${id};` + ), +]); +region( + 'cpp/patch_core/stored_items.cpp', + 'legacy-layouts', + legacyLayouts.flatMap((layout) => [ + ...comment(`${platformLabels[layout.platform]}: ${layout.doc.replace(/\n/g, ' ')}`, 6), + `{kLegacy${capitalize(layout.platform)},`, + ' // Carried: {key, legacy key, marker}.', + ' {', + ...Object.entries(layout.carried).map( + ([to, from]) => + ` {"${to}", ${cppText(from)}, ${schemaKeys.get(to)?.marker ? 'true' : 'false'}},` + ), + ' },', + ' // Dropped.', + ' {', + ...layout.dropped.map((key) => ` ${cppText(key)},`), + ' },', + ` {${layout.droppedPrefixes.map(cppText).join(', ')}}},`, + ]) +); + +// Android. +const javaItems = itemsOn('android'); +region(`${javaDir}/StoreSchema.java`, 'store-schema', [ + `static final int VERSION = ${storeSchema.version};`, + `static final String KEY_SCHEMA_VERSION = "${storeSchema.versionKey}";`, + `static final String STATE_STORE = "${storeSchema.stateStore}";`, + `static final String CACHE_STORE = "${storeSchema.cacheStore}";`, + ...booleanDoc.map((line) => `// ${line}`), + `static final String BOOLEAN_TRUE = "${storeSchema.booleans.true}";`, + `static final String BOOLEAN_FALSE = "${storeSchema.booleans.false}";`, + `static final int LEGACY_LAYOUT = ${layoutOf('android').id};`, + '// Carried by hand when the migration itself cannot run.', + `static final String LEGACY_KEY_UUID = "${layoutOf('android').carried.uuid}";`, + '// The state fields (pushy::state::State).', + ...stateFields.map( + ({ name }) => `static final String KEY_${snake(name)} = "${name}";` + ), + ...javaItems.flatMap(({ name, key, doc }) => [ + ...comment(doc, 4), + `static final String KEY_${snake(name)} = "${key}";`, + ]), +]); +region( + `${javaDir}/StoreSchema.java`, + 'read-state', + stateFields.map(({ name, type, fallback }) => + type === 'boolean' + ? `state.${name} = readBoolean(store, KEY_${snake(name)}, ${fallback});` + : `state.${name} = readString(store, KEY_${snake(name)});` + ) +); +region( + `${javaDir}/StoreSchema.java`, + 'write-state', + stateFields.map(({ name, type }) => + type === 'boolean' + ? `writeBoolean(editor, KEY_${snake(name)}, state.${name});` + : `writeString(editor, KEY_${snake(name)}, state.${name});` + ) +); + +// Harmony. +const etsItems = itemsOn('harmony'); +region(`${ets}/StoreSchema.ts`, 'store-schema', [ + `export const SCHEMA_VERSION = ${storeSchema.version};`, + `export const KEY_SCHEMA_VERSION = '${storeSchema.versionKey}';`, + `export const STATE_STORE = '${storeSchema.stateStore}';`, + `export const CACHE_STORE = '${storeSchema.cacheStore}';`, + ...booleanDoc.map((line) => `// ${line}`), + `export const BOOLEAN_TRUE = '${storeSchema.booleans.true}';`, + `export const BOOLEAN_FALSE = '${storeSchema.booleans.false}';`, + `export const LEGACY_LAYOUT = ${layoutOf('harmony').id};`, + '// Carried by hand when the migration itself cannot run.', + `export const LEGACY_KEY_UUID = '${layoutOf('harmony').carried.uuid}';`, + '// The state fields (pushy::state::State).', + ...stateFields.map(({ name }) => `export const KEY_${snake(name)} = '${name}';`), + ...etsItems.flatMap(({ name, key, doc }) => [ + ...comment(doc), + `export const KEY_${snake(name)} = '${key}';`, + ]), +]); +region( + `${ets}/StoreSchema.ts`, + 'read-state', + stateFields.map(({ name, type, fallback }) => + type === 'boolean' + ? `${name}: readBoolean(store, KEY_${snake(name)}, ${fallback}),` + : `${name}: store.read(KEY_${snake(name)}),` + ) +); +region( + `${ets}/StoreSchema.ts`, + 'write-state', + stateFields.map(({ name, type }) => + type === 'boolean' + ? `writeBoolean(store, KEY_${snake(name)}, state.${name});` + : `writeString(store, KEY_${snake(name)}, state.${name});` + ) +); + +// iOS: the keys are PUSHY_HIDDEN globals the other files declare extern. +const objcItems = itemsOn('ios'); +const objcKeys: [string, string, string | undefined][] = [ + ['SchemaVersion', storeSchema.versionKey, undefined], + ...stateFields.map(({ name }) => [capitalize(name), name, undefined] as [string, string, undefined]), + ...objcItems.map(({ name, key, doc }) => [name, key, doc] as [string, string, string]), +]; +const iosLegacy = layoutOf('ios'); +const iosLegacyKeys = [ + ...Object.values(iosLegacy.carried), + ...iosLegacy.dropped, + ...iosLegacy.droppedPrefixes, +]; +const iosLegacyPrefix = iosLegacyKeys.reduce((prefix, key) => { + let length = 0; + while (length < prefix.length && prefix[length] === key[length]) { + length++; + } + return prefix.slice(0, length); +}); +if (iosLegacyPrefix.length < 8) { + throw new Error('legacyLayouts ios: the legacy keys share no prefix'); +} +region('ios/RCTPushy/RCTPushyState.mm', 'store-schema', [ + `static NSString *const PushyStateStoreName = @"${storeSchema.stateStore}";`, + `static NSString *const PushyCacheStoreName = @"${storeSchema.cacheStore}";`, + ...booleanDoc.map((line) => `// ${line}`), + `static NSString *const PushyBooleanTrue = @"${storeSchema.booleans.true}";`, + `static NSString *const PushyBooleanFalse = @"${storeSchema.booleans.false}";`, + '// Carried by hand when the migration itself cannot run.', + `static NSString *const legacyKeyUuid = @"${iosLegacy.carried.uuid}";`, + '// Every legacy key starts with it: only those are snapshotted (the host app', + '// shares that store).', + `static NSString *const legacyKeyPrefix = @"${iosLegacyPrefix}";`, + ...objcKeys.flatMap(([name, key, doc]) => [ + ...comment(doc), + `PUSHY_HIDDEN NSString *const key${name} = @"${key}";`, + ]), +]); +region( + 'ios/RCTPushy/RCTPushyInternal.h', + 'store-keys', + objcKeys.map(([name]) => `extern PUSHY_HIDDEN NSString *const key${name};`) +); +region( + 'ios/RCTPushy/RCTPushyState.mm', + 'read-state', + stateFields.map(({ name, type, fallback }) => + type === 'boolean' + ? `state.${lowerSnake(name)} = PushyReadBool(defaults, key${capitalize(name)}, ${fallback});` + : `state.${lowerSnake(name)} = PushyToStdString(PushyReadString(defaults, key${capitalize(name)}));` + ) +); +region( + 'ios/RCTPushy/RCTPushyState.mm', + 'write-state', + stateFields.map(({ name, type }) => + type === 'boolean' + ? `PushyWriteBool(defaults, key${capitalize(name)}, state.${lowerSnake(name)});` + : `PushyWriteString(defaults, key${capitalize(name)}, PushyFromStdString(state.${lowerSnake(name)}));` + ) +); + +// Stored-item lifecycle: what a reset / binary upgrade deletes from the state +// store (the cache store's one item is cleared on both, by each platform's +// cache clear). +for (const item of storedItems) { + if (item.cache && item.clearedOn.length !== 2) { + throw new Error(`storedItems ${item.name}: the cache store is cleared on both events`); + } +} const cleared = ( event: Lifecycle, - platform: 'objc' | 'java' | 'arkts', - prefix: boolean + platform: Platform, + prefix: boolean, + name: (item: (typeof storedItems)[number]) => string ) => - storedItems + itemsOn(platform) .filter( - (item) => - item.clearedOn.includes(event) && - !!item.prefix === prefix && - item.keys[platform] !== undefined + (item) => item.clearedOn.includes(event) && !item.cache && !!item.prefix === prefix ) - .map((item) => item.keys[platform] as string); + .map(name); 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).', + 'What a reset to the packaged bundle and a binary upgrade delete from the', + 'state store besides the state fields (scripts/contract.ts storedItems;', + 'the cache store is cleared on both, 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(', ')}];`, - '}', - ]), - ] -); +const javaKey = (item: { name: string }) => `StoreSchema.KEY_${snake(item.name)}`; +region(`${javaDir}/VersionScopedKeys.java`, 'lifecycle', [ + ...lifecycleDoc.map((line) => `// ${line}`), + ...events.flatMap(([event, , upper]) => [ + `static final String[] ${upper}_KEYS = {${cleared(event, 'android', false, javaKey).join(', ')}};`, + `static final String[] ${upper}_PREFIXES = {${cleared(event, 'android', true, javaKey).join(', ')}};`, + ]), +]); +const objcKey = (item: { name: string }) => `key${item.name}`; +region('ios/RCTPushy/RCTPushyState.mm', 'lifecycle', [ + ...lifecycleDoc.map((line) => `// ${line}`), + ...events.flatMap(([event, camel]) => [ + `PUSHY_HIDDEN NSArray *PushyKeysClearedOn${camel}(void) {`, + ` return @[${cleared(event, 'ios', false, objcKey).join(', ')}];`, + '}', + `PUSHY_HIDDEN NSArray *PushyKeyPrefixesClearedOn${camel}(void) {`, + ` return @[${cleared(event, 'ios', true, objcKey).join(', ')}];`, + '}', + ]), +]); +const etsKey = (item: { name: string }) => `KEY_${snake(item.name)}`; 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(', ')}],`, + ` keys: [${cleared(event, 'harmony', false, etsKey).join(', ')}],`, + ` prefixes: [${cleared(event, 'harmony', true, etsKey).join(', ')}],`, '};', ]), ]); diff --git a/scripts/test-ios-purge-restore.sh b/scripts/test-ios-purge-restore.sh index a0885066..688f03b0 100755 --- a/scripts/test-ios-purge-restore.sh +++ b/scripts/test-ios-purge-restore.sh @@ -38,6 +38,7 @@ build() { xcrun clang++ -std=c++17 -fobjc-arc -fblocks $WARNING_FLAGS $SANITIZE_FLAGS \ -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/stored_items.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" \ @@ -108,7 +109,8 @@ if [ "${VERIFY_REGRESSIONS:-0}" = "1" ]; then for pair in late-activation:late_commit skip-reresolve:commit_before_signal ignore-reset:reset_wins \ trust-digestless:install_record_activation grandfather-recorded:switch_version_records \ - skip-commit-verify:round_commit_verifies; do + skip-commit-verify:round_commit_verifies skip-carry:legacy_migration_upgrade \ + remove-first:legacy_migration_write_failure; do variant="${pair%:*}" testcase="${pair#*:}" build "$variant" @@ -124,6 +126,8 @@ if [ "${VERIFY_REGRESSIONS:-0}" = "1" ]; then trust-digestless) expected="digest-less record must not activate" ;; grandfather-recorded) expected="recorded current version must be verified" ;; skip-commit-verify) expected="a version failing verification must not be activated by the round commit" ;; + skip-carry) expected="migration must keep the install's uuid" ;; + remove-first) expected="a failed migration must keep the uuid for the next launch" ;; esac if ! grep -F "[FAIL] $testcase: $expected" "$logfile"; then cat "$logfile" >&2 diff --git a/scripts/test-patch-core.sh b/scripts/test-patch-core.sh index bd28c112..d1964d39 100755 --- a/scripts/test-patch-core.sh +++ b/scripts/test-patch-core.sh @@ -73,6 +73,17 @@ c++ \ -o "$BUILD_DIR/resume_plan_test" "$BUILD_DIR/resume_plan_test" "$ROOT_DIR/cpp/patch_core/tests/resume-plan-vectors.json" +# The persisted-layout migration every platform's store delegates to. +c++ \ + -std=c++17 \ + $CORE_WARNINGS \ + $SANITIZE_FLAGS \ + "$ROOT_DIR/cpp/patch_core/tests/stored_items_test.cpp" \ + "$ROOT_DIR/cpp/patch_core/stored_items.cpp" \ + "$ROOT_DIR/cpp/update_flow_core/flow_json.cpp" \ + -o "$BUILD_DIR/stored_items_test" +"$BUILD_DIR/stored_items_test" "$ROOT_DIR/cpp/patch_core/tests/stored-items-vectors.json" + # 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 diff --git a/scripts/tests/ios-purge-restore/extract.py b/scripts/tests/ios-purge-restore/extract.py index 3bc9c3ea..ef36ca77 100644 --- a/scripts/tests/ios-purge-restore/extract.py +++ b/scripts/tests/ios-purge-restore/extract.py @@ -81,11 +81,13 @@ def declaration(source: str, name: str) -> tuple[int, str]: GLOBALS = [ - 'keyPushyInfo', 'paramPackageVersion', 'paramBuildTime', - 'legacyParamPackageVersion', 'legacyParamBuildTime', 'paramLastVersion', - 'paramCurrentVersion', 'paramIsFirstTime', 'paramIsFirstLoadOk', 'keyUuid', - 'keyHashInfo', 'keyFirstLoadMarked', 'keyRolledBackMarked', - 'keyNativeCheckCache', 'BUNDLE_FILE_NAME', 'ERROR_OPTIONS', + # The persisted keys and value encoding (generated from scripts/contract.ts). + 'keySchemaVersion', 'keyPackageVersion', 'keyBuildTime', 'keyCurrentVersion', + 'keyLastVersion', 'keyFirstTime', 'keyFirstTimeOk', 'keyRolledBackVersion', + 'keyHashInfo', 'keyFirstLoadMarker', 'keyBinaryUpgradedMarker', 'keyCheckCache', + 'keyRoundBreadcrumb', 'keyUuid', 'keyNativeConfig', 'keyBundleHashCache', + 'PushyBooleanTrue', 'PushyBooleanFalse', 'legacyKeyUuid', 'legacyKeyPrefix', + 'BUNDLE_FILE_NAME', 'ERROR_OPTIONS', 'pushyStateLock', 'ignoreRollback', 'pushyIsUsingBundleUrl', 'pushyResetGeneration', 'pushyNativeConfigGeneration', 'pushyLaunchVersion', 'pushyCrashHoldActive', 'pushyPurgeRestoreActive', 'pushyPurgeRestoreWindowOpen', @@ -94,12 +96,16 @@ def declaration(source: str, name: str) -> tuple[int, str]: HELPERS = [ # The exception boundary every entry point uses (CODE_AUDIT 4.4). 'PushyCaughtReason', 'PushyGuard', - 'PushyWithStateLock', 'PushyToStdString', 'PushyFromStdString', 'KeyPackageUpdatedMarked', + 'PushyWithStateLock', 'PushyToStdString', 'PushyFromStdString', # What reset / binary upgrade delete (generated from scripts/contract.ts). 'PushyKeysClearedOnReset', 'PushyKeyPrefixesClearedOnReset', 'PushyKeysClearedOnUpgrade', 'PushyKeyPrefixesClearedOnUpgrade', 'PushyRemoveKeys', 'PushyJsonDictionary', 'PushyJsonString', 'PushyRejectCaught', 'PushyFileQueue', - 'PushySetNullableString', 'PushyHashInfoKey', 'PushyBinaryIdentityValue', + 'PushyHashInfoKey', + # The value encoding, the State mapping and the legacy-layout migration + # (over the shared cpp/patch_core/stored_items.cpp). + 'PushyReadString', 'PushyWriteString', 'PushyReadBool', 'PushyWriteBool', + 'PushyReadMarker', 'PushyWriteMarker', 'PushyDefaultsText', 'PushyMigrateStores', 'PushyStateFromDefaults', 'PushyApplyStateToDefaults', 'PushySwitchVersionLocked', # Canonical config equality (contract C1): the binding over # flowcore::ConfigsEquivalent, tested directly. @@ -116,14 +122,16 @@ def declaration(source: str, name: str) -> tuple[int, str]: # against a scripted engine. Their globals and pure helpers come along; the # IO collaborators are the host's. ROUND_GLOBALS = [ - 'keyNativeConfig', 'keyNativeCheckIncomplete', 'pushyResetGeneration', + 'keyNativeConfig', 'keyRoundBreadcrumb', 'PushyBooleanTrue', 'PushyBooleanFalse', + 'pushyResetGeneration', 'pushyNativeConfigGeneration', 'pushyCrashHoldActive', 'pushyPurgeRestoreActive', 'pushyHostRoundResult', 'pushyHostRoundConfig', 'pushyHostRoundGeneration', 'pushyHostRoundConfigGeneration', 'pushyUnactivatedHash', 'pushyUnactivatedGeneration', 'pushyUnactivatedConfigGeneration', ] ROUND_HELPERS = ['PushyToStdString', 'PushyFromStdString', 'PushyJsonDictionary', - 'PushyHostResult', 'PushyWaitSeconds'] + 'PushyHostResult', 'PushyWaitSeconds', 'PushyReadString', 'PushyReadBool', + 'PushyReadMarker', 'PushyWriteMarker'] ROUND_METHODS = ['runOnce', 'perform', 'download', 'commit', 'finishRound'] # Emitted ahead of HELPERS, which use them. STRUCTS: list[str] = ['PushyVerifiedActivation'] @@ -134,8 +142,10 @@ def declaration(source: str, name: str) -> tuple[int, str]: CORE_MUTATIONS = ('trust-digestless', 'grandfather-recorded') # Mutate the round adapter (round.inc) instead of the purge-restore methods. ROUND_MUTATIONS = ('drop-config-changed',) +# Mutate the legacy-layout migration (a helper in RCTPushyState.mm). +STORE_MUTATIONS = ('skip-carry', 'remove-first') MUTATIONS = ('late-activation', 'skip-reresolve', 'ignore-reset', - 'skip-commit-verify') + CORE_MUTATIONS + ROUND_MUTATIONS + 'skip-commit-verify') + CORE_MUTATIONS + ROUND_MUTATIONS + STORE_MUTATIONS def mutate(text: str, mutation: str) -> str: @@ -157,6 +167,16 @@ def mutate(text: str, mutation: str) -> str: 'drop-config-changed': ( r'event\.Set\("reason", flowjson::Value::String\("config_changed"\)\);', '(void)configGeneration;'), + # Write only the schema: every carried item (the uuid among them) is + # lost while the legacy keys are still deleted. + 'skip-carry': ( + r'\[state setObject:PushyFromStdString\(put\.second\) forKey:PushyFromStdString\(put\.first\)\];', + 'if (put.first == pushy::stored::kSchemaVersionKey) { [state setObject:@"1" forKey:keySchemaVersion]; }'), + # Delete the legacy keys before anything is written, so a failed + # write loses them. + 'remove-first': ( + r'(for \(const auto &put : plan\.puts\) \{)', + r'for (const std::string &key : plan.removes) { [legacy removeObjectForKey:PushyFromStdString(key)]; }\n \1'), # Activate from the round commit without the activation gate (both # the out-of-lock verification and the under-lock re-check). 'skip-commit-verify': [ @@ -203,6 +223,9 @@ 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] + if mutation in STORE_MUTATIONS: + helpers = [(path, line, mutate(text, mutation) if name == 'PushyMigrateStores' else text) + for name, (path, line, text) in zip(HELPERS, helpers)] switch_version = locate(sources, method, 'switchVersion') core_path = (source_dir / '..' / '..' / CORE_SOURCE).resolve() core = core_path.read_text(encoding='utf-8') @@ -214,7 +237,7 @@ def generate(source_dir: Path, output: Path, mutation: str | None = None) -> Non core = mutate(core, mutation) elif mutation == 'skip-reresolve': restore = (restore[0], restore[1], mutate(restore[2], mutation)) - elif mutation: + elif mutation and mutation not in STORE_MUTATIONS: commit = (commit[0], commit[1], mutate(commit[2], mutation)) sections = { 'globals.inc': [locate(sources, declaration, name) for name in GLOBALS], diff --git a/scripts/tests/ios-purge-restore/purge_restore_test.mm b/scripts/tests/ios-purge-restore/purge_restore_test.mm index b0d79096..bd2939a8 100644 --- a/scripts/tests/ios-purge-restore/purge_restore_test.mm +++ b/scripts/tests/ios-purge-restore/purge_restore_test.mm @@ -19,6 +19,7 @@ #include "cpp/patch_core/digest.h" #include "cpp/patch_core/install_record.h" #include "cpp/patch_core/native_texts.h" +#include "cpp/patch_core/stored_items.h" #include "cpp/update_flow_core/native_config.h" #include "ios/RCTPushy/RCTPushyCommon.h" @@ -86,6 +87,11 @@ static void MaybeThrow(void) { throw std::length_error("state too large"); } } +// Makes every write to that store throw, as a full disk or a sandbox denial +// surfaces from the preferences daemon. +static id failWritesOn = nil; +// Makes that store's snapshot throw (the migration's first read). +static id failSnapshotOn = nil; // In-memory collaborator, deliberately NOT an NSUserDefaults subclass: inherited // accessors could otherwise reach real preferences without ObserveWrite(). Only @@ -116,7 +122,13 @@ - (instancetype)init { if (self) { _values = [NSMutableDictionary new]; } return self; } -- (void)setObject:(id)value forKey:(NSString *)key { ObserveWrite(); _values[key] = value; } +- (void)setObject:(id)value forKey:(NSString *)key { + ObserveWrite(); + if (self == failWritesOn) { + [NSException raise:NSInternalInconsistencyException format:@"write failed"]; + } + _values[key] = value; +} - (void)removeObjectForKey:(NSString *)key { ObserveWrite(); [_values removeObjectForKey:key]; } - (id)objectForKey:(NSString *)key { MaybeThrow(); return _values[key]; } - (NSString *)stringForKey:(NSString *)key { @@ -129,7 +141,12 @@ - (NSDictionary *)dictionaryForKey:(NSString *)key { id value = _values[key]; return [value isKindOfClass:NSDictionary.class] ? value : nil; } -- (NSDictionary *)dictionaryRepresentation { return [_values copy]; } +- (NSDictionary *)dictionaryRepresentation { + if (self == failSnapshotOn) { + throw std::length_error("snapshot too large"); + } + return [_values copy]; +} - (void)doesNotRecognizeSelector:(SEL)selector { // Fail even if product code catches Objective-C exceptions. Dynamic sends // must not fall through to a real defaults domain or turn into a passed test. @@ -159,7 +176,9 @@ static void DefaultsWriteProbe(NSUserDefaults *defaults) { #endif static TestDefaults *testDefaults; +static TestDefaults *testCacheDefaults; static NSUserDefaults *PushyDefaults(void) { return testDefaults; } +static NSUserDefaults *PushyCacheDefaults(void) { return testCacheDefaults; } static NSTimeInterval PushyMonotonicNow(void) { return 100; } static NSError *PushyErrorWithCode(const char *code, NSString *message) { return [NSError errorWithDomain:@(code) code:1 userInfo:@{NSLocalizedDescriptionKey: message}]; @@ -362,6 +381,7 @@ static void SetUp(Order selected) { processElapsed = 0; networkPath = YES; testDefaults = [TestDefaults new]; + testCacheDefaults = [TestDefaults new]; engine = [RCTPushy new]; root = [NSTemporaryDirectory() stringByAppendingPathComponent:NSUUID.UUID.UUIDString]; NSString *versionDir = [[RCTPushy downloadDir] stringByAppendingPathComponent:@"B"]; @@ -407,6 +427,12 @@ static id Value(NSString *key) { return value; } +static id CacheValue(void) { + __block id value; + PushyWithStateLock(^{ value = [testCacheDefaults objectForKey:keyCheckCache]; }); + return value; +} + static NSDictionary *Info(void) { NSString *json = Value(PushyHashInfoKey(@"B")); return json == nil ? nil : [NSJSONSerialization JSONObjectWithData: @@ -420,7 +446,7 @@ static void ExpectPackaged(NSURL *url) { Expect(state.last_version.empty(), "packaged launch must have no stale last version"); Expect(!state.first_time && state.first_time_ok, "packaged launch must not arm first load"); Expect(state.rolled_back_version.empty(), "purge must not add a rollback mark"); - Expect(Value(keyFirstLoadMarked) == nil, "packaged launch must not report isFirstTime"); + Expect(Value(keyFirstLoadMarker) == nil, "packaged launch must not report isFirstTime"); Expect(pushyLaunchVersion == nil, "packaged launch must not claim B ran"); } @@ -430,11 +456,11 @@ static void ExpectRestored(NSURL *url) { auto state = State(); Expect(state.current_version == "B", "state must select the launched B"); Expect(!state.first_time && !state.first_time_ok, "B first load must be consumed but not acknowledged"); - Expect([Value(keyFirstLoadMarked) boolValue], "B launch must report isFirstTime"); + Expect([Value(keyFirstLoadMarker) isEqual:@"1"], "B launch must report isFirstTime"); Expect([pushyLaunchVersion isEqualToString:@"B"], "running bundle identity must be B"); Expect([Info()[@"purgeRestore"] boolValue], "in-window activation must record purgeRestore"); Expect([Info()[@"forceBootRescue"] boolValue], "other metadata must survive the commit"); - Expect(Value(keyNativeCheckCache) != nil, "successful round must cache its response"); + Expect(CacheValue() != nil, "successful round must cache its response"); } static void Finish(void) { @@ -456,7 +482,7 @@ static void LateCommit(void) { Await(signalAttempted, "late round reached its done signal"); Expect(roundCommitted && !roundActivated, "late round may persist its response but must not activate"); ExpectPackaged(url); - Expect(Value(keyNativeCheckCache) != nil, "late response remains available to JS"); + Expect(CacheValue() != nil, "late response remains available to JS"); Expect(Info() != nil && Info()[@"purgeRestore"] == nil, "non-activation must not claim purgeRestore"); Finish(); } @@ -481,7 +507,7 @@ static void ResetOrdering(Order selected) { } ExpectPackaged(url); Expect(Value(PushyHashInfoKey(@"B")) == nil, "reset must leave no version metadata"); - Expect(Value(keyNativeCheckCache) == nil, "reset must leave no response cache"); + Expect(CacheValue() == nil, "reset must leave no response cache"); Expect([Value(keyUuid) isEqual:@"installation-uuid"], "reset must preserve install identity"); // The failed delete ran after the promise had already resolved, and did // not turn it into a rejection. @@ -735,7 +761,7 @@ static void RoundCommitVerifies(void) { "verification failures are SWITCH_VERSION_FAILED"); Expect(State().current_version == "A", "state must keep the previous version"); Expect(Info() == nil, "a rejected commit writes no version info"); - Expect(Value(keyNativeCheckCache) == nil, "a rejected commit writes no response cache"); + Expect(CacheValue() == nil, "a rejected commit writes no response cache"); // The intact bundle passes the same gate. Expect([@"B" writeToFile:bundle atomically:YES encoding:NSUTF8StringEncoding error:NULL], @@ -756,7 +782,6 @@ static void RoundCommitVerifies(void) { 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 @@ -764,9 +789,9 @@ static void BinaryUpgradeClearsVersionKeys(void) { 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]; + PushyWriteMarker(testDefaults, keyFirstLoadMarker, YES); + [testCacheDefaults setObject:@"{}" forKey:keyCheckCache]; + PushyWriteMarker(testDefaults, keyRoundBreadcrumb, YES); }); NSString *rolledBack = nil; NSString *purged = nil; @@ -774,11 +799,11 @@ static void BinaryUpgradeClearsVersionKeys(void) { 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(keyFirstLoadMarker) == nil, "an upgrade drops the first-load marker"); + Expect(CacheValue() == nil, "an upgrade drops the response cache"); + Expect([Value(keyBinaryUpgradedMarker) isEqual:@"1"], "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([Value(keyRoundBreadcrumb) isEqual:@"1"], "an upgrade keeps the round breadcrumb"); Expect([[NSFileManager defaultManager] removeItemAtPath:root error:NULL], "remove fixture files"); } @@ -821,6 +846,164 @@ static void CppExceptionBoundary(void) { Expect([[NSFileManager defaultManager] removeItemAtPath:root error:NULL], "remove fixture files"); } +// /, located from this source file +// (/scripts/tests/ios-purge-restore/). +static NSString *RepoFile(NSString *path) { + NSString *repo = [[[[@__FILE__ stringByDeletingLastPathComponent] + stringByDeletingLastPathComponent] stringByDeletingLastPathComponent] + stringByDeletingLastPathComponent]; + return [repo stringByAppendingPathComponent:path]; +} + +static TestDefaults *DefaultsWith(NSDictionary *values) { + TestDefaults *defaults = [TestDefaults new]; + PushyWithStateLock(^{ + for (NSString *key in values) { + [defaults setObject:values[key] forKey:key]; + } + }); + return defaults; +} + +static NSString *Migrate(TestDefaults *state, TestDefaults *legacy) { + __block NSString *result = nil; + PushyWithStateLock(^{ result = PushyMigrateStores(state, legacy); }); + return result; +} + +static NSDictionary *Contents(TestDefaults *defaults) { + __block NSDictionary *contents = nil; + PushyWithStateLock(^{ contents = [defaults dictionaryRepresentation]; }); + return contents; +} + +// The iOS cases of the shared vectors (cpp/patch_core/tests/ +// stored-items-vectors.json) through the real PushyMigrateStores and the +// real core: the snapshot conversion (NSNumber booleans, the legacy info +// dictionary), the order of the writes and the stores afterwards. +static void StoredItemsVectors(void) { + NSData *data = [NSData dataWithContentsOfFile:RepoFile( + @"cpp/patch_core/tests/stored-items-vectors.json")]; + Expect(data != nil, "stored-items-vectors.json is readable"); + NSDictionary *file = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + int replayed = 0; + for (NSDictionary *vector in file[@"cases"]) { + if (![vector[@"layout"] isEqual:@"ios"]) { + continue; + } + std::string name = std::string("vector \"") + [vector[@"name"] UTF8String] + "\""; + TestDefaults *legacy = DefaultsWith(vector[@"legacy"]); + TestDefaults *state = DefaultsWith(vector[@"current"]); + NSString *result = Migrate(state, legacy); + Expect([result isEqualToString:[vector[@"plan"][@"migrated"] boolValue] ? @"migrated" : @"current"], + (name + ": result").c_str()); + Expect([Contents(state) isEqual:vector[@"after"][@"current"]], (name + ": state store").c_str()); + Expect([Contents(legacy) isEqual:vector[@"after"][@"legacy"]], (name + ": legacy store").c_str()); + Expect([Migrate(state, legacy) isEqualToString:@"current"], (name + ": done means done").c_str()); + ++replayed; + } + Expect(replayed >= 3, "the vectors carry iOS cases"); + std::printf("[PASS] stored_items_vectors: %d shared vectors\n", replayed); +} + +// What an installed SDK up to 10.61 left in standardUserDefaults: version B +// running, its info, the launch markers, the install's own items, and keys +// of the host app. +static NSMutableDictionary *LegacyLayout(NSString *packageVersion, NSString *buildTime) { + return [@{ + @"REACTNATIVECN_PUSHY_PACKAGEVERSION": packageVersion, + @"REACTNATIVECN_PUSHY_BUILDTIME": buildTime, + @"REACTNATIVECN_PUSHY_INFO_KEY": @{@"currentVersion": @"B", @"isFirstTime": @NO, + @"isFirstLoadOK": @YES}, + @"REACTNATIVECN_PUSHY_HASH_B": @"{\"name\":\"B\"}", + @"REACTNATIVECN_PUSHY_FIRSTLOADMARKED_KEY": @YES, + @"REACTNATIVECN_PUSHY_UUID": @"legacy-uuid", + @"REACTNATIVECN_PUSHY_NATIVE_CONFIG_KEY": @"{\"appKey\":\"k\"}", + @"REACTNATIVECN_PUSHY_NATIVE_CHECK_INCOMPLETE_KEY": @YES, + @"REACTNATIVECN_PUSHY_NATIVE_CHECK_RESP_KEY": @"{}", + @"packageVersion": @"host-owned", + @"hostSetting": @"host-owned", + } mutableCopy]; +} + +static void ExpectInstallItemsCarried(TestDefaults *legacy) { + Expect([Value(keyUuid) isEqual:@"legacy-uuid"], "migration must keep the install's uuid"); + Expect([Value(keyNativeConfig) isEqual:@"{\"appKey\":\"k\"}"], "migration keeps the native config"); + Expect([Value(keyRoundBreadcrumb) isEqual:@"1"], "migration keeps the round breadcrumb"); + Expect([Value(keySchemaVersion) isEqual:@"1"], "migration writes the schema"); + Expect([Contents(legacy) isEqual:(@{@"packageVersion": @"host-owned", @"hostSetting": @"host-owned"})], + "migration deletes every legacy key and only those"); +} + +// The first launch of a binary with the new layout: the legacy identity is +// carried, so the upgrade is detected the usual way and drops the old +// binary's version; the install's own items survive it. +static void LegacyMigrationUpgrade(void) { + SetUp(Order::CloseFirst); + testDefaults = [TestDefaults new]; + TestDefaults *legacy = DefaultsWith(LegacyLayout(@"0.9", @"100")); + Expect([Migrate(testDefaults, legacy) isEqualToString:@"migrated"], "the legacy layout is migrated"); + ExpectInstallItemsCarried(legacy); + NSString *rolledBack = nil; + NSString *purged = nil; + NSURL *url = [RCTPushy resolveLaunchBundleURL:&rolledBack purgedVersion:&purged]; + Expect(url == nil, "the new binary launches its packaged bundle"); + Expect(State().package_version == "1.0" && State().build_time == "123", + "the state takes the new binary identity"); + Expect([Value(keyBinaryUpgradedMarker) isEqual:@"1"], "the upgrade is reported to getConstants"); + Expect([Value(keyUuid) isEqual:@"legacy-uuid"], "the upgrade keeps the uuid"); + Expect([Value(keyRoundBreadcrumb) isEqual:@"1"], "the upgrade keeps the breadcrumb"); + Expect([[NSFileManager defaultManager] removeItemAtPath:root error:NULL], "remove fixture files"); +} + +// Same identity (a debug build, whose buildTime is always "0"): no upgrade, +// and the old version is not carried either — the packaged bundle runs. +static void LegacyMigrationSameBinary(void) { + SetUp(Order::CloseFirst); + testDefaults = [TestDefaults new]; + TestDefaults *legacy = DefaultsWith(LegacyLayout(@"1.0", @"123")); + Expect([Migrate(testDefaults, legacy) isEqualToString:@"migrated"], "the legacy layout is migrated"); + ExpectInstallItemsCarried(legacy); + NSString *rolledBack = nil; + NSString *purged = nil; + NSURL *url = [RCTPushy resolveLaunchBundleURL:&rolledBack purgedVersion:&purged]; + Expect(url == nil, "the old version's state is not carried"); + Expect(Value(keyBinaryUpgradedMarker) == nil, "the same binary is no upgrade"); + Expect(Value(PushyHashInfoKey(@"B")) == nil, "no version info is carried"); + Expect(Value(keyFirstLoadMarker) == nil, "no launch marker is carried"); + Expect([[NSFileManager defaultManager] removeItemAtPath:root error:NULL], "remove fixture files"); +} + +// A write that fails leaves the legacy keys in place (they are deleted only +// after every write), so the next launch migrates the same uuid. +static void LegacyMigrationWriteFailure(void) { + TestDefaults *state = [TestDefaults new]; + TestDefaults *legacy = DefaultsWith(LegacyLayout(@"0.9", @"100")); + failWritesOn = state; + NSString *result = Migrate(state, legacy); + failWritesOn = nil; + Expect([result isEqualToString:@"fallback"], "a failed write falls back"); + Expect(Contents(state).count == 0, "nothing landed"); + Expect([Migrate(state, legacy) isEqualToString:@"migrated"], "the next launch migrates"); + Expect([[state stringForKey:keyUuid] isEqual:@"legacy-uuid"], + "a failed migration must keep the uuid for the next launch"); +} + +// The core cannot run (its snapshot throws): a fresh store that still carries +// the uuid, and no schema, so the next launch migrates the rest. +static void LegacyMigrationReadFailure(void) { + TestDefaults *state = [TestDefaults new]; + TestDefaults *legacy = DefaultsWith(LegacyLayout(@"0.9", @"100")); + failSnapshotOn = legacy; + NSString *result = Migrate(state, legacy); + failSnapshotOn = nil; + Expect([result isEqualToString:@"fallback"], "an unreadable legacy store falls back"); + Expect([Contents(state) isEqual:@{keyUuid: @"legacy-uuid"}], "the fallback carries the uuid alone"); + Expect([Migrate(state, legacy) isEqualToString:@"migrated"], "the next launch migrates"); + Expect([[state stringForKey:keyNativeConfig] isEqual:@"{\"appKey\":\"k\"}"], + "the next launch carries the rest"); +} + int main(int argc, char **argv) { @autoreleasepool { // These intentional failures run in separate processes. Erasing the @@ -856,6 +1039,11 @@ int main(int argc, char **argv) { {"round_commit_verifies", RoundCommitVerifies}, {"binary_upgrade_clears_version_keys", BinaryUpgradeClearsVersionKeys}, {"cpp_exception_boundary", CppExceptionBoundary}, + {"stored_items_vectors", StoredItemsVectors}, + {"legacy_migration_upgrade", LegacyMigrationUpgrade}, + {"legacy_migration_same_binary", LegacyMigrationSameBinary}, + {"legacy_migration_write_failure", LegacyMigrationWriteFailure}, + {"legacy_migration_read_failure", LegacyMigrationReadFailure}, }; int ran = 0; for (const auto &test : cases) { diff --git a/scripts/tests/ios-purge-restore/round_replay_test.mm b/scripts/tests/ios-purge-restore/round_replay_test.mm index ba6e4b24..9fecf023 100644 --- a/scripts/tests/ios-purge-restore/round_replay_test.mm +++ b/scripts/tests/ios-purge-restore/round_replay_test.mm @@ -452,7 +452,7 @@ static void Replay(const Value &steps) { Expect(downloaded && !activated ? [pushyUnactivatedHash isEqualToString:hash] : pushyUnactivatedHash == nil, "unactivated hash"); // However the round ends, the breadcrumb does not survive it. - Expect([testDefaults objectForKey:keyNativeCheckIncomplete] == nil, "breadcrumb survived"); + Expect([testDefaults objectForKey:keyRoundBreadcrumb] == nil, "breadcrumb survived"); } int main(int argc, const char **argv) { diff --git a/scripts/tests/ios-purge-restore/test_extract.py b/scripts/tests/ios-purge-restore/test_extract.py index c5dba656..13b9bea5 100644 --- a/scripts/tests/ios-purge-restore/test_extract.py +++ b/scripts/tests/ios-purge-restore/test_extract.py @@ -76,6 +76,11 @@ def test_mutations_fail_on_source_drift(self): mutate('event.Set("reason", flowjson::Value::String("config_changed"));', 'drop-config-changed'), '(void)configGeneration;') + self.assertIn('kSchemaVersionKey', mutate( + '[state setObject:PushyFromStdString(put.second) forKey:PushyFromStdString(put.first)];', + 'skip-carry')) + self.assertTrue(mutate('for (const auto &put : plan.puts) {', 'remove-first') + .startswith('for (const std::string &key : plan.removes)')) for source in ('', 'activation = nil; activation = nil;'): with self.assertRaises(ValueError): mutate(source, 'late-activation') diff --git a/src/__tests__/harmonyStateLifecycle.test.ts b/src/__tests__/harmonyStateLifecycle.test.ts index a22bcb54..4f5f119a 100644 --- a/src/__tests__/harmonyStateLifecycle.test.ts +++ b/src/__tests__/harmonyStateLifecycle.test.ts @@ -1,10 +1,12 @@ import { describe, expect, test } from 'bun:test'; import { storedItems } from '../../scripts/contract'; -import { evalHarmonyModule } from './nativeSources'; +import { evalHarmonyModule, harmonyStoreSchema, source } 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. +// The Harmony side of the persisted layout (scripts/contract.ts storeSchema): +// what a reset to the packaged bundle and a binary upgrade delete, through +// the actual UpdateContext.removeKeys, and the one-time migration out of the +// legacy layout, through the actual StoreSchema.migrateStore, against the +// shared vectors (cpp/patch_core/tests/stored-items-vectors.json). interface ClearedKeys { keys: string[]; @@ -13,12 +15,12 @@ interface ClearedKeys { function lifecycleHarness() { const values = new Map([ + ['schemaVersion', '1'], ['uuid', 'installation-id'], ['nativeConfig', '{"appKey":"k"}'], ['bundleHashCache', '1.0|1|sha'], ['nativeCheckIncomplete', '1'], - ['nativeCheckResp', '{}'], - ['firstLoadMarked', 'true'], + ['firstLoadMarked', '1'], ['hash_a', '{"name":"a"}'], ['hash_b', '{"name":"b"}'], ['currentVersion', 'a'], @@ -35,9 +37,8 @@ function lifecycleHarness() { store.preferences = preferences; ({ store, RESET_CLEARED, UPGRADE_CLEARED });`, { + ...harmonyStoreSchema(), preferences, - KEY_CONFIG: 'nativeConfig', - KEY_RESP_CACHE: 'nativeCheckResp', logger: { error() {} }, getErrorMessage: (error: unknown) => String(error), } @@ -51,6 +52,7 @@ function lifecycleHarness() { // The install's own keys and the state fields (rewritten by the state core). const survivors = [ + 'schemaVersion', 'uuid', 'nativeConfig', 'bundleHashCache', @@ -77,4 +79,209 @@ describe('harmony stored-item lifecycle', () => { ); } }); + + test('the state mapping writes text and reads it back', () => { + const schema = harmonyStoreSchema() as { + readState: (store: unknown) => Record; + writeState: (store: unknown, state: Record) => void; + }; + const values = new Map(); + const store = { + read: (key: string) => values.get(key) ?? '', + write: (key: string, value: string) => values.set(key, value), + remove: (key: string) => values.delete(key), + }; + expect(schema.readState(store)).toMatchObject({ + firstTime: false, + firstTimeOk: true, + currentVersion: '', + }); + const state = { + packageVersion: '1.0', + buildTime: '1700', + currentVersion: 'b', + lastVersion: '', + firstTime: true, + firstTimeOk: false, + rolledBackVersion: '', + }; + schema.writeState(store, state); + expect(Object.fromEntries(values)).toEqual({ + packageVersion: '1.0', + buildTime: '1700', + currentVersion: 'b', + firstTime: '1', + firstTimeOk: '0', + }); + expect(schema.readState(store)).toEqual(state); + }); +}); + +type Value = string | boolean | number | Record | unknown[]; +interface Plan { + schemaVersion: number; + migrated: boolean; + puts: [string, string][]; + removes: string[]; +} +interface Vector { + name: string; + layout: string | number; + legacy: Record; + current: Record; + plan: Plan | null; + after: { legacy: Record; current: Record }; +} + +const vectors = JSON.parse( + source('cpp/patch_core/tests/stored-items-vectors.json') +).cases as Vector[]; + +// How the core reads a legacy value (see the vectors' doc). +const text = (value: Value) => + typeof value === 'string' + ? value + : typeof value === 'boolean' + ? value + ? '1' + : '0' + : typeof value === 'number' + ? String(value) + : ''; + +interface MigrationStore { + snapshot(): Record; + put(key: string, value: string): void; + remove(key: string): void; + flush(): Promise; +} + +function fakeStore(initial: Record) { + const values = new Map(Object.entries(initial)); + const failures = { flush: false }; + const store: MigrationStore = { + snapshot: () => Object.fromEntries(values), + put: (key, value) => values.set(key, value), + remove: (key) => values.delete(key), + flush: async () => { + if (failures.flush) { + throw new Error('disk full'); + } + }, + }; + return { values, store, failures }; +} + +const schema = harmonyStoreSchema() as { + LEGACY_LAYOUT: number; + migrateStore: ( + state: MigrationStore, + legacy: MigrationStore, + planner: (layout: number, legacy: string[], current: string[]) => string + ) => Promise; +}; + +const pairs = (flat: string[]) => + Object.fromEntries( + flat.flatMap((_, i) => (i % 2 === 0 ? [[flat[i], flat[i + 1]]] : [])) + ); + +describe('harmony state store migration', () => { + for (const vector of vectors.filter((candidate) => candidate.plan)) { + test(vector.name, async () => { + const legacy = fakeStore(vector.legacy); + const state = fakeStore(vector.current); + const result = await schema.migrateStore( + state.store, + legacy.store, + (layout, legacyFlat, currentFlat) => { + // The snapshots reach the core as the vectors read them. + expect(layout).toBe(schema.LEGACY_LAYOUT); + expect(pairs(legacyFlat)).toEqual( + Object.fromEntries( + Object.entries(vector.legacy).map(([key, value]) => [ + key, + text(value), + ]) + ) + ); + expect(pairs(currentFlat)).toEqual(vector.current); + return JSON.stringify(vector.plan); + } + ); + expect(result).toBe(vector.plan?.migrated ? 'migrated' : 'current'); + expect(Object.fromEntries(state.values)).toEqual(vector.after.current); + expect(Object.fromEntries(legacy.values)).toEqual(vector.after.legacy); + }); + } + + const full = vectors[0]; + const plan = () => JSON.stringify(full.plan); + + test('the legacy keys stay until the state store is on disk', async () => { + const legacy = fakeStore(full.legacy); + const state = fakeStore({}); + state.failures.flush = true; + expect(await schema.migrateStore(state.store, legacy.store, plan)).toBe( + 'retry' + ); + expect(Object.fromEntries(legacy.values)).toEqual(full.legacy); + // The puts are in memory: this launch already reads the migrated items. + expect(state.values.get('uuid')).toBe('install-uuid'); + }); + + // The next launch finishes the deletes (the "killed after the puts" vector). + test('a failed legacy delete does not fail the migration', async () => { + const legacy = fakeStore(full.legacy); + const state = fakeStore({}); + legacy.failures.flush = true; + expect(await schema.migrateStore(state.store, legacy.store, plan)).toBe( + 'migrated' + ); + expect(Object.fromEntries(state.values)).toEqual(full.after.current); + }); + + for (const [what, planner] of [ + [ + 'the core throws', + () => { + throw new Error('bad_alloc'); + }, + ], + ['the core has no plan', () => 'null'], + ] as const) { + test(`${what}: a fresh state store that keeps the uuid, retried next launch`, async () => { + const legacy = fakeStore(full.legacy); + const state = fakeStore({}); + expect( + await schema.migrateStore(state.store, legacy.store, planner) + ).toBe('fallback'); + expect(Object.fromEntries(state.values)).toEqual({ + uuid: 'install-uuid', + }); + expect(Object.fromEntries(legacy.values)).toEqual(full.legacy); + // Next launch: the core works again and completes the move. + expect(await schema.migrateStore(state.store, legacy.store, plan)).toBe( + 'migrated' + ); + expect(Object.fromEntries(state.values)).toEqual(full.after.current); + expect(Object.fromEntries(legacy.values)).toEqual(full.after.legacy); + }); + } + + // Negative control: a store adapter that ignored the plan's order and + // deleted the legacy keys before the state store was durable would lose the + // uuid on a failed flush; the real one does not. + test('the uuid survives a failed flush followed by a fallback launch', async () => { + const legacy = fakeStore(full.legacy); + const state = fakeStore({}); + state.failures.flush = true; + await schema.migrateStore(state.store, legacy.store, plan); + // The process dies: memory is lost, only the legacy store is on disk. + const relaunched = fakeStore({}); + expect( + await schema.migrateStore(relaunched.store, legacy.store, () => 'null') + ).toBe('fallback'); + expect(relaunched.values.get('uuid')).toBe('install-uuid'); + }); }); diff --git a/src/__tests__/harmonySyncRound.test.ts b/src/__tests__/harmonySyncRound.test.ts index cb278864..27a20cac 100644 --- a/src/__tests__/harmonySyncRound.test.ts +++ b/src/__tests__/harmonySyncRound.test.ts @@ -8,7 +8,7 @@ import { createPushyError, PushyError, } from '../../harmony/pushy/src/main/ets/ErrorCodes'; -import { evalHarmonyModule } from './nativeSources'; +import { evalHarmonyModule, harmonyStoreSchema } from './nativeSources'; // The Harmony adapter of the shared round engine (SyncCoordinator.runOnce), // evaluated from source against the rounds in @@ -133,6 +133,7 @@ async function replay(round: Round) { 'SyncCoordinator.ts', 'httpRequest = fakeHttp;\n({ run: runOnce, result: () => roundResult });', { + ...harmonyStoreSchema(), BundlePreparationRound, bundlePreparationResult, PushyError, diff --git a/src/__tests__/nativeConfiguration.test.ts b/src/__tests__/nativeConfiguration.test.ts index b8c252e7..e79405fc 100644 --- a/src/__tests__/nativeConfiguration.test.ts +++ b/src/__tests__/nativeConfiguration.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from 'bun:test'; -import { evalHarmonyModule, harmonyConfigBindings } from './nativeSources'; +import { + evalHarmonyModule, + harmonyConfigBindings, + harmonyStoreSchema, +} from './nativeSources'; // Option validation (normalizePushyConfiguration) is the shared core's // NormalizeConfiguration, covered by cpp/update_flow_core/tests/ @@ -19,6 +23,7 @@ interface ConfigStore { function storeHarness() { const values = new Map(); + const cacheValues = new Map(); let flushes = 0; let shouldFail = false; const clearedSignals: string[] = []; @@ -31,14 +36,27 @@ function storeHarness() { if (shouldFail) throw new Error('storage unavailable'); }, }; + const cachePreferences = { + getSync: (key: string, fallback: string) => + cacheValues.get(key) ?? fallback, + putSync: (key: string, value: string) => cacheValues.set(key, value), + deleteSync: (key: string) => cacheValues.delete(key), + flush: async () => { + if (shouldFail) throw new Error('storage unavailable'); + }, + }; const store = evalHarmonyModule( 'UpdateContext.ts', - 'const store = Object.create(UpdateContext.prototype); store.preferences = preferences; store.flushBatchDepth = 0; store;', + `const store = Object.create(UpdateContext.prototype); + store.preferences = preferences; + store.cachePreferences = cachePreferences; + store.flushBatchDepth = 0; + store;`, { + ...harmonyStoreSchema(), preferences, + cachePreferences, util: { generateRandomUUID: () => 'native-installation-id' }, - KEY_CONFIG: 'nativeConfig', - KEY_RESP_CACHE: 'nativeCheckResp', recordJsRound: (config: string) => clearedSignals.push(config), logger: { error() {} }, getErrorMessage: (error: unknown) => String(error), @@ -48,10 +66,12 @@ function storeHarness() { return { store, values, + cacheValues, clearedSignals, fail: (value: boolean) => { shouldFail = value; }, + // The state store's flushes. flushes: () => flushes, }; } @@ -61,9 +81,9 @@ describe('actual native configuration store', () => { const h = storeHarness(); await h.store.setNativeConfig('A'); const oldGeneration = h.store.getResetGeneration(); - h.values.set('nativeCheckResp', 'stale'); + h.cacheValues.set('nativeCheckResp', 'stale'); await h.store.setNativeConfig('B'); - expect(h.values.has('nativeCheckResp')).toBe(false); + expect(h.cacheValues.has('nativeCheckResp')).toBe(false); await h.store.setNativeConfig('A'); expect(h.store.getResetGeneration()).toBe(oldGeneration + 2); expect( diff --git a/src/__tests__/nativeHostApi.test.ts b/src/__tests__/nativeHostApi.test.ts index de3df080..ce087ccb 100644 --- a/src/__tests__/nativeHostApi.test.ts +++ b/src/__tests__/nativeHostApi.test.ts @@ -6,7 +6,11 @@ import { bundlePreparationResult, } from '../../harmony/pushy/src/main/ets/BundlePreparationResult'; import { STATUS_NONE } from '../../harmony/pushy/src/main/ets/Texts'; -import { evalHarmonyModule, harmonyConfigBindings } from './nativeSources'; +import { + evalHarmonyModule, + harmonyConfigBindings, + harmonyStoreSchema, +} from './nativeSources'; // Evaluate the actual Harmony orchestrator in an isolated VM per test. Only // platform imports, HTTP, download IO and the round engine are substituted @@ -189,6 +193,7 @@ function harness() { 'SyncCoordinator.ts', 'httpRequest = mockCheck;\n({ check: prepareBundleNative, schedule: scheduleNativeSync });', { + ...harmonyStoreSchema(), BundlePreparationRound, bundlePreparationResult, logger: { info() {}, warn() {}, error() {} }, diff --git a/src/__tests__/nativeSources.ts b/src/__tests__/nativeSources.ts index 35a8fb0a..ff5affd1 100644 --- a/src/__tests__/nativeSources.ts +++ b/src/__tests__/nativeSources.ts @@ -90,3 +90,25 @@ export const harmonyConfigBindings = { return trimmed ? JSON.stringify({ appKey: trimmed }) : invalid; }, }; + +/** + * The exports of harmony/pushy/src/main/ets/StoreSchema.ts (the generated + * keys and the store mapping), for modules evaluated with their imports + * stripped. + */ +export const harmonyStoreSchema = ( + logger: Record void> = { + info() {}, + warn() {}, + error() {}, + } +): Record => { + const names = [ + ...source('harmony/pushy/src/main/ets/StoreSchema.ts').matchAll( + /^export (?:const|function) (\w+)/gm + ), + ].map((match) => match[1]); + return evalHarmonyModule('StoreSchema.ts', `({ ${names.join(', ')} })`, { + logger, + }) as Record; +};