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..b09ee496 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,52 @@ 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 long roundConfigGeneration = -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); + if (!roundStarted.get()) { + return NativeUpdateResult.of(NativeUpdateResult.SKIPPED, "config_changed"); + } + roundDone.await(); + if (roundConfigGeneration != UpdateContext.getNativeConfigGeneration() + || !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"); + } + return roundResult; + } + static void markJsCheckCompleted(String config) { sJsCompletedConfig = config; @@ -99,6 +145,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) { @@ -142,7 +189,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; } @@ -150,6 +226,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(); @@ -170,7 +247,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 { @@ -223,32 +300,33 @@ 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; + roundConfigGeneration = UpdateContext.getNativeConfigGeneration(); + 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 +401,7 @@ private static void runConfiguredRound( String body = NativeUpdateFlow.buildCheckRequestBody(input.toString()); if (body == null) { + roundResult = NativeUpdateResult.of(NativeUpdateResult.FAILED, "invalid_request"); return; } @@ -339,19 +418,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 +448,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 +499,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 +516,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/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); + } +} 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; + } +} 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..c523443a --- /dev/null +++ b/android/src/main/java/cn/reactnative/modules/update/PushyNativeUpdate.java @@ -0,0 +1,127 @@ +package cn.reactnative.modules.update; + +import android.content.Context; +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; + +/** 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. */ + 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; + } + }); + + 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() { + } + + /** + * 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); + } + }); + } + }); + } +} 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..e5ba1432 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,41 @@ 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) { + 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); + } + + // 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 f53d0f3b..b5bfdbae 100644 --- a/harmony/pushy/index.ets +++ b/harmony/pushy/index.ets @@ -2,3 +2,5 @@ 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'; +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 c3b796b6..64543a37 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,94 @@ 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 roundConfigGeneration = -1; +let roundConfigJson: string | undefined; +let roundResult = nativeUpdateResult('failed', 'check_failed'); + +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); + } catch (e) { + logger.error(TAG, `native check failed: ${getErrorMessage(e)}`); + roundResult = nativeUpdateResult('failed', 'internal_error'); + } + return roundResult; + }); +} + +// 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 { + 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 (roundConfigGeneration !== context.getNativeConfigGeneration() + || configJson !== roundConfigJson || configJson !== context.getKv(KEY_CONFIG)) { + return nativeUpdateResult('cancelled', 'config_changed'); + } + if (roundGeneration !== context.getResetGeneration()) { + return nativeUpdateResult('cancelled', 'reset'); + } + // 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 +221,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 +234,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 +248,34 @@ async function runOnce( // 在任何 IO 之前采样:resetToPackagedBundle 会递增它,本轮运行期间发生的 // reset 必须赢过本轮的决策。 const resetGeneration = context.getResetGeneration(); + roundGeneration = resetGeneration; + roundConfigGeneration = context.getNativeConfigGeneration(); + 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 +371,7 @@ async function runConfiguredRound( }; const body = NativePatchCore.buildCheckRequestBody(JSON.stringify(input)); if (!body) { + roundResult = nativeUpdateResult('failed', 'invalid_request'); return; } @@ -285,23 +390,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 +430,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 +481,7 @@ async function runConfiguredRound( ); } catch (e) { logger.error(TAG, `commit failed: ${getErrorMessage(e)}`); + roundResult = nativeUpdateResult('failed', 'commit_failed'); return; } if (!committed) { @@ -377,6 +491,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/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); +} 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; + } +} diff --git a/harmony/pushy/src/main/ets/PushyFileJSBundleProvider.ets b/harmony/pushy/src/main/ets/PushyFileJSBundleProvider.ets index 8392235d..8976d847 100644 --- a/harmony/pushy/src/main/ets/PushyFileJSBundleProvider.ets +++ b/harmony/pushy/src/main/ets/PushyFileJSBundleProvider.ets @@ -6,6 +6,10 @@ 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'; +import { normalizeNativeUpdateConfig } from './NativeUpdateConfig'; +import type { NativeUpdateConfig } from './NativeUpdateConfig'; export class PushyFileJSBundleProvider extends JSBundleProvider { private updateContext: UpdateContext; @@ -43,6 +47,17 @@ 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); + } + getAppKeys(): string[] { return []; } 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..8380bd4b 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,29 @@ 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(''); + } + // 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 a44bb22a..a2e5b44e 100644 --- a/ios/RCTPushy/RCTPushy.h +++ b/ios/RCTPushy/RCTPushy.h @@ -1,9 +1,33 @@ #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 + * 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 diff --git a/ios/RCTPushy/RCTPushy.mm b/ios/RCTPushy/RCTPushy.mm index 291a8bb4..dcc852c3 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" @@ -36,6 +37,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 +728,9 @@ + (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; + (void)startRoundWithDeadline:(NSTimeInterval)deadlineUptime; @@ -761,6 +772,14 @@ + (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 std::atomic pushyNativeConfigGeneration{0}; +static uint64_t pushyHostRoundConfigGeneration = 0; static const NSTimeInterval kPushyRescueTriggerUptime = 60; static const NSTimeInterval kPushyRescueBudgetBackgroundThread = 10; @@ -1008,6 +1027,59 @@ + (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; + 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; @@ -1115,8 +1187,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 @@ -2138,6 +2215,39 @@ static BOOL PushyIsValidCheckResponse(NSString *responseText) { @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]; + }); + 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 @@ -2147,8 +2257,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 +2284,49 @@ + (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. + if (!pushyRoundStarted.load()) { + return PushyHostResult(@"skipped", @"config_changed", nil, NO); + } + dispatch_group_wait(pushyHostRoundGroup, DISPATCH_TIME_FOREVER); + if (pushyHostRoundConfigGeneration != pushyNativeConfigGeneration.load() + || ![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); + } + return pushyHostRoundResult ?: PushyHostResult(@"failed", @"internal_error", nil, NO); +#endif +} + + (void)markJsCheckCompleted:(NSString *)config { @synchronized (RCTPushyOrchestrator.class) { pushyJsCompletedConfig = [config copy]; @@ -2197,6 +2353,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; @@ -2206,9 +2366,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); } } @@ -2220,7 +2382,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, @@ -2292,27 +2454,33 @@ + (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; + pushyHostRoundConfigGeneration = pushyNativeConfigGeneration.load(); + 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 +2556,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 +2574,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 +2603,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 +2664,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 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); diff --git a/ios/RCTPushy/RCTPushyNativeConfig.mm b/ios/RCTPushy/RCTPushyNativeConfig.mm new file mode 100644 index 00000000..4303372c --- /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 (![disabled isKindOfClass:NSNumber.class] || 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; + } +} 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/__tests__/nativeConfiguration.test.ts b/src/__tests__/nativeConfiguration.test.ts new file mode 100644 index 00000000..2e6e0c42 --- /dev/null +++ b/src/__tests__/nativeConfiguration.test.ts @@ -0,0 +1,233 @@ +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, + util: { generateRandomUUID: () => 'native-installation-id' }, + 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.values.get('uuid')).toBe('native-installation-id'); + 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); + }); +}); diff --git a/src/__tests__/nativeHostApi.test.ts b/src/__tests__/nativeHostApi.test.ts new file mode 100644 index 00000000..4ff9951e --- /dev/null +++ b/src/__tests__/nativeHostApi.test.ts @@ -0,0 +1,240 @@ +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'; + +// 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, + getNativeConfigGeneration: () => 0, + 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); + }); +}); + +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') + ); +}); diff --git a/src/__tests__/nativeUpdateRound.test.ts b/src/__tests__/nativeUpdateRound.test.ts new file mode 100644 index 00000000..d1f3f207 --- /dev/null +++ b/src/__tests__/nativeUpdateRound.test.ts @@ -0,0 +1,87 @@ +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'; + +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, + }); + }); +}); 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 {