From de9e1609f3e22e8088df83847a7bb36594b59215 Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Sat, 19 Sep 2026 09:38:28 +0800 Subject: [PATCH 01/24] feat(android): add native update result contract --- .../modules/update/NativeUpdateResult.java | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 android/src/main/java/cn/reactnative/modules/update/NativeUpdateResult.java diff --git a/android/src/main/java/cn/reactnative/modules/update/NativeUpdateResult.java b/android/src/main/java/cn/reactnative/modules/update/NativeUpdateResult.java new file mode 100644 index 00000000..43a4452b --- /dev/null +++ b/android/src/main/java/cn/reactnative/modules/update/NativeUpdateResult.java @@ -0,0 +1,51 @@ +package cn.reactnative.modules.update; + +/** + * Snapshot of a native check-and-update round. A downloaded result does not + * mean that the running React Native instance has been reloaded. + */ +public final class NativeUpdateResult { + public static final String SKIPPED = "skipped"; + public static final String NO_UPDATE = "noUpdate"; + public static final String DOWNLOADED = "downloaded"; + public static final String FAILED = "failed"; + public static final String CANCELLED = "cancelled"; + + private final String status; + private final String reason; + private final String hash; + private final boolean activated; + + private NativeUpdateResult(String status, String reason, String hash, boolean activated) { + this.status = status; + this.reason = reason; + this.hash = hash; + this.activated = activated; + } + + static NativeUpdateResult of(String status, String reason) { + return new NativeUpdateResult(status, reason, "", false); + } + + static NativeUpdateResult downloaded(String hash, boolean activated) { + return new NativeUpdateResult(DOWNLOADED, "", hash, activated); + } + + public String getStatus() { + return status; + } + + public String getReason() { + return reason; + } + + /** Installed update hash, or an empty string when this round installed nothing. */ + public String getHash() { + return hash; + } + + /** Whether this round selected the downloaded version for the next launch. */ + public boolean isActivated() { + return activated; + } +} From 92d025528cb372b44bed62078dd1ed4bfa45fc91 Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Sat, 19 Sep 2026 09:38:44 +0800 Subject: [PATCH 02/24] feat(android): expose a bridge-free native update entry point --- .../modules/update/PushyNativeUpdate.java | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 android/src/main/java/cn/reactnative/modules/update/PushyNativeUpdate.java diff --git a/android/src/main/java/cn/reactnative/modules/update/PushyNativeUpdate.java b/android/src/main/java/cn/reactnative/modules/update/PushyNativeUpdate.java new file mode 100644 index 00000000..78bd77a1 --- /dev/null +++ b/android/src/main/java/cn/reactnative/modules/update/PushyNativeUpdate.java @@ -0,0 +1,74 @@ +package cn.reactnative.modules.update; + +import android.content.Context; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; + +/** Native host API. Configuration remains owned and persisted by the JS SDK. */ +public final class PushyNativeUpdate { + public interface Callback { + /** Always called on the main thread, including skipped and failed checks. */ + void onComplete(NativeUpdateResult result); + } + + // A single waiting worker, not one thread per caller. The actual round is + // shared with cold start and crash rescue by NativeCheckOrchestrator. + private static final Executor WORKER = Executors.newSingleThreadExecutor(new ThreadFactory() { + @Override + public Thread newThread(Runnable runnable) { + Thread thread = new Thread(runnable, "pushy-host-check"); + thread.setDaemon(true); + return thread; + } + }); + + private PushyNativeUpdate() { + } + + /** + * Start the process's native check now, join its in-flight round, or return + * its completed result. This does not reload React Native or show UI. + * + * Call after the host has resolved its real launch bundle with + * UpdateContext.getBundleUrl. Do not resolve the bundle again just to call + * this method: bundle resolution consumes first-load/rollback markers. + * Missing persisted configuration and debug builds are reported as skipped. + */ + public static void checkAndUpdate(Context context, final Callback callback) { + if (context == null || callback == null) { + throw new IllegalArgumentException("context and callback are required"); + } + final Context applicationContext = context.getApplicationContext(); + WORKER.execute(new Runnable() { + @Override + public void run() { + NativeUpdateResult outcome; + try { + if (BuildConfig.DEBUG) { + outcome = NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "debug"); + } else { + outcome = NativeCheckOrchestrator.checkAndUpdate( + UpdateContext.getInstance(applicationContext)); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + outcome = NativeUpdateResult.of(NativeUpdateResult.CANCELLED, "interrupted"); + } catch (Exception | LinkageError e) { + Log.w("react-native-update", "native host check failed", e); + outcome = NativeUpdateResult.of(NativeUpdateResult.FAILED, "internal_error"); + } + final NativeUpdateResult result = outcome; + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + callback.onComplete(result); + } + }); + } + }); + } +} From 3184da7e8b445c7c07ceab2217bc95834ab289f4 Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Sat, 19 Sep 2026 09:38:57 +0800 Subject: [PATCH 03/24] feat(ios): declare native check-and-update API for Objective-C and Swift --- ios/RCTPushy/RCTPushy.h | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/ios/RCTPushy/RCTPushy.h b/ios/RCTPushy/RCTPushy.h index a44bb22a..18477169 100644 --- a/ios/RCTPushy/RCTPushy.h +++ b/ios/RCTPushy/RCTPushy.h @@ -1,9 +1,23 @@ #import #import +typedef void (^RCTPushyNativeUpdateCompletion)(NSDictionary * _Nonnull result); @interface RCTPushy : RCTEventEmitter + (NSURL *)bundleURL; +/** + * Start, join, or reuse this process's native update round. Call after the + * host's real bundleURL resolution; this method never resolves the bundle + * again, reloads React Native, or displays UI. Configuration is the one + * persisted by the JS SDK. The optional completion runs on the main queue. + * + * Result keys: status (skipped/noUpdate/downloaded/failed/cancelled), reason, + * hash (empty when nothing was installed), activated (selected for NEXT + * launch, not a reload of the running instance). + */ ++ (void)checkAndUpdateWithCompletion:(RCTPushyNativeUpdateCompletion _Nullable)completion + NS_SWIFT_NAME(checkAndUpdate(completion:)); + @end From 1761fb9de65d450024bbbb7226da6327777fa9b7 Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Sat, 19 Sep 2026 09:43:14 +0800 Subject: [PATCH 04/24] feat(harmony): add native update results and shared round gate --- .../pushy/src/main/ets/NativeUpdateResult.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 harmony/pushy/src/main/ets/NativeUpdateResult.ts diff --git a/harmony/pushy/src/main/ets/NativeUpdateResult.ts b/harmony/pushy/src/main/ets/NativeUpdateResult.ts new file mode 100644 index 00000000..5489091e --- /dev/null +++ b/harmony/pushy/src/main/ets/NativeUpdateResult.ts @@ -0,0 +1,32 @@ +/** A native round snapshot, not the current state of the running RN instance. */ +export interface NativeUpdateResult { + /** skipped, noUpdate, downloaded, failed, or cancelled. */ + status: string; + reason: string; + hash: string; + /** The version was selected for the next launch; no reload is performed. */ + activated: boolean; +} + +export function nativeUpdateResult( + status: string, + reason: string = '', + hash: string = '', + activated: boolean = false, +): NativeUpdateResult { + return { status, reason, hash, activated }; +} + +/** Internal gate shared by the delayed check and the native host API. */ +export class NativeUpdateRound { + private task: Promise | undefined; + + run(operation: () => Promise): Promise { + if (this.task !== undefined) { + return this.task; + } + // Defer invocation until the promise is stored, including reentrant callers. + this.task = Promise.resolve().then(operation); + return this.task; + } +} From 638706573b462cf153d8c1ece674fa6dbf6f1488 Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Sat, 19 Sep 2026 09:44:35 +0800 Subject: [PATCH 05/24] chore: stage guarded native host API source transformation --- scripts/add-native-host-api.py | 499 +++++++++++++++++++++++++++++++++ 1 file changed, 499 insertions(+) create mode 100644 scripts/add-native-host-api.py diff --git a/scripts/add-native-host-api.py b/scripts/add-native-host-api.py new file mode 100644 index 00000000..ec7dead2 --- /dev/null +++ b/scripts/add-native-host-api.py @@ -0,0 +1,499 @@ +from pathlib import Path + +ROOT = Path.cwd() +PENDING = [] + +def replace(text, old, new): + count = text.count(old) + if count != 1: + raise RuntimeError(f'Expected one match, found {count}: {old[:100]!r}') + return text.replace(old, new, 1) + +def region(text, start, end, transform): + a = text.index(start) + b = text.index(end, a + len(start)) + return text[:a] + transform(text[a:b]) + text[b:] + +def edit(path, transform): + target = ROOT / path + before = target.read_text() + after = transform(before) + if before == after: + raise RuntimeError(f'No changes: {path}') + PENDING.append((target, after)) + +A = 'android/src/main/java/cn/reactnative/modules/update/NativeCheckOrchestrator.java' +I = 'ios/RCTPushy/RCTPushy.mm' +H = 'harmony/pushy/src/main/ets/NativeCheckOrchestrator.ts' + +ANDROID_HOST = ''' /** Blocking only on the host API's worker; never call on the UI thread. */ + static NativeUpdateResult checkAndUpdate(UpdateContext context) throws InterruptedException { + if (UpdateContext.DEBUG) { + return NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "debug"); + } + if (!nativeReady || sContext != context || !context.getIsUsingBundleUrl()) { + return NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "not_initialized"); + } + String configJson = context.getKv(KEY_CONFIG); + if (configJson == null || configJson.isEmpty()) { + return NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "not_configured"); + } + try { + JSONObject config = new JSONObject(configJson); + if (config.optBoolean("disabled", false)) { + return NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "disabled"); + } + if (config.optString("appKey", "").isEmpty()) { + return NativeUpdateResult.of(NativeUpdateResult.FAILED, "invalid_config"); + } + } catch (JSONException e) { + return NativeUpdateResult.of(NativeUpdateResult.FAILED, "invalid_config"); + } + startRound(0); + roundDone.await(); + if (roundGeneration != UpdateContext.getResetGeneration()) { + return NativeUpdateResult.of(NativeUpdateResult.CANCELLED, "reset"); + } + if (!configJson.equals(roundConfigJson) || !configJson.equals(context.getKv(KEY_CONFIG))) { + return NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "config_changed"); + } + return roundResult; + } + +''' + +ANDROID_ONCE = ''' private static void runOnce( + UpdateContext context, + String launchRolledBackVersion, + long deadlineNanos + ) throws JSONException { + final long resetGeneration = UpdateContext.getResetGeneration(); + roundGeneration = resetGeneration; + roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "check_failed"); + String configJson = context.getKv(KEY_CONFIG); + roundConfigJson = configJson; + if (configJson == null || configJson.isEmpty()) { + roundResult = NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "not_configured"); + return; + } + JSONObject config; + try { + config = new JSONObject(configJson); + } catch (JSONException e) { + roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "invalid_config"); + return; + } + if (config.optBoolean("disabled", false)) { + roundResult = NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "disabled"); + return; + } + String appKey = config.optString("appKey", ""); + if (appKey.isEmpty()) { + roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "invalid_config"); + return; + } + // Keep the existing interrupted-round breadcrumb and reset generation. + try { + context.setKv(KEY_ROUND_INCOMPLETE, "1"); + } catch (IllegalStateException ignored) { + } + try { + runConfiguredRound( + context, launchRolledBackVersion, deadlineNanos, + resetGeneration, configJson, config, appKey); + } finally { + try { + context.removeKv(KEY_ROUND_INCOMPLETE); + } catch (IllegalStateException ignored) { + } + } + } + +''' + +def android_configured(s): + s = replace(s, 'if (body == null) {\n return;', 'if (body == null) {\n roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "invalid_request");\n return;') + s = replace(s, 'if (decisionJson == null) {\n return;', 'if (decisionJson == null) {\n roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "invalid_response");\n return;') + s = replace(s, ''' context.commitNativeCheckResult( + resetGeneration, null, null, false, + buildResponseCacheJson(configJson, body, responseText, responseAtSeconds)); + Log.i(UpdateContext.TAG, + "native check: nothing to do (" + decision.optString("reason") + ")");''', ''' boolean committed = context.commitNativeCheckResult( + resetGeneration, null, null, false, + buildResponseCacheJson(configJson, body, responseText, responseAtSeconds)); + roundResult = committed + ? NativeUpdateResult.of(NativeUpdateResult.NO_UPDATE, decision.optString("reason")) + : NativeUpdateResult.of(NativeUpdateResult.CANCELLED, "reset"); + Log.i(UpdateContext.TAG, + "native check: nothing to do (" + decision.optString("reason") + ")");''') + s = replace(s, '''if (!UpdateFileUtils.isSafePathComponent(hash)) { + return;''', '''if (!UpdateFileUtils.isSafePathComponent(hash)) { + roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "invalid_response"); + return;''') + s = replace(s, ''' context.commitNativeCheckResult( + resetGeneration, null, null, false, + buildResponseCacheJson(configJson, body, responseText, responseAtSeconds)); + return;''', ''' boolean committed = context.commitNativeCheckResult( + resetGeneration, null, null, false, + buildResponseCacheJson(configJson, body, responseText, responseAtSeconds)); + roundResult = NativeUpdateResult.of( + committed ? NativeUpdateResult.FAILED : NativeUpdateResult.CANCELLED, + committed ? "download_failed" : "reset"); + return;''') + s = replace(s, 'Log.w(UpdateContext.TAG, "native check: commit failed: " + e);\n return;', 'Log.w(UpdateContext.TAG, "native check: commit failed: " + e);\n roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "commit_failed");\n return;') + s = replace(s, ''' "native check: downloaded " + hash + ", activation left to JS"); + } + } +''', ''' "native check: downloaded " + hash + ", activation left to JS"); + } + roundResult = committed + ? NativeUpdateResult.downloaded(hash, activate) + : NativeUpdateResult.of(NativeUpdateResult.CANCELLED, "reset"); + } +''') + return s + +def android(s): + s = replace(s, ' private static volatile String sJsCompletedConfig;\n', ''' private static volatile String sJsCompletedConfig; + // Published after the launch rollback snapshot, before host calls are accepted. + private static volatile boolean nativeReady; + private static volatile NativeUpdateResult roundResult = + NativeUpdateResult.of(NativeUpdateResult.FAILED, "check_failed"); + private static volatile long roundGeneration = -1; + private static volatile String roundConfigJson; + +''' + ANDROID_HOST) + s = replace(s, ' sLaunchRolledBackVersion = launchRolledBackVersion;\n', ' sLaunchRolledBackVersion = launchRolledBackVersion;\n nativeReady = true;\n') + s = region(s, ' private static void startRound(', ' static void runRescue(', lambda t: replace(t, + ' Log.w(UpdateContext.TAG, "native check failed: " + e);', + ' Log.w(UpdateContext.TAG, "native check failed: " + e);\n roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "internal_error");')) + s = region(s, ' private static void runOnce(', ' private static void runConfiguredRound(', lambda _: ANDROID_ONCE) + return region(s, ' private static void runConfiguredRound(', ' private static String buildResponseCacheJson(', android_configured) + +IOS_HOST = '''+ (NSDictionary *)checkAndUpdate { +#if DEBUG + return PushyHostResult(@"skipped", @"debug", nil, NO); +#else + if (!pushyNativeCheckReady.load()) { + return PushyHostResult(@"skipped", @"not_initialized", nil, NO); + } + NSString *configJson = [PushyDefaults() stringForKey:keyNativeConfig]; + if (configJson.length == 0) { + return PushyHostResult(@"skipped", @"not_configured", nil, NO); + } + id config = [NSJSONSerialization JSONObjectWithData: + [configJson dataUsingEncoding:NSUTF8StringEncoding] options:0 error:nil]; + if (![config isKindOfClass:NSDictionary.class]) { + return PushyHostResult(@"failed", @"invalid_config", nil, NO); + } + id disabled = config[@"disabled"]; + if ([disabled respondsToSelector:@selector(boolValue)] && [disabled boolValue]) { + return PushyHostResult(@"skipped", @"disabled", nil, NO); + } + id appKey = config[@"appKey"]; + if (![appKey isKindOfClass:NSString.class] || [appKey length] == 0) { + return PushyHostResult(@"failed", @"invalid_config", nil, NO); + } + [self startRoundWithDeadline:0]; + // A group is broadcast-style. Sharing the rescue semaphore would let one + // waiter consume the only signal and leave the other waiting forever. + dispatch_group_wait(pushyHostRoundGroup, DISPATCH_TIME_FOREVER); + if (pushyHostRoundGeneration != pushyResetGeneration.load()) { + return PushyHostResult(@"cancelled", @"reset", nil, NO); + } + if (![configJson isEqualToString:pushyHostRoundConfig] + || ![configJson isEqualToString:[PushyDefaults() stringForKey:keyNativeConfig]]) { + return PushyHostResult(@"skipped", @"config_changed", nil, NO); + } + return pushyHostRoundResult ?: PushyHostResult(@"failed", @"internal_error", nil, NO); +#endif +} + +''' + +IOS_PUBLIC = '''+ (void)checkAndUpdateWithCompletion:(RCTPushyNativeUpdateCompletion)completion +{ + static dispatch_queue_t hostQueue; + static dispatch_once_t once; + dispatch_once(&once, ^{ + hostQueue = dispatch_queue_create("cn.reactnative.pushy.host-check", DISPATCH_QUEUE_SERIAL); + }); + dispatch_async(hostQueue, ^{ + NSDictionary *result; + @try { + result = [RCTPushyOrchestrator checkAndUpdate]; + } @catch (NSException *exception) { + RCTLogWarn(@"RCTPushy -- native host check failed: %@", exception.reason); + result = PushyHostResult(@"failed", @"internal_error", nil, NO); + } + if (completion != nil) { + NSDictionary *snapshot = [result copy]; + dispatch_async(dispatch_get_main_queue(), ^{ + completion(snapshot); + }); + } + }); +} + +''' + +IOS_ONCE = '''+ (void)runOnce:(NSString *)launchRolledBackVersion deadline:(NSTimeInterval)deadlineUptime { + const uint64_t resetGeneration = pushyResetGeneration.load(); + pushyHostRoundGeneration = resetGeneration; + pushyHostRoundResult = PushyHostResult(@"failed", @"check_failed", nil, NO); + NSUserDefaults *defaults = PushyDefaults(); + NSString *configJson = [defaults stringForKey:keyNativeConfig]; + pushyHostRoundConfig = [configJson copy]; + if (configJson.length == 0) { + pushyHostRoundResult = PushyHostResult(@"skipped", @"not_configured", nil, NO); + return; + } + bool ok = false; + flowjson::Value config = flowjson::Parse(PushyToStdString(configJson), &ok); + if (!ok || !config.IsObject()) { + pushyHostRoundResult = PushyHostResult(@"failed", @"invalid_config", nil, NO); + return; + } + if (config.Get("disabled").Truthy()) { + pushyHostRoundResult = PushyHostResult(@"skipped", @"disabled", nil, NO); + return; + } + NSString *appKey = PushyFromStdString(config.Get("appKey").AsString()); + if (appKey.length == 0) { + pushyHostRoundResult = PushyHostResult(@"failed", @"invalid_config", nil, NO); + return; + } + // Preserve the interrupted-round breadcrumb and reset-safe atomic commit. + [defaults setObject:@YES forKey:keyNativeCheckIncomplete]; + @try { + [self runConfiguredRound:config + configJson:configJson + appKey:appKey + launchRolledBackVersion:launchRolledBackVersion + resetGeneration:resetGeneration + deadline:deadlineUptime]; + } @finally { + [defaults removeObjectForKey:keyNativeCheckIncomplete]; + } +} + +''' + +def ios_configured(s): + s = replace(s, 'RCTLogWarn(@"RCTPushy -- native check: request body is not valid UTF-8");\n return;', 'RCTLogWarn(@"RCTPushy -- native check: request body is not valid UTF-8");\n pushyHostRoundResult = PushyHostResult(@"failed", @"invalid_request", nil, NO);\n return;') + s = replace(s, ''' [self commitRoundWithGeneration:resetGeneration + hashInfo:nil + activate:nil + responseText:responseText + request:body + config:configJson + responseAt:responseAtSeconds]; + RCTLogInfo(@"RCTPushy -- native check: nothing to do (%s)",''', ''' BOOL committed = [self commitRoundWithGeneration:resetGeneration + hashInfo:nil + activate:nil + responseText:responseText + request:body + config:configJson + responseAt:responseAtSeconds]; + pushyHostRoundResult = committed + ? PushyHostResult(@"noUpdate", PushyFromStdString(decision.Get("reason").AsString()), nil, NO) + : PushyHostResult(@"cancelled", @"reset", nil, NO); + RCTLogInfo(@"RCTPushy -- native check: nothing to do (%s)",''') + s = replace(s, 'if (!PushyIsSafePathComponent(hash)) {\n return;', 'if (!PushyIsSafePathComponent(hash)) {\n pushyHostRoundResult = PushyHostResult(@"failed", @"invalid_response", nil, NO);\n return;') + s = replace(s, ''' [self commitRoundWithGeneration:resetGeneration + hashInfo:nil + activate:nil + responseText:responseText + request:body + config:configJson + responseAt:responseAtSeconds]; + return;''', ''' BOOL committed = [self commitRoundWithGeneration:resetGeneration + hashInfo:nil + activate:nil + responseText:responseText + request:body + config:configJson + responseAt:responseAtSeconds]; + pushyHostRoundResult = committed + ? PushyHostResult(@"failed", @"download_failed", nil, NO) + : PushyHostResult(@"cancelled", @"reset", nil, NO); + return;''') + s = replace(s, ''' RCTLogInfo(@"RCTPushy -- native check: downloaded %@, activation left to JS", hash); + } +} +''', ''' RCTLogInfo(@"RCTPushy -- native check: downloaded %@, activation left to JS", hash); + } + pushyHostRoundResult = committed + ? PushyHostResult(@"downloaded", @"", hash, activate) + : PushyHostResult(@"cancelled", @"reset", nil, NO); +} +''') + return s + +def ios(s): + s = replace(s, '#include \n', '''#include + +// Immutable host-facing snapshot; activated always means NEXT launch. +static NSDictionary *PushyHostResult(NSString *status, NSString *reason, + NSString *hash, BOOL activated) { + return @{@"status": status, @"reason": reason ?: @"", + @"hash": hash ?: @"", @"activated": @(activated)}; +} +''') + s = replace(s, '@interface RCTPushyOrchestrator : NSObject\n', '@interface RCTPushyOrchestrator : NSObject\n+ (NSDictionary *)checkAndUpdate;\n') + s = replace(s, 'static NSString *pushyJsCompletedConfig = nil;\n', '''static NSString *pushyJsCompletedConfig = nil; +// The group supplements (rather than consumes) the crash-rescue semaphore. +static dispatch_group_t pushyHostRoundGroup; +static std::atomic pushyNativeCheckReady{false}; +static NSDictionary *pushyHostRoundResult = nil; +static NSString *pushyHostRoundConfig = nil; +static uint64_t pushyHostRoundGeneration = 0; +''') + s = replace(s, '+ (BOOL)requiresMainQueueSetup\n', IOS_PUBLIC + '+ (BOOL)requiresMainQueueSetup\n') + s = replace(s, ' pushyRoundDone = dispatch_semaphore_create(0);\n', ' pushyRoundDone = dispatch_semaphore_create(0);\n pushyHostRoundGroup = dispatch_group_create();\n dispatch_group_enter(pushyHostRoundGroup);\n') + s = replace(s, ' pushyLaunchRolledBackForRescue = [launchRolledBackVersion copy];\n', ' pushyLaunchRolledBackForRescue = [launchRolledBackVersion copy];\n pushyNativeCheckReady.store(true);\n') + s = replace(s, '+ (void)markJsCheckCompleted:(NSString *)config {\n', IOS_HOST + '+ (void)markJsCheckCompleted:(NSString *)config {\n') + s = region(s, '+ (void)startRoundWithDeadline:(NSTimeInterval)deadlineUptime {', '+ (void)runRescueWithDeadline:(NSTimeInterval)deadlineUptime {', lambda t: replace(replace(t, + ' RCTLogWarn(@"RCTPushy -- native check crashed: %@", exception.reason);', + ' RCTLogWarn(@"RCTPushy -- native check crashed: %@", exception.reason);\n pushyHostRoundResult = PushyHostResult(@"failed", @"internal_error", nil, NO);'), + ' dispatch_semaphore_signal(pushyRoundDone);', + ' dispatch_semaphore_signal(pushyRoundDone);\n dispatch_group_leave(pushyHostRoundGroup);')) + s = region(s, '+ (void)runOnce:(NSString *)launchRolledBackVersion deadline:(NSTimeInterval)deadlineUptime {', '+ (void)runConfiguredRound:', lambda _: IOS_ONCE) + return region(s, '+ (void)runConfiguredRound:', '+ (BOOL)commitRoundWithGeneration:(uint64_t)generation\n', ios_configured) + +HARMONY_HOST = '''// The host and delayed check use one promise, including its settled result. +const hostRound = new NativeUpdateRound(); +let scheduledContext: UpdateContext | undefined; +let scheduledRollback = ''; +let roundGeneration = -1; +let roundConfigJson: string | undefined; +let roundResult = nativeUpdateResult('failed', 'check_failed'); + +function startNativeRound( + context: UpdateContext, + launchRolledBackVersion: string, +): Promise { + return hostRound.run(async () => { + try { + await runOnce(context, launchRolledBackVersion); + } catch (e) { + logger.error(TAG, `native check failed: ${getErrorMessage(e)}`); + roundResult = nativeUpdateResult('failed', 'internal_error'); + } + return roundResult; + }); +} + +export async function checkAndUpdateNative( + context: UpdateContext, +): Promise { + if (scheduledContext !== context) { + return nativeUpdateResult('skipped', 'not_initialized'); + } + const configJson = context.getKv(KEY_CONFIG); + if (!configJson) { + return nativeUpdateResult('skipped', 'not_configured'); + } + try { + const config = JSON.parse(configJson) as NativeConfig; + if (!config || typeof config !== 'object' || Array.isArray(config)) { + return nativeUpdateResult('failed', 'invalid_config'); + } + if (config.disabled) { + return nativeUpdateResult('skipped', 'disabled'); + } + if (typeof config.appKey !== 'string' || !config.appKey) { + return nativeUpdateResult('failed', 'invalid_config'); + } + } catch (e) { + return nativeUpdateResult('failed', 'invalid_config'); + } + const result = await startNativeRound(context, scheduledRollback); + if (roundGeneration !== context.getResetGeneration()) { + return nativeUpdateResult('cancelled', 'reset'); + } + if (configJson !== roundConfigJson || configJson !== context.getKv(KEY_CONFIG)) { + return nativeUpdateResult('skipped', 'config_changed'); + } + // Do not let a caller mutate the cached result observed by later callers. + return nativeUpdateResult(result.status, result.reason, result.hash, result.activated); +} + +''' + +def harmony_once(s): + s = replace(s, ' const resetGeneration = context.getResetGeneration();\n const configJson = context.getKv(KEY_CONFIG);', ''' const resetGeneration = context.getResetGeneration(); + roundGeneration = resetGeneration; + roundResult = nativeUpdateResult('failed', 'check_failed'); + const configJson = context.getKv(KEY_CONFIG); + roundConfigJson = configJson;''') + s = replace(s, ' // 无落盘配置(老接入/首启):静默不跑——这就是灰度开关。\n return;', " // No persisted configuration: report the rollout gate to native callers.\n roundResult = nativeUpdateResult('skipped', 'not_configured');\n return;") + s = replace(s, ''' config = JSON.parse(configJson) as NativeConfig; + } catch (e) { + return;''', ''' config = JSON.parse(configJson) as NativeConfig; + if (!config || typeof config !== 'object' || Array.isArray(config)) { + roundResult = nativeUpdateResult('failed', 'invalid_config'); + return; + } + } catch (e) { + roundResult = nativeUpdateResult('failed', 'invalid_config'); + return;''') + s = replace(s, ' if (config.disabled) {\n return;', " if (config.disabled) {\n roundResult = nativeUpdateResult('skipped', 'disabled');\n return;") + s = replace(s, ' if (!appKey) {\n return;', " if (!appKey) {\n roundResult = nativeUpdateResult('failed', 'invalid_config');\n return;") + return s + +def harmony_configured(s): + s = replace(s, ' if (!body) {\n return;', " if (!body) {\n roundResult = nativeUpdateResult('failed', 'invalid_request');\n return;") + s = replace(s, ' if (!decisionJson) {\n return;', " if (!decisionJson) {\n roundResult = nativeUpdateResult('failed', 'invalid_response');\n return;") + old = ''' await context.commitNativeCheckResult( + resetGeneration, + '', + '', + false, + buildResponseCacheJson(configJson, body, responseText, responseAtSeconds), + );''' + a = s.index(" if (decision.action !== 'download') {") + b = s.index(" const hash = decision.hash ?? '';", a) + piece = replace(s[a:b], old, old.replace(' await context.', ' const committed = await context.') + "\n roundResult = committed\n ? nativeUpdateResult('noUpdate', decision.reason ?? '')\n : nativeUpdateResult('cancelled', 'reset');") + s = s[:a] + piece + s[b:] + s = replace(s, " logger.warn(TAG, 'decision carries an unsafe hash, ignoring');\n return;", " logger.warn(TAG, 'decision carries an unsafe hash, ignoring');\n roundResult = nativeUpdateResult('failed', 'invalid_response');\n return;") + s = replace(s, old, old.replace(' await context.', ' const committed = await context.') + "\n roundResult = committed\n ? nativeUpdateResult('failed', 'download_failed')\n : nativeUpdateResult('cancelled', 'reset');") + s = replace(s, ' logger.error(TAG, `commit failed: ${getErrorMessage(e)}`);\n return;', " logger.error(TAG, `commit failed: ${getErrorMessage(e)}`);\n roundResult = nativeUpdateResult('failed', 'commit_failed');\n return;") + s = replace(s, ''' logger.info(TAG, `downloaded ${hash}, activation left to JS`); + } +} +''', ''' logger.info(TAG, `downloaded ${hash}, activation left to JS`); + } + roundResult = committed + ? nativeUpdateResult('downloaded', '', hash, activate) + : nativeUpdateResult('cancelled', 'reset'); +} +''') + return s + +def harmony(s): + s = replace(s, "import type { UpdateContext } from './UpdateContext';\n", "import type { UpdateContext } from './UpdateContext';\nimport { NativeUpdateRound, nativeUpdateResult } from './NativeUpdateResult';\nimport type { NativeUpdateResult } from './NativeUpdateResult';\n") + s = replace(s, 'let scheduled = false;\n', 'let scheduled = false;\n' + HARMONY_HOST) + s = replace(s, ' scheduled = true;\n', ' scheduled = true;\n scheduledContext = context;\n scheduledRollback = launchRolledBackVersion;\n') + s = replace(s, ' runOnce(context, launchRolledBackVersion).catch', ' startNativeRound(context, launchRolledBackVersion).catch') + s = region(s, 'async function runOnce(', '// 配置端点全为 https 时', harmony_once) + return region(s, 'async function runConfiguredRound(', 'function buildResponseCacheJson(', harmony_configured) + +edit(A, android) +edit(I, ios) +edit(H, harmony) +provider = 'harmony/pushy/src/main/ets/PushyFileJSBundleProvider.ets' +edit(provider, lambda s: replace(replace(s, + "import { UpdateContext } from './UpdateContext';\n", + "import { UpdateContext } from './UpdateContext';\nimport { checkAndUpdateNative } from './NativeCheckOrchestrator';\nimport type { NativeUpdateResult } from './NativeUpdateResult';\n"), + ' getAppKeys(): string[] {\n', ''' /** Call after the host's real bundle resolution; never resolves it again. */ + checkAndUpdate(): Promise { + return checkAndUpdateNative(this.updateContext); + } + + getAppKeys(): string[] { +''')) +edit('harmony/pushy/index.ets', lambda s: s + "export type { NativeUpdateResult } from './src/main/ets/NativeUpdateResult';\n") +for target, content in PENDING: + target.write_text(content) +print('Applied native host API changes to Android, iOS and Harmony.') From 3d787782b330ca794079067a7d66238a3a4b6d47 Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Sat, 19 Sep 2026 09:44:49 +0800 Subject: [PATCH 06/24] chore: apply guarded native API edits on the feature branch --- .github/workflows/native-host-api-prepare.yml | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/workflows/native-host-api-prepare.yml diff --git a/.github/workflows/native-host-api-prepare.yml b/.github/workflows/native-host-api-prepare.yml new file mode 100644 index 00000000..88c4c008 --- /dev/null +++ b/.github/workflows/native-host-api-prepare.yml @@ -0,0 +1,25 @@ +name: Prepare native host API +on: + push: + branches: [feat/native-host-update-api] + paths: [.github/workflows/native-host-api-prepare.yml] +permissions: + contents: write +jobs: + apply: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - name: Apply source edits + run: | + python3 scripts/add-native-host-api.py + git diff --check + git diff --stat + - name: Commit only the native API implementation + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add android/src/main/java/cn/reactnative/modules/update/NativeCheckOrchestrator.java ios/RCTPushy/RCTPushy.mm harmony/pushy/src/main/ets/NativeCheckOrchestrator.ts harmony/pushy/src/main/ets/PushyFileJSBundleProvider.ets harmony/pushy/index.ets + git commit -m 'feat: connect native host APIs to the shared update round' + git push origin HEAD:refs/heads/feat/native-host-update-api From 155ccbae40d063a111dba53f070c16403cf887cb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 19 Sep 2026 01:44:56 +0000 Subject: [PATCH 07/24] feat: connect native host APIs to the shared update round --- .../update/NativeCheckOrchestrator.java | 76 ++++++++++-- harmony/pushy/index.ets | 1 + .../src/main/ets/NativeCheckOrchestrator.ts | 94 +++++++++++++- .../main/ets/PushyFileJSBundleProvider.ets | 7 ++ ios/RCTPushy/RCTPushy.mm | 116 ++++++++++++++++-- 5 files changed, 271 insertions(+), 23 deletions(-) diff --git a/android/src/main/java/cn/reactnative/modules/update/NativeCheckOrchestrator.java b/android/src/main/java/cn/reactnative/modules/update/NativeCheckOrchestrator.java index 44d83180..d21962f8 100644 --- a/android/src/main/java/cn/reactnative/modules/update/NativeCheckOrchestrator.java +++ b/android/src/main/java/cn/reactnative/modules/update/NativeCheckOrchestrator.java @@ -71,6 +71,47 @@ final class NativeCheckOrchestrator { // (markJsCheckCompleted). Process-scoped by design: the next launch // starts with no signal and the cold-start round runs again. private static volatile String sJsCompletedConfig; + // Published after the launch rollback snapshot, before host calls are accepted. + private static volatile boolean nativeReady; + private static volatile NativeUpdateResult roundResult = + NativeUpdateResult.of(NativeUpdateResult.FAILED, "check_failed"); + private static volatile long roundGeneration = -1; + private static volatile String roundConfigJson; + + /** Blocking only on the host API's worker; never call on the UI thread. */ + static NativeUpdateResult checkAndUpdate(UpdateContext context) throws InterruptedException { + if (UpdateContext.DEBUG) { + return NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "debug"); + } + if (!nativeReady || sContext != context || !context.getIsUsingBundleUrl()) { + return NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "not_initialized"); + } + String configJson = context.getKv(KEY_CONFIG); + if (configJson == null || configJson.isEmpty()) { + return NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "not_configured"); + } + try { + JSONObject config = new JSONObject(configJson); + if (config.optBoolean("disabled", false)) { + return NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "disabled"); + } + if (config.optString("appKey", "").isEmpty()) { + return NativeUpdateResult.of(NativeUpdateResult.FAILED, "invalid_config"); + } + } catch (JSONException e) { + return NativeUpdateResult.of(NativeUpdateResult.FAILED, "invalid_config"); + } + startRound(0); + roundDone.await(); + if (roundGeneration != UpdateContext.getResetGeneration()) { + return NativeUpdateResult.of(NativeUpdateResult.CANCELLED, "reset"); + } + if (!configJson.equals(roundConfigJson) || !configJson.equals(context.getKv(KEY_CONFIG))) { + return NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "config_changed"); + } + return roundResult; + } + static void markJsCheckCompleted(String config) { sJsCompletedConfig = config; @@ -99,6 +140,7 @@ static void schedule(final UpdateContext context, final String launchRolledBackV } sContext = context; sLaunchRolledBackVersion = launchRolledBackVersion; + 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) { @@ -150,6 +192,7 @@ private static void startRound(long deadlineNanos) { runOnce(sContext, sLaunchRolledBackVersion, deadlineNanos); } catch (Throwable e) { Log.w(UpdateContext.TAG, "native check failed: " + e); + roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "internal_error"); } finally { roundCompleted = true; roundDone.countDown(); @@ -223,32 +266,32 @@ private static void runOnce( String launchRolledBackVersion, long deadlineNanos ) throws JSONException { - // Sampled before any IO: a reset landing while this round runs must - // win over the round's decision. final long resetGeneration = UpdateContext.getResetGeneration(); + roundGeneration = resetGeneration; + roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "check_failed"); String configJson = context.getKv(KEY_CONFIG); + roundConfigJson = configJson; if (configJson == null || configJson.isEmpty()) { - // No persisted config (old integration / first ever launch): the - // native check silently does not run — this is the rollout gate. + roundResult = NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "not_configured"); return; } JSONObject config; try { config = new JSONObject(configJson); } catch (JSONException e) { + roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "invalid_config"); return; } if (config.optBoolean("disabled", false)) { + roundResult = NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "disabled"); return; } String appKey = config.optString("appKey", ""); if (appKey.isEmpty()) { + roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "invalid_config"); return; } - // From here on the round does real work: leave the breadcrumb that - // the next launch reads to skip its 5s delay if we die mid-round. - // Best-effort: a lost breadcrumb costs one 5s delay, it must not - // abort the rescue round itself. + // Keep the existing interrupted-round breadcrumb and reset generation. try { context.setKv(KEY_ROUND_INCOMPLETE, "1"); } catch (IllegalStateException ignored) { @@ -323,6 +366,7 @@ private static void runConfiguredRound( String body = NativeUpdateFlow.buildCheckRequestBody(input.toString()); if (body == null) { + roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "invalid_request"); return; } @@ -339,19 +383,24 @@ private static void runConfiguredRound( String decisionJson = NativeUpdateFlow.handleCheckResponse( responseText, identity.toString(), config.optString("afterDownload", "")); if (decisionJson == null) { + roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "invalid_response"); return; } JSONObject decision = new JSONObject(decisionJson); if (!"download".equals(decision.optString("action"))) { - context.commitNativeCheckResult( + boolean committed = context.commitNativeCheckResult( resetGeneration, null, null, false, buildResponseCacheJson(configJson, body, responseText, responseAtSeconds)); + roundResult = committed + ? NativeUpdateResult.of(NativeUpdateResult.NO_UPDATE, decision.optString("reason")) + : NativeUpdateResult.of(NativeUpdateResult.CANCELLED, "reset"); Log.i(UpdateContext.TAG, "native check: nothing to do (" + decision.optString("reason") + ")"); return; } String hash = decision.optString("hash", ""); if (!UpdateFileUtils.isSafePathComponent(hash)) { + roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "invalid_response"); return; } @@ -364,9 +413,12 @@ private static void runConfiguredRound( if (!downloaded) { // The native attempt has finished, so JS may safely reuse the // response and retry through its own strategy chain. - context.commitNativeCheckResult( + boolean committed = context.commitNativeCheckResult( resetGeneration, null, null, false, buildResponseCacheJson(configJson, body, responseText, responseAtSeconds)); + roundResult = NativeUpdateResult.of( + committed ? NativeUpdateResult.FAILED : NativeUpdateResult.CANCELLED, + committed ? "download_failed" : "reset"); return; } @@ -412,6 +464,7 @@ private static void runConfiguredRound( buildResponseCacheJson(configJson, body, responseText, responseAtSeconds)); } catch (Exception e) { Log.w(UpdateContext.TAG, "native check: commit failed: " + e); + roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "commit_failed"); return; } if (!committed) { @@ -428,6 +481,9 @@ private static void runConfiguredRound( Log.i(UpdateContext.TAG, "native check: downloaded " + hash + ", activation left to JS"); } + roundResult = committed + ? NativeUpdateResult.downloaded(hash, activate) + : NativeUpdateResult.of(NativeUpdateResult.CANCELLED, "reset"); } private static String buildResponseCacheJson( diff --git a/harmony/pushy/index.ets b/harmony/pushy/index.ets index f53d0f3b..432a8cd2 100644 --- a/harmony/pushy/index.ets +++ b/harmony/pushy/index.ets @@ -2,3 +2,4 @@ export { PushyPackage as default } from './src/main/ets/PushyPackage'; export { PushyPackage } from './src/main/ets/PushyPackage'; export { PushyTurboModule } from './src/main/ets/PushyTurboModule'; export { PushyFileJSBundleProvider } from './src/main/ets/PushyFileJSBundleProvider'; +export type { NativeUpdateResult } from './src/main/ets/NativeUpdateResult'; diff --git a/harmony/pushy/src/main/ets/NativeCheckOrchestrator.ts b/harmony/pushy/src/main/ets/NativeCheckOrchestrator.ts index c3b796b6..210f4b63 100644 --- a/harmony/pushy/src/main/ets/NativeCheckOrchestrator.ts +++ b/harmony/pushy/src/main/ets/NativeCheckOrchestrator.ts @@ -3,6 +3,8 @@ import deviceInfo from '@ohos.deviceInfo'; import logger from './Logger'; import NativePatchCore from './NativePatchCore'; import type { UpdateContext } from './UpdateContext'; +import { NativeUpdateRound, nativeUpdateResult } from './NativeUpdateResult'; +import type { NativeUpdateResult } from './NativeUpdateResult'; import { isSafePathComponent } from './PathUtils'; import { monotonicNowMs } from './MonotonicClock'; import { @@ -106,6 +108,64 @@ interface RespCacheEntry { } let scheduled = false; +// The host and delayed check use one promise, including its settled result. +const hostRound = new NativeUpdateRound(); +let scheduledContext: UpdateContext | undefined; +let scheduledRollback = ''; +let roundGeneration = -1; +let roundConfigJson: string | undefined; +let roundResult = nativeUpdateResult('failed', 'check_failed'); + +function startNativeRound( + context: UpdateContext, + launchRolledBackVersion: string, +): Promise { + return hostRound.run(async () => { + try { + await runOnce(context, launchRolledBackVersion); + } catch (e) { + logger.error(TAG, `native check failed: ${getErrorMessage(e)}`); + roundResult = nativeUpdateResult('failed', 'internal_error'); + } + return roundResult; + }); +} + +export async function checkAndUpdateNative( + context: UpdateContext, +): Promise { + if (scheduledContext !== context) { + return nativeUpdateResult('skipped', 'not_initialized'); + } + const configJson = context.getKv(KEY_CONFIG); + if (!configJson) { + return nativeUpdateResult('skipped', 'not_configured'); + } + try { + const config = JSON.parse(configJson) as NativeConfig; + if (!config || typeof config !== 'object' || Array.isArray(config)) { + return nativeUpdateResult('failed', 'invalid_config'); + } + if (config.disabled) { + return nativeUpdateResult('skipped', 'disabled'); + } + if (typeof config.appKey !== 'string' || !config.appKey) { + return nativeUpdateResult('failed', 'invalid_config'); + } + } catch (e) { + return nativeUpdateResult('failed', 'invalid_config'); + } + const result = await startNativeRound(context, scheduledRollback); + if (roundGeneration !== context.getResetGeneration()) { + return nativeUpdateResult('cancelled', 'reset'); + } + if (configJson !== roundConfigJson || configJson !== context.getKv(KEY_CONFIG)) { + return nativeUpdateResult('skipped', 'config_changed'); + } + // Do not let a caller mutate the cached result observed by later callers. + return nativeUpdateResult(result.status, result.reason, result.hash, result.activated); +} + // JS 在本进程内已拿到有效检查响应时对应的配置 JSON(markJsCheckCompleted)。 // 进程级:下次启动无信号,冷启动轮次照常运行。 let jsCompletedConfig: string | undefined; @@ -131,6 +191,8 @@ export function scheduleNativeCheck( return; } scheduled = true; + scheduledContext = context; + scheduledRollback = launchRolledBackVersion; // 结果本来就是"下次启动生效",延迟几秒让开冷启动关键路径(§7 R5)—— // 除非上个进程死于轮中(残留标记),那时每一秒启动时间都要用来续传。 const delayMs = context.getKv(KEY_ROUND_INCOMPLETE) ? 0 : START_DELAY_MS; @@ -142,7 +204,7 @@ export function scheduleNativeCheck( ); return; } - runOnce(context, launchRolledBackVersion).catch((e: Object) => { + startNativeRound(context, launchRolledBackVersion).catch((e: Object) => { // 救援路径自身绝不能把应用拖垮。 logger.error(TAG, `native check failed: ${getErrorMessage(e)}`); }); @@ -156,22 +218,33 @@ async function runOnce( // 在任何 IO 之前采样:resetToPackagedBundle 会递增它,本轮运行期间发生的 // reset 必须赢过本轮的决策。 const resetGeneration = context.getResetGeneration(); + roundGeneration = resetGeneration; + roundResult = nativeUpdateResult('failed', 'check_failed'); const configJson = context.getKv(KEY_CONFIG); + roundConfigJson = configJson; if (!configJson) { - // 无落盘配置(老接入/首启):静默不跑——这就是灰度开关。 + // No persisted configuration: report the rollout gate to native callers. + roundResult = nativeUpdateResult('skipped', 'not_configured'); return; } let config: NativeConfig; try { config = JSON.parse(configJson) as NativeConfig; + if (!config || typeof config !== 'object' || Array.isArray(config)) { + roundResult = nativeUpdateResult('failed', 'invalid_config'); + return; + } } catch (e) { + roundResult = nativeUpdateResult('failed', 'invalid_config'); return; } if (config.disabled) { + roundResult = nativeUpdateResult('skipped', 'disabled'); return; } const appKey = config.appKey ?? ''; if (!appKey) { + roundResult = nativeUpdateResult('failed', 'invalid_config'); return; } // 从这里起本轮开始做真实工作:留下面包屑,死于轮中时下次启动零延迟续传。 @@ -267,6 +340,7 @@ async function runConfiguredRound( }; const body = NativePatchCore.buildCheckRequestBody(JSON.stringify(input)); if (!body) { + roundResult = nativeUpdateResult('failed', 'invalid_request'); return; } @@ -285,23 +359,28 @@ async function runConfiguredRound( config.afterDownload ?? '', ); if (!decisionJson) { + roundResult = nativeUpdateResult('failed', 'invalid_response'); return; } const decision = JSON.parse(decisionJson) as Decision; if (decision.action !== 'download') { - await context.commitNativeCheckResult( + const committed = await context.commitNativeCheckResult( resetGeneration, '', '', false, buildResponseCacheJson(configJson, body, responseText, responseAtSeconds), ); + roundResult = committed + ? nativeUpdateResult('noUpdate', decision.reason ?? '') + : nativeUpdateResult('cancelled', 'reset'); logger.info(TAG, `nothing to do (${decision.reason ?? ''})`); return; } const hash = decision.hash ?? ''; if (!isSafePathComponent(hash)) { logger.warn(TAG, 'decision carries an unsafe hash, ignoring'); + roundResult = nativeUpdateResult('failed', 'invalid_response'); return; } @@ -320,13 +399,16 @@ async function runConfiguredRound( } if (!downloaded) { logger.warn(TAG, `all download attempts for ${hash} failed`); - await context.commitNativeCheckResult( + const committed = await context.commitNativeCheckResult( resetGeneration, '', '', false, buildResponseCacheJson(configJson, body, responseText, responseAtSeconds), ); + roundResult = committed + ? nativeUpdateResult('failed', 'download_failed') + : nativeUpdateResult('cancelled', 'reset'); return; } @@ -368,6 +450,7 @@ async function runConfiguredRound( ); } catch (e) { logger.error(TAG, `commit failed: ${getErrorMessage(e)}`); + roundResult = nativeUpdateResult('failed', 'commit_failed'); return; } if (!committed) { @@ -377,6 +460,9 @@ async function runConfiguredRound( } else { logger.info(TAG, `downloaded ${hash}, activation left to JS`); } + roundResult = committed + ? nativeUpdateResult('downloaded', '', hash, activate) + : nativeUpdateResult('cancelled', 'reset'); } function buildResponseCacheJson( diff --git a/harmony/pushy/src/main/ets/PushyFileJSBundleProvider.ets b/harmony/pushy/src/main/ets/PushyFileJSBundleProvider.ets index 8392235d..bb24578d 100644 --- a/harmony/pushy/src/main/ets/PushyFileJSBundleProvider.ets +++ b/harmony/pushy/src/main/ets/PushyFileJSBundleProvider.ets @@ -6,6 +6,8 @@ import { import common from '@ohos.app.ability.common'; import fs from '@ohos.file.fs'; import { UpdateContext } from './UpdateContext'; +import { checkAndUpdateNative } from './NativeCheckOrchestrator'; +import type { NativeUpdateResult } from './NativeUpdateResult'; export class PushyFileJSBundleProvider extends JSBundleProvider { private updateContext: UpdateContext; @@ -43,6 +45,11 @@ export class PushyFileJSBundleProvider extends JSBundleProvider { } } + /** Call after the host's real bundle resolution; never resolves it again. */ + checkAndUpdate(): Promise { + return checkAndUpdateNative(this.updateContext); + } + getAppKeys(): string[] { return []; } diff --git a/ios/RCTPushy/RCTPushy.mm b/ios/RCTPushy/RCTPushy.mm index 291a8bb4..2c7b907c 100644 --- a/ios/RCTPushy/RCTPushy.mm +++ b/ios/RCTPushy/RCTPushy.mm @@ -36,6 +36,13 @@ #include #include +// Immutable host-facing snapshot; activated always means NEXT launch. +static NSDictionary *PushyHostResult(NSString *status, NSString *reason, + NSString *hash, BOOL activated) { + return @{@"status": status, @"reason": reason ?: @"", + @"hash": hash ?: @"", @"activated": @(activated)}; +} + static NSString *const keyPushyInfo = @"REACTNATIVECN_PUSHY_INFO_KEY"; // Binary identity (SyncBinaryVersion input). Prefixed like every other key: // the unprefixed names collided with generic host-app/SDK defaults, and a @@ -720,6 +727,7 @@ + (NSString *)buildTime; // bundle — this is what lets a bricked hot update be replaced on the next // launch. Decisions come from cpp/update_flow_core; this class is IO glue. @interface RCTPushyOrchestrator : NSObject ++ (NSDictionary *)checkAndUpdate; + (void)scheduleFromColdStart:(NSString *)launchRolledBackVersion; + (void)markJsCheckCompleted:(NSString *)config; + (void)startRoundWithDeadline:(NSTimeInterval)deadlineUptime; @@ -761,6 +769,12 @@ + (BOOL)commitRoundWithGeneration:(uint64_t)generation // (markJsCheckCompleted). Process-scoped by design. Guarded by // @synchronized (RCTPushyOrchestrator class). static NSString *pushyJsCompletedConfig = nil; +// The group supplements (rather than consumes) the crash-rescue semaphore. +static dispatch_group_t pushyHostRoundGroup; +static std::atomic pushyNativeCheckReady{false}; +static NSDictionary *pushyHostRoundResult = nil; +static NSString *pushyHostRoundConfig = nil; +static uint64_t pushyHostRoundGeneration = 0; static const NSTimeInterval kPushyRescueTriggerUptime = 60; static const NSTimeInterval kPushyRescueBudgetBackgroundThread = 10; @@ -1008,6 +1022,30 @@ + (NSString *) rollback { return currentVersion; } ++ (void)checkAndUpdateWithCompletion:(RCTPushyNativeUpdateCompletion)completion +{ + static dispatch_queue_t hostQueue; + static dispatch_once_t once; + dispatch_once(&once, ^{ + hostQueue = dispatch_queue_create("cn.reactnative.pushy.host-check", DISPATCH_QUEUE_SERIAL); + }); + dispatch_async(hostQueue, ^{ + NSDictionary *result; + @try { + result = [RCTPushyOrchestrator checkAndUpdate]; + } @catch (NSException *exception) { + RCTLogWarn(@"RCTPushy -- native host check failed: %@", exception.reason); + result = PushyHostResult(@"failed", @"internal_error", nil, NO); + } + if (completion != nil) { + NSDictionary *snapshot = [result copy]; + dispatch_async(dispatch_get_main_queue(), ^{ + completion(snapshot); + }); + } + }); +} + + (BOOL)requiresMainQueueSetup { return NO; @@ -2147,8 +2185,11 @@ + (void)scheduleFromColdStart:(NSString *)launchRolledBackVersion { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ pushyRoundDone = dispatch_semaphore_create(0); + pushyHostRoundGroup = dispatch_group_create(); + dispatch_group_enter(pushyHostRoundGroup); pushyProcessAnchorUptime = PushyMonotonicNow(); pushyLaunchRolledBackForRescue = [launchRolledBackVersion copy]; + pushyNativeCheckReady.store(true); NSUserDefaults *defaults = PushyDefaults(); // The crash-hold rescue shares the orchestrator's rollout gate: no // persisted config, no handler (§11.3). @@ -2171,6 +2212,45 @@ + (void)scheduleFromColdStart:(NSString *)launchRolledBackVersion { #endif } ++ (NSDictionary *)checkAndUpdate { +#if DEBUG + return PushyHostResult(@"skipped", @"debug", nil, NO); +#else + if (!pushyNativeCheckReady.load()) { + return PushyHostResult(@"skipped", @"not_initialized", nil, NO); + } + NSString *configJson = [PushyDefaults() stringForKey:keyNativeConfig]; + if (configJson.length == 0) { + return PushyHostResult(@"skipped", @"not_configured", nil, NO); + } + id config = [NSJSONSerialization JSONObjectWithData: + [configJson dataUsingEncoding:NSUTF8StringEncoding] options:0 error:nil]; + if (![config isKindOfClass:NSDictionary.class]) { + return PushyHostResult(@"failed", @"invalid_config", nil, NO); + } + id disabled = config[@"disabled"]; + if ([disabled respondsToSelector:@selector(boolValue)] && [disabled boolValue]) { + return PushyHostResult(@"skipped", @"disabled", nil, NO); + } + id appKey = config[@"appKey"]; + if (![appKey isKindOfClass:NSString.class] || [appKey length] == 0) { + return PushyHostResult(@"failed", @"invalid_config", nil, NO); + } + [self startRoundWithDeadline:0]; + // A group is broadcast-style. Sharing the rescue semaphore would let one + // waiter consume the only signal and leave the other waiting forever. + dispatch_group_wait(pushyHostRoundGroup, DISPATCH_TIME_FOREVER); + if (pushyHostRoundGeneration != pushyResetGeneration.load()) { + return PushyHostResult(@"cancelled", @"reset", nil, NO); + } + if (![configJson isEqualToString:pushyHostRoundConfig] + || ![configJson isEqualToString:[PushyDefaults() stringForKey:keyNativeConfig]]) { + return PushyHostResult(@"skipped", @"config_changed", nil, NO); + } + return pushyHostRoundResult ?: PushyHostResult(@"failed", @"internal_error", nil, NO); +#endif +} + + (void)markJsCheckCompleted:(NSString *)config { @synchronized (RCTPushyOrchestrator.class) { pushyJsCompletedConfig = [config copy]; @@ -2206,9 +2286,11 @@ + (void)startRoundWithDeadline:(NSTimeInterval)deadlineUptime { } @catch (NSException *exception) { // The rescue path must never take the app down with it. RCTLogWarn(@"RCTPushy -- native check crashed: %@", exception.reason); + pushyHostRoundResult = PushyHostResult(@"failed", @"internal_error", nil, NO); } @finally { pushyRoundCompleted.store(true); dispatch_semaphore_signal(pushyRoundDone); + dispatch_group_leave(pushyHostRoundGroup); } } @@ -2292,27 +2374,32 @@ + (RCTPushy *)engine { } + (void)runOnce:(NSString *)launchRolledBackVersion deadline:(NSTimeInterval)deadlineUptime { - // Sampled before any IO: resetToPackagedBundle bumps it, and a reset that - // lands while this round is running must win over the round's decision. const uint64_t resetGeneration = pushyResetGeneration.load(); + pushyHostRoundGeneration = resetGeneration; + pushyHostRoundResult = PushyHostResult(@"failed", @"check_failed", nil, NO); NSUserDefaults *defaults = PushyDefaults(); NSString *configJson = [defaults stringForKey:keyNativeConfig]; + pushyHostRoundConfig = [configJson copy]; if (configJson.length == 0) { - // No persisted config (old integration / first ever launch): the - // native check silently does not run — this is the rollout gate. + pushyHostRoundResult = PushyHostResult(@"skipped", @"not_configured", nil, NO); return; } bool ok = false; flowjson::Value config = flowjson::Parse(PushyToStdString(configJson), &ok); - if (!ok || !config.IsObject() || config.Get("disabled").Truthy()) { + if (!ok || !config.IsObject()) { + pushyHostRoundResult = PushyHostResult(@"failed", @"invalid_config", nil, NO); + return; + } + if (config.Get("disabled").Truthy()) { + pushyHostRoundResult = PushyHostResult(@"skipped", @"disabled", nil, NO); return; } NSString *appKey = PushyFromStdString(config.Get("appKey").AsString()); if (appKey.length == 0) { + pushyHostRoundResult = PushyHostResult(@"failed", @"invalid_config", nil, NO); return; } - // From here on the round does real work: leave the breadcrumb that the - // next launch reads to skip its 5s delay if we die mid-round (§11.4). + // Preserve the interrupted-round breadcrumb and reset-safe atomic commit. [defaults setObject:@YES forKey:keyNativeCheckIncomplete]; @try { [self runConfiguredRound:config @@ -2388,6 +2475,7 @@ + (void)runConfiguredRound:(const flowjson::Value &)config NSString *body = [NSString stringWithUTF8String:bodyJson.c_str()]; if (body == nil) { RCTLogWarn(@"RCTPushy -- native check: request body is not valid UTF-8"); + pushyHostRoundResult = PushyHostResult(@"failed", @"invalid_request", nil, NO); return; } @@ -2405,19 +2493,23 @@ + (void)runConfiguredRound:(const flowjson::Value &)config PushyToStdString(responseText), identity, false, config.Get("afterDownload").AsString()); if (decision.Get("action").AsString() != "download") { - [self commitRoundWithGeneration:resetGeneration + BOOL committed = [self commitRoundWithGeneration:resetGeneration hashInfo:nil activate:nil responseText:responseText request:body config:configJson responseAt:responseAtSeconds]; + pushyHostRoundResult = committed + ? PushyHostResult(@"noUpdate", PushyFromStdString(decision.Get("reason").AsString()), nil, NO) + : PushyHostResult(@"cancelled", @"reset", nil, NO); RCTLogInfo(@"RCTPushy -- native check: nothing to do (%s)", decision.Get("reason").AsString().c_str()); return; } NSString *hash = PushyFromStdString(decision.Get("hash").AsString()); if (!PushyIsSafePathComponent(hash)) { + pushyHostRoundResult = PushyHostResult(@"failed", @"invalid_response", nil, NO); return; } @@ -2430,13 +2522,16 @@ + (void)runConfiguredRound:(const flowjson::Value &)config deadline:deadlineUptime]; } if (!downloaded) { - [self commitRoundWithGeneration:resetGeneration + BOOL committed = [self commitRoundWithGeneration:resetGeneration hashInfo:nil activate:nil responseText:responseText request:body config:configJson responseAt:responseAtSeconds]; + pushyHostRoundResult = committed + ? PushyHostResult(@"failed", @"download_failed", nil, NO) + : PushyHostResult(@"cancelled", @"reset", nil, NO); return; } @@ -2488,6 +2583,9 @@ + (void)runConfiguredRound:(const flowjson::Value &)config } RCTLogInfo(@"RCTPushy -- native check: downloaded %@, activation left to JS", hash); } + pushyHostRoundResult = committed + ? PushyHostResult(@"downloaded", @"", hash, activate) + : PushyHostResult(@"cancelled", @"reset", nil, NO); } // Everything a round persists — version info, the activation, the response From 9dd20c43eaeb7a3578aff231cf97d6828d05ccec Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Sat, 19 Sep 2026 09:45:51 +0800 Subject: [PATCH 08/24] test: cover native round deduplication and result snapshots --- src/__tests__/nativeUpdateRound.test.ts | 87 +++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 src/__tests__/nativeUpdateRound.test.ts diff --git a/src/__tests__/nativeUpdateRound.test.ts b/src/__tests__/nativeUpdateRound.test.ts new file mode 100644 index 00000000..6747e95d --- /dev/null +++ b/src/__tests__/nativeUpdateRound.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from 'bun:test'; +import { + NativeUpdateRound, + nativeUpdateResult, +} from '../../harmony/pushy/src/main/ets/NativeUpdateResult'; +import type { NativeUpdateResult } from '../../harmony/pushy/src/main/ets/NativeUpdateResult'; + +describe('native host update round', () => { + test('concurrent callers share the same in-flight operation', async () => { + const round = new NativeUpdateRound(); + let calls = 0; + let complete: (result: NativeUpdateResult) => void = () => {}; + const operation = () => { + calls += 1; + return new Promise((resolve) => { + complete = resolve; + }); + }; + const first = round.run(operation); + const second = round.run(operation); + expect(second).toBe(first); + await Promise.resolve(); + expect(calls).toBe(1); + const result = nativeUpdateResult('downloaded', '', 'version-1', true); + complete(result); + expect(await first).toEqual(result); + expect(await second).toEqual(result); + expect(await round.run(operation)).toEqual(result); + expect(calls).toBe(1); + }); + + test('the promise is published before a reentrant caller runs', async () => { + const round = new NativeUpdateRound(); + let nested: Promise | undefined; + const first = round.run(async () => { + nested = round.run(async () => { + throw new Error('a second operation must not execute'); + }); + return nativeUpdateResult('noUpdate', 'up_to_date'); + }); + await first; + expect(nested).toBe(first); + }); + + test('a failed round is reused rather than causing a retry storm', async () => { + const round = new NativeUpdateRound(); + let calls = 0; + const operation = async () => { + calls += 1; + return nativeUpdateResult('failed', 'download_failed'); + }; + expect((await round.run(operation)).status).toBe('failed'); + expect((await round.run(operation)).reason).toBe('download_failed'); + expect(calls).toBe(1); + }); + + test('unexpected rejection also cannot start a second round', async () => { + const round = new NativeUpdateRound(); + let calls = 0; + const operation = async (): Promise => { + calls += 1; + throw new Error('transport unavailable'); + }; + const first = round.run(operation); + await expect(first).rejects.toThrow('transport unavailable'); + expect(round.run(operation)).toBe(first); + expect(calls).toBe(1); + }); + + test('download and activation are separate facts', () => { + expect(nativeUpdateResult('downloaded', '', 'version-1')).toEqual({ + status: 'downloaded', + reason: '', + hash: 'version-1', + activated: false, + }); + expect(nativeUpdateResult('downloaded', '', 'version-1', true).activated).toBe( + true, + ); + expect(nativeUpdateResult('skipped', 'not_configured')).toEqual({ + status: 'skipped', + reason: 'not_configured', + hash: '', + activated: false, + }); + }); +}); From a4acb137b29bf812da63d88646ac030970ad3843 Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Sat, 19 Sep 2026 09:46:18 +0800 Subject: [PATCH 09/24] chore: remove completed one-off source preparation workflow --- .github/workflows/native-host-api-prepare.yml | 25 ------------------- 1 file changed, 25 deletions(-) delete mode 100644 .github/workflows/native-host-api-prepare.yml diff --git a/.github/workflows/native-host-api-prepare.yml b/.github/workflows/native-host-api-prepare.yml deleted file mode 100644 index 88c4c008..00000000 --- a/.github/workflows/native-host-api-prepare.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Prepare native host API -on: - push: - branches: [feat/native-host-update-api] - paths: [.github/workflows/native-host-api-prepare.yml] -permissions: - contents: write -jobs: - apply: - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - uses: actions/checkout@v4 - - name: Apply source edits - run: | - python3 scripts/add-native-host-api.py - git diff --check - git diff --stat - - name: Commit only the native API implementation - run: | - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add android/src/main/java/cn/reactnative/modules/update/NativeCheckOrchestrator.java ios/RCTPushy/RCTPushy.mm harmony/pushy/src/main/ets/NativeCheckOrchestrator.ts harmony/pushy/src/main/ets/PushyFileJSBundleProvider.ets harmony/pushy/index.ets - git commit -m 'feat: connect native host APIs to the shared update round' - git push origin HEAD:refs/heads/feat/native-host-update-api From d496e48127c773011c635b79ec878370185a9c1e Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Sat, 19 Sep 2026 09:46:27 +0800 Subject: [PATCH 10/24] chore: remove applied native API transformation script --- scripts/add-native-host-api.py | 499 --------------------------------- 1 file changed, 499 deletions(-) delete mode 100644 scripts/add-native-host-api.py diff --git a/scripts/add-native-host-api.py b/scripts/add-native-host-api.py deleted file mode 100644 index ec7dead2..00000000 --- a/scripts/add-native-host-api.py +++ /dev/null @@ -1,499 +0,0 @@ -from pathlib import Path - -ROOT = Path.cwd() -PENDING = [] - -def replace(text, old, new): - count = text.count(old) - if count != 1: - raise RuntimeError(f'Expected one match, found {count}: {old[:100]!r}') - return text.replace(old, new, 1) - -def region(text, start, end, transform): - a = text.index(start) - b = text.index(end, a + len(start)) - return text[:a] + transform(text[a:b]) + text[b:] - -def edit(path, transform): - target = ROOT / path - before = target.read_text() - after = transform(before) - if before == after: - raise RuntimeError(f'No changes: {path}') - PENDING.append((target, after)) - -A = 'android/src/main/java/cn/reactnative/modules/update/NativeCheckOrchestrator.java' -I = 'ios/RCTPushy/RCTPushy.mm' -H = 'harmony/pushy/src/main/ets/NativeCheckOrchestrator.ts' - -ANDROID_HOST = ''' /** Blocking only on the host API's worker; never call on the UI thread. */ - static NativeUpdateResult checkAndUpdate(UpdateContext context) throws InterruptedException { - if (UpdateContext.DEBUG) { - return NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "debug"); - } - if (!nativeReady || sContext != context || !context.getIsUsingBundleUrl()) { - return NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "not_initialized"); - } - String configJson = context.getKv(KEY_CONFIG); - if (configJson == null || configJson.isEmpty()) { - return NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "not_configured"); - } - try { - JSONObject config = new JSONObject(configJson); - if (config.optBoolean("disabled", false)) { - return NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "disabled"); - } - if (config.optString("appKey", "").isEmpty()) { - return NativeUpdateResult.of(NativeUpdateResult.FAILED, "invalid_config"); - } - } catch (JSONException e) { - return NativeUpdateResult.of(NativeUpdateResult.FAILED, "invalid_config"); - } - startRound(0); - roundDone.await(); - if (roundGeneration != UpdateContext.getResetGeneration()) { - return NativeUpdateResult.of(NativeUpdateResult.CANCELLED, "reset"); - } - if (!configJson.equals(roundConfigJson) || !configJson.equals(context.getKv(KEY_CONFIG))) { - return NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "config_changed"); - } - return roundResult; - } - -''' - -ANDROID_ONCE = ''' private static void runOnce( - UpdateContext context, - String launchRolledBackVersion, - long deadlineNanos - ) throws JSONException { - final long resetGeneration = UpdateContext.getResetGeneration(); - roundGeneration = resetGeneration; - roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "check_failed"); - String configJson = context.getKv(KEY_CONFIG); - roundConfigJson = configJson; - if (configJson == null || configJson.isEmpty()) { - roundResult = NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "not_configured"); - return; - } - JSONObject config; - try { - config = new JSONObject(configJson); - } catch (JSONException e) { - roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "invalid_config"); - return; - } - if (config.optBoolean("disabled", false)) { - roundResult = NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "disabled"); - return; - } - String appKey = config.optString("appKey", ""); - if (appKey.isEmpty()) { - roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "invalid_config"); - return; - } - // Keep the existing interrupted-round breadcrumb and reset generation. - try { - context.setKv(KEY_ROUND_INCOMPLETE, "1"); - } catch (IllegalStateException ignored) { - } - try { - runConfiguredRound( - context, launchRolledBackVersion, deadlineNanos, - resetGeneration, configJson, config, appKey); - } finally { - try { - context.removeKv(KEY_ROUND_INCOMPLETE); - } catch (IllegalStateException ignored) { - } - } - } - -''' - -def android_configured(s): - s = replace(s, 'if (body == null) {\n return;', 'if (body == null) {\n roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "invalid_request");\n return;') - s = replace(s, 'if (decisionJson == null) {\n return;', 'if (decisionJson == null) {\n roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "invalid_response");\n return;') - s = replace(s, ''' context.commitNativeCheckResult( - resetGeneration, null, null, false, - buildResponseCacheJson(configJson, body, responseText, responseAtSeconds)); - Log.i(UpdateContext.TAG, - "native check: nothing to do (" + decision.optString("reason") + ")");''', ''' boolean committed = context.commitNativeCheckResult( - resetGeneration, null, null, false, - buildResponseCacheJson(configJson, body, responseText, responseAtSeconds)); - roundResult = committed - ? NativeUpdateResult.of(NativeUpdateResult.NO_UPDATE, decision.optString("reason")) - : NativeUpdateResult.of(NativeUpdateResult.CANCELLED, "reset"); - Log.i(UpdateContext.TAG, - "native check: nothing to do (" + decision.optString("reason") + ")");''') - s = replace(s, '''if (!UpdateFileUtils.isSafePathComponent(hash)) { - return;''', '''if (!UpdateFileUtils.isSafePathComponent(hash)) { - roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "invalid_response"); - return;''') - s = replace(s, ''' context.commitNativeCheckResult( - resetGeneration, null, null, false, - buildResponseCacheJson(configJson, body, responseText, responseAtSeconds)); - return;''', ''' boolean committed = context.commitNativeCheckResult( - resetGeneration, null, null, false, - buildResponseCacheJson(configJson, body, responseText, responseAtSeconds)); - roundResult = NativeUpdateResult.of( - committed ? NativeUpdateResult.FAILED : NativeUpdateResult.CANCELLED, - committed ? "download_failed" : "reset"); - return;''') - s = replace(s, 'Log.w(UpdateContext.TAG, "native check: commit failed: " + e);\n return;', 'Log.w(UpdateContext.TAG, "native check: commit failed: " + e);\n roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "commit_failed");\n return;') - s = replace(s, ''' "native check: downloaded " + hash + ", activation left to JS"); - } - } -''', ''' "native check: downloaded " + hash + ", activation left to JS"); - } - roundResult = committed - ? NativeUpdateResult.downloaded(hash, activate) - : NativeUpdateResult.of(NativeUpdateResult.CANCELLED, "reset"); - } -''') - return s - -def android(s): - s = replace(s, ' private static volatile String sJsCompletedConfig;\n', ''' private static volatile String sJsCompletedConfig; - // Published after the launch rollback snapshot, before host calls are accepted. - private static volatile boolean nativeReady; - private static volatile NativeUpdateResult roundResult = - NativeUpdateResult.of(NativeUpdateResult.FAILED, "check_failed"); - private static volatile long roundGeneration = -1; - private static volatile String roundConfigJson; - -''' + ANDROID_HOST) - s = replace(s, ' sLaunchRolledBackVersion = launchRolledBackVersion;\n', ' sLaunchRolledBackVersion = launchRolledBackVersion;\n nativeReady = true;\n') - s = region(s, ' private static void startRound(', ' static void runRescue(', lambda t: replace(t, - ' Log.w(UpdateContext.TAG, "native check failed: " + e);', - ' Log.w(UpdateContext.TAG, "native check failed: " + e);\n roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "internal_error");')) - s = region(s, ' private static void runOnce(', ' private static void runConfiguredRound(', lambda _: ANDROID_ONCE) - return region(s, ' private static void runConfiguredRound(', ' private static String buildResponseCacheJson(', android_configured) - -IOS_HOST = '''+ (NSDictionary *)checkAndUpdate { -#if DEBUG - return PushyHostResult(@"skipped", @"debug", nil, NO); -#else - if (!pushyNativeCheckReady.load()) { - return PushyHostResult(@"skipped", @"not_initialized", nil, NO); - } - NSString *configJson = [PushyDefaults() stringForKey:keyNativeConfig]; - if (configJson.length == 0) { - return PushyHostResult(@"skipped", @"not_configured", nil, NO); - } - id config = [NSJSONSerialization JSONObjectWithData: - [configJson dataUsingEncoding:NSUTF8StringEncoding] options:0 error:nil]; - if (![config isKindOfClass:NSDictionary.class]) { - return PushyHostResult(@"failed", @"invalid_config", nil, NO); - } - id disabled = config[@"disabled"]; - if ([disabled respondsToSelector:@selector(boolValue)] && [disabled boolValue]) { - return PushyHostResult(@"skipped", @"disabled", nil, NO); - } - id appKey = config[@"appKey"]; - if (![appKey isKindOfClass:NSString.class] || [appKey length] == 0) { - return PushyHostResult(@"failed", @"invalid_config", nil, NO); - } - [self startRoundWithDeadline:0]; - // A group is broadcast-style. Sharing the rescue semaphore would let one - // waiter consume the only signal and leave the other waiting forever. - dispatch_group_wait(pushyHostRoundGroup, DISPATCH_TIME_FOREVER); - if (pushyHostRoundGeneration != pushyResetGeneration.load()) { - return PushyHostResult(@"cancelled", @"reset", nil, NO); - } - if (![configJson isEqualToString:pushyHostRoundConfig] - || ![configJson isEqualToString:[PushyDefaults() stringForKey:keyNativeConfig]]) { - return PushyHostResult(@"skipped", @"config_changed", nil, NO); - } - return pushyHostRoundResult ?: PushyHostResult(@"failed", @"internal_error", nil, NO); -#endif -} - -''' - -IOS_PUBLIC = '''+ (void)checkAndUpdateWithCompletion:(RCTPushyNativeUpdateCompletion)completion -{ - static dispatch_queue_t hostQueue; - static dispatch_once_t once; - dispatch_once(&once, ^{ - hostQueue = dispatch_queue_create("cn.reactnative.pushy.host-check", DISPATCH_QUEUE_SERIAL); - }); - dispatch_async(hostQueue, ^{ - NSDictionary *result; - @try { - result = [RCTPushyOrchestrator checkAndUpdate]; - } @catch (NSException *exception) { - RCTLogWarn(@"RCTPushy -- native host check failed: %@", exception.reason); - result = PushyHostResult(@"failed", @"internal_error", nil, NO); - } - if (completion != nil) { - NSDictionary *snapshot = [result copy]; - dispatch_async(dispatch_get_main_queue(), ^{ - completion(snapshot); - }); - } - }); -} - -''' - -IOS_ONCE = '''+ (void)runOnce:(NSString *)launchRolledBackVersion deadline:(NSTimeInterval)deadlineUptime { - const uint64_t resetGeneration = pushyResetGeneration.load(); - pushyHostRoundGeneration = resetGeneration; - pushyHostRoundResult = PushyHostResult(@"failed", @"check_failed", nil, NO); - NSUserDefaults *defaults = PushyDefaults(); - NSString *configJson = [defaults stringForKey:keyNativeConfig]; - pushyHostRoundConfig = [configJson copy]; - if (configJson.length == 0) { - pushyHostRoundResult = PushyHostResult(@"skipped", @"not_configured", nil, NO); - return; - } - bool ok = false; - flowjson::Value config = flowjson::Parse(PushyToStdString(configJson), &ok); - if (!ok || !config.IsObject()) { - pushyHostRoundResult = PushyHostResult(@"failed", @"invalid_config", nil, NO); - return; - } - if (config.Get("disabled").Truthy()) { - pushyHostRoundResult = PushyHostResult(@"skipped", @"disabled", nil, NO); - return; - } - NSString *appKey = PushyFromStdString(config.Get("appKey").AsString()); - if (appKey.length == 0) { - pushyHostRoundResult = PushyHostResult(@"failed", @"invalid_config", nil, NO); - return; - } - // Preserve the interrupted-round breadcrumb and reset-safe atomic commit. - [defaults setObject:@YES forKey:keyNativeCheckIncomplete]; - @try { - [self runConfiguredRound:config - configJson:configJson - appKey:appKey - launchRolledBackVersion:launchRolledBackVersion - resetGeneration:resetGeneration - deadline:deadlineUptime]; - } @finally { - [defaults removeObjectForKey:keyNativeCheckIncomplete]; - } -} - -''' - -def ios_configured(s): - s = replace(s, 'RCTLogWarn(@"RCTPushy -- native check: request body is not valid UTF-8");\n return;', 'RCTLogWarn(@"RCTPushy -- native check: request body is not valid UTF-8");\n pushyHostRoundResult = PushyHostResult(@"failed", @"invalid_request", nil, NO);\n return;') - s = replace(s, ''' [self commitRoundWithGeneration:resetGeneration - hashInfo:nil - activate:nil - responseText:responseText - request:body - config:configJson - responseAt:responseAtSeconds]; - RCTLogInfo(@"RCTPushy -- native check: nothing to do (%s)",''', ''' BOOL committed = [self commitRoundWithGeneration:resetGeneration - hashInfo:nil - activate:nil - responseText:responseText - request:body - config:configJson - responseAt:responseAtSeconds]; - pushyHostRoundResult = committed - ? PushyHostResult(@"noUpdate", PushyFromStdString(decision.Get("reason").AsString()), nil, NO) - : PushyHostResult(@"cancelled", @"reset", nil, NO); - RCTLogInfo(@"RCTPushy -- native check: nothing to do (%s)",''') - s = replace(s, 'if (!PushyIsSafePathComponent(hash)) {\n return;', 'if (!PushyIsSafePathComponent(hash)) {\n pushyHostRoundResult = PushyHostResult(@"failed", @"invalid_response", nil, NO);\n return;') - s = replace(s, ''' [self commitRoundWithGeneration:resetGeneration - hashInfo:nil - activate:nil - responseText:responseText - request:body - config:configJson - responseAt:responseAtSeconds]; - return;''', ''' BOOL committed = [self commitRoundWithGeneration:resetGeneration - hashInfo:nil - activate:nil - responseText:responseText - request:body - config:configJson - responseAt:responseAtSeconds]; - pushyHostRoundResult = committed - ? PushyHostResult(@"failed", @"download_failed", nil, NO) - : PushyHostResult(@"cancelled", @"reset", nil, NO); - return;''') - s = replace(s, ''' RCTLogInfo(@"RCTPushy -- native check: downloaded %@, activation left to JS", hash); - } -} -''', ''' RCTLogInfo(@"RCTPushy -- native check: downloaded %@, activation left to JS", hash); - } - pushyHostRoundResult = committed - ? PushyHostResult(@"downloaded", @"", hash, activate) - : PushyHostResult(@"cancelled", @"reset", nil, NO); -} -''') - return s - -def ios(s): - s = replace(s, '#include \n', '''#include - -// Immutable host-facing snapshot; activated always means NEXT launch. -static NSDictionary *PushyHostResult(NSString *status, NSString *reason, - NSString *hash, BOOL activated) { - return @{@"status": status, @"reason": reason ?: @"", - @"hash": hash ?: @"", @"activated": @(activated)}; -} -''') - s = replace(s, '@interface RCTPushyOrchestrator : NSObject\n', '@interface RCTPushyOrchestrator : NSObject\n+ (NSDictionary *)checkAndUpdate;\n') - s = replace(s, 'static NSString *pushyJsCompletedConfig = nil;\n', '''static NSString *pushyJsCompletedConfig = nil; -// The group supplements (rather than consumes) the crash-rescue semaphore. -static dispatch_group_t pushyHostRoundGroup; -static std::atomic pushyNativeCheckReady{false}; -static NSDictionary *pushyHostRoundResult = nil; -static NSString *pushyHostRoundConfig = nil; -static uint64_t pushyHostRoundGeneration = 0; -''') - s = replace(s, '+ (BOOL)requiresMainQueueSetup\n', IOS_PUBLIC + '+ (BOOL)requiresMainQueueSetup\n') - s = replace(s, ' pushyRoundDone = dispatch_semaphore_create(0);\n', ' pushyRoundDone = dispatch_semaphore_create(0);\n pushyHostRoundGroup = dispatch_group_create();\n dispatch_group_enter(pushyHostRoundGroup);\n') - s = replace(s, ' pushyLaunchRolledBackForRescue = [launchRolledBackVersion copy];\n', ' pushyLaunchRolledBackForRescue = [launchRolledBackVersion copy];\n pushyNativeCheckReady.store(true);\n') - s = replace(s, '+ (void)markJsCheckCompleted:(NSString *)config {\n', IOS_HOST + '+ (void)markJsCheckCompleted:(NSString *)config {\n') - s = region(s, '+ (void)startRoundWithDeadline:(NSTimeInterval)deadlineUptime {', '+ (void)runRescueWithDeadline:(NSTimeInterval)deadlineUptime {', lambda t: replace(replace(t, - ' RCTLogWarn(@"RCTPushy -- native check crashed: %@", exception.reason);', - ' RCTLogWarn(@"RCTPushy -- native check crashed: %@", exception.reason);\n pushyHostRoundResult = PushyHostResult(@"failed", @"internal_error", nil, NO);'), - ' dispatch_semaphore_signal(pushyRoundDone);', - ' dispatch_semaphore_signal(pushyRoundDone);\n dispatch_group_leave(pushyHostRoundGroup);')) - s = region(s, '+ (void)runOnce:(NSString *)launchRolledBackVersion deadline:(NSTimeInterval)deadlineUptime {', '+ (void)runConfiguredRound:', lambda _: IOS_ONCE) - return region(s, '+ (void)runConfiguredRound:', '+ (BOOL)commitRoundWithGeneration:(uint64_t)generation\n', ios_configured) - -HARMONY_HOST = '''// The host and delayed check use one promise, including its settled result. -const hostRound = new NativeUpdateRound(); -let scheduledContext: UpdateContext | undefined; -let scheduledRollback = ''; -let roundGeneration = -1; -let roundConfigJson: string | undefined; -let roundResult = nativeUpdateResult('failed', 'check_failed'); - -function startNativeRound( - context: UpdateContext, - launchRolledBackVersion: string, -): Promise { - return hostRound.run(async () => { - try { - await runOnce(context, launchRolledBackVersion); - } catch (e) { - logger.error(TAG, `native check failed: ${getErrorMessage(e)}`); - roundResult = nativeUpdateResult('failed', 'internal_error'); - } - return roundResult; - }); -} - -export async function checkAndUpdateNative( - context: UpdateContext, -): Promise { - if (scheduledContext !== context) { - return nativeUpdateResult('skipped', 'not_initialized'); - } - const configJson = context.getKv(KEY_CONFIG); - if (!configJson) { - return nativeUpdateResult('skipped', 'not_configured'); - } - try { - const config = JSON.parse(configJson) as NativeConfig; - if (!config || typeof config !== 'object' || Array.isArray(config)) { - return nativeUpdateResult('failed', 'invalid_config'); - } - if (config.disabled) { - return nativeUpdateResult('skipped', 'disabled'); - } - if (typeof config.appKey !== 'string' || !config.appKey) { - return nativeUpdateResult('failed', 'invalid_config'); - } - } catch (e) { - return nativeUpdateResult('failed', 'invalid_config'); - } - const result = await startNativeRound(context, scheduledRollback); - if (roundGeneration !== context.getResetGeneration()) { - return nativeUpdateResult('cancelled', 'reset'); - } - if (configJson !== roundConfigJson || configJson !== context.getKv(KEY_CONFIG)) { - return nativeUpdateResult('skipped', 'config_changed'); - } - // Do not let a caller mutate the cached result observed by later callers. - return nativeUpdateResult(result.status, result.reason, result.hash, result.activated); -} - -''' - -def harmony_once(s): - s = replace(s, ' const resetGeneration = context.getResetGeneration();\n const configJson = context.getKv(KEY_CONFIG);', ''' const resetGeneration = context.getResetGeneration(); - roundGeneration = resetGeneration; - roundResult = nativeUpdateResult('failed', 'check_failed'); - const configJson = context.getKv(KEY_CONFIG); - roundConfigJson = configJson;''') - s = replace(s, ' // 无落盘配置(老接入/首启):静默不跑——这就是灰度开关。\n return;', " // No persisted configuration: report the rollout gate to native callers.\n roundResult = nativeUpdateResult('skipped', 'not_configured');\n return;") - s = replace(s, ''' config = JSON.parse(configJson) as NativeConfig; - } catch (e) { - return;''', ''' config = JSON.parse(configJson) as NativeConfig; - if (!config || typeof config !== 'object' || Array.isArray(config)) { - roundResult = nativeUpdateResult('failed', 'invalid_config'); - return; - } - } catch (e) { - roundResult = nativeUpdateResult('failed', 'invalid_config'); - return;''') - s = replace(s, ' if (config.disabled) {\n return;', " if (config.disabled) {\n roundResult = nativeUpdateResult('skipped', 'disabled');\n return;") - s = replace(s, ' if (!appKey) {\n return;', " if (!appKey) {\n roundResult = nativeUpdateResult('failed', 'invalid_config');\n return;") - return s - -def harmony_configured(s): - s = replace(s, ' if (!body) {\n return;', " if (!body) {\n roundResult = nativeUpdateResult('failed', 'invalid_request');\n return;") - s = replace(s, ' if (!decisionJson) {\n return;', " if (!decisionJson) {\n roundResult = nativeUpdateResult('failed', 'invalid_response');\n return;") - old = ''' await context.commitNativeCheckResult( - resetGeneration, - '', - '', - false, - buildResponseCacheJson(configJson, body, responseText, responseAtSeconds), - );''' - a = s.index(" if (decision.action !== 'download') {") - b = s.index(" const hash = decision.hash ?? '';", a) - piece = replace(s[a:b], old, old.replace(' await context.', ' const committed = await context.') + "\n roundResult = committed\n ? nativeUpdateResult('noUpdate', decision.reason ?? '')\n : nativeUpdateResult('cancelled', 'reset');") - s = s[:a] + piece + s[b:] - s = replace(s, " logger.warn(TAG, 'decision carries an unsafe hash, ignoring');\n return;", " logger.warn(TAG, 'decision carries an unsafe hash, ignoring');\n roundResult = nativeUpdateResult('failed', 'invalid_response');\n return;") - s = replace(s, old, old.replace(' await context.', ' const committed = await context.') + "\n roundResult = committed\n ? nativeUpdateResult('failed', 'download_failed')\n : nativeUpdateResult('cancelled', 'reset');") - s = replace(s, ' logger.error(TAG, `commit failed: ${getErrorMessage(e)}`);\n return;', " logger.error(TAG, `commit failed: ${getErrorMessage(e)}`);\n roundResult = nativeUpdateResult('failed', 'commit_failed');\n return;") - s = replace(s, ''' logger.info(TAG, `downloaded ${hash}, activation left to JS`); - } -} -''', ''' logger.info(TAG, `downloaded ${hash}, activation left to JS`); - } - roundResult = committed - ? nativeUpdateResult('downloaded', '', hash, activate) - : nativeUpdateResult('cancelled', 'reset'); -} -''') - return s - -def harmony(s): - s = replace(s, "import type { UpdateContext } from './UpdateContext';\n", "import type { UpdateContext } from './UpdateContext';\nimport { NativeUpdateRound, nativeUpdateResult } from './NativeUpdateResult';\nimport type { NativeUpdateResult } from './NativeUpdateResult';\n") - s = replace(s, 'let scheduled = false;\n', 'let scheduled = false;\n' + HARMONY_HOST) - s = replace(s, ' scheduled = true;\n', ' scheduled = true;\n scheduledContext = context;\n scheduledRollback = launchRolledBackVersion;\n') - s = replace(s, ' runOnce(context, launchRolledBackVersion).catch', ' startNativeRound(context, launchRolledBackVersion).catch') - s = region(s, 'async function runOnce(', '// 配置端点全为 https 时', harmony_once) - return region(s, 'async function runConfiguredRound(', 'function buildResponseCacheJson(', harmony_configured) - -edit(A, android) -edit(I, ios) -edit(H, harmony) -provider = 'harmony/pushy/src/main/ets/PushyFileJSBundleProvider.ets' -edit(provider, lambda s: replace(replace(s, - "import { UpdateContext } from './UpdateContext';\n", - "import { UpdateContext } from './UpdateContext';\nimport { checkAndUpdateNative } from './NativeCheckOrchestrator';\nimport type { NativeUpdateResult } from './NativeUpdateResult';\n"), - ' getAppKeys(): string[] {\n', ''' /** Call after the host's real bundle resolution; never resolves it again. */ - checkAndUpdate(): Promise { - return checkAndUpdateNative(this.updateContext); - } - - getAppKeys(): string[] { -''')) -edit('harmony/pushy/index.ets', lambda s: s + "export type { NativeUpdateResult } from './src/main/ets/NativeUpdateResult';\n") -for target, content in PENDING: - target.write_text(content) -print('Applied native host API changes to Android, iOS and Harmony.') From b9b7d302a18e02d5eb2eb4884967322f2caeae3e Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Sat, 19 Sep 2026 09:47:29 +0800 Subject: [PATCH 11/24] test: exercise native host orchestration with mocked platform IO --- src/__tests__/nativeHostApi.test.ts | 201 ++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 src/__tests__/nativeHostApi.test.ts diff --git a/src/__tests__/nativeHostApi.test.ts b/src/__tests__/nativeHostApi.test.ts new file mode 100644 index 00000000..c45015e4 --- /dev/null +++ b/src/__tests__/nativeHostApi.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { runInNewContext } from 'node:vm'; +import { + NativeUpdateRound, + nativeUpdateResult, +} from '../../harmony/pushy/src/main/ets/NativeUpdateResult'; +import type { NativeUpdateResult } from '../../harmony/pushy/src/main/ets/NativeUpdateResult'; + +// Evaluate the actual Harmony orchestrator in an isolated VM per test. Only +// platform imports, HTTP and download IO are substituted; entry points, +// scheduling, configuration gates, result mapping and reset checks are real. +const source = readFileSync( + new URL( + '../../harmony/pushy/src/main/ets/NativeCheckOrchestrator.ts', + import.meta.url, + ), + 'utf8', +) + .replace(/^import[\s\S]*?;\r?\n/gm, '') + .replace(/^export /gm, ''); +const javascript = new Bun.Transpiler({ loader: 'ts' }).transformSync(source); + +interface Decision { + action: string; + reason?: string; + hash?: string; + activate?: boolean; +} + +function harness() { + const values = new Map(); + values.set('nativeConfig', JSON.stringify({ appKey: 'test-app' })); + const timers: Array<() => void> = []; + const state = { + generation: 0, + checks: 0, + downloads: 0, + commits: 0, + downloadOK: true, + commitOK: true, + installed: false, + reachable: true, + decision: { action: 'none', reason: 'up_to_date' } as Decision, + beforeResponse: async (): Promise => {}, + }; + const context = { + getKv: (key: string) => values.get(key), + setKv: async (key: string, value: string) => { + values.set(key, value); + }, + removeKv: async (key: string) => { + values.delete(key); + }, + getResetGeneration: () => state.generation, + getCurrentVersion: () => '', + getPackageVersion: () => '1.0', + getBuildTime: () => '123', + getBundleHash: async () => 'binary-hash', + hasDownloadedVersion: () => state.installed, + getBundleUrl: () => { + throw new Error('host checks must not resolve the launch bundle again'); + }, + commitNativeCheckResult: async (generation: number) => { + state.commits += 1; + return generation === state.generation && state.commitOK; + }, + }; + const runtime = runInNewContext( + `${javascript}\nrunCheckRequest = mockCheck;\nperformAttempts = mockDownload;\n({ check: checkAndUpdateNative, schedule: scheduleNativeCheck });`, + { + NativeUpdateRound, + nativeUpdateResult, + logger: { info() {}, warn() {}, error() {} }, + deviceInfo: { osFullName: 'test-os' }, + setTimeout: (callback: () => void) => timers.push(callback), + isSafePathComponent: (hash: string) => /^[a-zA-Z0-9_-]+$/.test(hash), + getErrorMessage: (error: unknown) => String(error), + NativePatchCore: { + getSupportedDiffVersion: () => 2, + buildCheckRequestBody: (input: string) => input, + handleCheckResponse: (response: string) => response, + }, + mockCheck: async () => { + state.checks += 1; + await state.beforeResponse(); + return state.reachable ? JSON.stringify(state.decision) : undefined; + }, + mockDownload: async () => { + state.downloads += 1; + return state.downloadOK; + }, + }, + ) as { + check: (ctx: typeof context) => Promise; + schedule: (ctx: typeof context, rollback: string) => void; + }; + return { + values, + state, + timers, + check: () => runtime.check(context), + initialize: () => runtime.schedule(context, 'rolled-back-version'), + }; +} + +describe('native host API orchestration', () => { + test('requires real launch initialization, without resolving the bundle', async () => { + const h = harness(); + expect((await h.check()).reason).toBe('not_initialized'); + expect(h.state.checks).toBe(0); + h.initialize(); + expect((await h.check()).status).toBe('noUpdate'); + }); + + test('missing, disabled and malformed config do not consume a host round', async () => { + const h = harness(); + h.initialize(); + h.values.delete('nativeConfig'); + expect((await h.check()).reason).toBe('not_configured'); + h.values.set('nativeConfig', '{'); + expect((await h.check()).reason).toBe('invalid_config'); + h.values.set('nativeConfig', JSON.stringify({ disabled: true })); + expect((await h.check()).reason).toBe('disabled'); + expect(h.state.checks).toBe(0); + h.values.set('nativeConfig', JSON.stringify({ appKey: 'test-app' })); + expect((await h.check()).status).toBe('noUpdate'); + expect(h.state.checks).toBe(1); + }); + + test('manual, concurrent and delayed calls share one download and commit', async () => { + const h = harness(); + h.initialize(); + h.state.decision = { action: 'download', hash: 'v2', activate: true }; + const first = h.check(); + const second = h.check(); + for (const timer of h.timers) timer(); + const results = await Promise.all([first, second]); + expect(results[0]).toEqual(nativeUpdateResult('downloaded', '', 'v2', true)); + expect(results[1]).toEqual(results[0]); + results[0].hash = 'caller-mutated'; + expect((await h.check()).hash).toBe('v2'); + expect(h.state.checks).toBe(1); + expect(h.state.downloads).toBe(1); + expect(h.state.commits).toBe(1); + expect(h.values.has('nativeCheckIncomplete')).toBe(false); + }); + + test('a download need not select a bundle for the next launch', async () => { + const h = harness(); + h.initialize(); + h.state.decision = { action: 'download', hash: 'v2', activate: false }; + expect(await h.check()).toEqual(nativeUpdateResult('downloaded', '', 'v2')); + }); + + test('an installed version skips transfer but still reports activation', async () => { + const h = harness(); + h.initialize(); + h.state.installed = true; + h.state.decision = { action: 'download', hash: 'v2', activate: true }; + expect((await h.check()).activated).toBe(true); + expect(h.state.downloads).toBe(0); + }); + + test('network and download failure are not reported as no update', async () => { + const offline = harness(); + offline.initialize(); + offline.state.reachable = false; + expect(await offline.check()).toEqual(nativeUpdateResult('failed', 'check_failed')); + const h = harness(); + h.initialize(); + h.state.decision = { action: 'download', hash: 'v2' }; + h.state.downloadOK = false; + expect(await h.check()).toEqual(nativeUpdateResult('failed', 'download_failed')); + await h.check(); + expect(h.state.downloads).toBe(1); + }); + + test('reset during a round cancels its result', async () => { + const h = harness(); + h.initialize(); + h.state.beforeResponse = async () => { + h.state.generation += 1; + }; + expect(await h.check()).toEqual(nativeUpdateResult('cancelled', 'reset')); + }); + + test('reset and configuration changes invalidate completed snapshots', async () => { + const reset = harness(); + reset.initialize(); + await reset.check(); + reset.state.generation += 1; + expect((await reset.check()).reason).toBe('reset'); + const h = harness(); + h.initialize(); + await h.check(); + h.values.set('nativeConfig', JSON.stringify({ appKey: 'other-app' })); + expect((await h.check()).reason).toBe('config_changed'); + expect(h.state.checks).toBe(1); + }); +}); From b0927f63eb01a8ac7ced1ebb16bb39c261e377e8 Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Sat, 19 Sep 2026 09:49:46 +0800 Subject: [PATCH 12/24] test: align native API regression tests with repository formatting --- src/__tests__/nativeHostApi.test.ts | 20 +++++++++++++------- src/__tests__/nativeUpdateRound.test.ts | 8 ++++---- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/__tests__/nativeHostApi.test.ts b/src/__tests__/nativeHostApi.test.ts index c45015e4..1ef42992 100644 --- a/src/__tests__/nativeHostApi.test.ts +++ b/src/__tests__/nativeHostApi.test.ts @@ -1,11 +1,11 @@ import { describe, expect, test } from 'bun:test'; import { readFileSync } from 'node:fs'; import { runInNewContext } from 'node:vm'; +import type { NativeUpdateResult } from '../../harmony/pushy/src/main/ets/NativeUpdateResult'; import { NativeUpdateRound, nativeUpdateResult, } from '../../harmony/pushy/src/main/ets/NativeUpdateResult'; -import type { NativeUpdateResult } from '../../harmony/pushy/src/main/ets/NativeUpdateResult'; // Evaluate the actual Harmony orchestrator in an isolated VM per test. Only // platform imports, HTTP and download IO are substituted; entry points, @@ -13,9 +13,9 @@ import type { NativeUpdateResult } from '../../harmony/pushy/src/main/ets/Native const source = readFileSync( new URL( '../../harmony/pushy/src/main/ets/NativeCheckOrchestrator.ts', - import.meta.url, + import.meta.url ), - 'utf8', + 'utf8' ) .replace(/^import[\s\S]*?;\r?\n/gm, '') .replace(/^export /gm, ''); @@ -90,7 +90,7 @@ function harness() { state.downloads += 1; return state.downloadOK; }, - }, + } ) as { check: (ctx: typeof context) => Promise; schedule: (ctx: typeof context, rollback: string) => void; @@ -136,7 +136,9 @@ describe('native host API orchestration', () => { const second = h.check(); for (const timer of h.timers) timer(); const results = await Promise.all([first, second]); - expect(results[0]).toEqual(nativeUpdateResult('downloaded', '', 'v2', true)); + expect(results[0]).toEqual( + nativeUpdateResult('downloaded', '', 'v2', true) + ); expect(results[1]).toEqual(results[0]); results[0].hash = 'caller-mutated'; expect((await h.check()).hash).toBe('v2'); @@ -166,12 +168,16 @@ describe('native host API orchestration', () => { const offline = harness(); offline.initialize(); offline.state.reachable = false; - expect(await offline.check()).toEqual(nativeUpdateResult('failed', 'check_failed')); + expect(await offline.check()).toEqual( + nativeUpdateResult('failed', 'check_failed') + ); const h = harness(); h.initialize(); h.state.decision = { action: 'download', hash: 'v2' }; h.state.downloadOK = false; - expect(await h.check()).toEqual(nativeUpdateResult('failed', 'download_failed')); + expect(await h.check()).toEqual( + nativeUpdateResult('failed', 'download_failed') + ); await h.check(); expect(h.state.downloads).toBe(1); }); diff --git a/src/__tests__/nativeUpdateRound.test.ts b/src/__tests__/nativeUpdateRound.test.ts index 6747e95d..d1f3f207 100644 --- a/src/__tests__/nativeUpdateRound.test.ts +++ b/src/__tests__/nativeUpdateRound.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from 'bun:test'; +import type { NativeUpdateResult } from '../../harmony/pushy/src/main/ets/NativeUpdateResult'; import { NativeUpdateRound, nativeUpdateResult, } from '../../harmony/pushy/src/main/ets/NativeUpdateResult'; -import type { NativeUpdateResult } from '../../harmony/pushy/src/main/ets/NativeUpdateResult'; describe('native host update round', () => { test('concurrent callers share the same in-flight operation', async () => { @@ -74,9 +74,9 @@ describe('native host update round', () => { hash: 'version-1', activated: false, }); - expect(nativeUpdateResult('downloaded', '', 'version-1', true).activated).toBe( - true, - ); + expect( + nativeUpdateResult('downloaded', '', 'version-1', true).activated + ).toBe(true); expect(nativeUpdateResult('skipped', 'not_configured')).toEqual({ status: 'skipped', reason: 'not_configured', From ef2ad10e2c66f750c172a79f8c488cfe1a8e1b47 Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Sat, 19 Sep 2026 10:02:14 +0800 Subject: [PATCH 13/24] feat(harmony): validate and normalize native host configuration --- .../pushy/src/main/ets/NativeUpdateConfig.ts | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 harmony/pushy/src/main/ets/NativeUpdateConfig.ts diff --git a/harmony/pushy/src/main/ets/NativeUpdateConfig.ts b/harmony/pushy/src/main/ets/NativeUpdateConfig.ts new file mode 100644 index 00000000..ccaff6a2 --- /dev/null +++ b/harmony/pushy/src/main/ets/NativeUpdateConfig.ts @@ -0,0 +1,102 @@ +/** Options accepted by PushyFileJSBundleProvider.configure(). */ +export interface NativeUpdateConfig { + appKey: string; + /** Omitted: Pushy's built-in endpoints. Custom endpoints never inherit discovery URLs. */ + endpoints?: string[]; + queryUrls?: string[]; + /** Default: none. setNeedUpdate selects a downloaded bundle for the NEXT launch. */ + afterDownload?: 'none' | 'setNeedUpdate'; + disabled?: boolean; + /** Omit to use the installed application's version. */ + packageVersion?: string; + /** Optional telemetry version strings; not required to check or install an update. */ + rnu?: string; + rn?: string; +} + +interface PersistedNativeUpdateConfig { + appKey: string; + endpoints: string[]; + queryUrls: string[]; + afterDownload: string; + disabled: boolean; + packageVersion?: string; + rnu: string; + rn: string; +} + +const DEFAULT_ENDPOINTS: string[] = [ + 'https://update.react-native.cn/api', + 'https://update.reactnative.cn/api', +]; +const DEFAULT_QUERY_URLS: string[] = [ + 'https://gitee.com/sunnylqm/react-native-pushy/raw/master/endpoints.json', + 'https://cdn.jsdelivr.net/gh/reactnativecn/react-native-update@master/endpoints.json', +]; +const CONFIG_KEYS: string[] = [ + 'appKey', 'endpoints', 'queryUrls', 'afterDownload', 'disabled', + 'packageVersion', 'rnu', 'rn', +]; + +function configString(value: string, name: string, allowEmpty: boolean): string { + if (typeof value !== 'string' || (!allowEmpty && value.trim().length === 0)) { + throw new Error(`Invalid native configuration: ${name} must be a string${allowEmpty ? '' : ' and must not be blank'}`); + } + return value; +} + +function configUrls(values: string[], name: string, base: boolean): string[] { + if (!Array.isArray(values) || (base && values.length === 0)) { + throw new Error(`Invalid native configuration: ${name} must be ${base ? 'a non-empty' : 'an'} array`); + } + const result: string[] = []; + for (const value of values) { + const url = configString(value, name, false).trim(); + // Restrict the authority and scheme, without relying on browser URL globals + // unavailable in ArkTS. Platform networking performs the final URL parsing. + if (!/^https?:\/\/(\[[0-9a-fA-F:]+\]|[a-zA-Z0-9.-]+)(:[0-9]+)?([/?#]|$)/.test(url) + || /\s|\\/.test(url) || (base && /[?#]/.test(url))) { + throw new Error(`Invalid native configuration: ${name} requires absolute HTTP(S) URLs without credentials${base ? ', queries or fragments' : ''}`); + } + const normalized = base ? url.replace(/\/+$/, '') : url; + if (!result.includes(normalized)) { + result.push(normalized); + } + } + return result; +} + +/** Pure validation, before any native state is touched. Does not mutate options. */ +export function normalizeNativeUpdateConfig(options: NativeUpdateConfig): string { + if (!options || typeof options !== 'object' || Array.isArray(options)) { + throw new Error('Invalid native configuration: expected an object'); + } + for (const key of Object.keys(options)) { + if (!CONFIG_KEYS.includes(key)) { + throw new Error(`Invalid native configuration: unknown option ${key}`); + } + } + const appKey = configString(options.appKey, 'appKey', false); + const customEndpoints = options.endpoints !== undefined; + const endpoints = configUrls(customEndpoints ? options.endpoints! : DEFAULT_ENDPOINTS, 'endpoints', true); + const queryUrls = configUrls( + options.queryUrls !== undefined ? options.queryUrls : (customEndpoints ? [] : DEFAULT_QUERY_URLS), + 'queryUrls', false, + ); + const afterDownload = options.afterDownload === undefined ? 'none' : options.afterDownload; + if (afterDownload !== 'none' && afterDownload !== 'setNeedUpdate') { + throw new Error('Invalid native configuration: afterDownload must be none or setNeedUpdate'); + } + if (options.disabled !== undefined && typeof options.disabled !== 'boolean') { + throw new Error('Invalid native configuration: disabled must be a boolean'); + } + const result: PersistedNativeUpdateConfig = { + appKey, endpoints, queryUrls, afterDownload, disabled: options.disabled ?? false, + rnu: options.rnu === undefined ? '' : configString(options.rnu, 'rnu', true), + rn: options.rn === undefined ? '' : configString(options.rn, 'rn', true), + }; + if (options.packageVersion !== undefined) { + result.packageVersion = configString(options.packageVersion, 'packageVersion', false); + } + return JSON.stringify(result); +} From 5764c96d88c148cd7a11fec8dc2c48b809ea6753 Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Sat, 19 Sep 2026 10:02:32 +0800 Subject: [PATCH 14/24] feat(android): normalize native configuration before persistence --- .../modules/update/NativeUpdateConfig.java | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 android/src/main/java/cn/reactnative/modules/update/NativeUpdateConfig.java diff --git a/android/src/main/java/cn/reactnative/modules/update/NativeUpdateConfig.java b/android/src/main/java/cn/reactnative/modules/update/NativeUpdateConfig.java new file mode 100644 index 00000000..d7561783 --- /dev/null +++ b/android/src/main/java/cn/reactnative/modules/update/NativeUpdateConfig.java @@ -0,0 +1,99 @@ +package cn.reactnative.modules.update; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Iterator; +import java.util.Set; +import java.util.regex.Pattern; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +/** Internal normalizer for the JSONObject accepted by PushyNativeUpdate.configure. */ +final class NativeUpdateConfig { + private static final Set KEYS = new HashSet<>(Arrays.asList( + "appKey", "endpoints", "queryUrls", "afterDownload", "disabled", + "packageVersion", "rnu", "rn")); + private static final Pattern URL = Pattern.compile( + "^https?://(\\[[0-9a-fA-F:]+\\]|[a-zA-Z0-9.-]+)(:[0-9]+)?([/?#]|$)"); + private static final String[] ENDPOINTS = { + "https://update.react-native.cn/api", "https://update.reactnative.cn/api" + }; + private static final String[] QUERY_URLS = { + "https://gitee.com/sunnylqm/react-native-pushy/raw/master/endpoints.json", + "https://cdn.jsdelivr.net/gh/reactnativecn/react-native-update@master/endpoints.json" + }; + + private NativeUpdateConfig() {} + + static String normalize(String json) throws JSONException { + JSONObject options = new JSONObject(json); + for (Iterator keys = options.keys(); keys.hasNext();) { + String key = keys.next(); + if (!KEYS.contains(key)) { + throw invalid("unknown option " + key); + } + } + String appKey = string(options.opt("appKey"), "appKey", false); + boolean customEndpoints = options.has("endpoints"); + JSONArray endpoints = urls(customEndpoints ? options.get("endpoints") + : new JSONArray(Arrays.asList(ENDPOINTS)), "endpoints", true); + JSONArray queryUrls = urls(options.has("queryUrls") ? options.get("queryUrls") + : new JSONArray(Arrays.asList(customEndpoints ? new String[0] : QUERY_URLS)), + "queryUrls", false); + String afterDownload = options.has("afterDownload") + ? string(options.get("afterDownload"), "afterDownload", false) : "none"; + if (!"none".equals(afterDownload) && !"setNeedUpdate".equals(afterDownload)) { + throw invalid("afterDownload must be none or setNeedUpdate"); + } + Object disabled = options.has("disabled") ? options.get("disabled") : Boolean.FALSE; + if (!(disabled instanceof Boolean)) { + throw invalid("disabled must be a boolean"); + } + JSONObject result = new JSONObject(); + result.put("appKey", appKey); + result.put("endpoints", endpoints); + result.put("queryUrls", queryUrls); + result.put("afterDownload", afterDownload); + result.put("disabled", disabled); + result.put("rnu", options.has("rnu") ? string(options.get("rnu"), "rnu", true) : ""); + result.put("rn", options.has("rn") ? string(options.get("rn"), "rn", true) : ""); + if (options.has("packageVersion")) { + result.put("packageVersion", string(options.get("packageVersion"), "packageVersion", false)); + } + return result.toString(); + } + + private static String string(Object value, String name, boolean allowEmpty) { + if (!(value instanceof String) || (!allowEmpty && ((String) value).trim().isEmpty())) { + throw invalid(name + " must be a string" + (allowEmpty ? "" : " and must not be blank")); + } + return (String) value; + } + + private static JSONArray urls(Object value, String name, boolean base) throws JSONException { + if (!(value instanceof JSONArray) || (base && ((JSONArray) value).length() == 0)) { + throw invalid(name + " must be " + (base ? "a non-empty" : "an") + " array"); + } + JSONArray values = (JSONArray) value; + JSONArray result = new JSONArray(); + Set seen = new HashSet<>(); + for (int i = 0; i < values.length(); i++) { + String url = string(values.get(i), name, false).trim(); + if (!URL.matcher(url).find() || url.matches("(?s).*\\s.*") || url.contains("\\") + || (base && (url.contains("?") || url.contains("#")))) { + throw invalid(name + " requires absolute HTTP(S) URLs without credentials" + + (base ? ", queries or fragments" : "")); + } + String normalized = base ? url.replaceAll("/+$", "") : url; + if (seen.add(normalized)) { + result.put(normalized); + } + } + return result; + } + + private static IllegalArgumentException invalid(String message) { + return new IllegalArgumentException("Invalid native configuration: " + message); + } +} From ab44038e6c0e35e3c23ee86120466ea6b009f26d Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Sat, 19 Sep 2026 10:02:44 +0800 Subject: [PATCH 15/24] feat(ios): declare native configuration validation helper --- ios/RCTPushy/RCTPushyNativeConfig.h | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 ios/RCTPushy/RCTPushyNativeConfig.h diff --git a/ios/RCTPushy/RCTPushyNativeConfig.h b/ios/RCTPushy/RCTPushyNativeConfig.h new file mode 100644 index 00000000..99b99459 --- /dev/null +++ b/ios/RCTPushy/RCTPushyNativeConfig.h @@ -0,0 +1,5 @@ +#import + +// Validates and snapshots host options without changing any update state. +FOUNDATION_EXPORT NSString * _Nullable RCTPushyNormalizeNativeConfig( + NSDictionary * _Nonnull options, NSError * _Nullable * _Nullable error); From 2d427eb0e2cdf80394ea2d900382fb65427b88e9 Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Sat, 19 Sep 2026 10:03:05 +0800 Subject: [PATCH 16/24] feat(ios): validate and normalize native host configuration --- ios/RCTPushy/RCTPushyNativeConfig.mm | 103 +++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 ios/RCTPushy/RCTPushyNativeConfig.mm diff --git a/ios/RCTPushy/RCTPushyNativeConfig.mm b/ios/RCTPushy/RCTPushyNativeConfig.mm new file mode 100644 index 00000000..5586897d --- /dev/null +++ b/ios/RCTPushy/RCTPushyNativeConfig.mm @@ -0,0 +1,103 @@ +#import "RCTPushyNativeConfig.h" + +static void PushyConfigInvalid(NSString *message) { + @throw [NSException exceptionWithName:NSInvalidArgumentException + reason:[@"Invalid native configuration: " stringByAppendingString:message] + userInfo:nil]; +} + +static NSString *PushyConfigString(id value, NSString *name, BOOL allowEmpty) { + if (![value isKindOfClass:NSString.class] + || (!allowEmpty && [[value stringByTrimmingCharactersInSet: + NSCharacterSet.whitespaceAndNewlineCharacterSet] length] == 0)) { + PushyConfigInvalid([NSString stringWithFormat:@"%@ must be a string%@", + name, allowEmpty ? @"" : @" and must not be blank"]); + } + return value; +} + +static NSArray *PushyConfigUrls(id value, NSString *name, BOOL base) { + if (![value isKindOfClass:NSArray.class] || (base && [value count] == 0)) { + PushyConfigInvalid([NSString stringWithFormat:@"%@ must be %@ array", + name, base ? @"a non-empty" : @"an"]); + } + NSMutableArray *result = [NSMutableArray array]; + NSRegularExpression *authority = [NSRegularExpression regularExpressionWithPattern: + @"^https?://(\\[[0-9a-fA-F:]+\\]|[a-zA-Z0-9.-]+)(:[0-9]+)?([/?#]|$)" + options:0 error:nil]; + for (id item in value) { + NSString *url = [PushyConfigString(item, name, NO) stringByTrimmingCharactersInSet: + NSCharacterSet.whitespaceAndNewlineCharacterSet]; + if ([authority firstMatchInString:url options:0 range:NSMakeRange(0, url.length)] == nil + || [url rangeOfCharacterFromSet:NSCharacterSet.whitespaceAndNewlineCharacterSet].location != NSNotFound + || [url containsString:@"\\"] + || (base && ([url containsString:@"?"] || [url containsString:@"#"]))) { + PushyConfigInvalid([NSString stringWithFormat: + @"%@ requires absolute HTTP(S) URLs without credentials%@", + name, base ? @", queries or fragments" : @""]); + } + if (base) { + while ([url hasSuffix:@"/"]) { + url = [url substringToIndex:url.length - 1]; + } + } + if (![result containsObject:url]) { + [result addObject:url]; + } + } + return result; +} + +NSString *RCTPushyNormalizeNativeConfig(NSDictionary *options, NSError **error) { + @try { + if (![options isKindOfClass:NSDictionary.class]) { + PushyConfigInvalid(@"expected an object"); + } + NSArray *keys = @[@"appKey", @"endpoints", @"queryUrls", @"afterDownload", + @"disabled", @"packageVersion", @"rnu", @"rn"]; + for (id key in options) { + if (![keys containsObject:key]) { + PushyConfigInvalid([NSString stringWithFormat:@"unknown option %@", key]); + } + } + NSString *appKey = PushyConfigString(options[@"appKey"], @"appKey", NO); + BOOL customEndpoints = options[@"endpoints"] != nil; + NSArray *endpoints = PushyConfigUrls(customEndpoints ? options[@"endpoints"] + : @[@"https://update.react-native.cn/api", @"https://update.reactnative.cn/api"], + @"endpoints", YES); + NSArray *queryUrls = PushyConfigUrls(options[@"queryUrls"] ?: (customEndpoints ? @[] + : @[@"https://gitee.com/sunnylqm/react-native-pushy/raw/master/endpoints.json", + @"https://cdn.jsdelivr.net/gh/reactnativecn/react-native-update@master/endpoints.json"]), + @"queryUrls", NO); + NSString *afterDownload = options[@"afterDownload"] + ? PushyConfigString(options[@"afterDownload"], @"afterDownload", NO) : @"none"; + if (![@[@"none", @"setNeedUpdate"] containsObject:afterDownload]) { + PushyConfigInvalid(@"afterDownload must be none or setNeedUpdate"); + } + id disabled = options[@"disabled"] ?: @NO; + if (CFGetTypeID((__bridge CFTypeRef)disabled) != CFBooleanGetTypeID()) { + PushyConfigInvalid(@"disabled must be a boolean"); + } + NSMutableDictionary *result = [@{ + @"appKey": appKey, @"endpoints": endpoints, @"queryUrls": queryUrls, + @"afterDownload": afterDownload, @"disabled": disabled, + @"rnu": options[@"rnu"] ? PushyConfigString(options[@"rnu"], @"rnu", YES) : @"", + @"rn": options[@"rn"] ? PushyConfigString(options[@"rn"], @"rn", YES) : @"" + } mutableCopy]; + if (options[@"packageVersion"] != nil) { + result[@"packageVersion"] = PushyConfigString(options[@"packageVersion"], @"packageVersion", NO); + } + // Stable ordering makes repeated identical configure calls idempotent. + NSData *data = [NSJSONSerialization dataWithJSONObject:result + options:NSJSONWritingSortedKeys error:error]; + return data == nil ? nil : [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; + } @catch (NSException *exception) { + if (error != NULL) { + *error = [NSError errorWithDomain:@"cn.reactnative.pushy" code:1 userInfo:@{ + NSLocalizedDescriptionKey: exception.reason ?: @"Invalid native configuration", + @"PushyErrorCode": @"INVALID_OPTIONS" + }]; + } + return nil; + } +} From 9752f53232942468a9d4703cea91f74158280c15 Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Sat, 19 Sep 2026 10:04:44 +0800 Subject: [PATCH 17/24] chore: stage guarded native configuration integration --- scripts/prepare-native-configuration.py | 349 ++++++++++++++++++++++++ 1 file changed, 349 insertions(+) create mode 100644 scripts/prepare-native-configuration.py diff --git a/scripts/prepare-native-configuration.py b/scripts/prepare-native-configuration.py new file mode 100644 index 00000000..5efb4876 --- /dev/null +++ b/scripts/prepare-native-configuration.py @@ -0,0 +1,349 @@ +from pathlib import Path + +pending = {} +def change(path, old, new, count=1): + text = pending.get(path, Path(path).read_text()) + found = text.count(old) + if found != count: + raise RuntimeError(f'{path}: expected {count}, found {found}: {old[:100]!r}') + pending[path] = text.replace(old, new) + +A = 'android/src/main/java/cn/reactnative/modules/update/' +H = 'harmony/pushy/src/main/ets/' +I = 'ios/RCTPushy/' + +change(A+'PushyNativeUpdate.java', 'import android.util.Log;\n', 'import android.util.Log;\nimport androidx.annotation.Nullable;\nimport org.json.JSONObject;\n') +change(A+'PushyNativeUpdate.java', '/** Native host API. Configuration remains owned and persisted by the JS SDK. */', '/** Bridge-free native configuration and update APIs. */') +change(A+'PushyNativeUpdate.java', ' private PushyNativeUpdate() {\n', ''' public interface ConfigurationCallback { + /** Main thread; null means configuration was persisted successfully. */ + void onComplete(@Nullable Exception error); + } + + // Configuration must not wait behind a network round that it invalidates. + private static final Executor CONFIG_WORKER = Executors.newSingleThreadExecutor(new ThreadFactory() { + @Override + public Thread newThread(Runnable runnable) { + Thread thread = new Thread(runnable, "pushy-host-config"); + thread.setDaemon(true); + return thread; + } + }); + + /** + * Validate and persist a complete configuration, even before JS or bundle + * resolution. This starts no network work and never resolves a bundle. + * Await the callback before continuing startup/checkAndUpdate. Unless JS + * uses nativeConfigSource: 'native', later JS config writes can replace it. + */ + public static void configure(Context context, JSONObject options, final ConfigurationCallback callback) { + if (context == null || options == null || callback == null) { + throw new IllegalArgumentException("context, options and callback are required"); + } + final Context applicationContext = context.getApplicationContext(); + // Snapshot caller-owned JSON before dispatch, not minutes later on a worker. + final String snapshot = options.toString(); + CONFIG_WORKER.execute(new Runnable() { + @Override + public void run() { + Exception failure = null; + try { + String config = NativeUpdateConfig.normalize(snapshot); + UpdateContext.getInstance(applicationContext).setNativeConfig(config); + } catch (Exception e) { + failure = e; + } catch (LinkageError e) { + failure = new IllegalStateException("Native configuration failed", e); + } + final Exception error = failure; + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + callback.onComplete(error); + } + }); + } + }); + } + + private PushyNativeUpdate() { +''') +change(A+'UpdateContext.java', ' static long getResetGeneration() {\n', ''' /** Shared by JS and native hosts. Config replacement invalidates old native decisions. */ + void setNativeConfig(String config) { + synchronized (commitLock) { + if (config.equals(sp.getString(NativeCheckOrchestrator.KEY_CONFIG, null))) { + return; + } + // Also invalidate on a failed persistence attempt: never allow an + // older round to commit over uncertain configuration state. + resetGeneration.incrementAndGet(); + SharedPreferences.Editor editor = sp.edit(); + editor.putString(NativeCheckOrchestrator.KEY_CONFIG, config); + editor.remove(NativeCheckOrchestrator.KEY_RESP_CACHE); + NativeCheckOrchestrator.markJsCheckCompleted(null); + persistEditorOrThrow(editor, "configure native update"); + } + NativeCheckOrchestrator.onConfigured(this); + } + + // Native-decision generation: bumped by reset AND configuration replacement. + static long getResetGeneration() { +''') +change(A+'UpdateModuleImpl.java', 'updateContext.setKv(NativeCheckOrchestrator.KEY_CONFIG, config);', 'updateContext.setNativeConfig(config);') +change(A+'NativeCheckOrchestrator.java', ' startRound(0);\n roundDone.await();', ''' startRound(0); + if (!roundStarted.get()) { + return NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "config_changed"); + } + roundDone.await();''') +change(A+'NativeCheckOrchestrator.java', ''' if (roundGeneration != UpdateContext.getResetGeneration()) { + return NativeUpdateResult.of(NativeUpdateResult.CANCELLED, "reset"); + } + if (!configJson.equals(roundConfigJson) || !configJson.equals(context.getKv(KEY_CONFIG))) { + return NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "config_changed"); + }''', ''' if (!configJson.equals(roundConfigJson) || !configJson.equals(context.getKv(KEY_CONFIG))) { + return NativeUpdateResult.of(NativeUpdateResult.CANCELLED, "config_changed"); + } + if (roundGeneration != UpdateContext.getResetGeneration()) { + return NativeUpdateResult.of(NativeUpdateResult.CANCELLED, "reset"); + }''') +change(A+'NativeCheckOrchestrator.java', ' private static void startRound(long deadlineNanos) {\n', ''' private static boolean hasRunnableConfig(UpdateContext context) { + if (context == null) { + return false; + } + try { + String json = context.getKv(KEY_CONFIG); + if (json == null) { + return false; + } + JSONObject config = new JSONObject(json); + return !config.optBoolean("disabled", false) + && config.opt("appKey") instanceof String + && !config.getString("appKey").trim().isEmpty(); + } catch (JSONException e) { + return false; + } + } + + static void onConfigured(UpdateContext context) { + if (nativeReady && sContext == context && hasRunnableConfig(context)) { + CrashRescue.install(); + } + } + + private static void startRound(long deadlineNanos) { + // An automatic check before first-run provisioning must not consume + // the process's only round. Hosts may configure later in this launch. + if (!hasRunnableConfig(sContext)) { + return; + } +''') +change(A+'NativeCheckOrchestrator.java', ' if (!roundCompleted) {\n', ' if (roundStarted.get() && !roundCompleted) {\n') + +change(I+'RCTPushy.h', 'typedef void (^RCTPushyNativeUpdateCompletion)', 'typedef void (^RCTPushyNativeConfigurationCompletion)(NSError * _Nullable error);\n\ntypedef void (^RCTPushyNativeUpdateCompletion)') +change(I+'RCTPushy.h', '+ (NSURL *)bundleURL;\n', '''+ (NSURL *)bundleURL; + +/** Validate and persist native options without JS, network work or bundle resolution. + * Completion is on the main queue; nil error means success. Call before the + * normal launch bundle resolution for first-install native-only provisioning. + */ ++ (void)configure:(NSDictionary * _Nonnull)options + completion:(RCTPushyNativeConfigurationCompletion _Nullable)completion + NS_SWIFT_NAME(configure(_:completion:)); +''') +change(I+'RCTPushy.mm', '#import "RCTPushy.h"\n', '#import "RCTPushy.h"\n#import "RCTPushyNativeConfig.h"\n') +change(I+'RCTPushy.mm', '@interface RCTPushyOrchestrator : NSObject\n', '@interface RCTPushyOrchestrator : NSObject\n+ (void)persistConfiguration:(NSString *)config;\n+ (BOOL)hasRunnableConfig;\n') +change(I+'RCTPushy.mm', '+ (void)checkAndUpdateWithCompletion:(RCTPushyNativeUpdateCompletion)completion\n', '''+ (void)configure:(NSDictionary *)options + completion:(RCTPushyNativeConfigurationCompletion)completion +{ + NSError *validationError = nil; + // Snapshot nested mutable caller values before crossing a queue boundary. + NSString *config = RCTPushyNormalizeNativeConfig(options, &validationError); + static dispatch_queue_t configQueue; + static dispatch_once_t once; + dispatch_once(&once, ^{ + configQueue = dispatch_queue_create("cn.reactnative.pushy.host-config", DISPATCH_QUEUE_SERIAL); + }); + dispatch_async(configQueue, ^{ + NSError *failure = validationError; + if (config != nil) { + @try { + [RCTPushyOrchestrator persistConfiguration:config]; + } @catch (NSException *exception) { + failure = PushyErrorWithCode(pushy::error_codes::kFileOperationFailed, + exception.reason ?: @"Native configuration failed"); + } + } + if (completion != nil) { + dispatch_async(dispatch_get_main_queue(), ^{ + completion(failure); + }); + } + }); +} + ++ (void)checkAndUpdateWithCompletion:(RCTPushyNativeUpdateCompletion)completion +''') +change(I+'RCTPushy.mm', ' [PushyDefaults() setObject:config forKey:keyNativeConfig];\n resolve(@true);', ''' @try { + [RCTPushyOrchestrator persistConfiguration:config]; + resolve(@true); + } @catch (NSException *exception) { + PushyRejectError(reject, PushyErrorWithCode(pushy::error_codes::kFileOperationFailed, + exception.reason ?: @"Native configuration failed")); + }''') +change(I+'RCTPushy.mm', '@implementation RCTPushyOrchestrator\n', '''@implementation RCTPushyOrchestrator + ++ (void)persistConfiguration:(NSString *)config { + PushyWithStateLock(^{ + NSUserDefaults *defaults = PushyDefaults(); + if ([[defaults stringForKey:keyNativeConfig] isEqualToString:config]) { + return; + } + // The same generation protects reset and replacement of the request + // identity/policy, including a late crash-rescue activation. + pushyResetGeneration.fetch_add(1); + [defaults setObject:config forKey:keyNativeConfig]; + [defaults removeObjectForKey:keyNativeCheckCache]; + [self markJsCheckCompleted:nil]; + }); + if (pushyNativeCheckReady.load() && [self hasRunnableConfig]) { + PushyInstallCrashRescueHandler(); + } +} + ++ (BOOL)hasRunnableConfig { + NSString *json = [PushyDefaults() stringForKey:keyNativeConfig]; + if (json.length == 0) { + return NO; + } + bool ok = false; + flowjson::Value config = flowjson::Parse(PushyToStdString(json), &ok); + return ok && config.IsObject() && !config.Get("disabled").Truthy() + && !config.Get("appKey").AsString().empty(); +} +''') +change(I+'RCTPushy.mm', ' dispatch_group_wait(pushyHostRoundGroup, DISPATCH_TIME_FOREVER);\n', ''' if (!pushyRoundStarted.load()) { + return PushyHostResult(@"skipped", @"config_changed", nil, NO); + } + dispatch_group_wait(pushyHostRoundGroup, DISPATCH_TIME_FOREVER); +''') +change(I+'RCTPushy.mm', ''' if (pushyHostRoundGeneration != pushyResetGeneration.load()) { + return PushyHostResult(@"cancelled", @"reset", nil, NO); + } + if (![configJson isEqualToString:pushyHostRoundConfig] + || ![configJson isEqualToString:[PushyDefaults() stringForKey:keyNativeConfig]]) { + return PushyHostResult(@"skipped", @"config_changed", nil, NO); + }''', ''' if (![configJson isEqualToString:pushyHostRoundConfig] + || ![configJson isEqualToString:[PushyDefaults() stringForKey:keyNativeConfig]]) { + return PushyHostResult(@"cancelled", @"config_changed", nil, NO); + } + if (pushyHostRoundGeneration != pushyResetGeneration.load()) { + return PushyHostResult(@"cancelled", @"reset", nil, NO); + }''') +change(I+'RCTPushy.mm', '+ (void)startRoundWithDeadline:(NSTimeInterval)deadlineUptime {\n', '''+ (void)startRoundWithDeadline:(NSTimeInterval)deadlineUptime { + // Missing/disabled configuration is a preflight skip, not a used round. + if (![self hasRunnableConfig]) { + return; + } +''') +change(I+'RCTPushy.mm', ' if (!pushyRoundCompleted.load()) {\n', ' if (pushyRoundStarted.load() && !pushyRoundCompleted.load()) {\n') + +change(H+'UpdateContext.ts', ' KEY_RESP_CACHE,\n scheduleNativeCheck,', ' KEY_CONFIG,\n KEY_RESP_CACHE,\n markJsCheckCompleted,\n scheduleNativeCheck,') +change(H+'UpdateContext.ts', ' public setKv(key: string, value: string): Promise {\n', ''' /** Shared JS/native configuration writer; all mutations precede the first await. */ + public setNativeConfig(config: string): Promise { + if (this.getKv(KEY_CONFIG) !== config) { + // Also guards A -> B -> A replacements and late download commits. + UpdateContext.resetGeneration += 1; + this.preferences.putSync(KEY_CONFIG, config); + this.preferences.deleteSync(KEY_RESP_CACHE); + markJsCheckCompleted(''); + } + // Flush even an equal value so a retry after a storage error can succeed. + return this.flushPreferences('configure native update'); + } + + public setKv(key: string, value: string): Promise { +''') +change(H+'PushyTurboModule.ts', 'await this.context.setKv(KEY_CONFIG, config);', 'await this.context.setNativeConfig(config);') +change(H+'PushyFileJSBundleProvider.ets', "import type { NativeUpdateResult } from './NativeUpdateResult';\n", "import type { NativeUpdateResult } from './NativeUpdateResult';\nimport { normalizeNativeUpdateConfig } from './NativeUpdateConfig';\nimport type { NativeUpdateConfig } from './NativeUpdateConfig';\n") +change(H+'PushyFileJSBundleProvider.ets', ' /** Call after the host\'s real bundle resolution; never resolves it again. */\n', ''' /** Configure before normal bundle resolution; no JS or network work is required. */ + async configure(options: NativeUpdateConfig): Promise { + const config = normalizeNativeUpdateConfig(options); + await this.updateContext.setNativeConfig(config); + } + + /** Call after the host's real bundle resolution; never resolves it again. */ +''') +change('harmony/pushy/index.ets', "export type { NativeUpdateResult } from './src/main/ets/NativeUpdateResult';\n", "export type { NativeUpdateResult } from './src/main/ets/NativeUpdateResult';\nexport type { NativeUpdateConfig } from './src/main/ets/NativeUpdateConfig';\n") +change(H+'NativeCheckOrchestrator.ts', ''' return hostRound.run(async () => { +''', ''' const preflight = configurationError(context); + if (preflight !== undefined) { + return Promise.resolve(preflight); + } + return hostRound.run(async () => { +''') +change(H+'NativeCheckOrchestrator.ts', 'export async function checkAndUpdateNative(\n', '''// A preflight skip must not consume the process's only round: configuration +// may arrive after the delayed startup timer on a first-ever launch. +function configurationError(context: UpdateContext): NativeUpdateResult | undefined { + const json = context.getKv(KEY_CONFIG); + if (!json) { + return nativeUpdateResult('skipped', 'not_configured'); + } + try { + const config = JSON.parse(json) as NativeConfig; + if (!config || typeof config !== 'object' || Array.isArray(config)) { + return nativeUpdateResult('failed', 'invalid_config'); + } + if (config.disabled) { + return nativeUpdateResult('skipped', 'disabled'); + } + if (typeof config.appKey !== 'string' || config.appKey.trim().length === 0) { + return nativeUpdateResult('failed', 'invalid_config'); + } + } catch (e) { + return nativeUpdateResult('failed', 'invalid_config'); + } + return undefined; +} + +export async function checkAndUpdateNative( +''') +change(H+'NativeCheckOrchestrator.ts', ''' if (roundGeneration !== context.getResetGeneration()) { + return nativeUpdateResult('cancelled', 'reset'); + } + if (configJson !== roundConfigJson || configJson !== context.getKv(KEY_CONFIG)) { + return nativeUpdateResult('skipped', 'config_changed'); + }''', ''' if (configJson !== roundConfigJson || configJson !== context.getKv(KEY_CONFIG)) { + return nativeUpdateResult('cancelled', 'config_changed'); + } + if (roundGeneration !== context.getResetGeneration()) { + return nativeUpdateResult('cancelled', 'reset'); + }''') + +change('src/type.ts', ' disableNativeCheck?: boolean;\n', ''' disableNativeCheck?: boolean; + /** + * Owner of persisted native update configuration. Default: 'javascript'. + * Use 'native' when configure() is called by the host: JS will not overwrite + * that configuration. This does not configure or disable JS checks itself; + * keep appKey/server consistent and use checkStrategy to control JS checks. + */ + nativeConfigSource?: 'javascript' | 'native'; +''') +change('src/client.ts', ' private flushNativeConfig = () => {\n', ''' private flushNativeConfig = () => { + if (this.options.nativeConfigSource === 'native') { + this.pendingNativeConfigJson = undefined; + return; + } +''') +change('src/client.ts', ' private syncNativeConfig = () => {\n', ''' private syncNativeConfig = () => { + if (this.options.nativeConfigSource === 'native') { + this.pendingNativeConfigJson = undefined; + return; + } +''') +change('src/NativePushy.ts', ''' * single config source — a native side without persisted config silently + * skips its check, which doubles as the feature's rollout gate.''', ''' * default config source. Native hosts may also call configure(); select + * nativeConfigSource: 'native' in JS to leave host configuration untouched. + * A native side without persisted config skips its check.''') + +for path, content in pending.items(): + Path(path).write_text(content) +print('Integrated native configuration across', len(pending), 'files') From 799c678517799c3b162b9e49f267425391eda5d0 Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Sat, 19 Sep 2026 10:05:08 +0800 Subject: [PATCH 18/24] chore: apply and validate native configuration integration --- .../native-configuration-prepare.yml | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/workflows/native-configuration-prepare.yml diff --git a/.github/workflows/native-configuration-prepare.yml b/.github/workflows/native-configuration-prepare.yml new file mode 100644 index 00000000..e8ec5f72 --- /dev/null +++ b/.github/workflows/native-configuration-prepare.yml @@ -0,0 +1,32 @@ +name: Prepare native configuration +on: + push: + branches: [feat/native-host-update-api] + paths: [.github/workflows/native-configuration-prepare.yml] +permissions: + contents: write +jobs: + prepare: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.4.0 + - name: Apply guarded source changes + run: python3 scripts/prepare-native-configuration.py + - name: Check JS types and tests + run: | + bun install --frozen-lockfile + bunx biome check --write src + bunx tsc --noEmit + bun test src/__tests__ + git diff --check + - name: Commit implementation + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add android/src/main/java/cn/reactnative/modules/update/{PushyNativeUpdate,UpdateContext,UpdateModuleImpl,NativeCheckOrchestrator}.java ios/RCTPushy/RCTPushy.{h,mm} harmony/pushy/src/main/ets/{UpdateContext,PushyTurboModule,NativeCheckOrchestrator}.ts harmony/pushy/src/main/ets/PushyFileJSBundleProvider.ets harmony/pushy/index.ets src/client.ts src/type.ts src/NativePushy.ts + git commit -m 'feat: configure native updates before JS with explicit configuration ownership' + git push origin HEAD:refs/heads/feat/native-host-update-api From b1c6dcf80e93125ac539faa61107d343a5e0574a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 19 Sep 2026 02:05:19 +0000 Subject: [PATCH 19/24] feat: configure native updates before JS with explicit configuration ownership --- .../update/NativeCheckOrchestrator.java | 40 ++++++++- .../modules/update/PushyNativeUpdate.java | 55 +++++++++++- .../modules/update/UpdateContext.java | 19 ++++ .../modules/update/UpdateModuleImpl.java | 2 +- harmony/pushy/index.ets | 1 + .../src/main/ets/NativeCheckOrchestrator.ts | 34 +++++++- .../main/ets/PushyFileJSBundleProvider.ets | 8 ++ .../pushy/src/main/ets/PushyTurboModule.ts | 2 +- harmony/pushy/src/main/ets/UpdateContext.ts | 15 ++++ ios/RCTPushy/RCTPushy.h | 10 +++ ios/RCTPushy/RCTPushy.mm | 87 +++++++++++++++++-- src/NativePushy.ts | 5 +- src/client.ts | 8 ++ src/type.ts | 7 ++ 14 files changed, 274 insertions(+), 19 deletions(-) diff --git a/android/src/main/java/cn/reactnative/modules/update/NativeCheckOrchestrator.java b/android/src/main/java/cn/reactnative/modules/update/NativeCheckOrchestrator.java index d21962f8..dd55e6c9 100644 --- a/android/src/main/java/cn/reactnative/modules/update/NativeCheckOrchestrator.java +++ b/android/src/main/java/cn/reactnative/modules/update/NativeCheckOrchestrator.java @@ -102,13 +102,16 @@ static NativeUpdateResult checkAndUpdate(UpdateContext context) throws Interrupt return NativeUpdateResult.of(NativeUpdateResult.FAILED, "invalid_config"); } startRound(0); + if (!roundStarted.get()) { + return NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "config_changed"); + } roundDone.await(); + if (!configJson.equals(roundConfigJson) || !configJson.equals(context.getKv(KEY_CONFIG))) { + return NativeUpdateResult.of(NativeUpdateResult.CANCELLED, "config_changed"); + } if (roundGeneration != UpdateContext.getResetGeneration()) { return NativeUpdateResult.of(NativeUpdateResult.CANCELLED, "reset"); } - if (!configJson.equals(roundConfigJson) || !configJson.equals(context.getKv(KEY_CONFIG))) { - return NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "config_changed"); - } return roundResult; } @@ -184,7 +187,36 @@ static boolean isRoundInFlight() { * started it yet. deadlineNanos > 0 (crash rescue) caps every HTTP call * and download phase to the remaining budget. */ + private static boolean hasRunnableConfig(UpdateContext context) { + if (context == null) { + return false; + } + try { + String json = context.getKv(KEY_CONFIG); + if (json == null) { + return false; + } + JSONObject config = new JSONObject(json); + return !config.optBoolean("disabled", false) + && config.opt("appKey") instanceof String + && !config.getString("appKey").trim().isEmpty(); + } catch (JSONException e) { + return false; + } + } + + static void onConfigured(UpdateContext context) { + if (nativeReady && sContext == context && hasRunnableConfig(context)) { + CrashRescue.install(); + } + } + private static void startRound(long deadlineNanos) { + // An automatic check before first-run provisioning must not consume + // the process's only round. Hosts may configure later in this launch. + if (!hasRunnableConfig(sContext)) { + return; + } if (!roundStarted.compareAndSet(false, true)) { return; } @@ -213,7 +245,7 @@ static void runRescue(long deadlineNanos) { } crashRescueActive = true; startRound(deadlineNanos); - if (!roundCompleted) { + if (roundStarted.get() && !roundCompleted) { long remainingNanos = deadlineNanos - System.nanoTime(); if (remainingNanos > 0) { try { diff --git a/android/src/main/java/cn/reactnative/modules/update/PushyNativeUpdate.java b/android/src/main/java/cn/reactnative/modules/update/PushyNativeUpdate.java index 78bd77a1..c523443a 100644 --- a/android/src/main/java/cn/reactnative/modules/update/PushyNativeUpdate.java +++ b/android/src/main/java/cn/reactnative/modules/update/PushyNativeUpdate.java @@ -4,11 +4,13 @@ import android.os.Handler; import android.os.Looper; import android.util.Log; +import androidx.annotation.Nullable; +import org.json.JSONObject; import java.util.concurrent.Executor; import java.util.concurrent.Executors; import java.util.concurrent.ThreadFactory; -/** Native host API. Configuration remains owned and persisted by the JS SDK. */ +/** Bridge-free native configuration and update APIs. */ public final class PushyNativeUpdate { public interface Callback { /** Always called on the main thread, including skipped and failed checks. */ @@ -26,6 +28,57 @@ public Thread newThread(Runnable runnable) { } }); + public interface ConfigurationCallback { + /** Main thread; null means configuration was persisted successfully. */ + void onComplete(@Nullable Exception error); + } + + // Configuration must not wait behind a network round that it invalidates. + private static final Executor CONFIG_WORKER = Executors.newSingleThreadExecutor(new ThreadFactory() { + @Override + public Thread newThread(Runnable runnable) { + Thread thread = new Thread(runnable, "pushy-host-config"); + thread.setDaemon(true); + return thread; + } + }); + + /** + * Validate and persist a complete configuration, even before JS or bundle + * resolution. This starts no network work and never resolves a bundle. + * Await the callback before continuing startup/checkAndUpdate. Unless JS + * uses nativeConfigSource: 'native', later JS config writes can replace it. + */ + public static void configure(Context context, JSONObject options, final ConfigurationCallback callback) { + if (context == null || options == null || callback == null) { + throw new IllegalArgumentException("context, options and callback are required"); + } + final Context applicationContext = context.getApplicationContext(); + // Snapshot caller-owned JSON before dispatch, not minutes later on a worker. + final String snapshot = options.toString(); + CONFIG_WORKER.execute(new Runnable() { + @Override + public void run() { + Exception failure = null; + try { + String config = NativeUpdateConfig.normalize(snapshot); + UpdateContext.getInstance(applicationContext).setNativeConfig(config); + } catch (Exception e) { + failure = e; + } catch (LinkageError e) { + failure = new IllegalStateException("Native configuration failed", e); + } + final Exception error = failure; + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + callback.onComplete(error); + } + }); + } + }); + } + private PushyNativeUpdate() { } 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 a1400c8d..2bcfecf9 100644 --- a/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +++ b/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java @@ -737,6 +737,25 @@ public String getBundleUrl(String defaultAssetsUrl) { } /** Sampled/compared by the native check orchestrator; see resetGeneration. */ + /** Shared by JS and native hosts. Config replacement invalidates old native decisions. */ + void setNativeConfig(String config) { + synchronized (commitLock) { + if (config.equals(sp.getString(NativeCheckOrchestrator.KEY_CONFIG, null))) { + return; + } + // Also invalidate on a failed persistence attempt: never allow an + // older round to commit over uncertain configuration state. + resetGeneration.incrementAndGet(); + SharedPreferences.Editor editor = sp.edit(); + editor.putString(NativeCheckOrchestrator.KEY_CONFIG, config); + editor.remove(NativeCheckOrchestrator.KEY_RESP_CACHE); + NativeCheckOrchestrator.markJsCheckCompleted(null); + persistEditorOrThrow(editor, "configure native update"); + } + NativeCheckOrchestrator.onConfigured(this); + } + + // Native-decision generation: bumped by reset AND configuration replacement. static long getResetGeneration() { return resetGeneration.get(); } diff --git a/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java b/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java index e32ddf8d..eca14bca 100644 --- a/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java +++ b/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java @@ -339,7 +339,7 @@ public void syncNativeConfig(final String config, final Promise promise) { StateSerialRunner.run(promise, ErrorCodes.FILE_OPERATION_FAILED, "syncNativeConfig", new StateSerialRunner.Operation() { @Override public void run() { - updateContext.setKv(NativeCheckOrchestrator.KEY_CONFIG, config); + updateContext.setNativeConfig(config); promise.resolve(true); } }); diff --git a/harmony/pushy/index.ets b/harmony/pushy/index.ets index 432a8cd2..b5bfdbae 100644 --- a/harmony/pushy/index.ets +++ b/harmony/pushy/index.ets @@ -3,3 +3,4 @@ export { PushyPackage } from './src/main/ets/PushyPackage'; export { PushyTurboModule } from './src/main/ets/PushyTurboModule'; export { PushyFileJSBundleProvider } from './src/main/ets/PushyFileJSBundleProvider'; export type { NativeUpdateResult } from './src/main/ets/NativeUpdateResult'; +export type { NativeUpdateConfig } from './src/main/ets/NativeUpdateConfig'; diff --git a/harmony/pushy/src/main/ets/NativeCheckOrchestrator.ts b/harmony/pushy/src/main/ets/NativeCheckOrchestrator.ts index 210f4b63..5ad36453 100644 --- a/harmony/pushy/src/main/ets/NativeCheckOrchestrator.ts +++ b/harmony/pushy/src/main/ets/NativeCheckOrchestrator.ts @@ -120,6 +120,10 @@ function startNativeRound( context: UpdateContext, launchRolledBackVersion: string, ): Promise { + const preflight = configurationError(context); + if (preflight !== undefined) { + return Promise.resolve(preflight); + } return hostRound.run(async () => { try { await runOnce(context, launchRolledBackVersion); @@ -131,6 +135,30 @@ function startNativeRound( }); } +// A preflight skip must not consume the process's only round: configuration +// may arrive after the delayed startup timer on a first-ever launch. +function configurationError(context: UpdateContext): NativeUpdateResult | undefined { + const json = context.getKv(KEY_CONFIG); + if (!json) { + return nativeUpdateResult('skipped', 'not_configured'); + } + try { + const config = JSON.parse(json) as NativeConfig; + if (!config || typeof config !== 'object' || Array.isArray(config)) { + return nativeUpdateResult('failed', 'invalid_config'); + } + if (config.disabled) { + return nativeUpdateResult('skipped', 'disabled'); + } + if (typeof config.appKey !== 'string' || config.appKey.trim().length === 0) { + return nativeUpdateResult('failed', 'invalid_config'); + } + } catch (e) { + return nativeUpdateResult('failed', 'invalid_config'); + } + return undefined; +} + export async function checkAndUpdateNative( context: UpdateContext, ): Promise { @@ -156,12 +184,12 @@ export async function checkAndUpdateNative( return nativeUpdateResult('failed', 'invalid_config'); } const result = await startNativeRound(context, scheduledRollback); + if (configJson !== roundConfigJson || configJson !== context.getKv(KEY_CONFIG)) { + return nativeUpdateResult('cancelled', 'config_changed'); + } if (roundGeneration !== context.getResetGeneration()) { return nativeUpdateResult('cancelled', 'reset'); } - if (configJson !== roundConfigJson || configJson !== context.getKv(KEY_CONFIG)) { - return nativeUpdateResult('skipped', 'config_changed'); - } // Do not let a caller mutate the cached result observed by later callers. return nativeUpdateResult(result.status, result.reason, result.hash, result.activated); } diff --git a/harmony/pushy/src/main/ets/PushyFileJSBundleProvider.ets b/harmony/pushy/src/main/ets/PushyFileJSBundleProvider.ets index bb24578d..8976d847 100644 --- a/harmony/pushy/src/main/ets/PushyFileJSBundleProvider.ets +++ b/harmony/pushy/src/main/ets/PushyFileJSBundleProvider.ets @@ -8,6 +8,8 @@ import fs from '@ohos.file.fs'; import { UpdateContext } from './UpdateContext'; import { checkAndUpdateNative } from './NativeCheckOrchestrator'; import type { NativeUpdateResult } from './NativeUpdateResult'; +import { normalizeNativeUpdateConfig } from './NativeUpdateConfig'; +import type { NativeUpdateConfig } from './NativeUpdateConfig'; export class PushyFileJSBundleProvider extends JSBundleProvider { private updateContext: UpdateContext; @@ -45,6 +47,12 @@ export class PushyFileJSBundleProvider extends JSBundleProvider { } } + /** Configure before normal bundle resolution; no JS or network work is required. */ + async configure(options: NativeUpdateConfig): Promise { + const config = normalizeNativeUpdateConfig(options); + await this.updateContext.setNativeConfig(config); + } + /** Call after the host's real bundle resolution; never resolves it again. */ checkAndUpdate(): Promise { return checkAndUpdateNative(this.updateContext); diff --git a/harmony/pushy/src/main/ets/PushyTurboModule.ts b/harmony/pushy/src/main/ets/PushyTurboModule.ts index 9738439c..add21ad8 100644 --- a/harmony/pushy/src/main/ets/PushyTurboModule.ts +++ b/harmony/pushy/src/main/ets/PushyTurboModule.ts @@ -273,7 +273,7 @@ export class PushyTurboModule extends UITurboModule { ); } try { - await this.context.setKv(KEY_CONFIG, config); + await this.context.setNativeConfig(config); } catch (error) { throw toUpdateError(error, ERROR_FILE_OPERATION_FAILED); } diff --git a/harmony/pushy/src/main/ets/UpdateContext.ts b/harmony/pushy/src/main/ets/UpdateContext.ts index 27d8bb1e..954ca994 100644 --- a/harmony/pushy/src/main/ets/UpdateContext.ts +++ b/harmony/pushy/src/main/ets/UpdateContext.ts @@ -12,7 +12,9 @@ import { bundleManager } from '@kit.AbilityKit'; import { util } from '@kit.ArkTS'; import logger from './Logger'; import { + KEY_CONFIG, KEY_RESP_CACHE, + markJsCheckCompleted, scheduleNativeCheck, } from './NativeCheckOrchestrator'; import NativePatchCore, { @@ -458,6 +460,19 @@ export class UpdateContext { } /** 写入并落盘;flushSync 不可用时以 flush() 的结果拒绝。 */ + /** Shared JS/native configuration writer; all mutations precede the first await. */ + public setNativeConfig(config: string): Promise { + if (this.getKv(KEY_CONFIG) !== config) { + // Also guards A -> B -> A replacements and late download commits. + UpdateContext.resetGeneration += 1; + this.preferences.putSync(KEY_CONFIG, config); + this.preferences.deleteSync(KEY_RESP_CACHE); + markJsCheckCompleted(''); + } + // Flush even an equal value so a retry after a storage error can succeed. + return this.flushPreferences('configure native update'); + } + public setKv(key: string, value: string): Promise { this.preferences.putSync(key, value); return this.flushPreferences(`set key ${key}`); diff --git a/ios/RCTPushy/RCTPushy.h b/ios/RCTPushy/RCTPushy.h index 18477169..a2e5b44e 100644 --- a/ios/RCTPushy/RCTPushy.h +++ b/ios/RCTPushy/RCTPushy.h @@ -1,12 +1,22 @@ #import #import +typedef void (^RCTPushyNativeConfigurationCompletion)(NSError * _Nullable error); + typedef void (^RCTPushyNativeUpdateCompletion)(NSDictionary * _Nonnull result); @interface RCTPushy : RCTEventEmitter + (NSURL *)bundleURL; +/** Validate and persist native options without JS, network work or bundle resolution. + * Completion is on the main queue; nil error means success. Call before the + * normal launch bundle resolution for first-install native-only provisioning. + */ ++ (void)configure:(NSDictionary * _Nonnull)options + completion:(RCTPushyNativeConfigurationCompletion _Nullable)completion + NS_SWIFT_NAME(configure(_:completion:)); + /** * Start, join, or reuse this process's native update round. Call after the * host's real bundleURL resolution; this method never resolves the bundle diff --git a/ios/RCTPushy/RCTPushy.mm b/ios/RCTPushy/RCTPushy.mm index 2c7b907c..a2433846 100644 --- a/ios/RCTPushy/RCTPushy.mm +++ b/ios/RCTPushy/RCTPushy.mm @@ -1,4 +1,5 @@ #import "RCTPushy.h" +#import "RCTPushyNativeConfig.h" #import "RCTPushyDownloader.h" #import "ZipArchive.h" #include "../../cpp/patch_core/archive_limits.h" @@ -727,6 +728,8 @@ + (NSString *)buildTime; // bundle — this is what lets a bricked hot update be replaced on the next // launch. Decisions come from cpp/update_flow_core; this class is IO glue. @interface RCTPushyOrchestrator : NSObject ++ (void)persistConfiguration:(NSString *)config; ++ (BOOL)hasRunnableConfig; + (NSDictionary *)checkAndUpdate; + (void)scheduleFromColdStart:(NSString *)launchRolledBackVersion; + (void)markJsCheckCompleted:(NSString *)config; @@ -1022,6 +1025,35 @@ + (NSString *) rollback { return currentVersion; } ++ (void)configure:(NSDictionary *)options + completion:(RCTPushyNativeConfigurationCompletion)completion +{ + NSError *validationError = nil; + // Snapshot nested mutable caller values before crossing a queue boundary. + NSString *config = RCTPushyNormalizeNativeConfig(options, &validationError); + static dispatch_queue_t configQueue; + static dispatch_once_t once; + dispatch_once(&once, ^{ + configQueue = dispatch_queue_create("cn.reactnative.pushy.host-config", DISPATCH_QUEUE_SERIAL); + }); + dispatch_async(configQueue, ^{ + NSError *failure = validationError; + if (config != nil) { + @try { + [RCTPushyOrchestrator persistConfiguration:config]; + } @catch (NSException *exception) { + failure = PushyErrorWithCode(pushy::error_codes::kFileOperationFailed, + exception.reason ?: @"Native configuration failed"); + } + } + if (completion != nil) { + dispatch_async(dispatch_get_main_queue(), ^{ + completion(failure); + }); + } + }); +} + + (void)checkAndUpdateWithCompletion:(RCTPushyNativeUpdateCompletion)completion { static dispatch_queue_t hostQueue; @@ -1153,8 +1185,13 @@ - (instancetype)init error != nil ? error.localizedDescription : ERROR_OPTIONS)); return; } - [PushyDefaults() setObject:config forKey:keyNativeConfig]; - resolve(@true); + @try { + [RCTPushyOrchestrator persistConfiguration:config]; + resolve(@true); + } @catch (NSException *exception) { + PushyRejectError(reject, PushyErrorWithCode(pushy::error_codes::kFileOperationFailed, + exception.reason ?: @"Native configuration failed")); + } } RCT_EXPORT_METHOD(getNativeCheckCache:(RCTPromiseResolveBlock)resolve @@ -2176,6 +2213,35 @@ static BOOL PushyIsValidCheckResponse(NSString *responseText) { @implementation RCTPushyOrchestrator ++ (void)persistConfiguration:(NSString *)config { + PushyWithStateLock(^{ + NSUserDefaults *defaults = PushyDefaults(); + if ([[defaults stringForKey:keyNativeConfig] isEqualToString:config]) { + return; + } + // The same generation protects reset and replacement of the request + // identity/policy, including a late crash-rescue activation. + pushyResetGeneration.fetch_add(1); + [defaults setObject:config forKey:keyNativeConfig]; + [defaults removeObjectForKey:keyNativeCheckCache]; + [self markJsCheckCompleted:nil]; + }); + if (pushyNativeCheckReady.load() && [self hasRunnableConfig]) { + PushyInstallCrashRescueHandler(); + } +} + ++ (BOOL)hasRunnableConfig { + NSString *json = [PushyDefaults() stringForKey:keyNativeConfig]; + if (json.length == 0) { + return NO; + } + bool ok = false; + flowjson::Value config = flowjson::Parse(PushyToStdString(json), &ok); + return ok && config.IsObject() && !config.Get("disabled").Truthy() + && !config.Get("appKey").AsString().empty(); +} + + (void)scheduleFromColdStart:(NSString *)launchRolledBackVersion { #if !DEBUG // Once per process; a few seconds of delay keeps the check away from the @@ -2239,13 +2305,16 @@ + (NSDictionary *)checkAndUpdate { [self startRoundWithDeadline:0]; // A group is broadcast-style. Sharing the rescue semaphore would let one // waiter consume the only signal and leave the other waiting forever. - dispatch_group_wait(pushyHostRoundGroup, DISPATCH_TIME_FOREVER); - if (pushyHostRoundGeneration != pushyResetGeneration.load()) { - return PushyHostResult(@"cancelled", @"reset", nil, NO); + if (!pushyRoundStarted.load()) { + return PushyHostResult(@"skipped", @"config_changed", nil, NO); } + dispatch_group_wait(pushyHostRoundGroup, DISPATCH_TIME_FOREVER); if (![configJson isEqualToString:pushyHostRoundConfig] || ![configJson isEqualToString:[PushyDefaults() stringForKey:keyNativeConfig]]) { - return PushyHostResult(@"skipped", @"config_changed", nil, NO); + return PushyHostResult(@"cancelled", @"config_changed", nil, NO); + } + if (pushyHostRoundGeneration != pushyResetGeneration.load()) { + return PushyHostResult(@"cancelled", @"reset", nil, NO); } return pushyHostRoundResult ?: PushyHostResult(@"failed", @"internal_error", nil, NO); #endif @@ -2277,6 +2346,10 @@ + (BOOL)isJsCheckCompleted { // started it yet. deadlineUptime > 0 (crash rescue) caps every HTTP call and // download phase to the remaining budget. + (void)startRoundWithDeadline:(NSTimeInterval)deadlineUptime { + // Missing/disabled configuration is a preflight skip, not a used round. + if (![self hasRunnableConfig]) { + return; + } bool expected = false; if (!pushyRoundStarted.compare_exchange_strong(expected, true)) { return; @@ -2302,7 +2375,7 @@ + (void)startRoundWithDeadline:(NSTimeInterval)deadlineUptime { + (void)runRescueWithDeadline:(NSTimeInterval)deadlineUptime { pushyCrashRescueActive.store(true); [self startRoundWithDeadline:deadlineUptime]; - if (!pushyRoundCompleted.load()) { + if (pushyRoundStarted.load() && !pushyRoundCompleted.load()) { NSTimeInterval remaining = deadlineUptime - PushyMonotonicNow(); if (remaining > 0) { dispatch_semaphore_wait(pushyRoundDone, dispatch_time(DISPATCH_TIME_NOW, diff --git a/src/NativePushy.ts b/src/NativePushy.ts index 04d6d0ac..85055381 100644 --- a/src/NativePushy.ts +++ b/src/NativePushy.ts @@ -26,8 +26,9 @@ export interface Spec extends TurboModule { * Persist the config subset the native cold-start update check consumes * (appKey, endpoints, afterDownload policy; NATIVE_CHECKUPDATE_DESIGN * §10.1). Stored as a raw JSON string, parsed natively on read. JS is the - * single config source — a native side without persisted config silently - * skips its check, which doubles as the feature's rollout gate. + * default config source. Native hosts may also call configure(); select + * nativeConfigSource: 'native' in JS to leave host configuration untouched. + * A native side without persisted config skips its check. */ syncNativeConfig(config: string): Promise; /** diff --git a/src/client.ts b/src/client.ts index 27f87b22..186b0248 100644 --- a/src/client.ts +++ b/src/client.ts @@ -420,6 +420,10 @@ export class Pushy { }; private flushNativeConfig = () => { + if (this.options.nativeConfigSource === 'native') { + this.pendingNativeConfigJson = undefined; + return; + } if (this.nativeConfigSyncInFlight) { return; } @@ -480,6 +484,10 @@ export class Pushy { }; private syncNativeConfig = () => { + if (this.options.nativeConfigSource === 'native') { + this.pendingNativeConfigJson = undefined; + return; + } if ( Platform.OS === 'web' || typeof PushyModule.syncNativeConfig !== 'function' diff --git a/src/type.ts b/src/type.ts index 4e7d7321..6931c273 100644 --- a/src/type.ts +++ b/src/type.ts @@ -228,6 +228,13 @@ export interface ClientOptions { * per-version forceBoot directive may. */ disableNativeCheck?: boolean; + /** + * Owner of persisted native update configuration. Default: 'javascript'. + * Use 'native' when configure() is called by the host: JS will not overwrite + * that configuration. This does not configure or disable JS checks itself; + * keep appKey/server consistent and use checkStrategy to control JS checks. + */ + nativeConfigSource?: 'javascript' | 'native'; } export interface UpdateTestPayload { From acc11d5844a5400bfd95c3037d631f94162c75eb Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Sat, 19 Sep 2026 10:06:44 +0800 Subject: [PATCH 20/24] test: cover native provisioning validation, ownership and stale-commit protection --- src/__tests__/nativeConfiguration.test.ts | 195 ++++++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 src/__tests__/nativeConfiguration.test.ts diff --git a/src/__tests__/nativeConfiguration.test.ts b/src/__tests__/nativeConfiguration.test.ts new file mode 100644 index 00000000..3c21c297 --- /dev/null +++ b/src/__tests__/nativeConfiguration.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { runInNewContext } from 'node:vm'; +import type { NativeUpdateConfig } from '../../harmony/pushy/src/main/ets/NativeUpdateConfig'; +import { normalizeNativeUpdateConfig } from '../../harmony/pushy/src/main/ets/NativeUpdateConfig'; + +function runtimeSource(relativePath: string): string { + const source = readFileSync(new URL(relativePath, import.meta.url), 'utf8') + .replace(/^import[\s\S]*?;\r?\n/gm, '') + .replace(/^export\s*\{[^}]*\}(?:\s*from\s*[^;]*)?;\r?\n/gm, '') + .replace(/^export /gm, ''); + return new Bun.Transpiler({ loader: 'ts' }).transformSync(source); +} + +const storeSource = runtimeSource('../../harmony/pushy/src/main/ets/UpdateContext.ts'); +const clientSource = runtimeSource('../client.ts'); + +interface ConfigStore { + setNativeConfig: (config: string) => Promise; + getResetGeneration: () => number; + commitNativeCheckResult: ( + generation: number, + hash: string, + info: string, + activate: boolean, + cache: string + ) => Promise; +} + +function storeHarness() { + const values = new Map(); + let flushes = 0; + let shouldFail = false; + const clearedSignals: string[] = []; + const preferences = { + getSync: (key: string, fallback: string) => values.get(key) ?? fallback, + putSync: (key: string, value: string) => values.set(key, value), + deleteSync: (key: string) => values.delete(key), + flush: async () => { + flushes++; + if (shouldFail) throw new Error('storage unavailable'); + }, + }; + const store = runInNewContext( + `${storeSource}\nconst store = Object.create(UpdateContext.prototype); store.preferences = preferences; store.flushBatchDepth = 0; store;`, + { + preferences, + KEY_CONFIG: 'nativeConfig', + KEY_RESP_CACHE: 'nativeCheckResp', + markJsCheckCompleted: (config: string) => clearedSignals.push(config), + logger: { error() {} }, + getErrorMessage: (error: unknown) => String(error), + } + ) as ConfigStore; + return { + store, values, clearedSignals, + fail: (value: boolean) => { shouldFail = value; }, + flushes: () => flushes, + }; +} + +function clientHarness(native: boolean) { + const writes: string[] = []; + const client = runInNewContext( + `${clientSource}\nnew Pushy({appKey: 'test-app', nativeConfigSource: source, disableTelemetry: true});`, + { + source: native ? 'native' : 'javascript', + __DEV__: false, + assertWeb() {}, + noop() {}, + log() {}, + setDebugLogging() {}, + cInfo: { rnu: 'test-sdk', rn: 'test-rn' }, + packageVersion: '1.0', + isRolledBack: false, + Platform: { OS: 'android' }, + i18n: { setLocale() {} }, + dedupeEndpoints: (urls: string[]) => [...new Set(urls)], + PushyModule: { + syncNativeConfig: async (config: string) => { writes.push(config); }, + }, + } + ) as { setOptions: (options: Record) => void }; + return { client, writes }; +} + +describe('native configuration normalization', () => { + test('appKey alone supplies Pushy endpoints without automatically activating', () => { + const config = JSON.parse(normalizeNativeUpdateConfig({ appKey: 'test-app' })); + expect(config.endpoints).toEqual([ + 'https://update.react-native.cn/api', 'https://update.reactnative.cn/api', + ]); + expect(config.queryUrls).toHaveLength(2); + expect(config.afterDownload).toBe('none'); + expect(config.disabled).toBe(false); + expect(config.packageVersion).toBeUndefined(); + }); + + test('custom endpoints do not inherit public discovery, and are deduplicated', () => { + const options: NativeUpdateConfig = { + appKey: 'test-app', + endpoints: ['https://updates.example/api/', 'https://updates.example/api'], + afterDownload: 'setNeedUpdate', + }; + const original = JSON.stringify(options); + const config = JSON.parse(normalizeNativeUpdateConfig(options)); + expect(config.endpoints).toEqual(['https://updates.example/api']); + expect(config.queryUrls).toEqual([]); + expect(config.afterDownload).toBe('setNeedUpdate'); + expect(JSON.stringify(options)).toBe(original); + }); + + test('allows explicit discovery URLs and version identity overrides', () => { + const config = JSON.parse(normalizeNativeUpdateConfig({ + appKey: 'test-app', endpoints: ['http://localhost:8080/api'], + queryUrls: ['https://updates.example/endpoints.json?v=1'], + packageVersion: '2.0', rn: '0.77.3', rnu: 'test-sdk', disabled: true, + })); + expect(config.packageVersion).toBe('2.0'); + expect(config.disabled).toBe(true); + expect(config.queryUrls[0]).toContain('?v=1'); + }); + + for (const [name, options] of [ + ['missing key', {}], ['blank key', { appKey: ' ' }], + ['wrong key type', { appKey: 12 }], + ['empty endpoints', { appKey: 'a', endpoints: [] }], + ['null endpoints', { appKey: 'a', endpoints: null }], + ['non-array endpoints', { appKey: 'a', endpoints: 'https://example.com' }], + ['unsafe scheme', { appKey: 'a', endpoints: ['file:///tmp/update'] }], + ['credentials', { appKey: 'a', endpoints: ['https://user:pass@example.com'] }], + ['relative URL', { appKey: 'a', endpoints: ['/api'] }], + ['base query', { appKey: 'a', endpoints: ['https://example.com/api?x=1'] }], + ['wrong discovery type', { appKey: 'a', queryUrls: [42] }], + ['wrong activation', { appKey: 'a', afterDownload: 'immediate' }], + ['wrong disabled type', { appKey: 'a', disabled: 'false' }], + ['blank package version', { appKey: 'a', packageVersion: '' }], + ['unknown option', { appKey: 'a', endponts: ['https://example.com'] }], + ] as const) { + test(`rejects ${name} before storage is touched`, () => { + expect(() => normalizeNativeUpdateConfig(options as unknown as NativeUpdateConfig)).toThrow(); + }); + } +}); + +describe('actual native configuration store', () => { + test('configuration invalidates cache and old in-flight commits, even across A-B-A', async () => { + const h = storeHarness(); + await h.store.setNativeConfig('A'); + const oldGeneration = h.store.getResetGeneration(); + h.values.set('nativeCheckResp', 'stale'); + await h.store.setNativeConfig('B'); + expect(h.values.has('nativeCheckResp')).toBe(false); + await h.store.setNativeConfig('A'); + expect(h.store.getResetGeneration()).toBe(oldGeneration + 2); + expect(await h.store.commitNativeCheckResult(oldGeneration, 'old', '{}', true, 'stale')).toBe(false); + expect(h.values.has('hash_old')).toBe(false); + expect(h.clearedSignals).toEqual(['', '', '']); + }); + + test('identical configuration is idempotent but still confirms persistence', async () => { + const h = storeHarness(); + await h.store.setNativeConfig('A'); + const generation = h.store.getResetGeneration(); + await h.store.setNativeConfig('A'); + expect(h.store.getResetGeneration()).toBe(generation); + expect(h.flushes()).toBe(2); + }); + + test('storage errors reject and an equal-value retry can recover', async () => { + const h = storeHarness(); + h.fail(true); + await expect(h.store.setNativeConfig('A')).rejects.toThrow('storage unavailable'); + h.fail(false); + await h.store.setNativeConfig('A'); + expect(h.flushes()).toBe(2); + }); +}); + +describe('JS native configuration ownership', () => { + test('native ownership suppresses constructor and setOptions writes', () => { + const h = clientHarness(true); + expect(h.writes).toEqual([]); + h.client.setOptions({ updateStrategy: 'silentAndLater' }); + expect(h.writes).toEqual([]); + }); + + test('JS ownership retains existing synchronization, with explicit handover', () => { + const js = clientHarness(false); + expect(js.writes).toHaveLength(1); + const native = clientHarness(true); + native.client.setOptions({ nativeConfigSource: 'javascript' }); + expect(native.writes).toHaveLength(1); + }); +}); From a036f9d118b4aeb411b7be55c3584a7fd25f5f86 Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Sat, 19 Sep 2026 10:08:03 +0800 Subject: [PATCH 21/24] chore: stage native-first identity and provisioning regression coverage --- scripts/finalize-native-configuration.py | 143 +++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 scripts/finalize-native-configuration.py diff --git a/scripts/finalize-native-configuration.py b/scripts/finalize-native-configuration.py new file mode 100644 index 00000000..7dc15875 --- /dev/null +++ b/scripts/finalize-native-configuration.py @@ -0,0 +1,143 @@ +from pathlib import Path +pending = {} +def replace(path, old, new, count=1): + s = pending.get(path, Path(path).read_text()) + if s.count(old) != count: + raise RuntimeError(f'{path}: match count {s.count(old)} for {old[:100]!r}') + pending[path] = s.replace(old, new) +A='android/src/main/java/cn/reactnative/modules/update/' +H='harmony/pushy/src/main/ets/' +I='ios/RCTPushy/' +replace(A+'UpdateContext.java', ''' void setNativeConfig(String config) { + synchronized (commitLock) { + if (config.equals(sp.getString(NativeCheckOrchestrator.KEY_CONFIG, null))) { + return; + } + // Also invalidate on a failed persistence attempt: never allow an + // older round to commit over uncertain configuration state. + resetGeneration.incrementAndGet(); + SharedPreferences.Editor editor = sp.edit(); + editor.putString(NativeCheckOrchestrator.KEY_CONFIG, config); + editor.remove(NativeCheckOrchestrator.KEY_RESP_CACHE); + NativeCheckOrchestrator.markJsCheckCompleted(null); + persistEditorOrThrow(editor, "configure native update"); + } + NativeCheckOrchestrator.onConfigured(this); + }''', ''' private static final java.util.concurrent.atomic.AtomicLong nativeConfigGeneration = + new java.util.concurrent.atomic.AtomicLong(0); + + static long getNativeConfigGeneration() { + return nativeConfigGeneration.get(); + } + + void setNativeConfig(String config) { + synchronized (commitLock) { + boolean changed = !config.equals(sp.getString(NativeCheckOrchestrator.KEY_CONFIG, null)); + SharedPreferences.Editor editor = sp.edit(); + if (changed) { + // Also invalidate on a failed persistence attempt: an older + // round must not commit over uncertain configuration state. + resetGeneration.incrementAndGet(); + nativeConfigGeneration.incrementAndGet(); + editor.remove(NativeCheckOrchestrator.KEY_RESP_CACHE); + NativeCheckOrchestrator.markJsCheckCompleted(null); + } + // 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()); + } + editor.putString(NativeCheckOrchestrator.KEY_CONFIG, config); + // Persist even an equal value: a previous commit may have updated + // SharedPreferences memory but failed to write its file. + persistEditorOrThrow(editor, "configure native update"); + } + NativeCheckOrchestrator.onConfigured(this); + }''') +replace(A+'NativeCheckOrchestrator.java', ' private static volatile long roundGeneration = -1;\n', ' private static volatile long roundGeneration = -1;\n private static volatile long roundConfigGeneration = -1;\n') +replace(A+'NativeCheckOrchestrator.java', ' if (!configJson.equals(roundConfigJson) || !configJson.equals(context.getKv(KEY_CONFIG))) {', ' if (roundConfigGeneration != UpdateContext.getNativeConfigGeneration()\n || !configJson.equals(roundConfigJson) || !configJson.equals(context.getKv(KEY_CONFIG))) {') +replace(A+'NativeCheckOrchestrator.java', ' roundGeneration = resetGeneration;\n', ' roundGeneration = resetGeneration;\n roundConfigGeneration = UpdateContext.getNativeConfigGeneration();\n') + +replace(I+'RCTPushy.mm', 'static uint64_t pushyHostRoundGeneration = 0;\n', '''static uint64_t pushyHostRoundGeneration = 0; +static std::atomic pushyNativeConfigGeneration{0}; +static uint64_t pushyHostRoundConfigGeneration = 0; +''') +replace(I+'RCTPushy.mm', ''' if ([[defaults stringForKey:keyNativeConfig] isEqualToString:config]) { + return; + } + // The same generation protects reset and replacement of the request + // identity/policy, including a late crash-rescue activation. + pushyResetGeneration.fetch_add(1); + [defaults setObject:config forKey:keyNativeConfig]; + [defaults removeObjectForKey:keyNativeCheckCache]; + [self markJsCheckCompleted:nil];''', ''' if ([defaults stringForKey:keyUuid].length == 0) { + [defaults setObject:[NSUUID UUID].UUIDString forKey:keyUuid]; + } + if ([[defaults stringForKey:keyNativeConfig] isEqualToString:config]) { + return; + } + // The same generation protects reset and replacement of the request + // identity/policy, including a late crash-rescue activation. + pushyResetGeneration.fetch_add(1); + pushyNativeConfigGeneration.fetch_add(1); + [defaults setObject:config forKey:keyNativeConfig]; + [defaults removeObjectForKey:keyNativeCheckCache]; + [self markJsCheckCompleted:nil];''') +replace(I+'RCTPushy.mm', ' if (![configJson isEqualToString:pushyHostRoundConfig]\n', ' if (pushyHostRoundConfigGeneration != pushyNativeConfigGeneration.load()\n || ![configJson isEqualToString:pushyHostRoundConfig]\n') +replace(I+'RCTPushy.mm', ' pushyHostRoundGeneration = resetGeneration;\n', ' pushyHostRoundGeneration = resetGeneration;\n pushyHostRoundConfigGeneration = pushyNativeConfigGeneration.load();\n') + +replace(H+'UpdateContext.ts', ' public setNativeConfig(config: string): Promise {\n', ''' private static nativeConfigGeneration: number = 0; + + public getNativeConfigGeneration(): number { + return UpdateContext.nativeConfigGeneration; + } + + public setNativeConfig(config: string): Promise { + if (!this.getKv('uuid')) { + this.preferences.putSync('uuid', util.generateRandomUUID()); + } +''') +replace(H+'UpdateContext.ts', ''' // Also guards A -> B -> A replacements and late download commits. + UpdateContext.resetGeneration += 1;''', ''' // Also guards A -> B -> A replacements and late download commits. + UpdateContext.resetGeneration += 1; + UpdateContext.nativeConfigGeneration += 1;''') +replace(H+'NativeCheckOrchestrator.ts', 'let roundGeneration = -1;\n', 'let roundGeneration = -1;\nlet roundConfigGeneration = -1;\n') +replace(H+'NativeCheckOrchestrator.ts', ' roundGeneration = resetGeneration;\n', ' roundGeneration = resetGeneration;\n roundConfigGeneration = context.getNativeConfigGeneration();\n') +replace(H+'NativeCheckOrchestrator.ts', ' if (configJson !== roundConfigJson || configJson !== context.getKv(KEY_CONFIG)) {', ' if (roundConfigGeneration !== context.getNativeConfigGeneration()\n || configJson !== roundConfigJson || configJson !== context.getKv(KEY_CONFIG)) {') +replace('src/__tests__/nativeHostApi.test.ts', ' getResetGeneration: () => state.generation,\n', ' getResetGeneration: () => state.generation,\n getNativeConfigGeneration: () => 0,\n') +replace('src/__tests__/nativeConfiguration.test.ts', " KEY_CONFIG: 'nativeConfig',\n", " util: { generateRandomUUID: () => 'native-installation-id' },\n KEY_CONFIG: 'nativeConfig',\n") +replace('src/__tests__/nativeConfiguration.test.ts', " expect(h.values.has('hash_old')).toBe(false);\n", " expect(h.values.has('hash_old')).toBe(false);\n expect(h.values.get('uuid')).toBe('native-installation-id');\n") + +path='src/__tests__/nativeHostApi.test.ts' +s = pending[path] +s += ''' + +test('automatic preflight without configuration leaves a round for later native provisioning', async () => { + const h = harness(); + h.values.delete('nativeConfig'); + h.initialize(); + for (const timer of h.timers) timer(); + await Promise.resolve(); + expect(h.state.checks).toBe(0); + h.values.set('nativeConfig', JSON.stringify({ appKey: 'native-first-app', afterDownload: 'setNeedUpdate' })); + h.state.decision = { action: 'download', hash: 'v2', activate: true }; + expect((await h.check()).activated).toBe(true); + expect(h.state.checks).toBe(1); +}); + +test('configuration replacement during an update cancels its returned snapshot', async () => { + const h = harness(); + h.initialize(); + h.state.decision = { action: 'download', hash: 'v2', activate: true }; + h.state.beforeResponse = async () => { + h.values.set('nativeConfig', JSON.stringify({ appKey: 'replacement' })); + h.state.generation += 1; + }; + expect(await h.check()).toEqual(nativeUpdateResult('cancelled', 'config_changed')); +}); +''' +pending[path] = s +for path, content in pending.items(): + Path(path).write_text(content) +print('Applied native-first identity and regression follow-ups') From 1ec02ad614752efd6269ada05f0471e0b37600ef Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Sat, 19 Sep 2026 10:08:39 +0800 Subject: [PATCH 22/24] chore: verify native-first configuration and its regression tests --- .../native-configuration-finalize.yml | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/workflows/native-configuration-finalize.yml diff --git a/.github/workflows/native-configuration-finalize.yml b/.github/workflows/native-configuration-finalize.yml new file mode 100644 index 00000000..51caf8a8 --- /dev/null +++ b/.github/workflows/native-configuration-finalize.yml @@ -0,0 +1,41 @@ +name: Verify native-first configuration +on: + push: + branches: [feat/native-host-update-api] + paths: [.github/workflows/native-configuration-finalize.yml] +permissions: + contents: write +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.4.0 + - name: Apply guarded follow-ups + run: | + python3 scripts/finalize-native-configuration.py + python3 - <<'PY' + from pathlib import Path + p = Path('ios/RCTPushy/RCTPushyNativeConfig.mm') + s = p.read_text() + old = 'if (CFGetTypeID((__bridge CFTypeRef)disabled) != CFBooleanGetTypeID()) {' + assert s.count(old) == 1 + p.write_text(s.replace(old, 'if (![disabled isKindOfClass:NSNumber.class] || CFGetTypeID((__bridge CFTypeRef)disabled) != CFBooleanGetTypeID()) {')) + PY + - name: Validate tests and types + run: | + bun install --frozen-lockfile + bunx biome check --write src + bunx tsc --noEmit + bun test src/__tests__ + git diff --check + - name: Commit verified changes + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add android/src/main/java/cn/reactnative/modules/update/{UpdateContext,NativeCheckOrchestrator}.java ios/RCTPushy/{RCTPushy.mm,RCTPushyNativeConfig.mm} harmony/pushy/src/main/ets/{UpdateContext,NativeCheckOrchestrator}.ts src/__tests__/nativeConfiguration.test.ts src/__tests__/nativeHostApi.test.ts + git commit -m 'feat: preserve native-first identity and invalidate superseded configuration rounds' + git push origin HEAD:refs/heads/feat/native-host-update-api From 9ee744a3137d03a44da17372f9610b4ecaa5f90d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 19 Sep 2026 02:08:50 +0000 Subject: [PATCH 23/24] feat: preserve native-first identity and invalidate superseded configuration rounds --- .../update/NativeCheckOrchestrator.java | 5 +- .../modules/update/UpdateContext.java | 32 ++++++--- .../src/main/ets/NativeCheckOrchestrator.ts | 5 +- harmony/pushy/src/main/ets/UpdateContext.ts | 10 +++ ios/RCTPushy/RCTPushy.mm | 10 ++- ios/RCTPushy/RCTPushyNativeConfig.mm | 2 +- src/__tests__/nativeConfiguration.test.ts | 72 ++++++++++++++----- src/__tests__/nativeHostApi.test.ts | 33 +++++++++ 8 files changed, 140 insertions(+), 29 deletions(-) diff --git a/android/src/main/java/cn/reactnative/modules/update/NativeCheckOrchestrator.java b/android/src/main/java/cn/reactnative/modules/update/NativeCheckOrchestrator.java index dd55e6c9..b09ee496 100644 --- a/android/src/main/java/cn/reactnative/modules/update/NativeCheckOrchestrator.java +++ b/android/src/main/java/cn/reactnative/modules/update/NativeCheckOrchestrator.java @@ -76,6 +76,7 @@ final class NativeCheckOrchestrator { private static volatile NativeUpdateResult roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "check_failed"); private static volatile long roundGeneration = -1; + private static volatile long roundConfigGeneration = -1; private static volatile String roundConfigJson; /** Blocking only on the host API's worker; never call on the UI thread. */ @@ -106,7 +107,8 @@ static NativeUpdateResult checkAndUpdate(UpdateContext context) throws Interrupt return NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "config_changed"); } roundDone.await(); - if (!configJson.equals(roundConfigJson) || !configJson.equals(context.getKv(KEY_CONFIG))) { + if (roundConfigGeneration != UpdateContext.getNativeConfigGeneration() + || !configJson.equals(roundConfigJson) || !configJson.equals(context.getKv(KEY_CONFIG))) { return NativeUpdateResult.of(NativeUpdateResult.CANCELLED, "config_changed"); } if (roundGeneration != UpdateContext.getResetGeneration()) { @@ -300,6 +302,7 @@ private static void runOnce( ) throws JSONException { final long resetGeneration = UpdateContext.getResetGeneration(); roundGeneration = resetGeneration; + roundConfigGeneration = UpdateContext.getNativeConfigGeneration(); roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "check_failed"); String configJson = context.getKv(KEY_CONFIG); roundConfigJson = configJson; 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 2bcfecf9..e5ba1432 100644 --- a/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +++ b/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java @@ -738,18 +738,34 @@ public String getBundleUrl(String defaultAssetsUrl) { /** Sampled/compared by the native check orchestrator; see resetGeneration. */ /** Shared by JS and native hosts. Config replacement invalidates old native decisions. */ + private static final java.util.concurrent.atomic.AtomicLong nativeConfigGeneration = + new java.util.concurrent.atomic.AtomicLong(0); + + static long getNativeConfigGeneration() { + return nativeConfigGeneration.get(); + } + void setNativeConfig(String config) { synchronized (commitLock) { - if (config.equals(sp.getString(NativeCheckOrchestrator.KEY_CONFIG, null))) { - return; - } - // Also invalidate on a failed persistence attempt: never allow an - // older round to commit over uncertain configuration state. - resetGeneration.incrementAndGet(); + boolean changed = !config.equals(sp.getString(NativeCheckOrchestrator.KEY_CONFIG, null)); SharedPreferences.Editor editor = sp.edit(); + if (changed) { + // Also invalidate on a failed persistence attempt: an older + // round must not commit over uncertain configuration state. + resetGeneration.incrementAndGet(); + nativeConfigGeneration.incrementAndGet(); + editor.remove(NativeCheckOrchestrator.KEY_RESP_CACHE); + NativeCheckOrchestrator.markJsCheckCompleted(null); + } + // 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()); + } editor.putString(NativeCheckOrchestrator.KEY_CONFIG, config); - editor.remove(NativeCheckOrchestrator.KEY_RESP_CACHE); - NativeCheckOrchestrator.markJsCheckCompleted(null); + // Persist even an equal value: a previous commit may have updated + // SharedPreferences memory but failed to write its file. persistEditorOrThrow(editor, "configure native update"); } NativeCheckOrchestrator.onConfigured(this); diff --git a/harmony/pushy/src/main/ets/NativeCheckOrchestrator.ts b/harmony/pushy/src/main/ets/NativeCheckOrchestrator.ts index 5ad36453..64543a37 100644 --- a/harmony/pushy/src/main/ets/NativeCheckOrchestrator.ts +++ b/harmony/pushy/src/main/ets/NativeCheckOrchestrator.ts @@ -113,6 +113,7 @@ const hostRound = new NativeUpdateRound(); let scheduledContext: UpdateContext | undefined; let scheduledRollback = ''; let roundGeneration = -1; +let roundConfigGeneration = -1; let roundConfigJson: string | undefined; let roundResult = nativeUpdateResult('failed', 'check_failed'); @@ -184,7 +185,8 @@ export async function checkAndUpdateNative( return nativeUpdateResult('failed', 'invalid_config'); } const result = await startNativeRound(context, scheduledRollback); - if (configJson !== roundConfigJson || configJson !== context.getKv(KEY_CONFIG)) { + if (roundConfigGeneration !== context.getNativeConfigGeneration() + || configJson !== roundConfigJson || configJson !== context.getKv(KEY_CONFIG)) { return nativeUpdateResult('cancelled', 'config_changed'); } if (roundGeneration !== context.getResetGeneration()) { @@ -247,6 +249,7 @@ async function runOnce( // reset 必须赢过本轮的决策。 const resetGeneration = context.getResetGeneration(); roundGeneration = resetGeneration; + roundConfigGeneration = context.getNativeConfigGeneration(); roundResult = nativeUpdateResult('failed', 'check_failed'); const configJson = context.getKv(KEY_CONFIG); roundConfigJson = configJson; diff --git a/harmony/pushy/src/main/ets/UpdateContext.ts b/harmony/pushy/src/main/ets/UpdateContext.ts index 954ca994..8380bd4b 100644 --- a/harmony/pushy/src/main/ets/UpdateContext.ts +++ b/harmony/pushy/src/main/ets/UpdateContext.ts @@ -461,10 +461,20 @@ export class UpdateContext { /** 写入并落盘;flushSync 不可用时以 flush() 的结果拒绝。 */ /** Shared JS/native configuration writer; all mutations precede the first await. */ + private static nativeConfigGeneration: number = 0; + + public getNativeConfigGeneration(): number { + return UpdateContext.nativeConfigGeneration; + } + public setNativeConfig(config: string): Promise { + if (!this.getKv('uuid')) { + this.preferences.putSync('uuid', util.generateRandomUUID()); + } if (this.getKv(KEY_CONFIG) !== 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); markJsCheckCompleted(''); diff --git a/ios/RCTPushy/RCTPushy.mm b/ios/RCTPushy/RCTPushy.mm index a2433846..dcc852c3 100644 --- a/ios/RCTPushy/RCTPushy.mm +++ b/ios/RCTPushy/RCTPushy.mm @@ -778,6 +778,8 @@ + (BOOL)commitRoundWithGeneration:(uint64_t)generation static NSDictionary *pushyHostRoundResult = nil; static NSString *pushyHostRoundConfig = nil; static uint64_t pushyHostRoundGeneration = 0; +static std::atomic pushyNativeConfigGeneration{0}; +static uint64_t pushyHostRoundConfigGeneration = 0; static const NSTimeInterval kPushyRescueTriggerUptime = 60; static const NSTimeInterval kPushyRescueBudgetBackgroundThread = 10; @@ -2216,12 +2218,16 @@ @implementation RCTPushyOrchestrator + (void)persistConfiguration:(NSString *)config { PushyWithStateLock(^{ NSUserDefaults *defaults = PushyDefaults(); + if ([defaults stringForKey:keyUuid].length == 0) { + [defaults setObject:[NSUUID UUID].UUIDString forKey:keyUuid]; + } if ([[defaults stringForKey:keyNativeConfig] isEqualToString:config]) { return; } // The same generation protects reset and replacement of the request // identity/policy, including a late crash-rescue activation. pushyResetGeneration.fetch_add(1); + pushyNativeConfigGeneration.fetch_add(1); [defaults setObject:config forKey:keyNativeConfig]; [defaults removeObjectForKey:keyNativeCheckCache]; [self markJsCheckCompleted:nil]; @@ -2309,7 +2315,8 @@ + (NSDictionary *)checkAndUpdate { return PushyHostResult(@"skipped", @"config_changed", nil, NO); } dispatch_group_wait(pushyHostRoundGroup, DISPATCH_TIME_FOREVER); - if (![configJson isEqualToString:pushyHostRoundConfig] + if (pushyHostRoundConfigGeneration != pushyNativeConfigGeneration.load() + || ![configJson isEqualToString:pushyHostRoundConfig] || ![configJson isEqualToString:[PushyDefaults() stringForKey:keyNativeConfig]]) { return PushyHostResult(@"cancelled", @"config_changed", nil, NO); } @@ -2449,6 +2456,7 @@ + (RCTPushy *)engine { + (void)runOnce:(NSString *)launchRolledBackVersion deadline:(NSTimeInterval)deadlineUptime { const uint64_t resetGeneration = pushyResetGeneration.load(); pushyHostRoundGeneration = resetGeneration; + pushyHostRoundConfigGeneration = pushyNativeConfigGeneration.load(); pushyHostRoundResult = PushyHostResult(@"failed", @"check_failed", nil, NO); NSUserDefaults *defaults = PushyDefaults(); NSString *configJson = [defaults stringForKey:keyNativeConfig]; diff --git a/ios/RCTPushy/RCTPushyNativeConfig.mm b/ios/RCTPushy/RCTPushyNativeConfig.mm index 5586897d..4303372c 100644 --- a/ios/RCTPushy/RCTPushyNativeConfig.mm +++ b/ios/RCTPushy/RCTPushyNativeConfig.mm @@ -75,7 +75,7 @@ static void PushyConfigInvalid(NSString *message) { PushyConfigInvalid(@"afterDownload must be none or setNeedUpdate"); } id disabled = options[@"disabled"] ?: @NO; - if (CFGetTypeID((__bridge CFTypeRef)disabled) != CFBooleanGetTypeID()) { + if (![disabled isKindOfClass:NSNumber.class] || CFGetTypeID((__bridge CFTypeRef)disabled) != CFBooleanGetTypeID()) { PushyConfigInvalid(@"disabled must be a boolean"); } NSMutableDictionary *result = [@{ diff --git a/src/__tests__/nativeConfiguration.test.ts b/src/__tests__/nativeConfiguration.test.ts index 3c21c297..2e6e0c42 100644 --- a/src/__tests__/nativeConfiguration.test.ts +++ b/src/__tests__/nativeConfiguration.test.ts @@ -12,7 +12,9 @@ function runtimeSource(relativePath: string): string { return new Bun.Transpiler({ loader: 'ts' }).transformSync(source); } -const storeSource = runtimeSource('../../harmony/pushy/src/main/ets/UpdateContext.ts'); +const storeSource = runtimeSource( + '../../harmony/pushy/src/main/ets/UpdateContext.ts' +); const clientSource = runtimeSource('../client.ts'); interface ConfigStore { @@ -45,6 +47,7 @@ function storeHarness() { `${storeSource}\nconst store = Object.create(UpdateContext.prototype); store.preferences = preferences; store.flushBatchDepth = 0; store;`, { preferences, + util: { generateRandomUUID: () => 'native-installation-id' }, KEY_CONFIG: 'nativeConfig', KEY_RESP_CACHE: 'nativeCheckResp', markJsCheckCompleted: (config: string) => clearedSignals.push(config), @@ -53,8 +56,12 @@ function storeHarness() { } ) as ConfigStore; return { - store, values, clearedSignals, - fail: (value: boolean) => { shouldFail = value; }, + store, + values, + clearedSignals, + fail: (value: boolean) => { + shouldFail = value; + }, flushes: () => flushes, }; } @@ -77,7 +84,9 @@ function clientHarness(native: boolean) { i18n: { setLocale() {} }, dedupeEndpoints: (urls: string[]) => [...new Set(urls)], PushyModule: { - syncNativeConfig: async (config: string) => { writes.push(config); }, + syncNativeConfig: async (config: string) => { + writes.push(config); + }, }, } ) as { setOptions: (options: Record) => void }; @@ -86,9 +95,12 @@ function clientHarness(native: boolean) { describe('native configuration normalization', () => { test('appKey alone supplies Pushy endpoints without automatically activating', () => { - const config = JSON.parse(normalizeNativeUpdateConfig({ appKey: 'test-app' })); + const config = JSON.parse( + normalizeNativeUpdateConfig({ appKey: 'test-app' }) + ); expect(config.endpoints).toEqual([ - 'https://update.react-native.cn/api', 'https://update.reactnative.cn/api', + 'https://update.react-native.cn/api', + 'https://update.reactnative.cn/api', ]); expect(config.queryUrls).toHaveLength(2); expect(config.afterDownload).toBe('none'); @@ -99,7 +111,10 @@ describe('native configuration normalization', () => { test('custom endpoints do not inherit public discovery, and are deduplicated', () => { const options: NativeUpdateConfig = { appKey: 'test-app', - endpoints: ['https://updates.example/api/', 'https://updates.example/api'], + endpoints: [ + 'https://updates.example/api/', + 'https://updates.example/api', + ], afterDownload: 'setNeedUpdate', }; const original = JSON.stringify(options); @@ -111,24 +126,34 @@ describe('native configuration normalization', () => { }); test('allows explicit discovery URLs and version identity overrides', () => { - const config = JSON.parse(normalizeNativeUpdateConfig({ - appKey: 'test-app', endpoints: ['http://localhost:8080/api'], - queryUrls: ['https://updates.example/endpoints.json?v=1'], - packageVersion: '2.0', rn: '0.77.3', rnu: 'test-sdk', disabled: true, - })); + const config = JSON.parse( + normalizeNativeUpdateConfig({ + appKey: 'test-app', + endpoints: ['http://localhost:8080/api'], + queryUrls: ['https://updates.example/endpoints.json?v=1'], + packageVersion: '2.0', + rn: '0.77.3', + rnu: 'test-sdk', + disabled: true, + }) + ); expect(config.packageVersion).toBe('2.0'); expect(config.disabled).toBe(true); expect(config.queryUrls[0]).toContain('?v=1'); }); for (const [name, options] of [ - ['missing key', {}], ['blank key', { appKey: ' ' }], + ['missing key', {}], + ['blank key', { appKey: ' ' }], ['wrong key type', { appKey: 12 }], ['empty endpoints', { appKey: 'a', endpoints: [] }], ['null endpoints', { appKey: 'a', endpoints: null }], ['non-array endpoints', { appKey: 'a', endpoints: 'https://example.com' }], ['unsafe scheme', { appKey: 'a', endpoints: ['file:///tmp/update'] }], - ['credentials', { appKey: 'a', endpoints: ['https://user:pass@example.com'] }], + [ + 'credentials', + { appKey: 'a', endpoints: ['https://user:pass@example.com'] }, + ], ['relative URL', { appKey: 'a', endpoints: ['/api'] }], ['base query', { appKey: 'a', endpoints: ['https://example.com/api?x=1'] }], ['wrong discovery type', { appKey: 'a', queryUrls: [42] }], @@ -138,7 +163,9 @@ describe('native configuration normalization', () => { ['unknown option', { appKey: 'a', endponts: ['https://example.com'] }], ] as const) { test(`rejects ${name} before storage is touched`, () => { - expect(() => normalizeNativeUpdateConfig(options as unknown as NativeUpdateConfig)).toThrow(); + expect(() => + normalizeNativeUpdateConfig(options as unknown as NativeUpdateConfig) + ).toThrow(); }); } }); @@ -153,8 +180,17 @@ describe('actual native configuration store', () => { expect(h.values.has('nativeCheckResp')).toBe(false); await h.store.setNativeConfig('A'); expect(h.store.getResetGeneration()).toBe(oldGeneration + 2); - expect(await h.store.commitNativeCheckResult(oldGeneration, 'old', '{}', true, 'stale')).toBe(false); + expect( + await h.store.commitNativeCheckResult( + oldGeneration, + 'old', + '{}', + true, + 'stale' + ) + ).toBe(false); expect(h.values.has('hash_old')).toBe(false); + expect(h.values.get('uuid')).toBe('native-installation-id'); expect(h.clearedSignals).toEqual(['', '', '']); }); @@ -170,7 +206,9 @@ describe('actual native configuration store', () => { test('storage errors reject and an equal-value retry can recover', async () => { const h = storeHarness(); h.fail(true); - await expect(h.store.setNativeConfig('A')).rejects.toThrow('storage unavailable'); + await expect(h.store.setNativeConfig('A')).rejects.toThrow( + 'storage unavailable' + ); h.fail(false); await h.store.setNativeConfig('A'); expect(h.flushes()).toBe(2); diff --git a/src/__tests__/nativeHostApi.test.ts b/src/__tests__/nativeHostApi.test.ts index 1ef42992..4ff9951e 100644 --- a/src/__tests__/nativeHostApi.test.ts +++ b/src/__tests__/nativeHostApi.test.ts @@ -53,6 +53,7 @@ function harness() { values.delete(key); }, getResetGeneration: () => state.generation, + getNativeConfigGeneration: () => 0, getCurrentVersion: () => '', getPackageVersion: () => '1.0', getBuildTime: () => '123', @@ -205,3 +206,35 @@ describe('native host API orchestration', () => { expect(h.state.checks).toBe(1); }); }); + +test('automatic preflight without configuration leaves a round for later native provisioning', async () => { + const h = harness(); + h.values.delete('nativeConfig'); + h.initialize(); + for (const timer of h.timers) timer(); + await Promise.resolve(); + expect(h.state.checks).toBe(0); + h.values.set( + 'nativeConfig', + JSON.stringify({ + appKey: 'native-first-app', + afterDownload: 'setNeedUpdate', + }) + ); + h.state.decision = { action: 'download', hash: 'v2', activate: true }; + expect((await h.check()).activated).toBe(true); + expect(h.state.checks).toBe(1); +}); + +test('configuration replacement during an update cancels its returned snapshot', async () => { + const h = harness(); + h.initialize(); + h.state.decision = { action: 'download', hash: 'v2', activate: true }; + h.state.beforeResponse = async () => { + h.values.set('nativeConfig', JSON.stringify({ appKey: 'replacement' })); + h.state.generation += 1; + }; + expect(await h.check()).toEqual( + nativeUpdateResult('cancelled', 'config_changed') + ); +}); From 11de3d4499539a471018e381c85006ed591ee69e Mon Sep 17 00:00:00 2001 From: Sunny Luo Date: Sat, 19 Sep 2026 10:11:07 +0800 Subject: [PATCH 24/24] chore: remove completed native configuration preparation helpers --- .../native-configuration-finalize.yml | 41 -- .../native-configuration-prepare.yml | 32 -- scripts/finalize-native-configuration.py | 143 ------- scripts/prepare-native-configuration.py | 349 ------------------ 4 files changed, 565 deletions(-) delete mode 100644 .github/workflows/native-configuration-finalize.yml delete mode 100644 .github/workflows/native-configuration-prepare.yml delete mode 100644 scripts/finalize-native-configuration.py delete mode 100644 scripts/prepare-native-configuration.py diff --git a/.github/workflows/native-configuration-finalize.yml b/.github/workflows/native-configuration-finalize.yml deleted file mode 100644 index 51caf8a8..00000000 --- a/.github/workflows/native-configuration-finalize.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Verify native-first configuration -on: - push: - branches: [feat/native-host-update-api] - paths: [.github/workflows/native-configuration-finalize.yml] -permissions: - contents: write -jobs: - verify: - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - uses: actions/checkout@v7 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: 1.4.0 - - name: Apply guarded follow-ups - run: | - python3 scripts/finalize-native-configuration.py - python3 - <<'PY' - from pathlib import Path - p = Path('ios/RCTPushy/RCTPushyNativeConfig.mm') - s = p.read_text() - old = 'if (CFGetTypeID((__bridge CFTypeRef)disabled) != CFBooleanGetTypeID()) {' - assert s.count(old) == 1 - p.write_text(s.replace(old, 'if (![disabled isKindOfClass:NSNumber.class] || CFGetTypeID((__bridge CFTypeRef)disabled) != CFBooleanGetTypeID()) {')) - PY - - name: Validate tests and types - run: | - bun install --frozen-lockfile - bunx biome check --write src - bunx tsc --noEmit - bun test src/__tests__ - git diff --check - - name: Commit verified changes - run: | - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add android/src/main/java/cn/reactnative/modules/update/{UpdateContext,NativeCheckOrchestrator}.java ios/RCTPushy/{RCTPushy.mm,RCTPushyNativeConfig.mm} harmony/pushy/src/main/ets/{UpdateContext,NativeCheckOrchestrator}.ts src/__tests__/nativeConfiguration.test.ts src/__tests__/nativeHostApi.test.ts - git commit -m 'feat: preserve native-first identity and invalidate superseded configuration rounds' - git push origin HEAD:refs/heads/feat/native-host-update-api diff --git a/.github/workflows/native-configuration-prepare.yml b/.github/workflows/native-configuration-prepare.yml deleted file mode 100644 index e8ec5f72..00000000 --- a/.github/workflows/native-configuration-prepare.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: Prepare native configuration -on: - push: - branches: [feat/native-host-update-api] - paths: [.github/workflows/native-configuration-prepare.yml] -permissions: - contents: write -jobs: - prepare: - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - uses: actions/checkout@v7 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: 1.4.0 - - name: Apply guarded source changes - run: python3 scripts/prepare-native-configuration.py - - name: Check JS types and tests - run: | - bun install --frozen-lockfile - bunx biome check --write src - bunx tsc --noEmit - bun test src/__tests__ - git diff --check - - name: Commit implementation - run: | - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add android/src/main/java/cn/reactnative/modules/update/{PushyNativeUpdate,UpdateContext,UpdateModuleImpl,NativeCheckOrchestrator}.java ios/RCTPushy/RCTPushy.{h,mm} harmony/pushy/src/main/ets/{UpdateContext,PushyTurboModule,NativeCheckOrchestrator}.ts harmony/pushy/src/main/ets/PushyFileJSBundleProvider.ets harmony/pushy/index.ets src/client.ts src/type.ts src/NativePushy.ts - git commit -m 'feat: configure native updates before JS with explicit configuration ownership' - git push origin HEAD:refs/heads/feat/native-host-update-api diff --git a/scripts/finalize-native-configuration.py b/scripts/finalize-native-configuration.py deleted file mode 100644 index 7dc15875..00000000 --- a/scripts/finalize-native-configuration.py +++ /dev/null @@ -1,143 +0,0 @@ -from pathlib import Path -pending = {} -def replace(path, old, new, count=1): - s = pending.get(path, Path(path).read_text()) - if s.count(old) != count: - raise RuntimeError(f'{path}: match count {s.count(old)} for {old[:100]!r}') - pending[path] = s.replace(old, new) -A='android/src/main/java/cn/reactnative/modules/update/' -H='harmony/pushy/src/main/ets/' -I='ios/RCTPushy/' -replace(A+'UpdateContext.java', ''' void setNativeConfig(String config) { - synchronized (commitLock) { - if (config.equals(sp.getString(NativeCheckOrchestrator.KEY_CONFIG, null))) { - return; - } - // Also invalidate on a failed persistence attempt: never allow an - // older round to commit over uncertain configuration state. - resetGeneration.incrementAndGet(); - SharedPreferences.Editor editor = sp.edit(); - editor.putString(NativeCheckOrchestrator.KEY_CONFIG, config); - editor.remove(NativeCheckOrchestrator.KEY_RESP_CACHE); - NativeCheckOrchestrator.markJsCheckCompleted(null); - persistEditorOrThrow(editor, "configure native update"); - } - NativeCheckOrchestrator.onConfigured(this); - }''', ''' private static final java.util.concurrent.atomic.AtomicLong nativeConfigGeneration = - new java.util.concurrent.atomic.AtomicLong(0); - - static long getNativeConfigGeneration() { - return nativeConfigGeneration.get(); - } - - void setNativeConfig(String config) { - synchronized (commitLock) { - boolean changed = !config.equals(sp.getString(NativeCheckOrchestrator.KEY_CONFIG, null)); - SharedPreferences.Editor editor = sp.edit(); - if (changed) { - // Also invalidate on a failed persistence attempt: an older - // round must not commit over uncertain configuration state. - resetGeneration.incrementAndGet(); - nativeConfigGeneration.incrementAndGet(); - editor.remove(NativeCheckOrchestrator.KEY_RESP_CACHE); - NativeCheckOrchestrator.markJsCheckCompleted(null); - } - // 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()); - } - editor.putString(NativeCheckOrchestrator.KEY_CONFIG, config); - // Persist even an equal value: a previous commit may have updated - // SharedPreferences memory but failed to write its file. - persistEditorOrThrow(editor, "configure native update"); - } - NativeCheckOrchestrator.onConfigured(this); - }''') -replace(A+'NativeCheckOrchestrator.java', ' private static volatile long roundGeneration = -1;\n', ' private static volatile long roundGeneration = -1;\n private static volatile long roundConfigGeneration = -1;\n') -replace(A+'NativeCheckOrchestrator.java', ' if (!configJson.equals(roundConfigJson) || !configJson.equals(context.getKv(KEY_CONFIG))) {', ' if (roundConfigGeneration != UpdateContext.getNativeConfigGeneration()\n || !configJson.equals(roundConfigJson) || !configJson.equals(context.getKv(KEY_CONFIG))) {') -replace(A+'NativeCheckOrchestrator.java', ' roundGeneration = resetGeneration;\n', ' roundGeneration = resetGeneration;\n roundConfigGeneration = UpdateContext.getNativeConfigGeneration();\n') - -replace(I+'RCTPushy.mm', 'static uint64_t pushyHostRoundGeneration = 0;\n', '''static uint64_t pushyHostRoundGeneration = 0; -static std::atomic pushyNativeConfigGeneration{0}; -static uint64_t pushyHostRoundConfigGeneration = 0; -''') -replace(I+'RCTPushy.mm', ''' if ([[defaults stringForKey:keyNativeConfig] isEqualToString:config]) { - return; - } - // The same generation protects reset and replacement of the request - // identity/policy, including a late crash-rescue activation. - pushyResetGeneration.fetch_add(1); - [defaults setObject:config forKey:keyNativeConfig]; - [defaults removeObjectForKey:keyNativeCheckCache]; - [self markJsCheckCompleted:nil];''', ''' if ([defaults stringForKey:keyUuid].length == 0) { - [defaults setObject:[NSUUID UUID].UUIDString forKey:keyUuid]; - } - if ([[defaults stringForKey:keyNativeConfig] isEqualToString:config]) { - return; - } - // The same generation protects reset and replacement of the request - // identity/policy, including a late crash-rescue activation. - pushyResetGeneration.fetch_add(1); - pushyNativeConfigGeneration.fetch_add(1); - [defaults setObject:config forKey:keyNativeConfig]; - [defaults removeObjectForKey:keyNativeCheckCache]; - [self markJsCheckCompleted:nil];''') -replace(I+'RCTPushy.mm', ' if (![configJson isEqualToString:pushyHostRoundConfig]\n', ' if (pushyHostRoundConfigGeneration != pushyNativeConfigGeneration.load()\n || ![configJson isEqualToString:pushyHostRoundConfig]\n') -replace(I+'RCTPushy.mm', ' pushyHostRoundGeneration = resetGeneration;\n', ' pushyHostRoundGeneration = resetGeneration;\n pushyHostRoundConfigGeneration = pushyNativeConfigGeneration.load();\n') - -replace(H+'UpdateContext.ts', ' public setNativeConfig(config: string): Promise {\n', ''' private static nativeConfigGeneration: number = 0; - - public getNativeConfigGeneration(): number { - return UpdateContext.nativeConfigGeneration; - } - - public setNativeConfig(config: string): Promise { - if (!this.getKv('uuid')) { - this.preferences.putSync('uuid', util.generateRandomUUID()); - } -''') -replace(H+'UpdateContext.ts', ''' // Also guards A -> B -> A replacements and late download commits. - UpdateContext.resetGeneration += 1;''', ''' // Also guards A -> B -> A replacements and late download commits. - UpdateContext.resetGeneration += 1; - UpdateContext.nativeConfigGeneration += 1;''') -replace(H+'NativeCheckOrchestrator.ts', 'let roundGeneration = -1;\n', 'let roundGeneration = -1;\nlet roundConfigGeneration = -1;\n') -replace(H+'NativeCheckOrchestrator.ts', ' roundGeneration = resetGeneration;\n', ' roundGeneration = resetGeneration;\n roundConfigGeneration = context.getNativeConfigGeneration();\n') -replace(H+'NativeCheckOrchestrator.ts', ' if (configJson !== roundConfigJson || configJson !== context.getKv(KEY_CONFIG)) {', ' if (roundConfigGeneration !== context.getNativeConfigGeneration()\n || configJson !== roundConfigJson || configJson !== context.getKv(KEY_CONFIG)) {') -replace('src/__tests__/nativeHostApi.test.ts', ' getResetGeneration: () => state.generation,\n', ' getResetGeneration: () => state.generation,\n getNativeConfigGeneration: () => 0,\n') -replace('src/__tests__/nativeConfiguration.test.ts', " KEY_CONFIG: 'nativeConfig',\n", " util: { generateRandomUUID: () => 'native-installation-id' },\n KEY_CONFIG: 'nativeConfig',\n") -replace('src/__tests__/nativeConfiguration.test.ts', " expect(h.values.has('hash_old')).toBe(false);\n", " expect(h.values.has('hash_old')).toBe(false);\n expect(h.values.get('uuid')).toBe('native-installation-id');\n") - -path='src/__tests__/nativeHostApi.test.ts' -s = pending[path] -s += ''' - -test('automatic preflight without configuration leaves a round for later native provisioning', async () => { - const h = harness(); - h.values.delete('nativeConfig'); - h.initialize(); - for (const timer of h.timers) timer(); - await Promise.resolve(); - expect(h.state.checks).toBe(0); - h.values.set('nativeConfig', JSON.stringify({ appKey: 'native-first-app', afterDownload: 'setNeedUpdate' })); - h.state.decision = { action: 'download', hash: 'v2', activate: true }; - expect((await h.check()).activated).toBe(true); - expect(h.state.checks).toBe(1); -}); - -test('configuration replacement during an update cancels its returned snapshot', async () => { - const h = harness(); - h.initialize(); - h.state.decision = { action: 'download', hash: 'v2', activate: true }; - h.state.beforeResponse = async () => { - h.values.set('nativeConfig', JSON.stringify({ appKey: 'replacement' })); - h.state.generation += 1; - }; - expect(await h.check()).toEqual(nativeUpdateResult('cancelled', 'config_changed')); -}); -''' -pending[path] = s -for path, content in pending.items(): - Path(path).write_text(content) -print('Applied native-first identity and regression follow-ups') diff --git a/scripts/prepare-native-configuration.py b/scripts/prepare-native-configuration.py deleted file mode 100644 index 5efb4876..00000000 --- a/scripts/prepare-native-configuration.py +++ /dev/null @@ -1,349 +0,0 @@ -from pathlib import Path - -pending = {} -def change(path, old, new, count=1): - text = pending.get(path, Path(path).read_text()) - found = text.count(old) - if found != count: - raise RuntimeError(f'{path}: expected {count}, found {found}: {old[:100]!r}') - pending[path] = text.replace(old, new) - -A = 'android/src/main/java/cn/reactnative/modules/update/' -H = 'harmony/pushy/src/main/ets/' -I = 'ios/RCTPushy/' - -change(A+'PushyNativeUpdate.java', 'import android.util.Log;\n', 'import android.util.Log;\nimport androidx.annotation.Nullable;\nimport org.json.JSONObject;\n') -change(A+'PushyNativeUpdate.java', '/** Native host API. Configuration remains owned and persisted by the JS SDK. */', '/** Bridge-free native configuration and update APIs. */') -change(A+'PushyNativeUpdate.java', ' private PushyNativeUpdate() {\n', ''' public interface ConfigurationCallback { - /** Main thread; null means configuration was persisted successfully. */ - void onComplete(@Nullable Exception error); - } - - // Configuration must not wait behind a network round that it invalidates. - private static final Executor CONFIG_WORKER = Executors.newSingleThreadExecutor(new ThreadFactory() { - @Override - public Thread newThread(Runnable runnable) { - Thread thread = new Thread(runnable, "pushy-host-config"); - thread.setDaemon(true); - return thread; - } - }); - - /** - * Validate and persist a complete configuration, even before JS or bundle - * resolution. This starts no network work and never resolves a bundle. - * Await the callback before continuing startup/checkAndUpdate. Unless JS - * uses nativeConfigSource: 'native', later JS config writes can replace it. - */ - public static void configure(Context context, JSONObject options, final ConfigurationCallback callback) { - if (context == null || options == null || callback == null) { - throw new IllegalArgumentException("context, options and callback are required"); - } - final Context applicationContext = context.getApplicationContext(); - // Snapshot caller-owned JSON before dispatch, not minutes later on a worker. - final String snapshot = options.toString(); - CONFIG_WORKER.execute(new Runnable() { - @Override - public void run() { - Exception failure = null; - try { - String config = NativeUpdateConfig.normalize(snapshot); - UpdateContext.getInstance(applicationContext).setNativeConfig(config); - } catch (Exception e) { - failure = e; - } catch (LinkageError e) { - failure = new IllegalStateException("Native configuration failed", e); - } - final Exception error = failure; - new Handler(Looper.getMainLooper()).post(new Runnable() { - @Override - public void run() { - callback.onComplete(error); - } - }); - } - }); - } - - private PushyNativeUpdate() { -''') -change(A+'UpdateContext.java', ' static long getResetGeneration() {\n', ''' /** Shared by JS and native hosts. Config replacement invalidates old native decisions. */ - void setNativeConfig(String config) { - synchronized (commitLock) { - if (config.equals(sp.getString(NativeCheckOrchestrator.KEY_CONFIG, null))) { - return; - } - // Also invalidate on a failed persistence attempt: never allow an - // older round to commit over uncertain configuration state. - resetGeneration.incrementAndGet(); - SharedPreferences.Editor editor = sp.edit(); - editor.putString(NativeCheckOrchestrator.KEY_CONFIG, config); - editor.remove(NativeCheckOrchestrator.KEY_RESP_CACHE); - NativeCheckOrchestrator.markJsCheckCompleted(null); - persistEditorOrThrow(editor, "configure native update"); - } - NativeCheckOrchestrator.onConfigured(this); - } - - // Native-decision generation: bumped by reset AND configuration replacement. - static long getResetGeneration() { -''') -change(A+'UpdateModuleImpl.java', 'updateContext.setKv(NativeCheckOrchestrator.KEY_CONFIG, config);', 'updateContext.setNativeConfig(config);') -change(A+'NativeCheckOrchestrator.java', ' startRound(0);\n roundDone.await();', ''' startRound(0); - if (!roundStarted.get()) { - return NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "config_changed"); - } - roundDone.await();''') -change(A+'NativeCheckOrchestrator.java', ''' if (roundGeneration != UpdateContext.getResetGeneration()) { - return NativeUpdateResult.of(NativeUpdateResult.CANCELLED, "reset"); - } - if (!configJson.equals(roundConfigJson) || !configJson.equals(context.getKv(KEY_CONFIG))) { - return NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "config_changed"); - }''', ''' if (!configJson.equals(roundConfigJson) || !configJson.equals(context.getKv(KEY_CONFIG))) { - return NativeUpdateResult.of(NativeUpdateResult.CANCELLED, "config_changed"); - } - if (roundGeneration != UpdateContext.getResetGeneration()) { - return NativeUpdateResult.of(NativeUpdateResult.CANCELLED, "reset"); - }''') -change(A+'NativeCheckOrchestrator.java', ' private static void startRound(long deadlineNanos) {\n', ''' private static boolean hasRunnableConfig(UpdateContext context) { - if (context == null) { - return false; - } - try { - String json = context.getKv(KEY_CONFIG); - if (json == null) { - return false; - } - JSONObject config = new JSONObject(json); - return !config.optBoolean("disabled", false) - && config.opt("appKey") instanceof String - && !config.getString("appKey").trim().isEmpty(); - } catch (JSONException e) { - return false; - } - } - - static void onConfigured(UpdateContext context) { - if (nativeReady && sContext == context && hasRunnableConfig(context)) { - CrashRescue.install(); - } - } - - private static void startRound(long deadlineNanos) { - // An automatic check before first-run provisioning must not consume - // the process's only round. Hosts may configure later in this launch. - if (!hasRunnableConfig(sContext)) { - return; - } -''') -change(A+'NativeCheckOrchestrator.java', ' if (!roundCompleted) {\n', ' if (roundStarted.get() && !roundCompleted) {\n') - -change(I+'RCTPushy.h', 'typedef void (^RCTPushyNativeUpdateCompletion)', 'typedef void (^RCTPushyNativeConfigurationCompletion)(NSError * _Nullable error);\n\ntypedef void (^RCTPushyNativeUpdateCompletion)') -change(I+'RCTPushy.h', '+ (NSURL *)bundleURL;\n', '''+ (NSURL *)bundleURL; - -/** Validate and persist native options without JS, network work or bundle resolution. - * Completion is on the main queue; nil error means success. Call before the - * normal launch bundle resolution for first-install native-only provisioning. - */ -+ (void)configure:(NSDictionary * _Nonnull)options - completion:(RCTPushyNativeConfigurationCompletion _Nullable)completion - NS_SWIFT_NAME(configure(_:completion:)); -''') -change(I+'RCTPushy.mm', '#import "RCTPushy.h"\n', '#import "RCTPushy.h"\n#import "RCTPushyNativeConfig.h"\n') -change(I+'RCTPushy.mm', '@interface RCTPushyOrchestrator : NSObject\n', '@interface RCTPushyOrchestrator : NSObject\n+ (void)persistConfiguration:(NSString *)config;\n+ (BOOL)hasRunnableConfig;\n') -change(I+'RCTPushy.mm', '+ (void)checkAndUpdateWithCompletion:(RCTPushyNativeUpdateCompletion)completion\n', '''+ (void)configure:(NSDictionary *)options - completion:(RCTPushyNativeConfigurationCompletion)completion -{ - NSError *validationError = nil; - // Snapshot nested mutable caller values before crossing a queue boundary. - NSString *config = RCTPushyNormalizeNativeConfig(options, &validationError); - static dispatch_queue_t configQueue; - static dispatch_once_t once; - dispatch_once(&once, ^{ - configQueue = dispatch_queue_create("cn.reactnative.pushy.host-config", DISPATCH_QUEUE_SERIAL); - }); - dispatch_async(configQueue, ^{ - NSError *failure = validationError; - if (config != nil) { - @try { - [RCTPushyOrchestrator persistConfiguration:config]; - } @catch (NSException *exception) { - failure = PushyErrorWithCode(pushy::error_codes::kFileOperationFailed, - exception.reason ?: @"Native configuration failed"); - } - } - if (completion != nil) { - dispatch_async(dispatch_get_main_queue(), ^{ - completion(failure); - }); - } - }); -} - -+ (void)checkAndUpdateWithCompletion:(RCTPushyNativeUpdateCompletion)completion -''') -change(I+'RCTPushy.mm', ' [PushyDefaults() setObject:config forKey:keyNativeConfig];\n resolve(@true);', ''' @try { - [RCTPushyOrchestrator persistConfiguration:config]; - resolve(@true); - } @catch (NSException *exception) { - PushyRejectError(reject, PushyErrorWithCode(pushy::error_codes::kFileOperationFailed, - exception.reason ?: @"Native configuration failed")); - }''') -change(I+'RCTPushy.mm', '@implementation RCTPushyOrchestrator\n', '''@implementation RCTPushyOrchestrator - -+ (void)persistConfiguration:(NSString *)config { - PushyWithStateLock(^{ - NSUserDefaults *defaults = PushyDefaults(); - if ([[defaults stringForKey:keyNativeConfig] isEqualToString:config]) { - return; - } - // The same generation protects reset and replacement of the request - // identity/policy, including a late crash-rescue activation. - pushyResetGeneration.fetch_add(1); - [defaults setObject:config forKey:keyNativeConfig]; - [defaults removeObjectForKey:keyNativeCheckCache]; - [self markJsCheckCompleted:nil]; - }); - if (pushyNativeCheckReady.load() && [self hasRunnableConfig]) { - PushyInstallCrashRescueHandler(); - } -} - -+ (BOOL)hasRunnableConfig { - NSString *json = [PushyDefaults() stringForKey:keyNativeConfig]; - if (json.length == 0) { - return NO; - } - bool ok = false; - flowjson::Value config = flowjson::Parse(PushyToStdString(json), &ok); - return ok && config.IsObject() && !config.Get("disabled").Truthy() - && !config.Get("appKey").AsString().empty(); -} -''') -change(I+'RCTPushy.mm', ' dispatch_group_wait(pushyHostRoundGroup, DISPATCH_TIME_FOREVER);\n', ''' if (!pushyRoundStarted.load()) { - return PushyHostResult(@"skipped", @"config_changed", nil, NO); - } - dispatch_group_wait(pushyHostRoundGroup, DISPATCH_TIME_FOREVER); -''') -change(I+'RCTPushy.mm', ''' if (pushyHostRoundGeneration != pushyResetGeneration.load()) { - return PushyHostResult(@"cancelled", @"reset", nil, NO); - } - if (![configJson isEqualToString:pushyHostRoundConfig] - || ![configJson isEqualToString:[PushyDefaults() stringForKey:keyNativeConfig]]) { - return PushyHostResult(@"skipped", @"config_changed", nil, NO); - }''', ''' if (![configJson isEqualToString:pushyHostRoundConfig] - || ![configJson isEqualToString:[PushyDefaults() stringForKey:keyNativeConfig]]) { - return PushyHostResult(@"cancelled", @"config_changed", nil, NO); - } - if (pushyHostRoundGeneration != pushyResetGeneration.load()) { - return PushyHostResult(@"cancelled", @"reset", nil, NO); - }''') -change(I+'RCTPushy.mm', '+ (void)startRoundWithDeadline:(NSTimeInterval)deadlineUptime {\n', '''+ (void)startRoundWithDeadline:(NSTimeInterval)deadlineUptime { - // Missing/disabled configuration is a preflight skip, not a used round. - if (![self hasRunnableConfig]) { - return; - } -''') -change(I+'RCTPushy.mm', ' if (!pushyRoundCompleted.load()) {\n', ' if (pushyRoundStarted.load() && !pushyRoundCompleted.load()) {\n') - -change(H+'UpdateContext.ts', ' KEY_RESP_CACHE,\n scheduleNativeCheck,', ' KEY_CONFIG,\n KEY_RESP_CACHE,\n markJsCheckCompleted,\n scheduleNativeCheck,') -change(H+'UpdateContext.ts', ' public setKv(key: string, value: string): Promise {\n', ''' /** Shared JS/native configuration writer; all mutations precede the first await. */ - public setNativeConfig(config: string): Promise { - if (this.getKv(KEY_CONFIG) !== config) { - // Also guards A -> B -> A replacements and late download commits. - UpdateContext.resetGeneration += 1; - this.preferences.putSync(KEY_CONFIG, config); - this.preferences.deleteSync(KEY_RESP_CACHE); - markJsCheckCompleted(''); - } - // Flush even an equal value so a retry after a storage error can succeed. - return this.flushPreferences('configure native update'); - } - - public setKv(key: string, value: string): Promise { -''') -change(H+'PushyTurboModule.ts', 'await this.context.setKv(KEY_CONFIG, config);', 'await this.context.setNativeConfig(config);') -change(H+'PushyFileJSBundleProvider.ets', "import type { NativeUpdateResult } from './NativeUpdateResult';\n", "import type { NativeUpdateResult } from './NativeUpdateResult';\nimport { normalizeNativeUpdateConfig } from './NativeUpdateConfig';\nimport type { NativeUpdateConfig } from './NativeUpdateConfig';\n") -change(H+'PushyFileJSBundleProvider.ets', ' /** Call after the host\'s real bundle resolution; never resolves it again. */\n', ''' /** Configure before normal bundle resolution; no JS or network work is required. */ - async configure(options: NativeUpdateConfig): Promise { - const config = normalizeNativeUpdateConfig(options); - await this.updateContext.setNativeConfig(config); - } - - /** Call after the host's real bundle resolution; never resolves it again. */ -''') -change('harmony/pushy/index.ets', "export type { NativeUpdateResult } from './src/main/ets/NativeUpdateResult';\n", "export type { NativeUpdateResult } from './src/main/ets/NativeUpdateResult';\nexport type { NativeUpdateConfig } from './src/main/ets/NativeUpdateConfig';\n") -change(H+'NativeCheckOrchestrator.ts', ''' return hostRound.run(async () => { -''', ''' const preflight = configurationError(context); - if (preflight !== undefined) { - return Promise.resolve(preflight); - } - return hostRound.run(async () => { -''') -change(H+'NativeCheckOrchestrator.ts', 'export async function checkAndUpdateNative(\n', '''// A preflight skip must not consume the process's only round: configuration -// may arrive after the delayed startup timer on a first-ever launch. -function configurationError(context: UpdateContext): NativeUpdateResult | undefined { - const json = context.getKv(KEY_CONFIG); - if (!json) { - return nativeUpdateResult('skipped', 'not_configured'); - } - try { - const config = JSON.parse(json) as NativeConfig; - if (!config || typeof config !== 'object' || Array.isArray(config)) { - return nativeUpdateResult('failed', 'invalid_config'); - } - if (config.disabled) { - return nativeUpdateResult('skipped', 'disabled'); - } - if (typeof config.appKey !== 'string' || config.appKey.trim().length === 0) { - return nativeUpdateResult('failed', 'invalid_config'); - } - } catch (e) { - return nativeUpdateResult('failed', 'invalid_config'); - } - return undefined; -} - -export async function checkAndUpdateNative( -''') -change(H+'NativeCheckOrchestrator.ts', ''' if (roundGeneration !== context.getResetGeneration()) { - return nativeUpdateResult('cancelled', 'reset'); - } - if (configJson !== roundConfigJson || configJson !== context.getKv(KEY_CONFIG)) { - return nativeUpdateResult('skipped', 'config_changed'); - }''', ''' if (configJson !== roundConfigJson || configJson !== context.getKv(KEY_CONFIG)) { - return nativeUpdateResult('cancelled', 'config_changed'); - } - if (roundGeneration !== context.getResetGeneration()) { - return nativeUpdateResult('cancelled', 'reset'); - }''') - -change('src/type.ts', ' disableNativeCheck?: boolean;\n', ''' disableNativeCheck?: boolean; - /** - * Owner of persisted native update configuration. Default: 'javascript'. - * Use 'native' when configure() is called by the host: JS will not overwrite - * that configuration. This does not configure or disable JS checks itself; - * keep appKey/server consistent and use checkStrategy to control JS checks. - */ - nativeConfigSource?: 'javascript' | 'native'; -''') -change('src/client.ts', ' private flushNativeConfig = () => {\n', ''' private flushNativeConfig = () => { - if (this.options.nativeConfigSource === 'native') { - this.pendingNativeConfigJson = undefined; - return; - } -''') -change('src/client.ts', ' private syncNativeConfig = () => {\n', ''' private syncNativeConfig = () => { - if (this.options.nativeConfigSource === 'native') { - this.pendingNativeConfigJson = undefined; - return; - } -''') -change('src/NativePushy.ts', ''' * single config source — a native side without persisted config silently - * skips its check, which doubles as the feature's rollout gate.''', ''' * default config source. Native hosts may also call configure(); select - * nativeConfigSource: 'native' in JS to leave host configuration untouched. - * A native side without persisted config skips its check.''') - -for path, content in pending.items(): - Path(path).write_text(content) -print('Integrated native configuration across', len(pending), 'files')