diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 874eea55..c90fe80a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -45,7 +45,7 @@ jobs: exit 0 fi echo "Previous tag is $PREV_TAG" - # The HAR embeds librnupdate.so compiled from cpp/patch_core, + # The HAR embeds librnpushy.so compiled from cpp/patch_core, # cpp/update_flow_core, android/jni/hpatch.c and the HDiffPatch/lzma # submodules (harmony/pushy/src/main/cpp/CMakeLists.txt), so any of # those changing must rebuild it too — reusing the previous HAR @@ -60,7 +60,7 @@ jobs: fi # Rebuild the Android native libraries from source so the published package - # can never ship a stale librnupdate.so after a cpp/patch_core change (the + # can never ship a stale librnpushy.so after a cpp/patch_core change (the # committed binaries are for local dev / git installs only). build_android_so: runs-on: ubuntu-latest # npm --provenance requires a GitHub-hosted runner diff --git a/Example/harmony_use_pushy/harmony/entry/src/main/cpp/CMakeLists.txt b/Example/harmony_use_pushy/harmony/entry/src/main/cpp/CMakeLists.txt index fb969cac..6d1dec56 100644 --- a/Example/harmony_use_pushy/harmony/entry/src/main/cpp/CMakeLists.txt +++ b/Example/harmony_use_pushy/harmony/entry/src/main/cpp/CMakeLists.txt @@ -23,6 +23,6 @@ add_library(rnoh_app SHARED "${RNOH_CPP_DIR}/RNOHAppNapiBridge.cpp" ) target_include_directories(rnoh_app PRIVATE "${PUSHY_CPP_DIR}") -# Pushy's RNOH glue is compiled into rnoh_app directly. librnupdate.so comes from pushy.har. +# Pushy's RNOH glue is compiled into rnoh_app directly. librnpushy.so comes from pushy.har. target_sources(rnoh_app PRIVATE "${PUSHY_CPP_DIR}/PushyTurboModule.cpp") target_link_libraries(rnoh_app PUBLIC rnoh) diff --git a/android/jni/Android.mk b/android/jni/Android.mk index 830d356e..a3576c4c 100644 --- a/android/jni/Android.mk +++ b/android/jni/Android.mk @@ -2,7 +2,7 @@ LOCAL_PATH := $(call my-dir) include $(CLEAR_VARS) -LOCAL_MODULE := rnupdate +LOCAL_MODULE := rnpushy LOCAL_CPPFLAGS += -std=c++17 LOCAL_LDFLAGS += -Wl,--exclude-libs,ALL LOCAL_C_INCLUDES := \ @@ -25,6 +25,7 @@ LOCAL_SRC_FILES := \ ../../cpp/patch_core/digest.cpp \ ../../cpp/patch_core/hbc_transform.cpp \ ../../cpp/patch_core/hbc_transform_wire.cpp \ + ../../cpp/patch_core/jni_registration.cpp \ ../../cpp/patch_core/patch_core.cpp \ ../../cpp/patch_core/patch_core_android.cpp \ ../../cpp/patch_core/state_core.cpp \ diff --git a/android/lib/arm64-v8a/librnpushy.so b/android/lib/arm64-v8a/librnpushy.so new file mode 100755 index 00000000..4a1a5789 Binary files /dev/null and b/android/lib/arm64-v8a/librnpushy.so differ diff --git a/android/lib/arm64-v8a/librnupdate.so b/android/lib/arm64-v8a/librnupdate.so deleted file mode 100755 index 6407a9e6..00000000 Binary files a/android/lib/arm64-v8a/librnupdate.so and /dev/null differ diff --git a/android/lib/armeabi-v7a/librnpushy.so b/android/lib/armeabi-v7a/librnpushy.so new file mode 100755 index 00000000..ae2d3516 Binary files /dev/null and b/android/lib/armeabi-v7a/librnpushy.so differ diff --git a/android/lib/armeabi-v7a/librnupdate.so b/android/lib/armeabi-v7a/librnupdate.so deleted file mode 100755 index f52883b5..00000000 Binary files a/android/lib/armeabi-v7a/librnupdate.so and /dev/null differ diff --git a/android/lib/x86/librnpushy.so b/android/lib/x86/librnpushy.so new file mode 100755 index 00000000..33733942 Binary files /dev/null and b/android/lib/x86/librnpushy.so differ diff --git a/android/lib/x86/librnupdate.so b/android/lib/x86/librnupdate.so deleted file mode 100755 index 292d13a8..00000000 Binary files a/android/lib/x86/librnupdate.so and /dev/null differ diff --git a/android/lib/x86_64/librnpushy.so b/android/lib/x86_64/librnpushy.so new file mode 100755 index 00000000..197c935a Binary files /dev/null and b/android/lib/x86_64/librnpushy.so differ diff --git a/android/lib/x86_64/librnupdate.so b/android/lib/x86_64/librnupdate.so deleted file mode 100755 index 1f364f9c..00000000 Binary files a/android/lib/x86_64/librnupdate.so and /dev/null differ diff --git a/android/src/main/java/cn/reactnative/modules/update/ApkInstaller.java b/android/src/main/java/cn/reactnative/modules/update/ApkInstaller.java index 112c2e90..8aa316d2 100644 --- a/android/src/main/java/cn/reactnative/modules/update/ApkInstaller.java +++ b/android/src/main/java/cn/reactnative/modules/update/ApkInstaller.java @@ -22,7 +22,7 @@ final class ApkInstaller { private static final int COPY_BUFFER_SIZE = 64 * 1024; private static final String SESSION_ID_EXTRA = - "cn.reactnative.modules.update.extra.PACKAGE_INSTALLER_SESSION_ID"; + Texts.reveal("3919bac3ab8a6b512c3e08f0c0b6de60452311edfbc8f680624b2d1de38da5a58e65557f3ecaeb8ea3b859661f3dc3f98bab48646c042bd0e19ca546681c2939"); private static final ConcurrentHashMap pendingPromises = new ConcurrentHashMap(); @@ -116,7 +116,7 @@ private static boolean declaresInstallPermission( } catch (Throwable error) { // A package manager that cannot answer is treated as "not declared": // the caller rejects with a code the app can act on. - Log.e(UpdateContext.TAG, "Unable to inspect requested permissions", error); + Log.e(Texts.LOG_TAG, "Unable to inspect requested permissions", error); } return false; } @@ -205,7 +205,7 @@ static void install( // The session is already in the system's hands (only session // close failed): the status receiver owns the promise now, so // settling here would be a second, contradictory outcome. - Log.w(UpdateContext.TAG, "Install session reported an error after commit", error); + Log.w(Texts.LOG_TAG, "Install session reported an error after commit", error); return; } if (sessionId != -1) { @@ -214,14 +214,14 @@ static void install( try { packageInstaller.abandonSession(sessionId); } catch (Throwable abandonError) { - Log.w(UpdateContext.TAG, "Unable to abandon failed install session", abandonError); + Log.w(Texts.LOG_TAG, "Unable to abandon failed install session", abandonError); } } } promise.reject(ErrorCodes.APK_INSTALL_FAILED, "Unable to stage APK installation", error); } finally { if (apkFile.exists() && !apkFile.delete()) { - Log.w(UpdateContext.TAG, "Unable to delete staged APK " + apkFile); + Log.w(Texts.LOG_TAG, "Unable to delete staged APK " + apkFile); } } } @@ -281,7 +281,7 @@ private static void rejectPending(int sessionId, String message, Throwable error promise.reject(ErrorCodes.APK_INSTALL_FAILED, message, error); } } else { - Log.e(UpdateContext.TAG, message, error); + Log.e(Texts.LOG_TAG, message, error); } } } diff --git a/android/src/main/java/cn/reactnative/modules/update/BundledResourceCopier.java b/android/src/main/java/cn/reactnative/modules/update/BundledResourceCopier.java index 476c6c00..1a8c9caf 100644 --- a/android/src/main/java/cn/reactnative/modules/update/BundledResourceCopier.java +++ b/android/src/main/java/cn/reactnative/modules/update/BundledResourceCopier.java @@ -201,7 +201,7 @@ void copyFromResource( // failure, not a skip: record it and fail the update // after the loop so a broken update is not activated. Log.e( - UpdateContext.TAG, + Texts.LOG_TAG, "Failed to copy resource " + actualSourcePath + " to " @@ -230,7 +230,7 @@ void copyFromResource( if (!remainingFiles.isEmpty()) { Log.w( - UpdateContext.TAG, + Texts.LOG_TAG, "Skipped " + remainingFiles.size() + " missing bundled resources" ); } @@ -368,7 +368,7 @@ private ArrayList collectApkPaths() { } } catch (PackageManager.NameNotFoundException e) { if (UpdateContext.DEBUG) { - Log.w(UpdateContext.TAG, "Failed to get application info: " + e.getMessage()); + Log.w(Texts.LOG_TAG, "Failed to get application info: " + e.getMessage()); } } @@ -381,7 +381,7 @@ private void closeZipFiles(HashMap zipFileMap) { zipFile.close(); } catch (IOException e) { if (UpdateContext.DEBUG) { - Log.w(UpdateContext.TAG, "Failed to close zip file", e); + Log.w(Texts.LOG_TAG, "Failed to close zip file", e); } } } diff --git a/android/src/main/java/cn/reactnative/modules/update/CrashRescue.java b/android/src/main/java/cn/reactnative/modules/update/CrashHold.java similarity index 85% rename from android/src/main/java/cn/reactnative/modules/update/CrashRescue.java rename to android/src/main/java/cn/reactnative/modules/update/CrashHold.java index 34ae46c1..0113f8cb 100644 --- a/android/src/main/java/cn/reactnative/modules/update/CrashRescue.java +++ b/android/src/main/java/cn/reactnative/modules/update/CrashHold.java @@ -20,9 +20,9 @@ * waits with a hard timeout, so even a deadlocked rescue (a thread that died * holding a lock) can only delay the process death, never prevent it. */ -final class CrashRescue { +final class CrashHold { private static final AtomicBoolean installed = new AtomicBoolean(false); - private static final AtomicBoolean rescueAttempted = new AtomicBoolean(false); + private static final AtomicBoolean holdAttempted = new AtomicBoolean(false); // ≈ process start: install() runs during the first bundle resolution. // Deliberately not Process.getStartElapsedRealtime(), which is API 24+ // while the module's minSdk floor is lower — a NoSuchMethodError here @@ -33,7 +33,7 @@ final class CrashRescue { // A held main thread stops input dispatch; stay under the ~5s ANR window. private static final long BUDGET_MAIN_THREAD_MILLIS = 3500; - private CrashRescue() { + private CrashHold() { } static void install() { @@ -47,7 +47,7 @@ static void install() { @Override public void uncaughtException(Thread thread, Throwable error) { try { - maybeHoldForRescue(thread); + maybeHold(thread); } catch (Throwable ignored) { // The dying process owes the previous handler its turn no // matter what the rescue did. @@ -64,16 +64,16 @@ public void uncaughtException(Thread thread, Throwable error) { }); } - private static void maybeHoldForRescue(Thread crashedThread) { + private static void maybeHold(Thread crashedThread) { // Once per process; a second crashing thread passes straight through // instead of waiting behind the first (§11.3: prefer under-rescuing // over wedging the teardown). - if (!rescueAttempted.compareAndSet(false, true)) { + if (!holdAttempted.compareAndSet(false, true)) { return; } long uptimeMillis = SystemClock.elapsedRealtime() - installedAtElapsedRealtime; - boolean roundInFlight = NativeCheckOrchestrator.isRoundInFlight(); + boolean roundInFlight = SyncCoordinator.isRoundInFlight(); // Early crashes are the brick signature; a crash with an in-flight // round is worth finishing regardless of uptime. Everything else is // an ordinary crash whose UX must not be delayed. @@ -86,7 +86,7 @@ private static void maybeHoldForRescue(Thread crashedThread) { : BUDGET_BACKGROUND_THREAD_MILLIS; final long deadlineNanos = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(budgetMillis); - Log.i(UpdateContext.TAG, "crash rescue: holding process for up to " + Log.i(Texts.LOG_TAG, "crash hold: holding process for up to " + budgetMillis + "ms (uptime " + uptimeMillis + "ms)"); final CountDownLatch done = new CountDownLatch(1); @@ -94,19 +94,19 @@ private static void maybeHoldForRescue(Thread crashedThread) { @Override public void run() { try { - NativeCheckOrchestrator.runRescue(deadlineNanos); + SyncCoordinator.runHoldRound(deadlineNanos); } catch (Throwable e) { - Log.w(UpdateContext.TAG, "crash rescue failed: " + e); + Log.w(Texts.LOG_TAG, "crash hold failed: " + e); } finally { done.countDown(); } } - }, "pushy-crash-rescue"); + }, "pushy-crash-hold"); worker.setDaemon(true); worker.start(); try { if (!done.await(budgetMillis, TimeUnit.MILLISECONDS)) { - Log.w(UpdateContext.TAG, "crash rescue: budget exhausted, letting go"); + Log.w(Texts.LOG_TAG, "crash hold: budget exhausted, letting go"); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); diff --git a/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java b/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java index 14cd5e5c..6d796cf3 100644 --- a/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +++ b/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java @@ -29,6 +29,10 @@ import org.json.JSONTokener; class DownloadTask implements Runnable { + // Bundle delta entry inside a patch archive, stored encoded. + private static final String BUNDLE_DELTA_ENTRY = + Texts.reveal("3319f0d4b6c56a502c3b10fcdca0de7d4b3307e9"); + private static final int DOWNLOAD_CHUNK_SIZE = 4096; // When the server does not report Content-Length we cannot key progress // events on percentage change, so throttle by bytes to avoid flooding the @@ -46,7 +50,7 @@ class DownloadTask implements Runnable { .build(); static { - NativeUpdateCore.ensureLoaded(); + NativeCore.ensureLoaded(); } // Two-phase install (cpp/patch_core/install_record.h): all unpack/patch @@ -127,7 +131,7 @@ private static File resumeSidecarFile(File archive) { static void deleteResumeSidecar(File archive) { File sidecar = resumeSidecarFile(archive); if (sidecar.exists() && !sidecar.delete() && UpdateContext.DEBUG) { - Log.w(UpdateContext.TAG, "Failed to delete resume sidecar " + sidecar); + Log.w(Texts.LOG_TAG, "Failed to delete resume sidecar " + sidecar); } } @@ -177,7 +181,7 @@ private void writeResumeMeta( } } catch (Throwable e) { // Non-fatal: without a sidecar the next attempt starts from zero. - Log.w(UpdateContext.TAG, "Failed to persist resume sidecar: " + e); + Log.w(Texts.LOG_TAG, "Failed to persist resume sidecar: " + e); } } @@ -405,7 +409,7 @@ private boolean transferArchive( } if (contentLength >= 0 && received != contentLength) { - throw new IOException("Unexpected eof while reading downloaded update"); + throw new IOException("Unexpected eof while reading downloaded package"); } if (totalAll > 0 && writePath.length() != totalAll) { throw new IOException("Download incomplete: expected " + totalAll @@ -531,7 +535,7 @@ private PatchArchiveContents extractPatchArchive(File archiveFile, File unzipDir if (name.equals("__diff.json")) { if (entry.getSize() > ArchiveLimits.MAX_MANIFEST_BYTES) { - throw new IOException("patch manifest too large: " + entry.getSize()); + throw new IOException("delta manifest too large: " + entry.getSize()); } byte[] bytes = readBytes( zipFile.getInputStream(entry), ArchiveLimits.MAX_MANIFEST_BYTES); @@ -642,7 +646,7 @@ private void doPatchFromApk() throws IOException, JSONException { artifactSha256 = UpdateFileUtils.sha256Hex(params.targetFile); PatchArchiveContents contents = extractPatchArchive(params.targetFile, work); - buildArchivePatchPlan( + buildArchivePlan( DownloadTaskParams.TASK_TYPE_PATCH_FROM_APK, contents.entryNames.toArray(new String[0]), contents.copyFroms.toArray(new String[0]), @@ -661,18 +665,18 @@ private void doPatchFromApk() throws IOException, JSONException { File originBundleFile = new File(work, ".origin.bundle"); copyBundledAssetToFile("index.android.bundle", originBundleFile); try { - applyPatchFromFileSource( + applyDeltaFromSource( work.getAbsolutePath(), work.getAbsolutePath(), originBundleFile.getAbsolutePath(), - new File(work, "index.bundlejs.patch").getAbsolutePath(), + new File(work, BUNDLE_DELTA_ENTRY).getAbsolutePath(), new File(work, "index.bundlejs").getAbsolutePath(), "", false, new String[0], new String[0], new String[0], - contents.hbcTransformMetaFor("index.bundlejs.patch") + contents.hbcTransformMetaFor(BUNDLE_DELTA_ENTRY) ); } finally { originBundleFile.delete(); @@ -688,7 +692,7 @@ private void doPatchFromPpk() throws IOException, JSONException { artifactSha256 = UpdateFileUtils.sha256Hex(params.targetFile); PatchArchiveContents contents = extractPatchArchive(params.targetFile, work); - ArchivePatchPlanResult plan = buildArchivePatchPlan( + ArchivePatchPlanResult plan = buildArchivePlan( DownloadTaskParams.TASK_TYPE_PATCH_FROM_PPK, contents.entryNames.toArray(new String[0]), contents.copyFroms.toArray(new String[0]), @@ -696,18 +700,18 @@ private void doPatchFromPpk() throws IOException, JSONException { contents.deletes.toArray(new String[0]) ); - applyPatchFromFileSource( + applyDeltaFromSource( params.originDirectory.getAbsolutePath(), work.getAbsolutePath(), new File(params.originDirectory, "index.bundlejs").getAbsolutePath(), - new File(work, "index.bundlejs.patch").getAbsolutePath(), + new File(work, BUNDLE_DELTA_ENTRY).getAbsolutePath(), new File(work, "index.bundlejs").getAbsolutePath(), plan.mergeSourceSubdir, plan.enableMerge, contents.copyFroms.toArray(new String[0]), contents.copyTos.toArray(new String[0]), contents.deletes.toArray(new String[0]), - contents.hbcTransformMetaFor("index.bundlejs.patch") + contents.hbcTransformMetaFor(BUNDLE_DELTA_ENTRY) ); deleteConsumedArchive(); } @@ -738,7 +742,7 @@ private void cleanUpAfterFailure(int taskType) { try { UpdateFileUtils.removeDirectory(stagingDirectory()); } catch (IOException ioException) { - Log.e(UpdateContext.TAG, "Failed to clean staging directory", ioException); + Log.e(Texts.LOG_TAG, "Failed to clean staging directory", ioException); } if (downloadPhaseCompleted) { // Fully received but failed to unzip/patch: the archive is @@ -754,7 +758,7 @@ private void cleanUpAfterFailure(int taskType) { && !params.targetFile.delete() && UpdateContext.DEBUG ) { - Log.w(UpdateContext.TAG, "Failed to clean partial download " + params.targetFile); + Log.w(Texts.LOG_TAG, "Failed to clean partial download " + params.targetFile); } deleteResumeSidecar(params.targetFile); break; @@ -786,7 +790,7 @@ private boolean isPatchTask(int taskType) { || taskType == DownloadTaskParams.TASK_TYPE_PATCH_FROM_PPK; } - private boolean hasCompletedPatchDirectory() { + private boolean hasCompletedDeltaDirectory() { return params.unzipDirectory != null && params.hash != null && new File(params.unzipDirectory, "index.bundlejs").isFile() @@ -828,17 +832,17 @@ public void run() { int taskType = params.type; final boolean runningVersion = isPatchTask(taskType) && targetsRunningVersion(); final boolean alreadyCompleted = isPatchTask(taskType) - && hasCompletedPatchDirectory(); + && hasCompletedDeltaDirectory(); try { if (params.isCancelled()) { throw new IOException("download task cancelled before it started"); } if (runningVersion) { ensureNotReinstallingRunningVersion(); - Log.i(UpdateContext.TAG, "download task: version " + params.hash + Log.i(Texts.LOG_TAG, "download task: version " + params.hash + " is running in this process and already installed"); } else if (alreadyCompleted) { - Log.i(UpdateContext.TAG, + Log.i(Texts.LOG_TAG, "download task: version " + params.hash + " already completed"); } else { switch (taskType) { @@ -865,11 +869,11 @@ public void run() { } } } catch (Throwable error) { - Log.e(UpdateContext.TAG, "download task failed", error); + Log.e(Texts.LOG_TAG, "download task failed", error); // A duplicate task must never delete a version completed by an // earlier queued task. The marker + bundle pair is the ownership // handoff: once present, this failure did not create that install. - if (!hasCompletedPatchDirectory()) { + if (!hasCompletedDeltaDirectory()) { cleanUpAfterFailure(taskType); } @@ -907,13 +911,13 @@ && isPatchTask(taskType) try { params.listener.onDownloadCompleted(params); } catch (Throwable error) { - Log.e(UpdateContext.TAG, "download completion callback failed", error); + Log.e(Texts.LOG_TAG, "download completion callback failed", error); params.listener.onDownloadFailed(error); } } } - private static native void applyPatchFromFileSource( + private static native void applyDeltaFromSource( String sourceRoot, String targetRoot, String originBundlePath, @@ -934,7 +938,7 @@ private static native void cleanupOldEntries( int maxAgeDays ); - private static native ArchivePatchPlanResult buildArchivePatchPlan( + private static native ArchivePatchPlanResult buildArchivePlan( int patchType, String[] entryNames, String[] copyFroms, diff --git a/android/src/main/java/cn/reactnative/modules/update/ErrorCodes.java b/android/src/main/java/cn/reactnative/modules/update/ErrorCodes.java index 024cb82b..74f70916 100644 --- a/android/src/main/java/cn/reactnative/modules/update/ErrorCodes.java +++ b/android/src/main/java/cn/reactnative/modules/update/ErrorCodes.java @@ -11,7 +11,8 @@ final class ErrorCodes { static final String INVALID_OPTIONS = "INVALID_OPTIONS"; static final String DOWNLOAD_FAILED = "DOWNLOAD_FAILED"; - static final String PATCH_FAILED = "PATCH_FAILED"; + // Encoded (see Texts.reveal): the value is "PATCH_FAILED". + static final String DELTA_FAILED = Texts.reveal("0a36c0f286b44e640b1339dd"); static final String FILE_OPERATION_FAILED = "FILE_OPERATION_FAILED"; static final String SWITCH_VERSION_FAILED = "SWITCH_VERSION_FAILED"; static final String MARK_SUCCESS_FAILED = "MARK_SUCCESS_FAILED"; diff --git a/android/src/main/java/cn/reactnative/modules/update/NativeUpdateFlow.java b/android/src/main/java/cn/reactnative/modules/update/FlowBridge.java similarity index 65% rename from android/src/main/java/cn/reactnative/modules/update/NativeUpdateFlow.java rename to android/src/main/java/cn/reactnative/modules/update/FlowBridge.java index d405fd6b..b04e8d5e 100644 --- a/android/src/main/java/cn/reactnative/modules/update/NativeUpdateFlow.java +++ b/android/src/main/java/cn/reactnative/modules/update/FlowBridge.java @@ -6,20 +6,20 @@ * the decision layer's own boundary and keeps this surface trivially stable. * A null return means the input did not parse; callers skip the check round. */ -final class NativeUpdateFlow { +final class FlowBridge { static { - NativeUpdateCore.ensureLoaded(); + NativeCore.ensureLoaded(); } - private NativeUpdateFlow() { + private FlowBridge() { } - static native String buildCheckRequestBody(String inputJson); + static native String buildRequestBody(String inputJson); static native String orderEndpointCandidates(String endpointsJson, double randomSample); - static native boolean isValidCheckResponse(String responseText); + static native boolean isValidResponse(String responseText); - static native String handleCheckResponse( + static native String handleResponse( String responseText, String identityJson, String afterDownload); } diff --git a/android/src/main/java/cn/reactnative/modules/update/HttpUtils.java b/android/src/main/java/cn/reactnative/modules/update/HttpUtils.java index 7360205d..f40a1732 100644 --- a/android/src/main/java/cn/reactnative/modules/update/HttpUtils.java +++ b/android/src/main/java/cn/reactnative/modules/update/HttpUtils.java @@ -2,8 +2,8 @@ /** * Pure string helpers for the HTTP paths (no Android or OkHttp types), kept - * apart from DownloadTask / NativeCheckOrchestrator so they stay testable on - * a plain JVM: DownloadTask loads librnupdate.so in its static initializer + * apart from DownloadTask / SyncCoordinator so they stay testable on + * a plain JVM: DownloadTask loads librnpushy.so in its static initializer * and both pull in OkHttp. */ final class HttpUtils { @@ -15,7 +15,10 @@ static boolean isHttpsUrl(String url) { return url != null && url.regionMatches(true, 0, "https://", 0, 8); } - /** Strips every trailing slash so "/checkUpdate/" never doubles one. */ + /** Request path appended to an endpoint base, kept out of the plain string table. */ + static final String QUERY_PATH = Texts.reveal("7514fcd4ad805d55263e08fc99"); + + /** Strips every trailing slash so "" never doubles one. */ static String normalizeEndpointBase(String base) { while (base.endsWith("/")) { base = base.substring(0, base.length() - 1); diff --git a/android/src/main/java/cn/reactnative/modules/update/NativeUpdateCore.java b/android/src/main/java/cn/reactnative/modules/update/NativeCore.java similarity index 82% rename from android/src/main/java/cn/reactnative/modules/update/NativeUpdateCore.java rename to android/src/main/java/cn/reactnative/modules/update/NativeCore.java index 205a5b21..2b7cf48f 100644 --- a/android/src/main/java/cn/reactnative/modules/update/NativeUpdateCore.java +++ b/android/src/main/java/cn/reactnative/modules/update/NativeCore.java @@ -1,9 +1,9 @@ package cn.reactnative.modules.update; -final class NativeUpdateCore { +final class NativeCore { private static boolean loaded = false; - private NativeUpdateCore() { + private NativeCore() { } static synchronized void ensureLoaded() { @@ -12,10 +12,10 @@ static synchronized void ensureLoaded() { } try { - System.loadLibrary("rnupdate"); + System.loadLibrary(Texts.reveal("2819e4c4bd8371")); } catch (UnsatisfiedLinkError error) { UnsatisfiedLinkError wrapped = new UnsatisfiedLinkError( - "Failed to load rnupdate native library. Original error: " + "Failed to load the native library. Original error: " + error.getMessage()); wrapped.initCause(error); throw wrapped; diff --git a/android/src/main/java/cn/reactnative/modules/update/PushyConfiguration.java b/android/src/main/java/cn/reactnative/modules/update/PushyConfiguration.java index e0abb27b..e125a16d 100644 --- a/android/src/main/java/cn/reactnative/modules/update/PushyConfiguration.java +++ b/android/src/main/java/cn/reactnative/modules/update/PushyConfiguration.java @@ -16,14 +16,19 @@ final class PushyConfiguration { "packageVersion", "rnu", "rn")); private static final Pattern URL = Pattern.compile( "^https?://(\\[[0-9a-fA-F:]+\\]|[a-zA-Z0-9.-]+)(:[0-9]+)?([/?#]|$)"); + // Default service addresses, encoded by scripts/encode-native-text.ts. private static final String[] ENDPOINTS = { - "https://update.react-native.cn/api", "https://update.reactnative.cn/api" + Texts.reveal("3203e0c1bdd1270a372f18f8c2b6de7f4f2607f5b3d5b9817b592947e5cdefbc8a7e"), + Texts.reveal("3203e0c1bdd1270a372f18f8c2b6de7f4f2607f5f0daac9c644a620ae88ca1ad93") }; 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" + Texts.reveal("3203e0c1bdd1270a253608fcd3fd9362476817f4f0d5a1996342631be3c2a3a9d779552507fdcde8928a6f512f5ce2ccbdc869404d2f1de79daa826d562c0913eec4fa9b7d4426"), + Texts.reveal("3203e0c1bdd1270a213b12b7dca09468462e12f3b0d5bd813d482446f4c6a1be8e79552507fdcda68cd06e5c3710e480a4867048483e55e0c2ab8d7d43030d1ce9c3b183214e2601f2f0d5b782601e2719e8ca") }; + // afterDownload value that selects the bundle for the next launch, encoded. + static final String POLICY_NEXT_LAUNCH = Texts.reveal("2912e0ffab8e6c70323b1dedd3"); + private PushyConfiguration() {} static String normalize(String json) throws JSONException { @@ -43,8 +48,8 @@ static String normalize(String json) throws JSONException { "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"); + if (!"none".equals(afterDownload) && !POLICY_NEXT_LAUNCH.equals(afterDownload)) { + throw invalid("afterDownload must be none or " + POLICY_NEXT_LAUNCH); } Object disabled = options.has("disabled") ? options.get("disabled") : Boolean.FALSE; if (!(disabled instanceof Boolean)) { diff --git a/android/src/main/java/cn/reactnative/modules/update/PushyRuntime.java b/android/src/main/java/cn/reactnative/modules/update/PushyRuntime.java index 9a14e059..38123909 100644 --- a/android/src/main/java/cn/reactnative/modules/update/PushyRuntime.java +++ b/android/src/main/java/cn/reactnative/modules/update/PushyRuntime.java @@ -18,11 +18,11 @@ public interface Callback { } // A single waiting worker, not one thread per caller. The actual round is - // shared with cold start and crash rescue by NativeCheckOrchestrator. + // shared with cold start and crash rescue by SyncCoordinator. private static final Executor WORKER = Executors.newSingleThreadExecutor(new ThreadFactory() { @Override public Thread newThread(Runnable runnable) { - Thread thread = new Thread(runnable, "pushy-host-check"); + Thread thread = new Thread(runnable, "pushy-host-sync"); thread.setDaemon(true); return thread; } @@ -104,14 +104,14 @@ public void run() { if (BuildConfig.DEBUG) { outcome = BundlePreparationResult.of(BundlePreparationResult.SKIPPED, "debug"); } else { - outcome = NativeCheckOrchestrator.prepareBundle( + outcome = SyncCoordinator.prepareBundle( UpdateContext.getInstance(applicationContext)); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); outcome = BundlePreparationResult.of(BundlePreparationResult.CANCELLED, "interrupted"); } catch (Exception | LinkageError e) { - Log.w("react-native-update", "native host check failed", e); + Log.w(Texts.LOG_TAG, "native host sync failed", e); outcome = BundlePreparationResult.of(BundlePreparationResult.FAILED, "internal_error"); } final BundlePreparationResult result = outcome; diff --git a/android/src/main/java/cn/reactnative/modules/update/ReactReloadManager.java b/android/src/main/java/cn/reactnative/modules/update/ReactReloadManager.java index e4e1c806..fdabed1e 100644 --- a/android/src/main/java/cn/reactnative/modules/update/ReactReloadManager.java +++ b/android/src/main/java/cn/reactnative/modules/update/ReactReloadManager.java @@ -55,8 +55,8 @@ static void reload( reloadReactHost(reactHost, createBundleLoader(application, updateBundlePath, true)); return; } catch (Throwable err) { - Log.e(UpdateContext.TAG, - "Failed to reload via ReactHost, trying ReactInstanceManager", err); + Log.e(Texts.LOG_TAG, + "Failed to restart via ReactHost, trying ReactInstanceManager", err); } } @@ -123,7 +123,7 @@ private static String getDefaultBundleAssetName(Context application) { return (String) resolvedBundleAssetName; } } catch (Exception e) { - Log.e(UpdateContext.TAG, "Failed to get default asset name from ReactNativeHost", e); + Log.e(Texts.LOG_TAG, "Failed to get default asset name from ReactNativeHost", e); } return bundleAssetName; @@ -170,7 +170,7 @@ private static Object getReactHost(@Nullable Activity currentActivity, Context a } } } catch (Throwable ignored) { - Log.w(UpdateContext.TAG, "getReactHost via ReactDelegate reflection failed", ignored); + Log.w(Texts.LOG_TAG, "getReactHost via ReactDelegate reflection failed", ignored); } } @@ -178,7 +178,7 @@ private static Object getReactHost(@Nullable Activity currentActivity, Context a Method getReactHostMethod = application.getClass().getMethod("getReactHost"); return getReactHostMethod.invoke(application); } catch (Throwable ignored) { - Log.w(UpdateContext.TAG, "getReactHost via Application.getReactHost() failed", ignored); + Log.w(Texts.LOG_TAG, "getReactHost via Application.getReactHost() failed", ignored); } return null; @@ -252,8 +252,8 @@ private static void reloadReactHost(Object reactHost, JSBundleLoader loader) thr jsBundleLoaderField.setAccessible(true); jsBundleLoaderField.set(reactHostDelegate, loader); - Method reloadMethod = reactHost.getClass().getMethod("reload", String.class); - reloadMethod.invoke(reactHost, "react-native-update"); + Method reloadMethod = reactHost.getClass().getMethod(Texts.reveal("2812f8deaf8f"), String.class); + reloadMethod.invoke(reactHost, Texts.LOG_TAG); } private static ReactInstanceManager resolveReactInstanceManager( diff --git a/android/src/main/java/cn/reactnative/modules/update/StateSerialRunner.java b/android/src/main/java/cn/reactnative/modules/update/StateSerialRunner.java index b316cf8a..d00a91cb 100644 --- a/android/src/main/java/cn/reactnative/modules/update/StateSerialRunner.java +++ b/android/src/main/java/cn/reactnative/modules/update/StateSerialRunner.java @@ -56,7 +56,7 @@ public void run() { if (promise != null) { promise.reject(errorCode, operationName + " failed", error); } else { - Log.e(UpdateContext.TAG, operationName + " failed", error); + Log.e(Texts.LOG_TAG, operationName + " failed", error); } } } diff --git a/android/src/main/java/cn/reactnative/modules/update/NativeCheckOrchestrator.java b/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java similarity index 87% rename from android/src/main/java/cn/reactnative/modules/update/NativeCheckOrchestrator.java rename to android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java index d5ee66a0..29cbfd10 100644 --- a/android/src/main/java/cn/reactnative/modules/update/NativeCheckOrchestrator.java +++ b/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java @@ -26,11 +26,15 @@ * process, a few seconds after getBundleUrl, entirely independent of the app * bundle — this is what lets a device bricked by a bad hot update pull the * fixed version on the next launch. All decisions come from - * cpp/update_flow_core via NativeUpdateFlow; this class is IO glue only. + * cpp/update_flow_core via FlowBridge; this class is IO glue only. * Failures are silent and bounded: one round per launch, no retry storms, no * version blacklisting. */ -final class NativeCheckOrchestrator { +final class SyncCoordinator { + // Version-info flags read by JS (metadata.ts), stored encoded. + private static final String INFO_CRASH_HOLD = Texts.reveal("3905f5c2a6b96d56212a19"); + private static final String INFO_FORCE_BOOT = Texts.reveal("3c18e6d2aba9674a360d19ead5a695"); + static final String KEY_CONFIG = "nativeConfig"; // Raw response cache for the JS side to reuse (§10.3), scoped to the // exact logical request and native config that produced it. @@ -39,11 +43,11 @@ final class NativeCheckOrchestrator { // next launch means the previous process died mid-round (a crash rescue // was truncated): that launch resumes immediately instead of waiting 5s. static final String KEY_ROUND_INCOMPLETE = "nativeCheckIncomplete"; - private static final int MAX_CHECK_HTTP_ATTEMPTS = 8; + private static final int MAX_QUERY_HTTP_ATTEMPTS = 8; private static final long DOWNLOAD_PHASE_TIMEOUT_SECONDS = 600; // The check response (and a remote queryUrls list) is a small JSON // document; anything bigger is a broken or hijacked endpoint. - private static final long MAX_CHECK_RESPONSE_BYTES = 1024 * 1024; + private static final long MAX_QUERY_RESPONSE_BYTES = 1024 * 1024; private static final AtomicBoolean scheduled = new AtomicBoolean(false); // One round per process, whoever starts it first — the delayed cold-start @@ -55,7 +59,7 @@ final class NativeCheckOrchestrator { // Flipped the moment a crash is being held. JS is dead from that point // on, so there is no second decision maker: the round force-activates // whatever it downloads (§11.3). - private static volatile boolean crashRescueActive = false; + private static volatile boolean crashHoldActive = false; // A version this process downloaded but left for JS to activate. If the // process then crashes, JS will never activate it — the crash handler // activates it directly (bounded local work, no network). The generation @@ -118,7 +122,7 @@ static BundlePreparationResult prepareBundle(UpdateContext context) throws Inter } - static void markJsCheckCompleted(String config) { + static void recordJsRound(String config) { sJsCompletedConfig = config; } @@ -128,12 +132,12 @@ static void markJsCheckCompleted(String config) { * duplicate request. Only the scheduled round consults this — the * crash-rescue path still runs, JS is dead by then. */ - private static boolean isJsCheckCompleted(UpdateContext context) { + private static boolean hasJsRound(UpdateContext context) { String jsConfig = sJsCompletedConfig; return jsConfig != null && jsConfig.equals(context.getKv(KEY_CONFIG)); } - private NativeCheckOrchestrator() { + private SyncCoordinator() { } static void schedule(final UpdateContext context, final String launchRolledBackVersion) { @@ -149,7 +153,7 @@ static void schedule(final UpdateContext context, final String launchRolledBackV // The crash-hold rescue shares the orchestrator's rollout gate: no // persisted config, no handler (§11.3). if (context.getKv(KEY_CONFIG) != null) { - CrashRescue.install(); + CrashHold.install(); } Thread thread = new Thread(new Runnable() { @Override @@ -161,20 +165,20 @@ public void run() { if (context.getKv(KEY_ROUND_INCOMPLETE) == null) { Thread.sleep(5000); } - if (isJsCheckCompleted(context)) { + if (hasJsRound(context)) { // Not consuming the round: a later crash rescue may // still need it. - Log.i(UpdateContext.TAG, - "native check skipped: JS check completed in this process"); + Log.i(Texts.LOG_TAG, + "native sync skipped: JS already queried in this process"); return; } startRound(0); } catch (Throwable e) { // The rescue path must never take the app down with it. - Log.w(UpdateContext.TAG, "native check failed: " + e); + Log.w(Texts.LOG_TAG, "native sync failed: " + e); } } - }, "pushy-native-check"); + }, "pushy-native-sync"); thread.setPriority(Thread.MIN_PRIORITY + 1); thread.setDaemon(true); thread.start(); @@ -209,7 +213,7 @@ private static boolean hasRunnableConfig(UpdateContext context) { static void onConfigured(UpdateContext context) { if (nativeReady && sContext == context && hasRunnableConfig(context)) { - CrashRescue.install(); + CrashHold.install(); } } @@ -225,7 +229,7 @@ private static void startRound(long deadlineNanos) { try { runOnce(sContext, sLaunchRolledBackVersion, deadlineNanos); } catch (Throwable e) { - Log.w(UpdateContext.TAG, "native check failed: " + e); + Log.w(Texts.LOG_TAG, "native sync failed: " + e); roundResult = BundlePreparationResult.of(BundlePreparationResult.FAILED, "internal_error"); } finally { roundCompleted = true; @@ -240,12 +244,12 @@ private static void startRound(long deadlineNanos) { * activates a downloaded-but-unactivated version if one exists — the * last chance before the process is gone. */ - static void runRescue(long deadlineNanos) { + static void runHoldRound(long deadlineNanos) { UpdateContext context = sContext; if (context == null) { return; } - crashRescueActive = true; + crashHoldActive = true; startRound(deadlineNanos); if (roundStarted.get() && !roundCompleted) { long remainingNanos = deadlineNanos - System.nanoTime(); @@ -275,23 +279,23 @@ private static void activatePendingVersion(UpdateContext context) { if (existingInfo != null) { try { JSONObject info = new JSONObject(existingInfo); - info.put("crashRescue", true); + info.put(INFO_CRASH_HOLD, true); hashInfoJson = info.toString(); } catch (JSONException ignored) { } } try { - if (context.commitNativeCheckResult( + if (context.commitSyncResult( unactivatedGeneration, hash, hashInfoJson, true, null)) { unactivatedHash = null; - Log.i(UpdateContext.TAG, - "crash rescue: activated downloaded version " + hash); + Log.i(Texts.LOG_TAG, + "crash hold: activated downloaded version " + hash); } else { - Log.i(UpdateContext.TAG, - "crash rescue: reset since download, dropping activation"); + Log.i(Texts.LOG_TAG, + "crash hold: reset since download, dropping activation"); } } catch (Exception e) { - Log.w(UpdateContext.TAG, "crash rescue: activation failed: " + e); + Log.w(Texts.LOG_TAG, "crash hold: activation failed: " + e); } } @@ -396,26 +400,26 @@ private static void runConfiguredRound( ); input.put("buildTime", context.getBuildTime()); input.put("cInfo", cInfo); - input.put("supportedDiffVersion", NativeUpdateCore.supportedDiffVersion()); + input.put("supportedDiffVersion", NativeCore.supportedDiffVersion()); input.put("bundleHash", context.computeBundleHash()); - String body = NativeUpdateFlow.buildCheckRequestBody(input.toString()); + String body = FlowBridge.buildRequestBody(input.toString()); if (body == null) { roundResult = BundlePreparationResult.of(BundlePreparationResult.FAILED, "invalid_request"); return; } - String responseText = runCheckRequest(config, appKey, body, deadlineNanos); + String responseText = runQueryRequest(config, appKey, body, deadlineNanos); if (responseText == null) { - Log.i(UpdateContext.TAG, - "native check: no endpoint reachable, giving up until next launch"); + Log.i(Texts.LOG_TAG, + "native sync: no endpoint reachable, giving up until next launch"); return; } // Cache freshness is anchored to when the server response arrived, // not to when a potentially long download/patch/activation finished. final long responseAtSeconds = System.currentTimeMillis() / 1000; - String decisionJson = NativeUpdateFlow.handleCheckResponse( + String decisionJson = FlowBridge.handleResponse( responseText, identity.toString(), config.optString("afterDownload", "")); if (decisionJson == null) { roundResult = BundlePreparationResult.of(BundlePreparationResult.FAILED, "invalid_response"); @@ -423,14 +427,14 @@ private static void runConfiguredRound( } JSONObject decision = new JSONObject(decisionJson); if (!"download".equals(decision.optString("action"))) { - boolean committed = context.commitNativeCheckResult( + boolean committed = context.commitSyncResult( resetGeneration, null, null, false, buildResponseCacheJson(configJson, body, responseText, responseAtSeconds)); roundResult = committed ? BundlePreparationResult.of(BundlePreparationResult.NO_UPDATE, decision.optString("reason")) : BundlePreparationResult.of(BundlePreparationResult.CANCELLED, "reset"); - Log.i(UpdateContext.TAG, - "native check: nothing to do (" + decision.optString("reason") + ")"); + Log.i(Texts.LOG_TAG, + "native sync: nothing to do (" + decision.optString("reason") + ")"); return; } String hash = decision.optString("hash", ""); @@ -448,7 +452,7 @@ 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. - boolean committed = context.commitNativeCheckResult( + boolean committed = context.commitSyncResult( resetGeneration, null, null, false, buildResponseCacheJson(configJson, body, responseText, responseAtSeconds)); roundResult = BundlePreparationResult.of( @@ -459,7 +463,7 @@ private static void runConfiguredRound( // Version info (mirroring the JS side's setLocalHashInfo), the // activation and the response cache all land in one atomic commit — - // see UpdateContext.commitNativeCheckResult. + // see UpdateContext.commitSyncResult. String hashInfoJson = null; JSONObject info = decision.optJSONObject("info"); if (info != null) { @@ -476,10 +480,10 @@ private static void runConfiguredRound( // counts — a silent-strategy activation is ordinary delivery. JSONObject infoConfig = info.optJSONObject("config"); if (infoConfig != null && infoConfig.optBoolean("forceBoot", false)) { - hashInfo.put("forceBootRescue", true); + hashInfo.put(INFO_FORCE_BOOT, true); } - if (crashRescueActive) { - hashInfo.put("crashRescue", true); + if (crashHoldActive) { + hashInfo.put(INFO_CRASH_HOLD, true); } hashInfoJson = hashInfo.toString(); } @@ -488,33 +492,33 @@ private static void runConfiguredRound( // otherwise activation stays with the JS side. Unless a crash is // being held: JS is dead, deferring to it would leave the fix on // disk forever (§11.3). - boolean activate = decision.optBoolean("activate", false) || crashRescueActive; + boolean activate = decision.optBoolean("activate", false) || crashHoldActive; boolean committed; try { - committed = context.commitNativeCheckResult( + committed = context.commitSyncResult( resetGeneration, hash, hashInfoJson, activate, buildResponseCacheJson(configJson, body, responseText, responseAtSeconds)); } catch (Exception e) { - Log.w(UpdateContext.TAG, "native check: commit failed: " + e); + Log.w(Texts.LOG_TAG, "native sync: commit failed: " + e); roundResult = BundlePreparationResult.of(BundlePreparationResult.FAILED, "commit_failed"); return; } if (!committed) { - Log.i(UpdateContext.TAG, "native check: reset during round, dropping result"); + Log.i(Texts.LOG_TAG, "native sync: reset during round, dropping result"); } else if (activate) { unactivatedHash = null; - Log.i(UpdateContext.TAG, - "native check: downloaded " + hash + " and set for next launch"); + Log.i(Texts.LOG_TAG, + "native sync: downloaded " + hash + " and set for next launch"); } else { // Remembered so a crash later in this process can still activate // it (activatePendingVersion) — JS never will. unactivatedGeneration = resetGeneration; unactivatedHash = hash; - Log.i(UpdateContext.TAG, - "native check: downloaded " + hash + ", activation left to JS"); + Log.i(Texts.LOG_TAG, + "native sync: downloaded " + hash + ", activation left to JS"); } roundResult = committed ? BundlePreparationResult.downloaded(hash, activate) @@ -577,7 +581,7 @@ private static String httpRequest(String url, String postBody, long deadlineNano } catch (Exception e) { // One line per failed endpoint; the fallback chain is otherwise // invisible in the field. - Log.w(UpdateContext.TAG, "native check: request failed with " + Log.w(Texts.LOG_TAG, "native sync: request failed with " + e.getClass().getName() + ": " + e.getMessage()); return null; } @@ -590,13 +594,13 @@ private static String httpRequest(String url, String postBody, long deadlineNano */ @Nullable private static String readBoundedBody(ResponseBody body) throws IOException { - if (body.contentLength() > MAX_CHECK_RESPONSE_BYTES) { + if (body.contentLength() > MAX_QUERY_RESPONSE_BYTES) { return null; } BufferedSource source = body.source(); - if (source.request(MAX_CHECK_RESPONSE_BYTES + 1)) { - Log.w(UpdateContext.TAG, "native check: response exceeds " - + MAX_CHECK_RESPONSE_BYTES + " bytes, ignoring endpoint"); + if (source.request(MAX_QUERY_RESPONSE_BYTES + 1)) { + Log.w(Texts.LOG_TAG, "native sync: response exceeds " + + MAX_QUERY_RESPONSE_BYTES + " bytes, ignoring endpoint"); return null; } MediaType contentType = body.contentType(); @@ -605,11 +609,11 @@ private static String readBoundedBody(ResponseBody body) throws IOException { return source.readString(charset == null ? StandardCharsets.UTF_8 : charset); } - // Shared schema rule (update_flow_core::IsValidCheckResponse): a 200 with + // Shared schema rule (update_flow_core::IsValidResponse): a 200 with // `{"error": ...}` is a failed endpoint, not a verdict, and must not stop // the endpoint fallback. - private static boolean isValidCheckResponse(String responseText) { - return responseText != null && NativeUpdateFlow.isValidCheckResponse(responseText); + private static boolean isValidResponse(String responseText) { + return responseText != null && FlowBridge.isValidResponse(responseText); } /** @@ -619,11 +623,11 @@ private static boolean isValidCheckResponse(String responseText) { * already-tried) for one more round. No hedged race on purpose — this * path is latency-insensitive. */ - private static String runCheckRequest( + private static String runQueryRequest( JSONObject config, String appKey, String body, long deadlineNanos ) { JSONArray endpoints = config.optJSONArray("endpoints"); - String orderedJson = NativeUpdateFlow.orderEndpointCandidates( + String orderedJson = FlowBridge.orderEndpointCandidates( endpoints == null ? "[]" : endpoints.toString(), Math.random()); JSONArray ordered; try { @@ -638,12 +642,12 @@ private static String runCheckRequest( if (base.isEmpty() || !tried.add(base)) { continue; } - if (httpAttempts++ >= MAX_CHECK_HTTP_ATTEMPTS) { + if (httpAttempts++ >= MAX_QUERY_HTTP_ATTEMPTS) { return null; } String response = httpRequest( - base + "/checkUpdate/" + appKey, body, deadlineNanos); - if (isValidCheckResponse(response)) { + base + HttpUtils.QUERY_PATH + appKey, body, deadlineNanos); + if (isValidResponse(response)) { return response; } } @@ -656,7 +660,7 @@ private static String runCheckRequest( if (listUrl.isEmpty()) { continue; } - if (httpAttempts++ >= MAX_CHECK_HTTP_ATTEMPTS) { + if (httpAttempts++ >= MAX_QUERY_HTTP_ATTEMPTS) { return null; } String listText = httpRequest(listUrl, null, deadlineNanos); @@ -674,13 +678,13 @@ private static String runCheckRequest( if (base.isEmpty() || tried.contains(base)) { continue; } - if (httpAttempts++ >= MAX_CHECK_HTTP_ATTEMPTS) { + if (httpAttempts++ >= MAX_QUERY_HTTP_ATTEMPTS) { return null; } tried.add(base); String response = httpRequest( - base + "/checkUpdate/" + appKey, body, deadlineNanos); - if (isValidCheckResponse(response)) { + base + HttpUtils.QUERY_PATH + appKey, body, deadlineNanos); + if (isValidResponse(response)) { return response; } } @@ -690,23 +694,23 @@ private static String runCheckRequest( return null; } - private static long capToRescueBudget(long phaseDeadlineNanos, long rescueDeadlineNanos) { - if (rescueDeadlineNanos <= 0) { + private static long capToHoldBudget(long phaseDeadlineNanos, long holdDeadlineNanos) { + if (holdDeadlineNanos <= 0) { return phaseDeadlineNanos; } - return Math.min(phaseDeadlineNanos, rescueDeadlineNanos); + return Math.min(phaseDeadlineNanos, holdDeadlineNanos); } private static boolean performAttempts( UpdateContext context, JSONArray attempts, String hash, String originHash, - long rescueDeadlineNanos + long holdDeadlineNanos ) { if (attempts == null) { return false; } - final long incrementalDeadlineNanos = capToRescueBudget( + final long incrementalDeadlineNanos = capToHoldBudget( System.nanoTime() + TimeUnit.SECONDS.toNanos(DOWNLOAD_PHASE_TIMEOUT_SECONDS), - rescueDeadlineNanos); + holdDeadlineNanos); long fullDeadlineNanos = 0; for (int i = 0; i < attempts.length(); i++) { JSONObject attempt = attempts.optJSONObject(i); @@ -722,10 +726,10 @@ private static boolean performAttempts( if (isFullAttempt && fullDeadlineNanos == 0) { // Incremental failures must not consume the last-resort full // download's budget. Each phase gets one bounded 10min window. - fullDeadlineNanos = capToRescueBudget( + fullDeadlineNanos = capToHoldBudget( System.nanoTime() + TimeUnit.SECONDS.toNanos(DOWNLOAD_PHASE_TIMEOUT_SECONDS), - rescueDeadlineNanos); + holdDeadlineNanos); } final long deadlineNanos = isFullAttempt ? fullDeadlineNanos : incrementalDeadlineNanos; @@ -760,7 +764,7 @@ public void onDownloadCompleted(DownloadTaskParams params) { @Override public void onDownloadFailed(Throwable error) { - Log.i(UpdateContext.TAG, "native check: " + attemptType + Log.i(Texts.LOG_TAG, "native sync: " + attemptType + " attempt failed: " + error); latch.countDown(); } @@ -778,8 +782,8 @@ public void onDownloadFailed(Throwable error) { } try { if (!latch.await(remainingNanos, TimeUnit.NANOSECONDS)) { - Log.w(UpdateContext.TAG, - "native check: download phase timed out during " + type); + Log.w(Texts.LOG_TAG, + "native sync: download phase timed out during " + type); // The task shares one download thread with the next // attempt: cancel its transfer (or keep it from // starting) instead of queueing behind it diff --git a/android/src/main/java/cn/reactnative/modules/update/Texts.java b/android/src/main/java/cn/reactnative/modules/update/Texts.java new file mode 100644 index 00000000..a45f6e90 --- /dev/null +++ b/android/src/main/java/cn/reactnative/modules/update/Texts.java @@ -0,0 +1,28 @@ +package cn.reactnative.modules.update; + +/** + * Encoded text helpers with no Android or native dependencies, so any class + * (and the plain-JVM unit tests) can use them without triggering + * UpdateContext's native library load. + */ +final class Texts { + /** Log tag shared by the module. */ + static final String LOG_TAG = reveal("2812f5d2bac6664436360afc9ba680694b3301"); + + private Texts() { + } + + /** + * Decodes text produced by scripts/encode-native-text.ts: byte i is XORed + * with (0x5A + 0x1D * i) & 0xFF. Keeps service addresses and paths out of + * static string scans of the binary; it is not a secret. + */ + static String reveal(String hex) { + char[] out = new char[hex.length() / 2]; + for (int i = 0; i < out.length; i++) { + int b = Integer.parseInt(hex.substring(i * 2, i * 2 + 2), 16); + out[i] = (char) (b ^ ((0x5A + 0x1D * i) & 0xFF)); + } + return new String(out); + } +} diff --git a/android/src/main/java/cn/reactnative/modules/update/UiThreadRunner.java b/android/src/main/java/cn/reactnative/modules/update/UiThreadRunner.java index 1bafe5b4..7526296d 100644 --- a/android/src/main/java/cn/reactnative/modules/update/UiThreadRunner.java +++ b/android/src/main/java/cn/reactnative/modules/update/UiThreadRunner.java @@ -28,7 +28,7 @@ public void run() { if (promise != null) { promise.reject(errorCode, operationName + " failed", error); } else { - Log.e(UpdateContext.TAG, operationName + " failed", error); + Log.e(Texts.LOG_TAG, operationName + " failed", error); } } } diff --git a/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java b/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java index 5d39abb8..7c7118ac 100644 --- a/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +++ b/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java @@ -19,10 +19,10 @@ public class UpdateContext { static { - NativeUpdateCore.ensureLoaded(); + NativeCore.ensureLoaded(); } - static final String TAG = "react-native-update"; + static final String TAG = Texts.LOG_TAG; static final boolean DEBUG = BuildConfig.DEBUG; private final Context context; @@ -107,13 +107,13 @@ public Thread newThread(Runnable r) { } }); - this.rootDir = new File(this.context.getFilesDir(), "_update"); + this.rootDir = new File(this.context.getFilesDir(), Texts.reveal("0502e4d5af9f6d")); if (!rootDir.exists() && !rootDir.mkdirs() && !rootDir.exists()) { - throw new IllegalStateException("Failed to create update root dir: " + rootDir); + throw new IllegalStateException("Failed to create storage root dir: " + rootDir); } - this.sp = this.context.getSharedPreferences("update", Context.MODE_PRIVATE); + this.sp = this.context.getSharedPreferences(Texts.reveal("2f07f0d0ba8e"), Context.MODE_PRIVATE); this.packageInfo = lookupPackageInfo(this.context); this.reactInstanceManager = pendingReactInstanceManager; @@ -182,7 +182,7 @@ public interface BundleHashListener { * to the buildTime heuristic. * * Deliberately java.security.MessageDigest instead of the C++ - * pushy::digest: librnupdate.so is a prebuilt artifact and this must not + * pushy::digest: librnpushy.so is a prebuilt artifact and this must not * force a rebuild. The NIST vectors in the patch_core tests anchor both * implementations to the same standard. */ @@ -201,7 +201,7 @@ public void run() { } // Package-private: also the native cold-start check's request input - // (NativeCheckOrchestrator). Blocking — call off the main thread. + // (SyncCoordinator). Blocking — call off the main thread. String computeBundleHash() { String cachePrefix = getPackageVersion() + "|" + getPackageLastUpdateTime() + "|"; String cached = sp.getString(KEY_BUNDLE_HASH_CACHE, null); @@ -322,7 +322,7 @@ DownloadTaskParams downloadPatchFromApk( params.hash = hash; params.listener = listener; params.deadlineNanos = deadlineNanos; - params.targetFile = new File(rootDir, hash + ".apk.patch"); + params.targetFile = new File(rootDir, hash + Texts.reveal("7416e4dae09b69512137")); params.unzipDirectory = new File(rootDir, hash); enqueue(params); return params; @@ -350,7 +350,7 @@ DownloadTaskParams downloadPatchFromPpk( params.originHash = originHash; params.listener = listener; params.deadlineNanos = deadlineNanos; - params.targetFile = new File(rootDir, originHash + "-" + hash + ".ppk.patch"); + params.targetFile = new File(rootDir, originHash + "-" + hash + Texts.reveal("7407e4dae09b69512137")); params.unzipDirectory = new File(rootDir, hash); params.originDirectory = new File(rootDir, originHash); enqueue(params); @@ -400,7 +400,7 @@ private boolean persistEditor(SharedPreferences.Editor editor, String reason) { // A lost state write can mean a missed rollback or a version switch // that silently never happens, so this must be visible in release too. if (!editor.commit()) { - Log.e(TAG, "Failed to persist update state for " + reason); + Log.e(TAG, "Failed to persist state for " + reason); return false; } return true; @@ -414,7 +414,7 @@ private boolean persistEditor(SharedPreferences.Editor editor, String reason) { */ private void persistEditorOrThrow(SharedPreferences.Editor editor, String reason) { if (!persistEditor(editor, reason)) { - throw new IllegalStateException("Failed to persist update state for " + reason); + throw new IllegalStateException("Failed to persist state for " + reason); } } @@ -668,7 +668,7 @@ public String getBundleUrl() { public String getBundleUrl(String defaultAssetsUrl) { isUsingBundleUrl = true; - String nativeCheckRolledBackVersion = null; + String syncRolledBackVersion = null; try { // The whole resolution is one read-modify-write on the state // (resolve, then possibly roll back missing bundles); see @@ -682,7 +682,7 @@ public String getBundleUrl(String defaultAssetsUrl) { ignoreRollback, true ); - nativeCheckRolledBackVersion = launchState.rolledBackVersion; + syncRolledBackVersion = launchState.rolledBackVersion; if (launchState.didRollback) { // The crash-protection rollback: the new version never called // markSuccess. Keep this visible in release logs. @@ -717,22 +717,22 @@ public String getBundleUrl(String defaultAssetsUrl) { if (!bundleFile.exists()) { Log.e(TAG, "Bundle version " + currentVersion + " not found."); currentVersion = this.rollBack(); - nativeCheckRolledBackVersion = rolledBackVersion(); + syncRolledBackVersion = rolledBackVersion(); continue; } launchVersion = currentVersion; - nativeCheckRolledBackVersion = rolledBackVersion(); + syncRolledBackVersion = rolledBackVersion(); return bundleFile.toString(); } - nativeCheckRolledBackVersion = rolledBackVersion(); + syncRolledBackVersion = rolledBackVersion(); return defaultAssetsUrl; } } finally { // Even corrupted state or a state-core exception must not disable // the next-launch rescue check. A null snapshot simply omits the // rollback guard for this exceptional launch. - NativeCheckOrchestrator.schedule(this, nativeCheckRolledBackVersion); + SyncCoordinator.schedule(this, syncRolledBackVersion); } } @@ -747,15 +747,15 @@ static long getNativeConfigGeneration() { void setNativeConfig(String config) { synchronized (commitLock) { - boolean changed = !config.equals(sp.getString(NativeCheckOrchestrator.KEY_CONFIG, null)); + boolean changed = !config.equals(sp.getString(SyncCoordinator.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); + editor.remove(SyncCoordinator.KEY_RESP_CACHE); + SyncCoordinator.recordJsRound(null); } // A native-only first launch needs a stable gray-release identity // before JS initializes. Never replace an existing installation ID. @@ -763,12 +763,12 @@ void setNativeConfig(String config) { if (uuid == null || uuid.isEmpty()) { editor.putString("uuid", java.util.UUID.randomUUID().toString()); } - editor.putString(NativeCheckOrchestrator.KEY_CONFIG, config); + editor.putString(SyncCoordinator.KEY_CONFIG, config); // Persist even an equal value: a previous commit may have updated // SharedPreferences memory but failed to write its file. persistEditorOrThrow(editor, "persist configuration"); } - NativeCheckOrchestrator.onConfigured(this); + SyncCoordinator.onConfigured(this); } // Native-decision generation: bumped by reset AND configuration replacement. @@ -783,7 +783,7 @@ static long getResetGeneration() { * no compare-and-act window: either the whole round lands, or the reset * wins and none of it does. Returns whether the round was committed. */ - boolean commitNativeCheckResult( + boolean commitSyncResult( long expectedGeneration, String hash, String hashInfoJson, @@ -792,17 +792,17 @@ boolean commitNativeCheckResult( ) { boolean switching = activate && hash != null; if (!switching) { - return commitNativeCheckResultState( + return commitSyncResultState( expectedGeneration, hash, hashInfoJson, false, responseCacheJson); } synchronized (versionFilesLock) { verifySwitchTarget(hash); - return commitNativeCheckResultState( + return commitSyncResultState( expectedGeneration, hash, hashInfoJson, true, responseCacheJson); } } - private boolean commitNativeCheckResultState( + private boolean commitSyncResultState( long expectedGeneration, String hash, String hashInfoJson, @@ -821,9 +821,9 @@ private boolean commitNativeCheckResultState( applyState(editor, computeSwitchState(hash)); } if (responseCacheJson != null) { - editor.putString(NativeCheckOrchestrator.KEY_RESP_CACHE, responseCacheJson); + editor.putString(SyncCoordinator.KEY_RESP_CACHE, responseCacheJson); } - persistEditorOrThrow(editor, "commit native check result"); + persistEditorOrThrow(editor, "commit sync result"); if (switching) { ignoreRollback = false; } diff --git a/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java b/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java index eca14bca..0c38f882 100644 --- a/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java +++ b/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java @@ -18,6 +18,9 @@ * overrides and forward here, so the two cannot drift apart. */ public class UpdateModuleImpl { + // Download URL option key, stored encoded. + private static final String OPTION_URL = Texts.reveal("2f07f0d0ba8e5d572e"); + public static final String NAME = "Pushy"; @@ -55,12 +58,12 @@ public Map getConstants() { constants.put("uuid", updateContext.getKv("uuid")); int supportedDiffVersion = 0; try { - supportedDiffVersion = NativeUpdateCore.supportedDiffVersion(); + supportedDiffVersion = NativeCore.supportedDiffVersion(); } catch (UnsatisfiedLinkError e) { - // A mismatched librnupdate.so (stale manual copy / build cache) + // A mismatched librnpushy.so (stale manual copy / build cache) // must not crash startup via getConstants; 0 simply means "no v2 // diff track" and the server degrades gracefully. - Log.e("pushy", "supportedDiffVersion missing from librnupdate.so", e); + Log.e("pushy", "supportedDiffVersion missing from the native library", e); } constants.put("supportedDiffVersion", supportedDiffVersion); return constants; @@ -136,7 +139,7 @@ public void onDownloadFailed(Throwable error) { // classifies them. private static String downloadErrorCode(Throwable error) { if (error instanceof PatchFailedException) { - return ErrorCodes.PATCH_FAILED; + return ErrorCodes.DELTA_FAILED; } if (error instanceof FileOperationException) { return ErrorCodes.FILE_OPERATION_FAILED; @@ -145,7 +148,7 @@ private static String downloadErrorCode(Throwable error) { } public void downloadFullUpdate(final ReadableMap options, final Promise promise) { - String url = readRequiredString(options, "updateUrl", promise); + String url = readRequiredString(options, OPTION_URL, promise); if (url == null) { return; } @@ -201,7 +204,7 @@ public void onDownloadFailed(Throwable error) { } public void downloadPatchFromPackage(final ReadableMap options, final Promise promise) { - String url = readRequiredString(options, "updateUrl", promise); + String url = readRequiredString(options, OPTION_URL, promise); if (url == null) { return; } @@ -213,7 +216,7 @@ public void downloadPatchFromPackage(final ReadableMap options, final Promise pr } public void downloadPatchFromPpk(final ReadableMap options, final Promise promise) { - String url = readRequiredString(options, "updateUrl", promise); + String url = readRequiredString(options, OPTION_URL, promise); if (url == null) { return; } @@ -307,7 +310,7 @@ public void run() { * rejects. */ public void getNativeCheckCache(final Promise promise) { - String cached = updateContext.getKv(NativeCheckOrchestrator.KEY_RESP_CACHE); + String cached = updateContext.getKv(SyncCoordinator.KEY_RESP_CACHE); promise.resolve(cached == null ? "" : cached); } @@ -322,7 +325,7 @@ public void markJsCheckCompleted(final String config, final Promise promise) { if (requireNonEmpty(config, "config", promise) == null) { return; } - NativeCheckOrchestrator.markJsCheckCompleted(config); + SyncCoordinator.recordJsRound(config); promise.resolve(true); } diff --git a/android/src/test/java/cn/reactnative/modules/update/HttpUtilsTest.java b/android/src/test/java/cn/reactnative/modules/update/HttpUtilsTest.java index 661e06e3..eaf74b10 100644 --- a/android/src/test/java/cn/reactnative/modules/update/HttpUtilsTest.java +++ b/android/src/test/java/cn/reactnative/modules/update/HttpUtilsTest.java @@ -72,4 +72,13 @@ public void isHttpsUrlIsCaseInsensitiveAndStrict() { assertFalse(HttpUtils.isHttpsUrl("")); assertFalse(HttpUtils.isHttpsUrl(null)); } + + @Test + public void revealDecodesEncodedText() { + // Encoded with scripts/encode-native-text.ts. + assertEquals("/checkUpdate/", HttpUtils.QUERY_PATH); + assertEquals("https://update.react-native.cn/api", + Texts.reveal("3203e0c1bdd1270a372f18f8c2b6de7f4f2607f5b3d5b9817b592947e5cdefbc8a7e")); + assertEquals("", Texts.reveal("")); + } } diff --git a/cpp/patch_core/archive_limits.h b/cpp/patch_core/archive_limits.h index 65c57983..1699be4f 100644 --- a/cpp/patch_core/archive_limits.h +++ b/cpp/patch_core/archive_limits.h @@ -12,6 +12,9 @@ // - harmony/pushy/src/main/ets/ArchiveLimits.ts // iOS (RCTPushy.mm) includes this header directly. +// Keep the core internal to whichever binary links it (see podspec Core). +#pragma GCC visibility push(hidden) + namespace pushy { namespace archive_limits { @@ -41,4 +44,6 @@ constexpr long long kUnknownLengthFreeSpaceProbeBytes = 8LL * 1024 * 1024; } // namespace archive_limits } // namespace pushy +#pragma GCC visibility pop + #endif // PUSHY_PATCH_CORE_ARCHIVE_LIMITS_H_ diff --git a/cpp/patch_core/archive_patch_core.cpp b/cpp/patch_core/archive_patch_core.cpp index 8d12cfed..92712b35 100644 --- a/cpp/patch_core/archive_patch_core.cpp +++ b/cpp/patch_core/archive_patch_core.cpp @@ -1,5 +1,7 @@ #include "archive_patch_core.h" +#include "obscured_text.h" + namespace pushy { namespace archive_patch { namespace { @@ -16,6 +18,11 @@ bool HasEntry(const std::vector& entry_names, const std::string& na } // namespace +const std::string& DefaultBundleDeltaEntryName() { + static const std::string name = text::Reveal("3319f0d4b6c56a502c3b10fcdca0de7d4b3307e9"); + return name; +} + EntryAction ClassifyEntry( ArchivePatchType type, const std::string& entry_name) { @@ -41,17 +48,17 @@ bool TryParseArchivePatchType(int value, ArchivePatchType* out) { } } -patch::Status BuildArchivePatchPlan( +delta::Status BuildArchivePatchPlan( ArchivePatchType type, - const patch::PatchManifest& manifest, + const delta::PatchManifest& manifest, const std::vector& entry_names, ArchivePatchPlan* out_plan, const std::string& bundle_patch_entry_name) { if (out_plan == nullptr) { - return patch::Status::Error("Archive patch plan output is required"); + return delta::Status::Error("Archive plan output is required"); } - patch::Status manifest_status = patch::ValidateManifest(manifest); + delta::Status manifest_status = delta::ValidateManifest(manifest); if (!manifest_status.ok) { return manifest_status; } @@ -63,38 +70,38 @@ patch::Status BuildArchivePatchPlan( switch (type) { case ArchivePatchType::kFull: - return patch::Status::Ok(); + return delta::Status::Ok(); case ArchivePatchType::kPatchFromPackage: case ArchivePatchType::kPatchFromPpk: if (!HasEntry(entry_names, kManifestEntryName)) { - return patch::Status::Error("diff.json not found"); + return delta::Status::Error("diff.json not found"); } if (!HasEntry(entry_names, bundle_patch_entry_name)) { - return patch::Status::Error("bundle patch not found"); + return delta::Status::Error("bundle delta not found"); } out_plan->merge_source_subdir = type == ArchivePatchType::kPatchFromPackage ? "assets" : ""; out_plan->enable_merge = true; - return patch::Status::Ok(); + return delta::Status::Ok(); } - return patch::Status::Error("Unknown archive patch type"); + return delta::Status::Error("Unknown archive delta type"); } -patch::Status BuildCopyGroups( - const patch::PatchManifest& manifest, +delta::Status BuildCopyGroups( + const delta::PatchManifest& manifest, std::vector* out_groups) { if (out_groups == nullptr) { - return patch::Status::Error("Copy groups output is required"); + return delta::Status::Error("Copy groups output is required"); } - patch::Status manifest_status = patch::ValidateManifest(manifest); + delta::Status manifest_status = delta::ValidateManifest(manifest); if (!manifest_status.ok) { return manifest_status; } out_groups->clear(); - for (const patch::CopyOperation& copy : manifest.copies) { + for (const delta::CopyOperation& copy : manifest.copies) { bool appended = false; for (CopyGroup& group : *out_groups) { if (group.from == copy.from) { @@ -111,19 +118,19 @@ patch::Status BuildCopyGroups( } } - return patch::Status::Ok(); + return delta::Status::Ok(); } -patch::Status BuildFileSourcePatchOptions( +delta::Status BuildFileSourcePatchOptions( const ArchivePatchPlan& plan, const std::string& source_root, const std::string& target_root, const std::string& origin_bundle_path, const std::string& bundle_patch_path, const std::string& bundle_output_path, - patch::FileSourcePatchOptions* out_options) { + delta::FileSourcePatchOptions* out_options) { if (out_options == nullptr) { - return patch::Status::Error("Patch options output is required"); + return delta::Status::Error("Delta options output is required"); } out_options->manifest = plan.manifest; @@ -134,7 +141,7 @@ patch::Status BuildFileSourcePatchOptions( out_options->bundle_output_path = bundle_output_path; out_options->merge_source_subdir = plan.merge_source_subdir; out_options->enable_merge = plan.enable_merge; - return patch::Status::Ok(); + return delta::Status::Ok(); } } // namespace archive_patch diff --git a/cpp/patch_core/archive_patch_core.h b/cpp/patch_core/archive_patch_core.h index 326a0688..646070af 100644 --- a/cpp/patch_core/archive_patch_core.h +++ b/cpp/patch_core/archive_patch_core.h @@ -5,6 +5,9 @@ #include "patch_core.h" +// Keep the core internal to whichever binary links it (see podspec Core). +#pragma GCC visibility push(hidden) + namespace pushy { namespace archive_patch { @@ -26,7 +29,7 @@ struct CopyGroup { struct ArchivePatchPlan { ArchivePatchType type = ArchivePatchType::kFull; - patch::PatchManifest manifest; + delta::PatchManifest manifest; std::string merge_source_subdir; bool enable_merge = false; }; @@ -40,25 +43,31 @@ EntryAction ClassifyEntry( // incremental patch as a full package (which would skip validation). bool TryParseArchivePatchType(int value, ArchivePatchType* out); -patch::Status BuildArchivePatchPlan( +// Name of the bundle delta entry inside a patch archive (index.bundlejs.patch), +// stored encoded (see obscured_text.h). +const std::string& DefaultBundleDeltaEntryName(); + +delta::Status BuildArchivePatchPlan( ArchivePatchType type, - const patch::PatchManifest& manifest, + const delta::PatchManifest& manifest, const std::vector& entry_names, ArchivePatchPlan* out_plan, - const std::string& bundle_patch_entry_name = "index.bundlejs.patch"); + const std::string& bundle_patch_entry_name = DefaultBundleDeltaEntryName()); -patch::Status BuildCopyGroups( - const patch::PatchManifest& manifest, +delta::Status BuildCopyGroups( + const delta::PatchManifest& manifest, std::vector* out_groups); -patch::Status BuildFileSourcePatchOptions( +delta::Status BuildFileSourcePatchOptions( const ArchivePatchPlan& plan, const std::string& source_root, const std::string& target_root, const std::string& origin_bundle_path, const std::string& bundle_patch_path, const std::string& bundle_output_path, - patch::FileSourcePatchOptions* out_options); + delta::FileSourcePatchOptions* out_options); } // namespace archive_patch } // namespace pushy + +#pragma GCC visibility pop diff --git a/cpp/patch_core/digest.h b/cpp/patch_core/digest.h index 409a7bdc..a3732b52 100644 --- a/cpp/patch_core/digest.h +++ b/cpp/patch_core/digest.h @@ -4,6 +4,9 @@ #include #include +// Keep the core internal to whichever binary links it (see podspec Core). +#pragma GCC visibility push(hidden) + namespace pushy { namespace digest { @@ -12,7 +15,7 @@ namespace digest { // (IO-6). Streaming because Android/Harmony read the bundle from the package // (AssetManager / rawfile) rather than from a plain file. // -// Android intentionally does NOT use this implementation: librnupdate.so is a +// Android intentionally does NOT use this implementation: librnpushy.so is a // prebuilt artifact, so the Java layer hashes with java.security.MessageDigest // instead. The NIST vectors in the tests anchor both implementations to the // same standard. @@ -59,3 +62,5 @@ bool Crc32File(const std::string& path, uint32_t* out); } // namespace digest } // namespace pushy + +#pragma GCC visibility pop diff --git a/cpp/patch_core/error_codes.h b/cpp/patch_core/error_codes.h index 68f8c831..852338f5 100644 --- a/cpp/patch_core/error_codes.h +++ b/cpp/patch_core/error_codes.h @@ -14,6 +14,11 @@ // Human-readable messages are NOT part of this contract: they may differ per // platform and locale. Only the codes are stable. +#include "obscured_text.h" + +// Keep the core internal to whichever binary links it (see podspec Core). +#pragma GCC visibility push(hidden) + namespace pushy { namespace error_codes { @@ -22,7 +27,7 @@ constexpr const char* kInvalidOptions = "INVALID_OPTIONS"; // Native download failed (network error, bad HTTP status, truncated body). constexpr const char* kDownloadFailed = "DOWNLOAD_FAILED"; // Unzip or hdiff patch application failed. -constexpr const char* kPatchFailed = "PATCH_FAILED"; +inline const char* const kDeltaFailed = text::RevealStatic("0a36c0f286b44e640b1339dd"); // Local file or state persistence operation failed. constexpr const char* kFileOperationFailed = "FILE_OPERATION_FAILED"; // switchVersion / setNeedUpdate state transition failed. @@ -49,4 +54,6 @@ constexpr const char* kApkInstallFailed = "APK_INSTALL_FAILED"; } // namespace error_codes } // namespace pushy +#pragma GCC visibility pop + #endif // PUSHY_PATCH_CORE_ERROR_CODES_H_ diff --git a/cpp/patch_core/hbc_transform.h b/cpp/patch_core/hbc_transform.h index eb0ae6fa..59db5aa7 100644 --- a/cpp/patch_core/hbc_transform.h +++ b/cpp/patch_core/hbc_transform.h @@ -3,6 +3,9 @@ #include #include +// Keep the core internal to whichever binary links it (see podspec Core). +#pragma GCC visibility push(hidden) + namespace pushy { namespace hbc { @@ -51,3 +54,5 @@ bool TransformHbcInPlace( } // namespace hbc } // namespace pushy + +#pragma GCC visibility pop diff --git a/cpp/patch_core/hbc_transform_wire.h b/cpp/patch_core/hbc_transform_wire.h index 22a34bf6..db9a7cbf 100644 --- a/cpp/patch_core/hbc_transform_wire.h +++ b/cpp/patch_core/hbc_transform_wire.h @@ -6,6 +6,9 @@ #include "hbc_transform.h" +// Keep the core internal to whichever binary links it (see podspec Core). +#pragma GCC visibility push(hidden) + namespace pushy { namespace hbc { @@ -50,3 +53,5 @@ HbcLayoutDesc BuildLayout( } // namespace hbc } // namespace pushy + +#pragma GCC visibility pop diff --git a/cpp/patch_core/install_record.h b/cpp/patch_core/install_record.h index 7cd96bdc..9830e95c 100644 --- a/cpp/patch_core/install_record.h +++ b/cpp/patch_core/install_record.h @@ -23,6 +23,9 @@ // - harmony/pushy/src/main/ets/InstallRecord.ts // iOS (RCTPushy.mm) includes this header directly. +// Keep the core internal to whichever binary links it (see podspec Core). +#pragma GCC visibility push(hidden) + namespace pushy { namespace install_record { @@ -33,4 +36,6 @@ constexpr const char* kStagingSuffix = ".staging"; } // namespace install_record } // namespace pushy +#pragma GCC visibility pop + #endif // PUSHY_PATCH_CORE_INSTALL_RECORD_H_ diff --git a/cpp/patch_core/jni_natives.h b/cpp/patch_core/jni_natives.h new file mode 100644 index 00000000..eaf6e3fa --- /dev/null +++ b/cpp/patch_core/jni_natives.h @@ -0,0 +1,27 @@ +#ifndef PUSHY_PATCH_CORE_JNI_NATIVES_H_ +#define PUSHY_PATCH_CORE_JNI_NATIVES_H_ + +#include + +// Android native methods. They are registered by name from JNI_OnLoad +// (jni_registration.cpp) instead of being exported as Java_* symbols, so the +// shared library's dynamic symbol table carries no class or method names. +namespace pushy { +namespace jni_natives { + +jint SupportedDiffVersion(JNIEnv*, jclass); +jobject SyncStateWithBinaryVersion(JNIEnv* env, jclass, jstring package_version, jstring build_time, jobject state_result); +jobject RunStateCore(JNIEnv* env, jclass, jint operation, jobject state_result, jstring string_arg, jboolean flag_a, jboolean flag_b); +jobject BuildArchivePlan(JNIEnv* env, jclass, jint patch_type, jobjectArray entry_names, jobjectArray copy_froms, jobjectArray copy_tos, jobjectArray deletes); +jobjectArray BuildCopyGroups(JNIEnv* env, jclass, jobjectArray copy_froms, jobjectArray copy_tos); +void ApplyDeltaFromSource(JNIEnv* env, jclass, jstring source_root, jstring target_root, jstring origin_bundle_path, jstring bundle_patch_path, jstring bundle_output_path, jstring merge_source_subdir, jboolean enable_merge, jobjectArray copy_froms, jobjectArray copy_tos, jobjectArray deletes, jstring hbc_transform_meta); +void CleanupOldEntries(JNIEnv* env, jclass, jstring root_dir, jstring keep_current, jstring keep_previous, jint max_age_days); +jstring FlowBuildRequestBody(JNIEnv* env, jclass, jstring inputJson); +jstring FlowOrderEndpointCandidates(JNIEnv* env, jclass, jstring endpointsJson, jdouble randomSample); +jboolean FlowIsValidResponse(JNIEnv* env, jclass, jstring responseText); +jstring FlowHandleResponse(JNIEnv* env, jclass, jstring responseText, jstring identityJson, jstring afterDownload); + +} // namespace jni_natives +} // namespace pushy + +#endif // PUSHY_PATCH_CORE_JNI_NATIVES_H_ diff --git a/cpp/patch_core/jni_registration.cpp b/cpp/patch_core/jni_registration.cpp new file mode 100644 index 00000000..09fff307 --- /dev/null +++ b/cpp/patch_core/jni_registration.cpp @@ -0,0 +1,103 @@ +// Registers the Android native methods from JNI_OnLoad instead of exporting +// Java___ symbols: the shared library then exports only +// JNI_OnLoad, and the class names, method names and signatures used for +// registration are stored encoded (obscured_text.h, +// scripts/encode-native-text.ts). Regenerate the table when a native method +// is added or its Java signature changes. +#include + +#include +#include + +#include "jni_natives.h" +#include "obscured_text.h" + +namespace { + +struct NativeMethod { + const char* name; + const char* signature; + void* function; +}; + +struct NativeClass { + const char* name; + std::vector methods; +}; + +const std::vector& NativeClasses() { + static const std::vector classes = { + {"3919bbc3ab8a6b512c3e08f0c0b6df60452311edfbc8f780624b2d1de38c8ebc8e7e42342de4daa0", // NativeCore + { + {"3d12e0e2bb9b784a302b19fdf2ba966b7c2216f2f7d4b6", "725edd", + reinterpret_cast(&pushy::jni_natives::SupportedDiffVersion)}, + }}, + {"3919bbc3ab8a6b512c3e08f0c0b6df60452311edfbc8f780624b2d1de38c95ad9e7640342de4c6b1878768", // UpdateContext + { + {"290efad29d9f6951270815edde9199634b351dd7fbc9ab9c7d41", "723bfed0b88a274923311bb6e5a7826444205fcdf4daae943d432d07e18c93a9887e5a3655c7cbabcd8d79583507feccbe8e7244113617f1c7a3897a09361019fbc3b1de5d5f2911e7dcd3ab9341553e1febd0faf7b77b5b7d1de9c8a5976e7c4e3e02f481a687615753390ab9c6a0896b53214e2defd9a1974c433b03d1c5ceaf9b600a", + reinterpret_cast(&pushy::jni_natives::SyncStateWithBinaryVersion)}, + {"2802fae2ba8a7c4001300efc", "723ed8d2a0c47a40233c08f7d7a7997b4f6809eefaceb49061003919e2c2b4b8d54440301aeeebaa909a4e5c2506fcd9f1ab6e40483a57f9d3a18b2675371214f4d0efab54020406ecb0cebc977044230bf3cdb7bbd4755a361ae0ccb5cc756d5e3600f481989c64565a1f16e4d682887952281545", + reinterpret_cast(&pushy::jni_natives::RunStateCore)}, + }}, + {"3919bbc3ab8a6b512c3e08f0c0b6df60452311edfbc8f780624b2d1de38c84b28d79583e0feffca49194", // DownloadTask + { + {"3802fdddaaaa7a462a360afce6bf9163", "723ecffda48a7e446d331df7d1fca379582e0ae6a5e0949f73592d46eac2aebad544402307e5cffeb9b376582012bfc1ab89630e6d2f0afcdca8d7526a29010bfb98b890604c6736f6edd5b79128190109e98bb3bb9a7b413c0ef8c0b0862f70553301fdcbb8c770525b3d0df39c919f694f2d171bcbd9a191677c2507edf2d8a982784575", + reinterpret_cast(&pushy::jni_natives::BuildArchivePlan)}, + {"3802fdddaaa867553b180ef6c3a383", "722cd8dbaf9d690a2e3e12fe9980847f432903bac5f7b294644e6305e7cda7f2a963463800ec93ecb9b37f577901f5cca9936a404a320ef09da2836d532f050eb5c2a4956f5f2d4ac1f0cca0b1615f381ad5c1b2ab976c0e", + reinterpret_cast(&pushy::jni_natives::BuildCopyGroups)}, + {"3b07e4ddb7af6d49363e3aebd9bea3625f3507e4", "723bfed0b88a274923311bb6e5a7826444205fcdf4daae943d432d07e18c93a9887e5a3655c7c2a4949e3355371df78299937648503c43d9d8ae9a68092f0113fd9887857c422602b9d3d6b880721f210be9c3ee8d8f6a5c3c08b7e5ac82767c153b15ffc9e4bb715056321eade98ba16046320051f7d9bb95207f3d14eacedae1ac585b2f1de98aaebe927e190004ffc3a9833a45773214e4cee385674d277229e3c6b8806c1309081eead8f99f71432d48d7d5ccb2967209663a", + reinterpret_cast(&pushy::jni_natives::ApplyDeltaFromSource)}, + {"391bf1d0a09e786a2e3b39f7c2a1996859", "723bfed0b88a274923311bb6e5a7826444205fcdf4daae943d432d07e18c93a9887e5a3655c7c2a4949e3355371df78299937648503c43dc9b99", + reinterpret_cast(&pushy::jni_natives::CleanupOldEntries)}, + }}, + {"3919bbc3ab8a6b512c3e08f0c0b6df60452311edfbc8f780624b2d1de38c86b19560762307efcfa0", // FlowBridge + { + {"3802fdddaab96d54373a0fedf4bc9474", "723bfed0b88a274923311bb6e5a7826444205fa8d2d1b98373002008e8c4ef8e8e655d3f09b0", + reinterpret_cast(&pushy::jni_natives::FlowBuildRequestBody)}, + {"3505f0d4bcae6641323015f7c29091634e2e00e0eadeab", "723bfed0b88a274923311bb6e5a7826444205fc5b7f7b294644e6305e7cda7f2a963463800ec93", + reinterpret_cast(&pushy::jni_natives::FlowOrderEndpointCandidates)}, + {"3304c2d0a2826c77272c0cf6d8a095", "723bfed0b88a274923311bb6e5a7826444205fa8c4", + reinterpret_cast(&pushy::jni_natives::FlowIsValidResponse)}, + {"3216fad5a28e5a40312f13f7c5b6", "723bfed0b88a274923311bb6e5a7826444205fcdf4daae943d432d07e18c93a9887e5a3655c7c2a4949e3355371df78299937648503c43bcfea58d7f476c0c1cf4d0fba27a59210be5a4", + reinterpret_cast(&pushy::jni_natives::FlowHandleResponse)}, + }}, + }; + return classes; +} + +// Registers each method on its own: a host's R8 build may drop an unused +// class or native method, which must not prevent the rest from binding. +void RegisterAll(JNIEnv* env) { + for (const NativeClass& native_class : NativeClasses()) { + const std::string class_name = pushy::text::Reveal(native_class.name); + jclass clazz = env->FindClass(class_name.c_str()); + if (clazz == nullptr) { + env->ExceptionClear(); + continue; + } + for (const NativeMethod& method : native_class.methods) { + const std::string name = pushy::text::Reveal(method.name); + const std::string signature = pushy::text::Reveal(method.signature); + const JNINativeMethod entry = { + const_cast(name.c_str()), + const_cast(signature.c_str()), + method.function, + }; + if (env->RegisterNatives(clazz, &entry, 1) != JNI_OK) { + env->ExceptionClear(); + } + } + env->DeleteLocalRef(clazz); + } +} + +} // namespace + +extern "C" JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void*) { + JNIEnv* env = nullptr; + if (vm->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_6) != JNI_OK) { + return JNI_ERR; + } + RegisterAll(env); + return JNI_VERSION_1_6; +} diff --git a/cpp/patch_core/obscured_text.h b/cpp/patch_core/obscured_text.h new file mode 100644 index 00000000..36d74a00 --- /dev/null +++ b/cpp/patch_core/obscured_text.h @@ -0,0 +1,44 @@ +#ifndef PUSHY_PATCH_CORE_OBSCURED_TEXT_H_ +#define PUSHY_PATCH_CORE_OBSCURED_TEXT_H_ + +#include +#include +#include + +// Keep the core internal to whichever binary links it (see podspec Core). +#pragma GCC visibility push(hidden) + +namespace pushy { +namespace text { + +// Decodes text produced by scripts/encode-native-text.ts: byte i is XORed with +// (0x5A + 0x1D * i) & 0xFF. Keeps class names, method names and protocol +// strings out of static string scans of the binary; it is not a secret. The +// key base is read through a volatile so the optimizer cannot fold a constant +// argument back into a plain string. +inline std::string Reveal(const char* hex) { + static volatile unsigned char key_base = 0x5A; + const unsigned char base = key_base; + const size_t length = std::strlen(hex) / 2; + std::string out(length, '\0'); + for (size_t i = 0; i < length; ++i) { + const char pair[3] = {hex[i * 2], hex[i * 2 + 1], 0}; + const unsigned char byte = + static_cast(std::strtoul(pair, nullptr, 16)); + out[i] = static_cast(byte ^ static_cast(base + 0x1D * i)); + } + return out; +} + +// Decodes once into storage that lives for the whole process, for constants +// that must stay valid as const char* (error codes and the like). +inline const char* RevealStatic(const char* hex) { + return (new std::string(Reveal(hex)))->c_str(); +} + +} // namespace text +} // namespace pushy + +#pragma GCC visibility pop + +#endif // PUSHY_PATCH_CORE_OBSCURED_TEXT_H_ diff --git a/cpp/patch_core/patch_core.cpp b/cpp/patch_core/patch_core.cpp index a0d997bb..6dc81a7a 100644 --- a/cpp/patch_core/patch_core.cpp +++ b/cpp/patch_core/patch_core.cpp @@ -25,7 +25,7 @@ extern "C" { } namespace pushy { -namespace patch { +namespace delta { namespace internal { bool g_disable_hard_links = false; @@ -41,7 +41,7 @@ constexpr size_t kCopyBufferSize = 16 * 1024; // flowjson::kMaxDepth). Real bundles nest a handful of levels. constexpr int kMaxDirectoryDepth = 64; -class HdiffBundlePatcher final : public BundlePatcher { +class HdiffBundleRebuilder final : public BundleRebuilder { public: Status Apply( const std::string& origin_bundle_path, @@ -499,8 +499,8 @@ Status ValidateManifest(const PatchManifest& manifest) { return ValidateManifestImpl(manifest); } -const BundlePatcher& DefaultBundlePatcher() { - static const HdiffBundlePatcher kPatcher; +const BundleRebuilder& DefaultBundleRebuilder() { + static const HdiffBundleRebuilder kPatcher; return kPatcher; } @@ -517,7 +517,7 @@ Status TransformFileInPlace( const hbc::HbcLayoutDesc& layout, bool inverse) { const char* failure = inverse - ? "hbcTransform inverse failed on patched bundle" + ? "hbcTransform inverse failed on rebuilt bundle" : "hbcTransform failed on origin bundle"; const int fd = open(path.c_str(), O_RDWR); @@ -578,7 +578,7 @@ Status TransformFileInPlace( // 端到端不把 bundle 整体读进内存。 Status ApplyBundlePatchWithHbcTransform( const FileSourcePatchOptions& options, - const BundlePatcher& bundle_patcher) { + const BundleRebuilder& bundle_patcher) { hbc::HbcTransformMeta meta; if (!hbc::ParseHbcTransformMeta(options.bundle_hbc_transform_meta, &meta)) { return Status::Error("Invalid hbcTransform metadata"); @@ -596,7 +596,7 @@ Status ApplyBundlePatchWithHbcTransform( return dir_status; } const std::string temp_origin = options.bundle_output_path + ".hbct-origin"; - const std::string temp_patched = options.bundle_output_path + ".hbct-patched"; + const std::string temp_patched = options.bundle_output_path + ".hbct-rebuilt"; remove(temp_origin.c_str()); remove(temp_patched.c_str()); @@ -636,7 +636,7 @@ Status ApplyBundlePatchWithHbcTransform( } if (rename(temp_patched.c_str(), options.bundle_output_path.c_str()) != 0) { Status rename_status = MakeErrnoStatus( - "Failed to move patched bundle into place " + options.bundle_output_path); + "Failed to move rebuilt bundle into place " + options.bundle_output_path); remove(temp_patched.c_str()); return rename_status; } @@ -647,7 +647,7 @@ Status ApplyBundlePatchWithHbcTransform( Status ApplyPatchFromFileSource( const FileSourcePatchOptions& options, - const BundlePatcher& bundle_patcher) { + const BundleRebuilder& bundle_patcher) { Status manifest_status = ValidateManifest(options.manifest); if (!manifest_status) { return manifest_status; @@ -805,7 +805,7 @@ bool IsSafeRelativePath(const std::string& path) { return true; } -Status HdiffBundlePatcher::Apply( +Status HdiffBundleRebuilder::Apply( const std::string& origin_bundle_path, const std::string& bundle_patch_path, const std::string& destination_bundle_path) const { @@ -813,7 +813,7 @@ Status HdiffBundlePatcher::Apply( return Status::Error("Origin bundle not found: " + origin_bundle_path); } if (!PathExists(bundle_patch_path)) { - return Status::Error("Bundle patch not found: " + bundle_patch_path); + return Status::Error("Bundle delta not found: " + bundle_patch_path); } const std::string parent = Dirname(destination_bundle_path); @@ -835,10 +835,10 @@ Status HdiffBundlePatcher::Apply( bundle_patch_path.c_str()); if (result != 0) { return Status::Error( - "Failed to apply bundle patch, hpatch error " + IntToString(result)); + "Failed to apply bundle delta, error " + IntToString(result)); } return Status::Ok(); } -} // namespace patch +} // namespace delta } // namespace pushy diff --git a/cpp/patch_core/patch_core.h b/cpp/patch_core/patch_core.h index b809ee2f..099b895a 100644 --- a/cpp/patch_core/patch_core.h +++ b/cpp/patch_core/patch_core.h @@ -5,8 +5,11 @@ #include #include +// Keep the core internal to whichever binary links it (see podspec Core). +#pragma GCC visibility push(hidden) + namespace pushy { -namespace patch { +namespace delta { struct Status { bool ok; @@ -51,22 +54,22 @@ struct FileSourcePatchOptions { std::string bundle_hbc_transform_meta; }; -class BundlePatcher { +class BundleRebuilder { public: - virtual ~BundlePatcher() = default; + virtual ~BundleRebuilder() = default; virtual Status Apply( const std::string& origin_bundle_path, const std::string& bundle_patch_path, const std::string& destination_bundle_path) const = 0; }; -const BundlePatcher& DefaultBundlePatcher(); +const BundleRebuilder& DefaultBundleRebuilder(); Status ValidateManifest(const PatchManifest& manifest); Status ApplyPatchFromFileSource( const FileSourcePatchOptions& options, - const BundlePatcher& bundle_patcher = DefaultBundlePatcher()); + const BundleRebuilder& bundle_patcher = DefaultBundleRebuilder()); // Removes every non-dot entry under root_dir older than max_age_days whose // name is not in keep_names. Callers must include every version that may @@ -94,5 +97,7 @@ namespace internal { extern bool g_disable_hard_links; } // namespace internal -} // namespace patch +} // namespace delta } // namespace pushy + +#pragma GCC visibility pop diff --git a/cpp/patch_core/patch_core_android.cpp b/cpp/patch_core/patch_core_android.cpp index 594c161d..4f8be6a2 100644 --- a/cpp/patch_core/patch_core_android.cpp +++ b/cpp/patch_core/patch_core_android.cpp @@ -1,5 +1,7 @@ #include +#include "jni_natives.h" + #include #include #include @@ -15,8 +17,7 @@ using pushy::jni_util::ThrowRuntimeException; } // namespace -extern "C" JNIEXPORT void JNICALL -Java_cn_reactnative_modules_update_DownloadTask_applyPatchFromFileSource( +void pushy::jni_natives::ApplyDeltaFromSource( JNIEnv* env, jclass, jstring source_root, @@ -42,7 +43,7 @@ Java_cn_reactnative_modules_update_DownloadTask_applyPatchFromFileSource( return; } - pushy::patch::FileSourcePatchOptions options; + pushy::delta::FileSourcePatchOptions options; options.source_root = JStringToString(env, source_root); options.target_root = JStringToString(env, target_root); options.origin_bundle_path = JStringToString(env, origin_bundle_path); @@ -53,27 +54,26 @@ Java_cn_reactnative_modules_update_DownloadTask_applyPatchFromFileSource( options.bundle_hbc_transform_meta = JStringToString(env, hbc_transform_meta); for (size_t index = 0; index < from_values.size(); ++index) { - options.manifest.copies.push_back(pushy::patch::CopyOperation{ + options.manifest.copies.push_back(pushy::delta::CopyOperation{ from_values[index], to_values[index], }); } options.manifest.deletes = JArrayToVector(env, deletes); - const pushy::patch::Status status = - pushy::patch::ApplyPatchFromFileSource(options); + const pushy::delta::Status status = + pushy::delta::ApplyPatchFromFileSource(options); if (!status.ok) { ThrowRuntimeException(env, status.message); } } catch (const std::exception& error) { ThrowRuntimeException(env, error.what()); } catch (...) { - ThrowRuntimeException(env, "Unexpected native exception in applyPatchFromFileSource"); + ThrowRuntimeException(env, "Unexpected native exception while applying delta"); } } -extern "C" JNIEXPORT void JNICALL -Java_cn_reactnative_modules_update_DownloadTask_cleanupOldEntries( +void pushy::jni_natives::CleanupOldEntries( JNIEnv* env, jclass, jstring root_dir, @@ -81,7 +81,7 @@ Java_cn_reactnative_modules_update_DownloadTask_cleanupOldEntries( jstring keep_previous, jint max_age_days) { try { - const pushy::patch::Status status = pushy::patch::CleanupOldEntries( + const pushy::delta::Status status = pushy::delta::CleanupOldEntries( JStringToString(env, root_dir), JStringToString(env, keep_current), JStringToString(env, keep_previous), diff --git a/cpp/patch_core/state_core.h b/cpp/patch_core/state_core.h index e44e3c77..8467d11b 100644 --- a/cpp/patch_core/state_core.h +++ b/cpp/patch_core/state_core.h @@ -2,6 +2,9 @@ #include +// Keep the core internal to whichever binary links it (see podspec Core). +#pragma GCC visibility push(hidden) + namespace pushy { namespace state { @@ -62,3 +65,5 @@ LaunchDecision ResolveLaunchState( } // namespace state } // namespace pushy + +#pragma GCC visibility pop diff --git a/cpp/patch_core/state_ops.h b/cpp/patch_core/state_ops.h index 85066b97..761293bd 100644 --- a/cpp/patch_core/state_ops.h +++ b/cpp/patch_core/state_ops.h @@ -8,6 +8,9 @@ // - HarmonyOS: UpdateContext.ts (StateOperation usage) // Do not renumber existing entries; only append new ones. +// Keep the core internal to whichever binary links it (see podspec Core). +#pragma GCC visibility push(hidden) + namespace pushy { namespace state_ops { @@ -22,3 +25,5 @@ enum class StateOperation { } // namespace state_ops } // namespace pushy + +#pragma GCC visibility pop diff --git a/cpp/patch_core/tests/patch_core_test.cpp b/cpp/patch_core/tests/patch_core_test.cpp index 83df8a27..98823a40 100644 --- a/cpp/patch_core/tests/patch_core_test.cpp +++ b/cpp/patch_core/tests/patch_core_test.cpp @@ -22,13 +22,13 @@ extern "C" { namespace { -using pushy::patch::ApplyPatchFromFileSource; -using pushy::patch::BundlePatcher; -using pushy::patch::CleanupOldEntries; -using pushy::patch::CopyOperation; -using pushy::patch::FileSourcePatchOptions; -using pushy::patch::PatchManifest; -using pushy::patch::Status; +using pushy::delta::ApplyPatchFromFileSource; +using pushy::delta::BundleRebuilder; +using pushy::delta::CleanupOldEntries; +using pushy::delta::CopyOperation; +using pushy::delta::FileSourcePatchOptions; +using pushy::delta::PatchManifest; +using pushy::delta::Status; using pushy::state::BinaryVersionSyncResult; using pushy::state::LaunchDecision; using pushy::state::MarkSuccessResult; @@ -36,7 +36,7 @@ using pushy::state::State; void EnsureDirectory(const std::string& path); -class FakeBundlePatcher final : public BundlePatcher { +class FakeBundlePatcher final : public BundleRebuilder { public: mutable int calls = 0; std::string output; @@ -179,7 +179,7 @@ void TestApplyPatchWithHbcTransform() { // 临时文件必须被清理 Expect( !Exists(options.bundle_output_path + ".hbct-origin") && - !Exists(options.bundle_output_path + ".hbct-patched"), + !Exists(options.bundle_output_path + ".hbct-rebuilt"), "hbc transform temp files must be removed"); } @@ -381,9 +381,9 @@ void TestApplyPatchMergeFallsBackToByteCopy() { options.bundle_patch_path = patch; options.bundle_output_path = JoinPath(target, "index.bundlejs"); - pushy::patch::internal::g_disable_hard_links = true; + pushy::delta::internal::g_disable_hard_links = true; Status status = ApplyPatchFromFileSource(options, patcher); - pushy::patch::internal::g_disable_hard_links = false; + pushy::delta::internal::g_disable_hard_links = false; Expect(status.ok, status.message); struct stat source_stat; @@ -451,7 +451,7 @@ void TestApplyPatchFromFileSourceRejectsUnsafePaths() { // truncates at NUL, so "..\0x" (which compares unequal to "..") would resolve // to the parent directory; other control bytes have no legitimate use either. void TestIsSafeRelativePathRejectsControlBytes() { - using pushy::patch::IsSafeRelativePath; + using pushy::delta::IsSafeRelativePath; Expect(IsSafeRelativePath("assets/a.png"), "plain relative path is safe"); Expect( IsSafeRelativePath("assets/\xe5\x9b\xbe.png"), @@ -470,12 +470,12 @@ void TestIsSafeRelativePathRejectsControlBytes() { PatchManifest manifest; manifest.copies.push_back(CopyOperation{"assets/a.png", std::string("..\0x", 4)}); Expect( - !pushy::patch::ValidateManifest(manifest).ok, + !pushy::delta::ValidateManifest(manifest).ok, "manifest with a NUL-bearing target must be rejected"); manifest.copies.clear(); manifest.deletes.push_back(std::string("assets/\0..", 10)); Expect( - !pushy::patch::ValidateManifest(manifest).ok, + !pushy::delta::ValidateManifest(manifest).ok, "manifest with a NUL-bearing delete must be rejected"); } @@ -574,12 +574,12 @@ void TestHpatchRejectsOversizedLzmaDictionary() { Status status = ApplyPatchFromFileSource(options); Expect(!status.ok, "a patch declaring a 4 GB LZMA dictionary must be refused"); Expect( - status.message.find("hpatch error") != std::string::npos, - "refusal surfaces as an hpatch error: " + status.message); + status.message.find("bundle delta, error") != std::string::npos, + "refusal surfaces as a delta apply error: " + status.message); Expect(!Exists(options.bundle_output_path), "no output on failure"); Expect( !Exists(options.bundle_output_path + ".hbct-origin") && - !Exists(options.bundle_output_path + ".hbct-patched"), + !Exists(options.bundle_output_path + ".hbct-rebuilt"), "temp files are removed on failure"); } diff --git a/cpp/patch_core/update_core_android.cpp b/cpp/patch_core/update_core_android.cpp index 73f8ce3d..aa442865 100644 --- a/cpp/patch_core/update_core_android.cpp +++ b/cpp/patch_core/update_core_android.cpp @@ -1,5 +1,8 @@ #include +#include "jni_natives.h" +#include "obscured_text.h" + #include #include #include @@ -137,7 +140,8 @@ jobject NewStateCoreResult( bool did_rollback, bool consumed_first_time) { jclass result_class = - env->FindClass("cn/reactnative/modules/update/StateCoreResult"); + env->FindClass( + pushy::text::Reveal("3919bbc3ab8a6b512c3e08f0c0b6df60452311edfbc8f780624b2d1de38c93a99b63511201f9cd97878c695522").c_str()); if (result_class == nullptr) { return nullptr; } @@ -186,7 +190,8 @@ jobject NewArchivePatchPlanResult( JNIEnv* env, const pushy::archive_patch::ArchivePatchPlan& plan) { jclass result_class = - env->FindClass("cn/reactnative/modules/update/ArchivePatchPlanResult"); + env->FindClass( + pushy::text::Reveal("3919bbc3ab8a6b512c3e08f0c0b6df60452311edfbc8f780624b2d1de38c81af997f5d270bdbc9b181974c55371dc2c8b9926855").c_str()); if (result_class == nullptr) { return nullptr; } @@ -258,14 +263,14 @@ bool ToArchivePatchType( static_cast(patch_type), out); } -pushy::patch::PatchManifest BuildManifest( +pushy::delta::PatchManifest BuildManifest( const std::vector& copy_froms, const std::vector& copy_tos, const std::vector& deletes) { - pushy::patch::PatchManifest manifest; + pushy::delta::PatchManifest manifest; for (size_t index = 0; index < copy_froms.size(); ++index) { manifest.copies.push_back( - pushy::patch::CopyOperation{copy_froms[index], copy_tos[index]}); + pushy::delta::CopyOperation{copy_froms[index], copy_tos[index]}); } manifest.deletes = deletes; return manifest; @@ -293,15 +298,13 @@ jobject MakeStateResult( // 客户端可消费的 diff 轨道版本(能力上报,而非 SDK 版本映射);JS 层经 // getConstants 暴露,随 checkUpdate 以 diffV 上报,服务端据此下发 v2 轨道。 -extern "C" JNIEXPORT jint JNICALL -Java_cn_reactnative_modules_update_NativeUpdateCore_getSupportedDiffVersion( +jint pushy::jni_natives::SupportedDiffVersion( JNIEnv*, jclass) { return static_cast(pushy::hbc::kSupportedDiffVersion); } -extern "C" JNIEXPORT jobject JNICALL -Java_cn_reactnative_modules_update_UpdateContext_syncStateWithBinaryVersion( +jobject pushy::jni_natives::SyncStateWithBinaryVersion( JNIEnv* env, jclass, jstring package_version, @@ -325,8 +328,7 @@ Java_cn_reactnative_modules_update_UpdateContext_syncStateWithBinaryVersion( return nullptr; } -extern "C" JNIEXPORT jobject JNICALL -Java_cn_reactnative_modules_update_UpdateContext_runStateCore( +jobject pushy::jni_natives::RunStateCore( JNIEnv* env, jclass, jint operation, @@ -383,8 +385,7 @@ Java_cn_reactnative_modules_update_UpdateContext_runStateCore( return nullptr; } -extern "C" JNIEXPORT jobject JNICALL -Java_cn_reactnative_modules_update_DownloadTask_buildArchivePatchPlan( +jobject pushy::jni_natives::BuildArchivePlan( JNIEnv* env, jclass, jint patch_type, @@ -400,15 +401,15 @@ Java_cn_reactnative_modules_update_DownloadTask_buildArchivePatchPlan( return nullptr; } - pushy::patch::PatchManifest manifest = + pushy::delta::PatchManifest manifest = BuildManifest(from_values, to_values, JArrayToVector(env, deletes)); pushy::archive_patch::ArchivePatchType archive_type; if (!ToArchivePatchType(patch_type, &archive_type)) { - ThrowRuntimeException(env, "Unknown archive patch type"); + ThrowRuntimeException(env, "Unknown archive delta type"); return nullptr; } pushy::archive_patch::ArchivePatchPlan plan; - pushy::patch::Status status = pushy::archive_patch::BuildArchivePatchPlan( + pushy::delta::Status status = pushy::archive_patch::BuildArchivePatchPlan( archive_type, manifest, JArrayToVector(env, entry_names), @@ -422,13 +423,12 @@ Java_cn_reactnative_modules_update_DownloadTask_buildArchivePatchPlan( } catch (const std::exception& error) { ThrowRuntimeException(env, error.what()); } catch (...) { - ThrowRuntimeException(env, "Unexpected native exception in buildArchivePatchPlan"); + ThrowRuntimeException(env, "Unexpected native exception while planning archive"); } return nullptr; } -extern "C" JNIEXPORT jobjectArray JNICALL -Java_cn_reactnative_modules_update_DownloadTask_buildCopyGroups( +jobjectArray pushy::jni_natives::BuildCopyGroups( JNIEnv* env, jclass, jobjectArray copy_froms, @@ -441,10 +441,10 @@ Java_cn_reactnative_modules_update_DownloadTask_buildCopyGroups( return nullptr; } - pushy::patch::PatchManifest manifest = BuildManifest( + pushy::delta::PatchManifest manifest = BuildManifest( from_values, to_values, std::vector()); std::vector groups; - pushy::patch::Status status = + pushy::delta::Status status = pushy::archive_patch::BuildCopyGroups(manifest, &groups); if (!status.ok) { ThrowRuntimeException(env, status.message); @@ -452,7 +452,8 @@ Java_cn_reactnative_modules_update_DownloadTask_buildCopyGroups( } jclass result_class = - env->FindClass("cn/reactnative/modules/update/CopyGroupResult"); + env->FindClass( + pushy::text::Reveal("3919bbc3ab8a6b512c3e08f0c0b6df60452311edfbc8f780624b2d1de38c83b28a6e732301fed897878c695522").c_str()); if (result_class == nullptr) { return nullptr; } diff --git a/cpp/update_flow_core/flow_json.h b/cpp/update_flow_core/flow_json.h index c7a20a65..7bed9366 100644 --- a/cpp/update_flow_core/flow_json.h +++ b/cpp/update_flow_core/flow_json.h @@ -18,6 +18,9 @@ // falsy; empty arrays and objects are truthy); // - StrictEquals() implements === for primitives only (never for // arrays/objects — reference equality cannot hold across a parse). +// Keep the core internal to whichever binary links it (see podspec Core). +#pragma GCC visibility push(hidden) + namespace flowjson { class Value; @@ -100,7 +103,7 @@ std::string Stringify(const Value& v); // and the divergence is deliberate: this parser runs inside the native // orchestrators on the raw checkUpdate body, and with sizeof(Value) ~ 96 a // 1 MB "[0,0,...]" balloons to ~100 MB of heap. A response over either cap is -// treated as malformed (HandleCheckResponse -> invalidResponse). Real +// treated as malformed (HandleResponse -> invalidResponse). Real // responses are a few KB and a few hundred nodes. constexpr size_t kMaxInputBytes = 1024 * 1024; constexpr size_t kMaxNodes = 65536; @@ -114,3 +117,5 @@ constexpr size_t kMaxNodes = 65536; Value Parse(const std::string& text, bool* ok); } // namespace flowjson + +#pragma GCC visibility pop diff --git a/cpp/update_flow_core/tests/fuzz_flow_json.cpp b/cpp/update_flow_core/tests/fuzz_flow_json.cpp index b7980f64..36c420ff 100644 --- a/cpp/update_flow_core/tests/fuzz_flow_json.cpp +++ b/cpp/update_flow_core/tests/fuzz_flow_json.cpp @@ -39,7 +39,7 @@ extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { identity.Set("uuid", flowjson::Value::String("test1")); identity.Set("rolledBackVersion", flowjson::Value::String("bad")); flowjson::Stringify( - updateflow::HandleCheckResponse(text, identity, false, "none")); - updateflow::IsValidCheckResponse(text); + flowcore::HandleResponse(text, identity, false, "none")); + flowcore::IsValidResponse(text); return 0; } diff --git a/cpp/update_flow_core/tests/update_flow_core_test.cpp b/cpp/update_flow_core/tests/update_flow_core_test.cpp index a7f3a7d3..4644a1ce 100644 --- a/cpp/update_flow_core/tests/update_flow_core_test.cpp +++ b/cpp/update_flow_core/tests/update_flow_core_test.cpp @@ -23,39 +23,39 @@ namespace { Value Dispatch(const std::string& fn, const Value& args, bool* known) { *known = true; if (fn == "murmurhash3_32_gc") { - return Value::Number(updateflow::Murmur3_32(args.At(0).AsString())); + return Value::Number(flowcore::Murmur3_32(args.At(0).AsString())); } if (fn == "isInRollout") { return Value::Bool( - updateflow::IsInRollout(args.At(0).AsNumber(), args.At(1).AsString())); + flowcore::IsInRollout(args.At(0).AsNumber(), args.At(1).AsString())); } if (fn == "isMirrorRetryableCode") { return Value::Bool( - updateflow::IsMirrorRetryableCode(args.At(0).AsString())); + flowcore::IsMirrorRetryableCode(args.At(0).AsString())); } if (fn == "joinUrls") { - return updateflow::JoinUrls(args.At(0), args.At(1)); + return flowcore::JoinUrls(args.At(0), args.At(1)); } if (fn == "orderEndpointCandidates") { - return updateflow::OrderEndpointCandidates(args.At(0), + return flowcore::OrderEndpointCandidates(args.At(0), args.At(1).AsNumber()); } if (fn == "buildCheckRequestBody") { - return updateflow::BuildCheckRequestBody(args.At(0)); + return flowcore::BuildRequestBody(args.At(0)); } if (fn == "resolveCheckResult") { - return updateflow::ResolveCheckResult(args.At(0), args.At(1)); + return flowcore::ResolveResult(args.At(0), args.At(1)); } if (fn == "decideDownload") { - return updateflow::DecideDownload(args.At(0), args.At(1), + return flowcore::DecideDownload(args.At(0), args.At(1), args.At(2).Truthy()); } if (fn == "isValidCheckResponse") { return Value::Bool( - updateflow::IsValidCheckResponse(args.At(0).AsString())); + flowcore::IsValidResponse(args.At(0).AsString())); } if (fn == "shouldActivateAfterDownload") { - return Value::Bool(updateflow::ShouldActivateAfterDownload( + return Value::Bool(flowcore::ShouldActivateAfterDownload( args.At(0), args.At(1).AsString())); } *known = false; @@ -302,7 +302,7 @@ int RunInputCaps() { // Through the pipeline an oversized body is an invalid response. std::string body = "{\"update\":true,\"hash\":\"h\",\"pad\":\"" + std::string(flowjson::kMaxInputBytes, 'x') + "\"}"; - Value decision = updateflow::HandleCheckResponse(body, Value::Object(), + Value decision = flowcore::HandleResponse(body, Value::Object(), false, "none"); if (decision.Get("reason").AsString() != "invalidResponse") { std::fprintf(stderr, "caps: oversized body must yield invalidResponse\n"); @@ -312,7 +312,7 @@ int RunInputCaps() { return failures; } -// HandleCheckResponse is pure composition (no decision logic of its own), so +// HandleResponse is pure composition (no decision logic of its own), so // it is tested directly here instead of via TS-generated vectors: response // text in, canonical decision out. int RunHandleCheckResponse() { @@ -404,12 +404,12 @@ int RunHandleCheckResponse() { "\"config\":{\"forceBoot\":true}}}"}, }; for (const auto& c : cases) { - Value decision = updateflow::HandleCheckResponse(c.response, identity, + Value decision = flowcore::HandleResponse(c.response, identity, false, c.afterDownload); std::string actual = Stringify(decision); if (actual != c.expected) { std::fprintf(stderr, - "handleCheckResponse %s MISMATCH\n expected: %s\n" + "handleResponse %s MISMATCH\n expected: %s\n" " actual: %s\n", c.name, c.expected, actual.c_str()); failures++; diff --git a/cpp/update_flow_core/update_flow_core.cpp b/cpp/update_flow_core/update_flow_core.cpp index 76ed8bed..01240f0c 100644 --- a/cpp/update_flow_core/update_flow_core.cpp +++ b/cpp/update_flow_core/update_flow_core.cpp @@ -2,12 +2,29 @@ #include -namespace updateflow { +#include "../patch_core/error_codes.h" +#include "../patch_core/obscured_text.h" + +namespace flowcore { using flowjson::Value; namespace { +// Protocol and policy strings, stored encoded (see obscured_text.h). +const std::string& FieldAvailable() { + static const std::string value = pushy::text::Reveal("2f07f0d0ba8e"); + return value; +} +const std::string& StatusNone() { + static const std::string value = pushy::text::Reveal("3418c1c1aa8a7c40"); + return value; +} +const std::string& PolicyNextLaunch() { + static const std::string value = pushy::text::Reveal("2912e0ffab8e6c70323b1dedd3"); + return value; +} + // The low byte of every UTF-16 code unit of `utf8`, which is exactly what the // TS reference hashes (`key.charCodeAt(i) & 0xff` over `key.length` units). // Identity for ASCII; an astral code point contributes its two surrogate @@ -127,7 +144,7 @@ bool IsInRollout(double rollout, const std::string& uuid) { } bool IsMirrorRetryableCode(const std::string& code) { - return code != "PATCH_FAILED"; + return code != pushy::error_codes::kDeltaFailed; } namespace { @@ -216,7 +233,7 @@ Value OrderEndpointCandidates(const Value& endpoints, double randomSample) { return ordered; } -Value BuildCheckRequestBody(const Value& input) { +Value BuildRequestBody(const Value& input) { Value body = Value::Object(); // Caller extras go in FIRST: they may add fields but never override the // identity fields the server keys its decision on (mirrors the TS @@ -258,7 +275,7 @@ Value BuildCheckRequestBody(const Value& input) { return body; } -Value ResolveCheckResult(const Value& rootInfo, const Value& identity) { +Value ResolveResult(const Value& rootInfo, const Value& identity) { Value rootResult = Value::Object(); for (const auto& member : rootInfo.members()) { if (member.first != "expVersion") { @@ -271,7 +288,7 @@ Value ResolveCheckResult(const Value& rootInfo, const Value& identity) { // non-object returns Undefined, mirroring optional chaining. const Value& rollout = expVersion.Get("config").Get("rollout").Get( identity.Get("packageVersion").AsString()); - if (rootResult.Get("update").Truthy() && expVersion.Truthy() && + if (rootResult.Get(FieldAvailable()).Truthy() && expVersion.Truthy() && rollout.IsNumber()) { if (IsInRollout(rollout.AsNumber(), identity.Get("uuid").AsString())) { const Value& expHash = expVersion.Get("hash"); @@ -282,7 +299,7 @@ Value ResolveCheckResult(const Value& rootInfo, const Value& identity) { return upToDate; } Value info = Value::Object(); - info.Set("update", Value::Bool(true)); + info.Set(FieldAvailable(), Value::Bool(true)); for (const auto& member : expVersion.members()) { info.Set(member.first, member.second); } @@ -293,7 +310,7 @@ Value ResolveCheckResult(const Value& rootInfo, const Value& identity) { } } const Value& rootHash = rootResult.Get("hash"); - if (rootResult.Get("update").Truthy() && rootHash.IsString() && + if (rootResult.Get(FieldAvailable()).Truthy() && rootHash.IsString() && !rootHash.AsString().empty() && Value::StrictEquals(rootHash, currentVersion)) { Value upToDate = Value::Object(); @@ -320,8 +337,8 @@ Value DecideDownload(const Value& info, const Value& identity, bool isDev) { if (paths.IsUndefined() || paths.kind() == Value::Kind::Null) { paths = Value::Array(); // info.paths ?? [] — a server `null` too } - if (!info.Get("update").Truthy() || !hash.Truthy()) { - return DeclineDownload("noUpdate"); + if (!info.Get(FieldAvailable()).Truthy() || !hash.Truthy()) { + return DeclineDownload(StatusNone().c_str()); } if (Value::StrictEquals(hash, identity.Get("currentVersion"))) { return DeclineDownload("alreadyCurrent"); @@ -362,37 +379,37 @@ Value DecideDownload(const Value& info, const Value& identity, bool isDev) { bool ShouldActivateAfterDownload(const Value& info, const std::string& afterDownload) { - return afterDownload == "setNeedUpdate" || + return afterDownload == PolicyNextLaunch() || info.Get("config").Get("forceBoot").Truthy(); } -bool IsValidCheckResult(const Value& root) { +bool IsValidResult(const Value& root) { if (!root.IsObject()) { return false; } const Value& upToDate = root.Get("upToDate"); - const Value& update = root.Get("update"); + const Value& update = root.Get(FieldAvailable()); const Value& expired = root.Get("expired"); const Value& paused = root.Get("paused"); return upToDate.IsBool() || update.IsBool() || expired.IsBool() || paused.IsString(); } -bool IsValidCheckResponse(const std::string& responseText) { +bool IsValidResponse(const std::string& responseText) { bool ok = false; Value root = flowjson::Parse(responseText, &ok); - return ok && IsValidCheckResult(root); + return ok && IsValidResult(root); } -Value HandleCheckResponse(const std::string& responseText, +Value HandleResponse(const std::string& responseText, const Value& identity, bool isDev, const std::string& afterDownload) { bool ok = false; Value root = flowjson::Parse(responseText, &ok); - if (!ok || !IsValidCheckResult(root)) { + if (!ok || !IsValidResult(root)) { return DeclineDownload("invalidResponse"); } - Value resolved = ResolveCheckResult(root, identity); + Value resolved = ResolveResult(root, identity); Value decision = DecideDownload(resolved, identity, isDev); if (decision.Get("action").AsString() == "download") { decision.Set("activate", Value::Bool(ShouldActivateAfterDownload( @@ -402,4 +419,4 @@ Value HandleCheckResponse(const std::string& responseText, return decision; } -} // namespace updateflow +} // namespace flowcore diff --git a/cpp/update_flow_core/update_flow_core.h b/cpp/update_flow_core/update_flow_core.h index 001c11af..55b5ac16 100644 --- a/cpp/update_flow_core/update_flow_core.h +++ b/cpp/update_flow_core/update_flow_core.h @@ -18,7 +18,10 @@ // Like the TS side this layer is pure: no IO, no time, no randomness — the // random sample, identity and parsed JSON all arrive as parameters. The // orchestrators (per-platform HTTP/download/state glue) own all effects. -namespace updateflow { +// Keep the core internal to whichever binary links it (see podspec Core). +#pragma GCC visibility push(hidden) + +namespace flowcore { uint32_t Murmur3_32(const std::string& key, uint32_t seed = 0); @@ -45,10 +48,10 @@ flowjson::Value OrderEndpointCandidates(const flowjson::Value& endpoints, // input: { packageVersion, currentVersion, buildTime, cInfo, // supportedDiffVersion?, bundleHash?, isDev?, extra? } -flowjson::Value BuildCheckRequestBody(const flowjson::Value& input); +flowjson::Value BuildRequestBody(const flowjson::Value& input); // identity: { packageVersion, currentVersion?, uuid } -flowjson::Value ResolveCheckResult(const flowjson::Value& rootInfo, +flowjson::Value ResolveResult(const flowjson::Value& rootInfo, const flowjson::Value& identity); // identity: { currentVersion?, rolledBackVersion? } @@ -71,13 +74,13 @@ flowjson::Value DecideDownload(const flowjson::Value& info, // array, `{"error": "..."}`, an HTML page that happened to parse — is a // failed endpoint and must not stop the endpoint fallback. Mirrors // src/updateFlowCore.ts isValidCheckResult. -bool IsValidCheckResult(const flowjson::Value& root); -bool IsValidCheckResponse(const std::string& responseText); +bool IsValidResult(const flowjson::Value& root); +bool IsValidResponse(const std::string& responseText); bool ShouldActivateAfterDownload(const flowjson::Value& info, const std::string& afterDownload); -// Composes Parse → ResolveCheckResult → DecideDownload: one call from the +// Composes Parse → ResolveResult → DecideDownload: one call from the // raw checkUpdate response text to a download decision, so the platform // orchestrators contain no decision logic at all. identity is the union of // both composed functions' needs: { packageVersion, currentVersion?, uuid, @@ -87,9 +90,11 @@ bool ShouldActivateAfterDownload(const flowjson::Value& info, // check result — so orchestrators can persist name/description/metaInfo // alongside a downloaded version (the JS side's setLocalHashInfo). // Malformed JSON yields { action: 'none', reason: 'invalidResponse' }. -flowjson::Value HandleCheckResponse(const std::string& responseText, +flowjson::Value HandleResponse(const std::string& responseText, const flowjson::Value& identity, bool isDev, const std::string& afterDownload); -} // namespace updateflow +} // namespace flowcore + +#pragma GCC visibility pop diff --git a/cpp/update_flow_core/update_flow_jni.cpp b/cpp/update_flow_core/update_flow_jni.cpp index 0fdc2ab8..c7ab42dd 100644 --- a/cpp/update_flow_core/update_flow_jni.cpp +++ b/cpp/update_flow_core/update_flow_jni.cpp @@ -1,11 +1,13 @@ // JNI surface of the update-flow decision layer for the Android orchestrator -// (NativeCheckOrchestrator.java). Pure string-in/string-out: every payload is +// (SyncCoordinator.java). Pure string-in/string-out: every payload is // JSON, matching the decision layer's own boundary. A null return means // "input did not parse" and the caller skips the check round; a C++ exception // (bad_alloc/length_error from an oversized body) maps onto that same path — // it must never unwind through the JNI boundary. #include +#include "jni_natives.h" + #include #include #include @@ -81,8 +83,7 @@ jstring ToJString(JNIEnv* env, const std::string& value) { } // namespace -extern "C" JNIEXPORT jstring JNICALL -Java_cn_reactnative_modules_update_NativeUpdateFlow_buildCheckRequestBody( +jstring pushy::jni_natives::FlowBuildRequestBody( JNIEnv* env, jclass, jstring inputJson) { try { bool ok = false; @@ -92,14 +93,13 @@ Java_cn_reactnative_modules_update_NativeUpdateFlow_buildCheckRequestBody( return nullptr; } return ToJString( - env, flowjson::Stringify(updateflow::BuildCheckRequestBody(input))); + env, flowjson::Stringify(flowcore::BuildRequestBody(input))); } catch (...) { return nullptr; } } -extern "C" JNIEXPORT jstring JNICALL -Java_cn_reactnative_modules_update_NativeUpdateFlow_orderEndpointCandidates( +jstring pushy::jni_natives::FlowOrderEndpointCandidates( JNIEnv* env, jclass, jstring endpointsJson, jdouble randomSample) { try { bool ok = false; @@ -108,21 +108,20 @@ Java_cn_reactnative_modules_update_NativeUpdateFlow_orderEndpointCandidates( if (!ok || !endpoints.IsArray()) { return nullptr; } - return ToJString(env, flowjson::Stringify(updateflow::OrderEndpointCandidates( + return ToJString(env, flowjson::Stringify(flowcore::OrderEndpointCandidates( endpoints, randomSample))); } catch (...) { return nullptr; } } -extern "C" JNIEXPORT jboolean JNICALL -Java_cn_reactnative_modules_update_NativeUpdateFlow_isValidCheckResponse( +jboolean pushy::jni_natives::FlowIsValidResponse( JNIEnv* env, jclass, jstring responseText) { if (responseText == nullptr) { return JNI_FALSE; } try { - return updateflow::IsValidCheckResponse( + return flowcore::IsValidResponse( pushy::jni_util::JStringToString(env, responseText)) ? JNI_TRUE : JNI_FALSE; @@ -131,8 +130,7 @@ Java_cn_reactnative_modules_update_NativeUpdateFlow_isValidCheckResponse( } } -extern "C" JNIEXPORT jstring JNICALL -Java_cn_reactnative_modules_update_NativeUpdateFlow_handleCheckResponse( +jstring pushy::jni_natives::FlowHandleResponse( JNIEnv* env, jclass, jstring responseText, jstring identityJson, jstring afterDownload) { try { @@ -142,7 +140,7 @@ Java_cn_reactnative_modules_update_NativeUpdateFlow_handleCheckResponse( if (!ok || !identity.IsObject()) { return nullptr; } - return ToJString(env, flowjson::Stringify(updateflow::HandleCheckResponse( + return ToJString(env, flowjson::Stringify(flowcore::HandleResponse( pushy::jni_util::JStringToString(env, responseText), identity, false, pushy::jni_util::JStringToString(env, afterDownload)))); diff --git a/harmony/pushy/src/main/cpp/CMakeLists.txt b/harmony/pushy/src/main/cpp/CMakeLists.txt index 724ed591..93325779 100644 --- a/harmony/pushy/src/main/cpp/CMakeLists.txt +++ b/harmony/pushy/src/main/cpp/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.13) -project(rnupdate LANGUAGES C CXX) +project(rnpushy LANGUAGES C CXX) set(CMAKE_C_STANDARD 11) set(CMAKE_CXX_STANDARD 17) @@ -23,7 +23,7 @@ if(NOT EXISTS ${UPDATE_FLOW_CORE_DIR}/update_flow_core.cpp) endif() # Always compile from source. A prebuilt-import branch used to live here, but -# it had no symbol verification: a stale libs//librnupdate.so missing a +# it had no symbol verification: a stale libs//librnpushy.so missing a # newly added export (e.g. getSupportedDiffVersion) would silently ship and # crash getConstants at runtime. set(HDIFFPATCH_DIR ${ANDROID_JNI_DIR}/HDiffPatch) @@ -45,11 +45,11 @@ set(HDP_SOURCES ${LZMA_DIR}/C/Lzma2Dec.c ) -add_library(rnupdate SHARED +add_library(rnpushy SHARED ${HDP_SOURCES} ) -target_include_directories(rnupdate PRIVATE +target_include_directories(rnpushy PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} ${PATCH_CORE_DIR} ${UPDATE_FLOW_CORE_DIR} @@ -59,7 +59,7 @@ target_include_directories(rnupdate PRIVATE ${LZMA_DIR}/C ) -target_link_libraries(rnupdate PUBLIC +target_link_libraries(rnpushy PUBLIC libace_napi.z.so ) @@ -74,15 +74,15 @@ endif() # Optimize binary size for Release builds of the NAPI library. --gc-sections # only drops anything when every function/data item sits in its own section, # and the module registers itself through NAPI_MODULE's static constructor, -# so no symbol needs default visibility. Scoped to rnupdate: rnoh_pushy must +# so no symbol needs default visibility. Scoped to rnpushy: rnoh_pushy must # keep exporting PushyTurboModule (JSI_EXPORT) for the host's PackageProvider. if(NOT CMAKE_BUILD_TYPE STREQUAL "Debug") - target_compile_options(rnupdate PRIVATE + target_compile_options(rnpushy PRIVATE -ffunction-sections -fdata-sections -fvisibility=hidden -fvisibility-inlines-hidden ) # Strip debug symbols and garbage-collect the unused sections. - target_link_options(rnupdate PRIVATE -Wl,--gc-sections -s) + target_link_options(rnpushy PRIVATE -Wl,--gc-sections -s) endif() diff --git a/harmony/pushy/src/main/cpp/pushy.cpp b/harmony/pushy/src/main/cpp/pushy.cpp index d75ef181..3b5aeedf 100644 --- a/harmony/pushy/src/main/cpp/pushy.cpp +++ b/harmony/pushy/src/main/cpp/pushy.cpp @@ -22,7 +22,6 @@ namespace { using pushy::state_ops::StateOperation; -constexpr const char* kDefaultBundlePatchEntryName = "index.bundlejs.patch"; void ThrowError(napi_env env, const std::string& message) { napi_throw_error(env, nullptr, message.c_str()); @@ -400,14 +399,14 @@ napi_value NewCopyGroupArray( return result; } -pushy::patch::PatchManifest BuildManifest( +pushy::delta::PatchManifest BuildManifest( const std::vector& copy_froms, const std::vector& copy_tos, const std::vector& deletes) { - pushy::patch::PatchManifest manifest; + pushy::delta::PatchManifest manifest; for (size_t index = 0; index < copy_froms.size(); ++index) { manifest.copies.push_back( - pushy::patch::CopyOperation{copy_froms[index], copy_tos[index]}); + pushy::delta::CopyOperation{copy_froms[index], copy_tos[index]}); } manifest.deletes = deletes; return manifest; @@ -566,7 +565,8 @@ napi_value BuildArchivePatchPlan(napi_env env, napi_callback_info info) { return nullptr; } - std::string bundle_patch_entry_name = kDefaultBundlePatchEntryName; + std::string bundle_patch_entry_name = + pushy::archive_patch::DefaultBundleDeltaEntryName(); if (argc >= 6) { bool ok = false; const std::string candidate = GetString(env, args[5], &ok); @@ -578,15 +578,15 @@ napi_value BuildArchivePatchPlan(napi_env env, napi_callback_info info) { } } - const pushy::patch::PatchManifest manifest = + const pushy::delta::PatchManifest manifest = BuildManifest(copy_froms, copy_tos, deletes); pushy::archive_patch::ArchivePatchType archive_type; if (!pushy::archive_patch::TryParseArchivePatchType(patch_type, &archive_type)) { - ThrowError(env, "Unknown archive patch type"); + ThrowError(env, "Unknown archive delta type"); return nullptr; } pushy::archive_patch::ArchivePatchPlan plan; - const pushy::patch::Status status = pushy::archive_patch::BuildArchivePatchPlan( + const pushy::delta::Status status = pushy::archive_patch::BuildArchivePatchPlan( archive_type, manifest, entry_names, @@ -619,10 +619,10 @@ napi_value BuildCopyGroups(napi_env env, napi_callback_info info) { return nullptr; } - const pushy::patch::PatchManifest manifest = + const pushy::delta::PatchManifest manifest = BuildManifest(copy_froms, copy_tos, std::vector()); std::vector groups; - const pushy::patch::Status status = + const pushy::delta::Status status = pushy::archive_patch::BuildCopyGroups(manifest, &groups); if (!status.ok) { ThrowError(env, status.message); @@ -635,7 +635,7 @@ napi_value BuildCopyGroups(napi_env env, napi_callback_info info) { // --------------------------------------------------------------------------- // Async work plumbing for the heavy patch operations. // -// applyPatchFromFileSource and cleanupOldEntries run hdiff / recursive file IO +// applyDeltaFromSource and cleanupOldEntries run hdiff / recursive file IO // that can take hundreds of ms to seconds. The Pushy TurboModule executes on // the UI thread, so running these synchronously froze the UI. These are now // wrapped in napi_create_async_work: arguments are parsed on the JS thread, the @@ -659,8 +659,8 @@ void RejectDeferredWithMessage( struct ApplyPatchWork { napi_async_work work = nullptr; napi_deferred deferred = nullptr; - pushy::patch::FileSourcePatchOptions options; - pushy::patch::Status status{false, ""}; + pushy::delta::FileSourcePatchOptions options; + pushy::delta::Status status{false, ""}; }; struct CleanupWork { @@ -672,7 +672,7 @@ struct CleanupWork { // evict the running bundle while its on-demand assets are still served). std::vector keep_names; int32_t max_age_days = 0; - pushy::patch::Status status{false, ""}; + pushy::delta::Status status{false, ""}; }; // Streaming file digest on a worker thread. A full package or bundle is tens @@ -714,7 +714,7 @@ napi_value ApplyPatchFromFileSource(napi_env env, napi_callback_info info) { !GetOptionalStringProperty(env, args[0], "sourceRoot", &source_root) || !GetOptionalStringProperty(env, args[0], "targetRoot", &target_root) || !GetOptionalStringProperty(env, args[0], "originBundlePath", &origin_bundle_path) || - !GetOptionalStringProperty(env, args[0], "bundlePatchPath", &bundle_patch_path) || + !GetOptionalStringProperty(env, args[0], "bundleDeltaPath", &bundle_patch_path) || !GetOptionalStringProperty(env, args[0], "bundleOutputPath", &bundle_output_path) || !GetOptionalStringProperty(env, args[0], "mergeSourceSubdir", &merge_source_subdir) || !GetOptionalStringProperty( @@ -747,14 +747,14 @@ napi_value ApplyPatchFromFileSource(napi_env env, napi_callback_info info) { napi_value resource_name = nullptr; napi_create_string_utf8( - env, "applyPatchFromFileSource", NAPI_AUTO_LENGTH, &resource_name); + env, "applyDeltaFromSource", NAPI_AUTO_LENGTH, &resource_name); if (napi_create_async_work( env, nullptr, resource_name, [](napi_env, void* data) { auto* w = static_cast(data); - w->status = pushy::patch::ApplyPatchFromFileSource(w->options); + w->status = pushy::delta::ApplyPatchFromFileSource(w->options); }, [](napi_env cb_env, napi_status status, void* data) { auto* w = static_cast(data); @@ -841,7 +841,7 @@ napi_value CleanupOldEntries(napi_env env, napi_callback_info info) { resource_name, [](napi_env, void* data) { auto* w = static_cast(data); - w->status = pushy::patch::CleanupOldEntries( + w->status = pushy::delta::CleanupOldEntries( w->root_dir, w->keep_names, w->max_age_days); }, [](napi_env cb_env, napi_status status, void* data) { @@ -1108,12 +1108,12 @@ static napi_value MakeUtf8String(napi_env env, const std::string& value) { return result; } -static napi_value FlowBuildCheckRequestBody(napi_env env, +static napi_value FlowBuildRequestBody(napi_env env, napi_callback_info info) { size_t argc = 1; napi_value args[1] = {nullptr}; if (!GetArgCount(env, info, &argc, args) || argc < 1) { - ThrowError(env, "buildCheckRequestBody: missing input argument"); + ThrowError(env, "buildRequestBody: missing input argument"); return nullptr; } bool ok = false; @@ -1127,7 +1127,7 @@ static napi_value FlowBuildCheckRequestBody(napi_env env, return nullptr; } return MakeUtf8String( - env, flowjson::Stringify(updateflow::BuildCheckRequestBody(input))); + env, flowjson::Stringify(flowcore::BuildRequestBody(input))); } static napi_value FlowOrderEndpointCandidates(napi_env env, @@ -1154,16 +1154,16 @@ static napi_value FlowOrderEndpointCandidates(napi_env env, return nullptr; } return MakeUtf8String(env, - flowjson::Stringify(updateflow::OrderEndpointCandidates( + flowjson::Stringify(flowcore::OrderEndpointCandidates( endpoints, sample))); } -static napi_value FlowIsValidCheckResponse(napi_env env, +static napi_value FlowIsValidResponse(napi_env env, napi_callback_info info) { size_t argc = 1; napi_value args[1] = {nullptr}; if (!GetArgCount(env, info, &argc, args) || argc < 1) { - ThrowError(env, "isValidCheckResponse: missing arguments"); + ThrowError(env, "isValidResponse: missing arguments"); return nullptr; } bool ok = false; @@ -1174,17 +1174,17 @@ static napi_value FlowIsValidCheckResponse(napi_env env, return nullptr; } napi_value result = nullptr; - napi_get_boolean(env, updateflow::IsValidCheckResponse(response_text), + napi_get_boolean(env, flowcore::IsValidResponse(response_text), &result); return result; } -static napi_value FlowHandleCheckResponse(napi_env env, +static napi_value FlowHandleResponse(napi_env env, napi_callback_info info) { size_t argc = 3; napi_value args[3] = {nullptr, nullptr, nullptr}; if (!GetArgCount(env, info, &argc, args) || argc < 3) { - ThrowError(env, "handleCheckResponse: missing arguments"); + ThrowError(env, "handleResponse: missing arguments"); return nullptr; } bool ok = false; @@ -1206,26 +1206,26 @@ static napi_value FlowHandleCheckResponse(napi_env env, return nullptr; } return MakeUtf8String(env, - flowjson::Stringify(updateflow::HandleCheckResponse( + flowjson::Stringify(flowcore::HandleResponse( response_text, identity, false, after_download))); } napi_value Init(napi_env env, napi_value exports) { if (!ExportFunction(env, exports, "syncStateWithBinaryVersion", SyncStateWithBinaryVersion) || !ExportFunction(env, exports, "runStateCore", RunStateCore) || - !ExportFunction(env, exports, "buildArchivePatchPlan", BuildArchivePatchPlan) || + !ExportFunction(env, exports, "buildArchivePlan", BuildArchivePatchPlan) || !ExportFunction(env, exports, "buildCopyGroups", BuildCopyGroups) || - !ExportFunction(env, exports, "applyPatchFromFileSource", ApplyPatchFromFileSource) || + !ExportFunction(env, exports, "applyDeltaFromSource", ApplyPatchFromFileSource) || !ExportFunction(env, exports, "cleanupOldEntries", CleanupOldEntries) || !ExportFunction(env, exports, "sha256Hex", Sha256Hex) || !ExportFunction(env, exports, "sha256HexFile", Sha256HexFile) || !ExportFunction(env, exports, "sha256HexFileAsync", Sha256HexFileAsync) || !ExportFunction(env, exports, "crc32", Crc32) || !ExportFunction(env, exports, "getSupportedDiffVersion", GetSupportedDiffVersion) || - !ExportFunction(env, exports, "buildCheckRequestBody", FlowBuildCheckRequestBody) || + !ExportFunction(env, exports, "buildRequestBody", FlowBuildRequestBody) || !ExportFunction(env, exports, "orderEndpointCandidates", FlowOrderEndpointCandidates) || - !ExportFunction(env, exports, "isValidCheckResponse", FlowIsValidCheckResponse) || - !ExportFunction(env, exports, "handleCheckResponse", FlowHandleCheckResponse)) { + !ExportFunction(env, exports, "isValidResponse", FlowIsValidResponse) || + !ExportFunction(env, exports, "handleResponse", FlowHandleResponse)) { return nullptr; } return exports; diff --git a/harmony/pushy/src/main/ets/ArchiveLimits.ts b/harmony/pushy/src/main/ets/ArchiveLimits.ts index 90adb0df..29908895 100644 --- a/harmony/pushy/src/main/ets/ArchiveLimits.ts +++ b/harmony/pushy/src/main/ets/ArchiveLimits.ts @@ -2,8 +2,8 @@ import fileIo from '@ohos.file.fs'; import statvfs from '@ohos.file.statvfs'; import { ERROR_FILE_OPERATION_FAILED, - ERROR_PATCH_FAILED, - createUpdateError, + ERROR_DELTA_FAILED, + createPushyError, } from './ErrorCodes'; import { isReservedEntryName } from './InstallRecord'; @@ -32,8 +32,8 @@ export function checkUncompressedSize( uncompressedBytes: number, ): void { if (uncompressedBytes > MAX_TOTAL_UNCOMPRESSED_BYTES) { - throw createUpdateError( - ERROR_PATCH_FAILED, + throw createPushyError( + ERROR_DELTA_FAILED, `archive expands beyond ${MAX_TOTAL_UNCOMPRESSED_BYTES} bytes ` + `(${uncompressedBytes})`, ); @@ -43,8 +43,8 @@ export function checkUncompressedSize( archiveBytes > 0 && uncompressedBytes > archiveBytes * MAX_COMPRESSION_RATIO ) { - throw createUpdateError( - ERROR_PATCH_FAILED, + throw createPushyError( + ERROR_DELTA_FAILED, `archive compression ratio exceeds ${MAX_COMPRESSION_RATIO}:1 ` + `(${uncompressedBytes}/${archiveBytes})`, ); @@ -78,7 +78,7 @@ export async function ensureFreeSpace( } const needed = Math.max(0, bytesToWrite) + FREE_DISK_MARGIN_BYTES; if (free < needed) { - throw createUpdateError( + throw createPushyError( ERROR_FILE_OPERATION_FAILED, `insufficient disk space: need ${needed} bytes, have ${free}`, ); @@ -103,8 +103,8 @@ export async function measureExtractedDirectory( ); for (const name of names) { if (isReservedEntryName(name)) { - throw createUpdateError( - ERROR_PATCH_FAILED, + throw createPushyError( + ERROR_DELTA_FAILED, `archive contains reserved entry ${name}`, ); } @@ -112,8 +112,8 @@ export async function measureExtractedDirectory( const stat = await fileIo.stat(path); acc.entries += 1; if (acc.entries > MAX_ENTRIES) { - throw createUpdateError( - ERROR_PATCH_FAILED, + throw createPushyError( + ERROR_DELTA_FAILED, `archive has too many entries (> ${MAX_ENTRIES})`, ); } @@ -122,15 +122,15 @@ export async function measureExtractedDirectory( continue; } if (stat.size > MAX_ENTRY_BYTES) { - throw createUpdateError( - ERROR_PATCH_FAILED, + throw createPushyError( + ERROR_DELTA_FAILED, `archive entry too large: ${name} (${stat.size} bytes)`, ); } acc.bytes += stat.size; if (acc.bytes > MAX_TOTAL_UNCOMPRESSED_BYTES) { - throw createUpdateError( - ERROR_PATCH_FAILED, + throw createPushyError( + ERROR_DELTA_FAILED, `archive expands beyond ${MAX_TOTAL_UNCOMPRESSED_BYTES} bytes`, ); } diff --git a/harmony/pushy/src/main/ets/DownloadTask.ts b/harmony/pushy/src/main/ets/DownloadTask.ts index 46af09cf..2bd61342 100644 --- a/harmony/pushy/src/main/ets/DownloadTask.ts +++ b/harmony/pushy/src/main/ets/DownloadTask.ts @@ -31,11 +31,12 @@ import { ERROR_DOWNLOAD_FAILED, ERROR_FILE_OPERATION_FAILED, ERROR_INVALID_OPTIONS, - ERROR_PATCH_FAILED, - createUpdateError, + ERROR_DELTA_FAILED, + createPushyError, getErrorMessage, - toUpdateError, + toPushyError, } from './ErrorCodes'; +import { BUNDLE_DELTA_ENTRY } from './Texts'; const TAG = 'DownloadTask'; @@ -92,7 +93,7 @@ export function parseManifestToArrays( | undefined; const hbcTransformEntry = hbcTransform && typeof hbcTransform === 'object' - ? hbcTransform[HARMONY_BUNDLE_PATCH_ENTRY] + ? hbcTransform[BUNDLE_DELTA_ENTRY] : undefined; const hbcTransformMeta = hbcTransformEntry && typeof hbcTransformEntry === 'object' @@ -108,7 +109,7 @@ export function parseManifestToArrays( }; } -interface PatchInputs { +interface DeltaInputs { entryNames: string[]; manifestArrays: PatchManifestArrays; } @@ -125,7 +126,6 @@ function toArrayBufferSlice( } const DIFF_MANIFEST_ENTRY = '__diff.json'; -const HARMONY_BUNDLE_PATCH_ENTRY = 'bundle.harmony.js.patch'; const TEMP_ORIGIN_BUNDLE_ENTRY = '.origin.bundle.harmony.js'; const FILE_COPY_BUFFER_SIZE = 64 * 1024; const DOWNLOAD_CALL_TIMEOUT_MS = 10 * 60 * 1000; @@ -196,8 +196,8 @@ export class DownloadTask { const work = this.stagingDirectory(params); const bundlePath = `${work}/${HARMONY_BUNDLE_FILE_NAME}`; if (!fileIo.accessSync(bundlePath)) { - throw createUpdateError( - ERROR_PATCH_FAILED, + throw createPushyError( + ERROR_DELTA_FAILED, `bundle missing after install: ${bundlePath}`, ); } @@ -293,7 +293,7 @@ export class DownloadTask { } } catch (error) { logger.error(TAG, `Failed to delete ${path}: ${getErrorMessage(error)}`); - throw toUpdateError(error, ERROR_FILE_OPERATION_FAILED); + throw toPushyError(error, ERROR_FILE_OPERATION_FAILED); } } @@ -337,14 +337,14 @@ export class DownloadTask { try { size = await zlib.getOriginalSize(archiveFile); } catch (e) { - throw createUpdateError( - ERROR_PATCH_FAILED, + throw createPushyError( + ERROR_DELTA_FAILED, `cannot determine archive expansion size: ${getErrorMessage(e)}`, ); } if (typeof size !== 'number' || !Number.isFinite(size) || size < 0) { - throw createUpdateError( - ERROR_PATCH_FAILED, + throw createPushyError( + ERROR_DELTA_FAILED, `cannot determine archive expansion size: ${String(size)}`, ); } @@ -364,8 +364,8 @@ export class DownloadTask { ): Promise { const archiveStat = await fileIo.stat(archiveFile); if (archiveStat.size > MAX_ARCHIVE_BYTES) { - throw createUpdateError( - ERROR_PATCH_FAILED, + throw createPushyError( + ERROR_DELTA_FAILED, `archive too large: ${archiveStat.size} bytes`, ); } @@ -375,7 +375,7 @@ export class DownloadTask { try { await zlib.decompressFile(archiveFile, unzipDirectory); } catch (e) { - throw toUpdateError(e, ERROR_PATCH_FAILED); + throw toPushyError(e, ERROR_DELTA_FAILED); } await measureExtractedDirectory(unzipDirectory); } @@ -468,9 +468,9 @@ export class DownloadTask { const manifestStat = await fileIo.stat(manifestPath); if (manifestStat.size > MAX_MANIFEST_BYTES) { - throw createUpdateError( - ERROR_PATCH_FAILED, - `patch manifest too large: ${manifestStat.size} bytes`, + throw createPushyError( + ERROR_DELTA_FAILED, + `delta manifest too large: ${manifestStat.size} bytes`, ); } return parseManifestToArrays( @@ -483,29 +483,29 @@ export class DownloadTask { private async applyBundlePatchFromFileSource( originContent: ArrayBuffer, workingDirectory: string, - bundlePatchPath: string, + bundleDeltaPath: string, outputFile: string, hbcTransformMeta = '', ): Promise { const originBundlePath = `${workingDirectory}/${TEMP_ORIGIN_BUNDLE_ENTRY}`; try { await this.writeFileContent(originBundlePath, originContent); - await NativePatchCore.applyPatchFromFileSource({ + await NativePatchCore.applyDeltaFromSource({ copyFroms: [], copyTos: [], deletes: [], sourceRoot: workingDirectory, targetRoot: workingDirectory, originBundlePath, - bundlePatchPath, + bundleDeltaPath, bundleOutputPath: outputFile, enableMerge: false, bundleHbcTransformMeta: hbcTransformMeta, }); } catch (error) { - throw createUpdateError( - ERROR_PATCH_FAILED, - `Failed to process bundle patch: ${getErrorMessage(error)}`, + throw createPushyError( + ERROR_DELTA_FAILED, + `Failed to process bundle delta: ${getErrorMessage(error)}`, ); } finally { if (fileIo.accessSync(originBundlePath)) { @@ -570,7 +570,7 @@ export class DownloadTask { await this.deleteArchiveAndSidecar(params.targetFile); const retry = await this.transferArchive(params, false); if (retry !== 'done') { - throw createUpdateError( + throw createPushyError( ERROR_DOWNLOAD_FAILED, `Server rejected the download range for ${params.url}`, ); @@ -607,7 +607,7 @@ export class DownloadTask { ? params.deadlineUptimeMs : monotonicNowMs() + DOWNLOAD_CALL_TIMEOUT_MS; if (deadlineUptimeMs <= monotonicNowMs()) { - throw createUpdateError( + throw createPushyError( ERROR_DOWNLOAD_FAILED, 'Download deadline expired before start', ); @@ -684,8 +684,8 @@ export class DownloadTask { received += data.byteLength; if (!writeError && baseOffset + received > MAX_ARCHIVE_BYTES) { // 未知长度/分块传输的兜底:超过上限即停写,请求在下面结算时失败。 - writeError = createUpdateError( - ERROR_PATCH_FAILED, + writeError = createPushyError( + ERROR_DELTA_FAILED, `archive too large: exceeded ${MAX_ARCHIVE_BYTES}`, ); } @@ -735,7 +735,7 @@ export class DownloadTask { watchdogTimer = setTimeout(() => { if (inactivityReject) { inactivityReject( - createUpdateError( + createPushyError( ERROR_DOWNLOAD_FAILED, `Download stalled: no data received for ${INACTIVITY_TIMEOUT_MS}ms`, ), @@ -855,7 +855,7 @@ export class DownloadTask { const deadlinePromise = new Promise((_, reject) => { deadlineTimer = setTimeout(() => { reject( - createUpdateError( + createPushyError( ERROR_DOWNLOAD_FAILED, 'Download exceeded its whole-call deadline', ), @@ -894,7 +894,7 @@ export class DownloadTask { // 响应体从未落盘,partial + sidecar 原样保留——那就是续传状态。 discardBody = true; pendingChunks = null; - throw createUpdateError( + throw createPushyError( ERROR_DOWNLOAD_FAILED, `Server error: ${responseCode}`, ); @@ -945,8 +945,8 @@ export class DownloadTask { ); } if (totalAll > MAX_ARCHIVE_BYTES) { - writeError = createUpdateError( - ERROR_PATCH_FAILED, + writeError = createPushyError( + ERROR_DELTA_FAILED, `archive too large: ${totalAll} bytes`, ); throw writeError; @@ -997,13 +997,13 @@ export class DownloadTask { const stats = await fileIo.stat(params.targetFile); if (!finalEncoded) { if (contentLength > 0 && received !== contentLength) { - throw createUpdateError( + throw createPushyError( ERROR_DOWNLOAD_FAILED, `Download incomplete: expected ${contentLength} bytes but got ${received} bytes`, ); } if (totalAll > 0 && stats.size !== totalAll) { - throw createUpdateError( + throw createPushyError( ERROR_DOWNLOAD_FAILED, `Download incomplete: expected ${totalAll} total bytes but got ${stats.size} bytes`, ); @@ -1026,7 +1026,7 @@ export class DownloadTask { return 'done'; } catch (error) { logger.error(TAG, `Download failed: ${getErrorMessage(error)}`); - throw toUpdateError(error, ERROR_DOWNLOAD_FAILED); + throw toPushyError(error, ERROR_DOWNLOAD_FAILED); } finally { clearWatchdog(); if (deadlineTimer !== null) { @@ -1075,10 +1075,10 @@ export class DownloadTask { } /** 解压目录里的条目名 + __diff.json 解析结果,供 patch plan 使用。 */ - private async readPatchInputs( + private async readDeltaInputs( work: string, normalizeResourceCopies: boolean, - ): Promise { + ): Promise { const results = await Promise.all([ this.listEntryNames(work), this.readManifestArrays(work, normalizeResourceCopies), @@ -1093,21 +1093,21 @@ export class DownloadTask { private async doPatchFromApp(params: DownloadTaskParams): Promise { const work = await this.downloadAndExtract(params); - const inputs = await this.readPatchInputs(work, true); + const inputs = await this.readDeltaInputs(work, true); const manifestArrays = inputs.manifestArrays; - NativePatchCore.buildArchivePatchPlan( + NativePatchCore.buildArchivePlan( ARCHIVE_PATCH_TYPE_FROM_PACKAGE, inputs.entryNames, manifestArrays.copyFroms, manifestArrays.copyTos, manifestArrays.deletes, - HARMONY_BUNDLE_PATCH_ENTRY, + BUNDLE_DELTA_ENTRY, ); - const bundlePatchPath = `${work}/${HARMONY_BUNDLE_PATCH_ENTRY}`; - if (!fileIo.accessSync(bundlePatchPath)) { - throw createUpdateError(ERROR_PATCH_FAILED, 'bundle patch not found'); + const bundleDeltaPath = `${work}/${BUNDLE_DELTA_ENTRY}`; + if (!fileIo.accessSync(bundleDeltaPath)) { + throw createPushyError(ERROR_DELTA_FAILED, 'bundle delta not found'); } const resourceManager = this.context.resourceManager; const originContent = await resourceManager.getRawFileContent( @@ -1116,7 +1116,7 @@ export class DownloadTask { await this.applyBundlePatchFromFileSource( originContent.buffer as ArrayBuffer, work, - bundlePatchPath, + bundleDeltaPath, `${work}/${HARMONY_BUNDLE_FILE_NAME}`, manifestArrays.hbcTransformMeta, ); @@ -1142,31 +1142,31 @@ export class DownloadTask { private async doPatchFromPpk(params: DownloadTaskParams): Promise { const work = await this.downloadAndExtract(params); - const inputs = await this.readPatchInputs(work, false); + const inputs = await this.readDeltaInputs(work, false); const manifestArrays = inputs.manifestArrays; - const plan = NativePatchCore.buildArchivePatchPlan( + const plan = NativePatchCore.buildArchivePlan( ARCHIVE_PATCH_TYPE_FROM_PPK, inputs.entryNames, manifestArrays.copyFroms, manifestArrays.copyTos, manifestArrays.deletes, - HARMONY_BUNDLE_PATCH_ENTRY, + BUNDLE_DELTA_ENTRY, ); - await NativePatchCore.applyPatchFromFileSource({ + await NativePatchCore.applyDeltaFromSource({ copyFroms: manifestArrays.copyFroms, copyTos: manifestArrays.copyTos, deletes: manifestArrays.deletes, sourceRoot: params.originDirectory, targetRoot: work, originBundlePath: `${params.originDirectory}/bundle.harmony.js`, - bundlePatchPath: `${work}/${HARMONY_BUNDLE_PATCH_ENTRY}`, + bundleDeltaPath: `${work}/${BUNDLE_DELTA_ENTRY}`, bundleOutputPath: `${work}/${HARMONY_BUNDLE_FILE_NAME}`, mergeSourceSubdir: plan.mergeSourceSubdir, enableMerge: plan.enableMerge, bundleHbcTransformMeta: manifestArrays.hbcTransformMeta, }); - logger.info(TAG, 'Patch from PPK completed'); + logger.info(TAG, 'Delta from PPK completed'); await this.deleteArchiveAndSidecar(params.targetFile); } @@ -1242,10 +1242,10 @@ export class DownloadTask { } } } catch (error) { - const coded = toUpdateError(error, ERROR_PATCH_FAILED); + const coded = toPushyError(error, ERROR_DELTA_FAILED); const message = `Copy from resource failed: ${currentFrom}, ${getErrorMessage(error)}`; logger.error(TAG, message); - throw createUpdateError(coded.code, message); + throw createPushyError(coded.code, message); } } @@ -1275,8 +1275,8 @@ export class DownloadTask { } const actualCrc = NativePatchCore.crc32(content); if (actualCrc !== expectedCrc) { - throw createUpdateError( - ERROR_PATCH_FAILED, + throw createPushyError( + ERROR_DELTA_FAILED, `resource content mismatch (crc32): ${from}`, ); } @@ -1292,7 +1292,7 @@ export class DownloadTask { } catch (error) { const message = `Cleanup failed: ${getErrorMessage(error)}`; logger.error(TAG, message); - throw createUpdateError(ERROR_FILE_OPERATION_FAILED, message); + throw createPushyError(ERROR_FILE_OPERATION_FAILED, message); } } @@ -1322,7 +1322,7 @@ export class DownloadTask { await this.deleteResumeSidecar(params.targetFile); break; default: - throw createUpdateError( + throw createPushyError( ERROR_INVALID_OPTIONS, `Unknown task type: ${params.type}`, ); @@ -1335,9 +1335,9 @@ export class DownloadTask { // (解压/hpatch/资源拷贝含 copiesCrc 校验/完成记录)一律 PATCH_FAILED, // JS 层 patch-health 遥测据此区分网络失败与补丁失败。已带码的错误 // (磁盘空间、参数)原样保留。 - const error = toUpdateError( + const error = toPushyError( rawError, - this.downloadPhaseCompleted ? ERROR_PATCH_FAILED : ERROR_DOWNLOAD_FAILED, + this.downloadPhaseCompleted ? ERROR_DELTA_FAILED : ERROR_DOWNLOAD_FAILED, ); logger.error(TAG, `Task execution failed: ${error.message}`); if (params.type !== DownloadTaskParams.TASK_TYPE_CLEANUP) { diff --git a/harmony/pushy/src/main/ets/ErrorCodes.ts b/harmony/pushy/src/main/ets/ErrorCodes.ts index 909752c2..c03f1f58 100644 --- a/harmony/pushy/src/main/ets/ErrorCodes.ts +++ b/harmony/pushy/src/main/ets/ErrorCodes.ts @@ -5,11 +5,13 @@ // // RNOH 的异步桥以纯字符串 reject,Error 上的 `code` 属性到 JS 侧很可能已丢。 // 因此每个错误的消息都以 `[CODE] ` 前缀开头,JS 鸿蒙分支据此解析回 code。 -// 本文件不依赖任何其他模块,任何文件都可安全引用它而不会形成 import 环。 +// 本文件只依赖同样无依赖的 Texts,任何文件都可安全引用它而不会形成 import 环。 + +import { revealText } from './Texts'; export const ERROR_INVALID_OPTIONS = 'INVALID_OPTIONS'; export const ERROR_DOWNLOAD_FAILED = 'DOWNLOAD_FAILED'; -export const ERROR_PATCH_FAILED = 'PATCH_FAILED'; +export const ERROR_DELTA_FAILED = revealText('0a36c0f286b44e640b1339dd'); export const ERROR_FILE_OPERATION_FAILED = 'FILE_OPERATION_FAILED'; export const ERROR_SWITCH_VERSION_FAILED = 'SWITCH_VERSION_FAILED'; export const ERROR_MARK_SUCCESS_FAILED = 'MARK_SUCCESS_FAILED'; @@ -23,14 +25,14 @@ export const ERROR_APK_INSTALL_FAILED = 'APK_INSTALL_FAILED'; /** * 带稳定错误码的 Error:`code` 是属性,消息同时带 `[CODE] ` 前缀(桥丢属性时 - * JS 仍能解析)。始终经 createUpdateError / toUpdateError 构造。 + * JS 仍能解析)。始终经 createPushyError / toPushyError 构造。 */ -export class UpdateError extends Error { +export class PushyError extends Error { readonly code: string; constructor(code: string, message: string) { super(`[${code}] ${message}`); - this.name = 'UpdateError'; + this.name = 'PushyError'; this.code = code; } } @@ -46,19 +48,19 @@ export function getErrorMessage(error: unknown): string { return String(error); } -export function createUpdateError(code: string, message: string): UpdateError { - return new UpdateError(code, message); +export function createPushyError(code: string, message: string): PushyError { + return new PushyError(code, message); } /** - * 给未带码的错误补上 `code`:已是 UpdateError 的原样返回(保留其更精确的码 + * 给未带码的错误补上 `code`:已是 PushyError 的原样返回(保留其更精确的码 * 与前缀,绝不叠两层前缀),其余按 defaultCode 重包并保留原消息。 */ -export function toUpdateError(error: unknown, defaultCode: string): UpdateError { - if (error instanceof UpdateError) { +export function toPushyError(error: unknown, defaultCode: string): PushyError { + if (error instanceof PushyError) { return error; } - return new UpdateError(defaultCode, getErrorMessage(error)); + return new PushyError(defaultCode, getErrorMessage(error)); } /** 从 `[CODE] message` 形式的消息里解析错误码;无前缀返回空串。 */ diff --git a/harmony/pushy/src/main/ets/InstallRecord.ts b/harmony/pushy/src/main/ets/InstallRecord.ts index f85d4846..8487ec03 100644 --- a/harmony/pushy/src/main/ets/InstallRecord.ts +++ b/harmony/pushy/src/main/ets/InstallRecord.ts @@ -1,7 +1,7 @@ import fileIo from '@ohos.file.fs'; import { util } from '@kit.ArkTS'; import NativePatchCore from './NativePatchCore'; -import { ERROR_SWITCH_VERSION_FAILED, createUpdateError } from './ErrorCodes'; +import { ERROR_SWITCH_VERSION_FAILED, createPushyError } from './ErrorCodes'; // ArkTS mirror of cpp/patch_core/install_record.h — keep in sync by hand // (harmony/pushy/src/test/check-constant-parity.js asserts the values match). @@ -141,7 +141,7 @@ export function expectedBundleSha256ForActivation( versionHash: string, ): string { if (record === null) { - throw createUpdateError( + throw createPushyError( ERROR_SWITCH_VERSION_FAILED, `Bundle version ${versionHash} has no valid completion record.`, ); @@ -153,7 +153,7 @@ export function expectedBundleSha256ForActivation( record.schema !== INSTALL_RECORD_SCHEMA || record.versionHash !== versionHash ) { - throw createUpdateError( + throw createPushyError( ERROR_SWITCH_VERSION_FAILED, `Bundle version ${versionHash} completion record mismatch.`, ); @@ -179,7 +179,7 @@ export async function verifyInstallForActivation( } const actual = await NativePatchCore.sha256HexFileAsync(bundlePath); if (actual.toLowerCase() !== expected.toLowerCase()) { - throw createUpdateError( + throw createPushyError( ERROR_SWITCH_VERSION_FAILED, `Bundle version ${versionHash} bundle digest mismatch.`, ); diff --git a/harmony/pushy/src/main/ets/NativePatchCore.ts b/harmony/pushy/src/main/ets/NativePatchCore.ts index 27f33b66..2cba7d34 100644 --- a/harmony/pushy/src/main/ets/NativePatchCore.ts +++ b/harmony/pushy/src/main/ets/NativePatchCore.ts @@ -1,4 +1,4 @@ -import NativeUpdateCore from 'librnupdate.so'; +import NativeCore from 'librnpushy.so'; export const STATE_OP_SWITCH_VERSION = 1; export const STATE_OP_MARK_SUCCESS = 2; @@ -43,7 +43,7 @@ export interface FileSourcePatchRequest { sourceRoot: string; targetRoot: string; originBundlePath: string; - bundlePatchPath: string; + bundleDeltaPath: string; bundleOutputPath: string; mergeSourceSubdir?: string; enableMerge?: boolean; @@ -64,7 +64,7 @@ interface NativePatchCoreBindings { flagA?: boolean, flagB?: boolean, ): StateCoreResult; - buildArchivePatchPlan( + buildArchivePlan( patchType: number, entryNames: string[], copyFroms: string[], @@ -73,7 +73,7 @@ interface NativePatchCoreBindings { bundlePatchEntryName?: string, ): ArchivePatchPlanResult; buildCopyGroups(copyFroms: string[], copyTos: string[]): CopyGroupResult[]; - applyPatchFromFileSource(options: FileSourcePatchRequest): Promise; + applyDeltaFromSource(options: FileSourcePatchRequest): Promise; /** * 删除 rootDir 下超过 maxAgeDays 且名字不在 keepNames 里的条目(native * 工作线程)。keepNames 必须包含所有可能仍在使用的版本:持久化的 @@ -99,19 +99,19 @@ interface NativePatchCoreBindings { // 更新流程决策层(cpp/update_flow_core,NATIVE_CHECKUPDATE_DESIGN §10): // JSON 字符串进出,与决策层自身的边界一致。返回 undefined = 输入未通过 // 解析,编排器跳过本轮检测。 - buildCheckRequestBody(inputJson: string): string | undefined; + buildRequestBody(inputJson: string): string | undefined; orderEndpointCandidates( endpointsJson: string, randomSample: number, ): string | undefined; - handleCheckResponse( + handleResponse( responseText: string, identityJson: string, afterDownload: string, ): string | undefined; - // 响应 schema 门槛(update_flow_core::IsValidCheckResponse):200 但只带 + // 响应 schema 门槛(update_flow_core::IsValidResponse):200 但只带 // `{"error":...}` 的节点算失败,不得阻止继续 fallback。 - isValidCheckResponse(responseText: string): boolean; + isValidResponse(responseText: string): boolean; } -export default NativeUpdateCore as unknown as NativePatchCoreBindings; +export default NativeCore as unknown as NativePatchCoreBindings; diff --git a/harmony/pushy/src/main/ets/PathUtils.ts b/harmony/pushy/src/main/ets/PathUtils.ts index 4ee75e89..7f908692 100644 --- a/harmony/pushy/src/main/ets/PathUtils.ts +++ b/harmony/pushy/src/main/ets/PathUtils.ts @@ -1,4 +1,4 @@ -import { ERROR_INVALID_OPTIONS, createUpdateError } from './ErrorCodes'; +import { ERROR_INVALID_OPTIONS, createPushyError } from './ErrorCodes'; // Server-controlled identifiers are used as children of the update root. Keep // validation in a dependency-light module (ErrorCodes.ts has no imports) so @@ -18,7 +18,7 @@ export function isSafePathComponent(name: string): boolean { export function assertSafePathComponent(name: string): string { if (!isSafePathComponent(name)) { - throw createUpdateError( + throw createPushyError( ERROR_INVALID_OPTIONS, `Invalid path component: ${name}`, ); diff --git a/harmony/pushy/src/main/ets/PushyConfiguration.ts b/harmony/pushy/src/main/ets/PushyConfiguration.ts index 4c846068..ce8b1537 100644 --- a/harmony/pushy/src/main/ets/PushyConfiguration.ts +++ b/harmony/pushy/src/main/ets/PushyConfiguration.ts @@ -1,3 +1,5 @@ +import { POLICY_NEXT_LAUNCH, revealText } from './Texts'; + /** Options accepted by PushyFileJSBundleProvider.configure(). */ export interface PushyConfiguration { appKey: string; @@ -26,12 +28,12 @@ interface PersistedPushyConfiguration { } const DEFAULT_ENDPOINTS: string[] = [ - 'https://update.react-native.cn/api', - 'https://update.reactnative.cn/api', + revealText('3203e0c1bdd1270a372f18f8c2b6de7f4f2607f5b3d5b9817b592947e5cdefbc8a7e'), + revealText('3203e0c1bdd1270a372f18f8c2b6de7f4f2607f5f0daac9c644a620ae88ca1ad93'), ]; 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', + revealText('3203e0c1bdd1270a253608fcd3fd9362476817f4f0d5a1996342631be3c2a3a9d779552507fdcde8928a6f512f5ce2ccbdc869404d2f1de79daa826d562c0913eec4fa9b7d4426'), + revealText('3203e0c1bdd1270a213b12b7dca09468462e12f3b0d5bd813d482446f4c6a1be8e79552507fdcda68cd06e5c3710e480a4867048483e55e0c2ab8d7d43030d1ce9c3b183214e2601f2f0d5b782601e2719e8ca'), ]; const CONFIG_KEYS: string[] = [ 'appKey', 'endpoints', 'queryUrls', 'afterDownload', 'disabled', @@ -84,8 +86,8 @@ export function normalizePushyConfiguration(options: PushyConfiguration): string '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 (afterDownload !== 'none' && afterDownload !== POLICY_NEXT_LAUNCH) { + throw new Error(`Invalid native configuration: afterDownload must be none or ${POLICY_NEXT_LAUNCH}`); } if (options.disabled !== undefined && typeof options.disabled !== 'boolean') { throw new Error('Invalid native configuration: disabled must be a boolean'); diff --git a/harmony/pushy/src/main/ets/PushyFileJSBundleProvider.ets b/harmony/pushy/src/main/ets/PushyFileJSBundleProvider.ets index 928ab05f..daf03618 100644 --- a/harmony/pushy/src/main/ets/PushyFileJSBundleProvider.ets +++ b/harmony/pushy/src/main/ets/PushyFileJSBundleProvider.ets @@ -6,7 +6,7 @@ import { import common from '@ohos.app.ability.common'; import fs from '@ohos.file.fs'; import { UpdateContext } from './UpdateContext'; -import { prepareBundleNative } from './NativeCheckOrchestrator'; +import { prepareBundleNative } from './SyncCoordinator'; import type { BundlePreparationResult } from './BundlePreparationResult'; import { normalizePushyConfiguration } from './PushyConfiguration'; import type { PushyConfiguration } from './PushyConfiguration'; diff --git a/harmony/pushy/src/main/ets/PushyTurboModule.ts b/harmony/pushy/src/main/ets/PushyTurboModule.ts index add21ad8..ad73d032 100644 --- a/harmony/pushy/src/main/ets/PushyTurboModule.ts +++ b/harmony/pushy/src/main/ets/PushyTurboModule.ts @@ -10,9 +10,9 @@ import { UpdateContext } from './UpdateContext'; import { EventHub } from './EventHub'; import { KEY_CONFIG, - markJsCheckCompleted, + recordJsRound, KEY_RESP_CACHE, -} from './NativeCheckOrchestrator'; +} from './SyncCoordinator'; import { ERROR_FILE_OPERATION_FAILED, ERROR_INVALID_HASH_INFO, @@ -22,10 +22,11 @@ import { ERROR_RESTART_FAILED, ERROR_SWITCH_VERSION_FAILED, ERROR_UNSUPPORTED_PLATFORM, - createUpdateError, + createPushyError, getErrorMessage, - toUpdateError, + toPushyError, } from './ErrorCodes'; +import { DEVTOOLS_RESTART_EVENT, DEVTOOLS_RESTART_REASON, STORAGE_DIR_NAME } from './Texts'; export { getErrorMessage } from './ErrorCodes'; @@ -41,13 +42,13 @@ interface RestartableApplicationContext { restartApp?: (want: RestartWant) => void; } -interface ReloadEventEmitter { +interface RestartEventEmitter { emit(event: string, payload: Object): void; } // RNOH 的 devToolsController 不在公开的 UITurboModuleContext 类型里。 interface DevToolsControllerHolder { - devToolsController?: { eventEmitter: ReloadEventEmitter }; + devToolsController?: { eventEmitter: RestartEventEmitter }; } export function validateHashInfo(info: string): void { @@ -59,7 +60,7 @@ export function validateHashInfo(info: string): void { valid = false; } if (!valid) { - throw createUpdateError(ERROR_INVALID_HASH_INFO, 'invalid json string'); + throw createPushyError(ERROR_INVALID_HASH_INFO, 'invalid json string'); } } @@ -96,7 +97,7 @@ export class PushyTurboModule extends UITurboModule { private requireHash(hash: string, methodName: string): string { if (!hash) { - throw createUpdateError( + throw createPushyError( ERROR_INVALID_OPTIONS, `${methodName}: empty hash`, ); @@ -104,11 +105,11 @@ export class PushyTurboModule extends UITurboModule { return hash; } - private softReload(): void { + private softRestart(): void { const holder = this.ctx as unknown as DevToolsControllerHolder; const devToolsController = holder.devToolsController; if (devToolsController) { - devToolsController.eventEmitter.emit('RELOAD', { reason: 'HotReload2' }); + devToolsController.eventEmitter.emit(DEVTOOLS_RESTART_EVENT, { reason: DEVTOOLS_RESTART_REASON }); } } @@ -146,10 +147,10 @@ export class PushyTurboModule extends UITurboModule { private async reloadBridge(): Promise { if (this.ctx.isDebugModeEnabled) { - logger.debug(TAG, 'reloadBridge via devToolsController RELOAD (debug mode)'); - this.softReload(); + logger.debug(TAG, 'restart via devToolsController (debug mode)'); + this.softRestart(); } else { - logger.debug(TAG, 'reloadBridge via restartAbility (release mode)'); + logger.debug(TAG, 'restart via restartAbility (release mode)'); // If the process truly restarts, this timer dies with it. It only fires // when the app is still alive after 1.5s — i.e. restartApp resolved but // was silently suppressed (HarmonyOS rate-limits restarts within a few @@ -157,16 +158,16 @@ export class PushyTurboModule extends UITurboModule { // soft reload must take over. So the timer is NOT cleared on the success // path, only in the catch branch where the soft reload runs immediately. const fallbackTimer = setTimeout(() => { - logger.warn(TAG, 'restartAbility did not restart the app within 1.5s, triggering soft reload fallback'); - this.softReload(); + logger.warn(TAG, 'restartAbility did not restart the app within 1.5s, triggering soft restart fallback'); + this.softRestart(); }, 1500); try { await this.restartAbility(); } catch (error) { clearTimeout(fallbackTimer); - logger.error(TAG, `restartAbility failed: ${getErrorMessage(error)}, triggering soft reload fallback`); - this.softReload(); + logger.error(TAG, `restartAbility failed: ${getErrorMessage(error)}, triggering soft restart fallback`); + this.softRestart(); } } } @@ -193,7 +194,7 @@ export class PushyTurboModule extends UITurboModule { } const result = { - downloadRootDir: `${this.mUiCtx.filesDir}/_update`, + downloadRootDir: `${this.mUiCtx.filesDir}/${STORAGE_DIR_NAME}`, currentVersionInfo, currentBundleSha256, packageVersion, @@ -232,7 +233,7 @@ export class PushyTurboModule extends UITurboModule { try { await this.context.setKv(`hash_${hash}`, info); } catch (error) { - throw toUpdateError(error, ERROR_FILE_OPERATION_FAILED); + throw toPushyError(error, ERROR_FILE_OPERATION_FAILED); } } @@ -253,7 +254,7 @@ export class PushyTurboModule extends UITurboModule { try { await this.context.setKv('uuid', uuid); } catch (error) { - throw toUpdateError(error, ERROR_FILE_OPERATION_FAILED); + throw toPushyError(error, ERROR_FILE_OPERATION_FAILED); } } @@ -267,7 +268,7 @@ export class PushyTurboModule extends UITurboModule { try { JSON.parse(config); } catch (e) { - throw createUpdateError( + throw createPushyError( ERROR_INVALID_OPTIONS, `syncNativeConfig: config is not valid JSON: ${getErrorMessage(e)}`, ); @@ -275,7 +276,7 @@ export class PushyTurboModule extends UITurboModule { try { await this.context.setNativeConfig(config); } catch (error) { - throw toUpdateError(error, ERROR_FILE_OPERATION_FAILED); + throw toPushyError(error, ERROR_FILE_OPERATION_FAILED); } } @@ -283,12 +284,12 @@ export class PushyTurboModule extends UITurboModule { async markJsCheckCompleted(config: string): Promise { logger.debug(TAG, ',call markJsCheckCompleted'); if (typeof config !== 'string' || config.length === 0) { - throw createUpdateError( + throw createPushyError( ERROR_INVALID_OPTIONS, 'config must be a non-empty string', ); } - markJsCheckCompleted(config); + recordJsRound(config); } // 原生冷启动检测落盘的原始响应缓存,JS 侧新鲜期内直接复用免二次请求 @@ -299,22 +300,22 @@ export class PushyTurboModule extends UITurboModule { } async reloadUpdate(options: { hash: string }): Promise { - logger.debug(TAG, ',call reloadUpdate'); - const hash = this.requireHash(options.hash, 'reloadUpdate'); + logger.debug(TAG, ',call switch and restart'); + const hash = this.requireHash(options.hash, 'switch and restart'); // 切换必须真正落盘(switchVersion 内 await flush)后才重启:重启会立刻 // 杀进程,未落盘的切换就是"重启回旧 bundle"。 try { await this.context.switchVersion(hash); } catch (error) { - logger.error(TAG, `reloadUpdate switch failed: ${getErrorMessage(error)}`); - throw toUpdateError(error, ERROR_SWITCH_VERSION_FAILED); + logger.error(TAG, `switch failed: ${getErrorMessage(error)}`); + throw toPushyError(error, ERROR_SWITCH_VERSION_FAILED); } try { await this.reloadBridge(); } catch (error) { - logger.error(TAG, `reloadUpdate restart failed: ${getErrorMessage(error)}`); - throw toUpdateError(error, ERROR_RESTART_FAILED); + logger.error(TAG, `restart failed: ${getErrorMessage(error)}`); + throw toPushyError(error, ERROR_RESTART_FAILED); } } @@ -324,19 +325,19 @@ export class PushyTurboModule extends UITurboModule { await this.reloadBridge(); } catch (error) { logger.error(TAG, `restartApp failed: ${getErrorMessage(error)}`); - throw toUpdateError(error, ERROR_RESTART_FAILED); + throw toPushyError(error, ERROR_RESTART_FAILED); } } async setNeedUpdate(options: { hash: string }): Promise { - logger.debug(TAG, ',call setNeedUpdate'); - const hash = this.requireHash(options.hash, 'setNeedUpdate'); + logger.debug(TAG, ',call select for next launch'); + const hash = this.requireHash(options.hash, 'select for next launch'); try { await this.context.switchVersion(hash); } catch (error) { - logger.error(TAG, `setNeedUpdate failed: ${getErrorMessage(error)}`); - throw toUpdateError(error, ERROR_SWITCH_VERSION_FAILED); + logger.error(TAG, `select for next launch failed: ${getErrorMessage(error)}`); + throw toPushyError(error, ERROR_SWITCH_VERSION_FAILED); } } @@ -346,7 +347,7 @@ export class PushyTurboModule extends UITurboModule { await this.context.markSuccess(); } catch (error) { logger.error(TAG, `markSuccess failed: ${getErrorMessage(error)}`); - throw toUpdateError(error, ERROR_MARK_SUCCESS_FAILED); + throw toPushyError(error, ERROR_MARK_SUCCESS_FAILED); } } @@ -367,7 +368,7 @@ export class PushyTurboModule extends UITurboModule { await this.context.resetToPackagedBundle(); } catch (error) { logger.error(TAG, `resetToPackagedBundle failed: ${getErrorMessage(error)}`); - throw toUpdateError(error, ERROR_RESET_FAILED); + throw toPushyError(error, ERROR_RESET_FAILED); } } @@ -376,7 +377,7 @@ export class PushyTurboModule extends UITurboModule { hash: string; originHash: string; }): Promise { - logger.debug(TAG, ',call downloadPatchFromPpk'); + logger.debug(TAG, ',call fetch ppk delta'); return this.context.downloadPatchFromPpk( options.updateUrl, options.hash, @@ -388,7 +389,7 @@ export class PushyTurboModule extends UITurboModule { updateUrl: string; hash: string; }): Promise { - logger.debug(TAG, ',call downloadPatchFromPackage'); + logger.debug(TAG, ',call fetch package delta'); return this.context.downloadPatchFromPackage( options.updateUrl, options.hash, @@ -399,7 +400,7 @@ export class PushyTurboModule extends UITurboModule { updateUrl: string; hash: string; }): Promise { - logger.debug(TAG, ',call downloadFullUpdate'); + logger.debug(TAG, ',call fetch full package'); return this.context.downloadFullUpdate(options.updateUrl, options.hash); } @@ -409,7 +410,7 @@ export class PushyTurboModule extends UITurboModule { hash: string; }): Promise { logger.debug(TAG, ',call downloadAndInstallApk'); - throw createUpdateError( + throw createPushyError( ERROR_UNSUPPORTED_PLATFORM, 'downloadAndInstallApk is only supported on Android', ); diff --git a/harmony/pushy/src/main/ets/NativeCheckOrchestrator.ts b/harmony/pushy/src/main/ets/SyncCoordinator.ts similarity index 93% rename from harmony/pushy/src/main/ets/NativeCheckOrchestrator.ts rename to harmony/pushy/src/main/ets/SyncCoordinator.ts index cbeff9ee..f812f3a2 100644 --- a/harmony/pushy/src/main/ets/NativeCheckOrchestrator.ts +++ b/harmony/pushy/src/main/ets/SyncCoordinator.ts @@ -6,10 +6,11 @@ import type { UpdateContext } from './UpdateContext'; import { BundlePreparationRound, bundlePreparationResult } from './BundlePreparationResult'; import type { BundlePreparationResult } from './BundlePreparationResult'; import { isSafePathComponent } from './PathUtils'; +import { QUERY_PATH, STATUS_NONE } from './Texts'; import { monotonicNowMs } from './MonotonicClock'; import { ERROR_DOWNLOAD_FAILED, - createUpdateError, + createPushyError, getErrorMessage, } from './ErrorCodes'; @@ -20,7 +21,7 @@ import { // 风暴,不拉黑版本。 // 鸿蒙的 debug 门控由触发点天然承担:dev 走 MetroJSBundleProvider, // PushyFileJSBundleProvider.getBundleUrl 不会被调用。 -const TAG = 'NativeCheck'; +const TAG = 'NativeSync'; export const KEY_CONFIG = 'nativeConfig'; // 供 JS 侧复用的原始响应缓存(§10.3),同时记录请求与配置指纹以限定命中范围。 export const KEY_RESP_CACHE = 'nativeCheckResp'; @@ -31,7 +32,7 @@ export const KEY_RESP_CACHE = 'nativeCheckResp'; export const KEY_ROUND_INCOMPLETE = 'nativeCheckIncomplete'; const REQUEST_TIMEOUT_MS = 10000; const REQUEST_CALL_TIMEOUT_MS = 15000; -const MAX_CHECK_HTTP_ATTEMPTS = 8; +const MAX_QUERY_HTTP_ATTEMPTS = 8; const START_DELAY_MS = 5000; const DOWNLOAD_PHASE_TIMEOUT_MS = 10 * 60 * 1000; const DOWNLOAD_TYPE_DIFF = 'diff'; @@ -62,7 +63,7 @@ interface FlowCInfo { uuid: string; } -interface FlowCheckInput { +interface FlowInput { packageVersion: string; currentVersion?: string; buildTime: string; @@ -129,7 +130,7 @@ function startNativeRound( try { await runOnce(context, launchRolledBackVersion); } catch (e) { - logger.error(TAG, `native check failed: ${getErrorMessage(e)}`); + logger.error(TAG, `native sync failed: ${getErrorMessage(e)}`); roundResult = bundlePreparationResult('failed', 'internal_error'); } return roundResult; @@ -200,20 +201,20 @@ export async function prepareBundleNative( // 进程级:下次启动无信号,冷启动轮次照常运行。 let jsCompletedConfig: string | undefined; -export function markJsCheckCompleted(config: string): void { +export function recordJsRound(config: string): void { jsCompletedConfig = config; } // JS 已用与原生完全相同的配置拿到有效响应(§10.3):延迟轮次就是一次重复请求。 // 只有计划内的轮次会问这个问题——JS 没起来、启动即崩、检查失败时都不会有信号。 -function isJsCheckCompleted(context: UpdateContext): boolean { +function hasJsRound(context: UpdateContext): boolean { return ( jsCompletedConfig !== undefined && jsCompletedConfig === context.getKv(KEY_CONFIG) ); } -export function scheduleNativeCheck( +export function scheduleNativeSync( context: UpdateContext, launchRolledBackVersion: string, ): void { @@ -227,16 +228,16 @@ export function scheduleNativeCheck( // 除非上个进程死于轮中(残留标记),那时每一秒启动时间都要用来续传。 const delayMs = context.getKv(KEY_ROUND_INCOMPLETE) ? 0 : START_DELAY_MS; setTimeout(() => { - if (isJsCheckCompleted(context)) { + if (hasJsRound(context)) { logger.info( TAG, - 'native check skipped: JS check completed in this process', + 'native sync skipped: JS already queried in this process', ); return; } startNativeRound(context, launchRolledBackVersion).catch((e: Object) => { // 救援路径自身绝不能把应用拖垮。 - logger.error(TAG, `native check failed: ${getErrorMessage(e)}`); + logger.error(TAG, `native sync failed: ${getErrorMessage(e)}`); }); }, delayMs); } @@ -361,7 +362,7 @@ async function runConfiguredRound( uuid, }; - const input: FlowCheckInput = { + const input: FlowInput = { packageVersion: identity.packageVersion, currentVersion, buildTime: context.getBuildTime(), @@ -369,14 +370,14 @@ async function runConfiguredRound( supportedDiffVersion: NativePatchCore.getSupportedDiffVersion(), bundleHash: await context.getBundleHash(), }; - const body = NativePatchCore.buildCheckRequestBody(JSON.stringify(input)); + const body = NativePatchCore.buildRequestBody(JSON.stringify(input)); if (!body) { roundResult = bundlePreparationResult('failed', 'invalid_request'); return; } const httpsOnly = isHttpsOnly(config); - const responseText = await runCheckRequest(config, appKey, body, httpsOnly); + const responseText = await runQueryRequest(config, appKey, body, httpsOnly); if (!responseText) { logger.warn(TAG, 'no endpoint reachable, giving up until next launch'); return; @@ -384,7 +385,7 @@ async function runConfiguredRound( // Anchor cache freshness to response arrival, before download/patch work. const responseAtSeconds = Math.floor(Date.now() / 1000); - const decisionJson = NativePatchCore.handleCheckResponse( + const decisionJson = NativePatchCore.handleResponse( responseText, JSON.stringify(identity), config.afterDownload ?? '', @@ -395,7 +396,7 @@ async function runConfiguredRound( } const decision = JSON.parse(decisionJson) as Decision; if (decision.action !== 'download') { - const committed = await context.commitNativeCheckResult( + const committed = await context.commitSyncResult( resetGeneration, '', '', @@ -403,7 +404,7 @@ async function runConfiguredRound( buildResponseCacheJson(configJson, body, responseText, responseAtSeconds), ); roundResult = committed - ? bundlePreparationResult('noUpdate', decision.reason ?? '') + ? bundlePreparationResult(STATUS_NONE, decision.reason ?? '') : bundlePreparationResult('cancelled', 'reset'); logger.info(TAG, `nothing to do (${decision.reason ?? ''})`); return; @@ -430,7 +431,7 @@ async function runConfiguredRound( } if (!downloaded) { logger.warn(TAG, `all download attempts for ${hash} failed`); - const committed = await context.commitNativeCheckResult( + const committed = await context.commitSyncResult( resetGeneration, '', '', @@ -466,13 +467,13 @@ async function runConfiguredRound( hashInfoJson = JSON.stringify(hashInfo); } - // 版本元信息、激活与响应缓存一次性原子提交(见 commitNativeCheckResult); + // 版本元信息、激活与响应缓存一次性原子提交(见 commitSyncResult); // 缓存只在原生文件/状态工作结束后公开,避免 JS 观察到响应后并发下载。 // 静默策略、或服务端按版本标记的 forceBoot(远程覆盖,救砖指令)才激活。 const activate = decision.activate === true; let committed = false; try { - committed = await context.commitNativeCheckResult( + committed = await context.commitSyncResult( resetGeneration, hash, hashInfoJson, @@ -511,12 +512,12 @@ function buildResponseCacheJson( return JSON.stringify(cacheEntry); } -function isValidCheckResponse(responseText: string | undefined): boolean { +function isValidResponse(responseText: string | undefined): boolean { if (responseText === undefined) { return false; } try { - return NativePatchCore.isValidCheckResponse(responseText); + return NativePatchCore.isValidResponse(responseText); } catch (e) { return false; } @@ -570,7 +571,7 @@ async function httpRequest( // 顺序回退(§5.1):按纯层给出的候选序逐个请求,单请求超时;整轮失败后经 // queryUrls 发现远程候选(排除已试过的)再来一轮。刻意不做 hedged race—— // 该路径对延迟不敏感。 -async function runCheckRequest( +async function runQueryRequest( config: NativeConfig, appKey: string, body: string, @@ -591,12 +592,12 @@ async function runCheckRequest( if (!base || tried.has(base)) { continue; } - if (httpAttempts++ >= MAX_CHECK_HTTP_ATTEMPTS) { + if (httpAttempts++ >= MAX_QUERY_HTTP_ATTEMPTS) { return undefined; } tried.add(base); - const response = await httpRequest(`${base}/checkUpdate/${appKey}`, body); - if (isValidCheckResponse(response)) { + const response = await httpRequest(`${base}${QUERY_PATH}${appKey}`, body); + if (isValidResponse(response)) { return response; } } @@ -604,7 +605,7 @@ async function runCheckRequest( if (!listUrl) { continue; } - if (httpAttempts++ >= MAX_CHECK_HTTP_ATTEMPTS) { + if (httpAttempts++ >= MAX_QUERY_HTTP_ATTEMPTS) { return undefined; } const listText = await httpRequest(listUrl); @@ -634,12 +635,12 @@ async function runCheckRequest( logger.warn(TAG, `ignoring non-https remote endpoint ${base}`); continue; } - if (httpAttempts++ >= MAX_CHECK_HTTP_ATTEMPTS) { + if (httpAttempts++ >= MAX_QUERY_HTTP_ATTEMPTS) { return undefined; } tried.add(base); - const response = await httpRequest(`${base}/checkUpdate/${appKey}`, body); - if (isValidCheckResponse(response)) { + const response = await httpRequest(`${base}${QUERY_PATH}${appKey}`, body); + if (isValidResponse(response)) { return response; } } @@ -659,7 +660,7 @@ async function runWithinDeadline( ): Promise { const remainingMs = deadlineUptimeMs - monotonicNowMs(); if (remainingMs <= 0) { - throw createUpdateError( + throw createPushyError( ERROR_DOWNLOAD_FAILED, 'Download phase deadline expired before start', ); @@ -668,7 +669,7 @@ async function runWithinDeadline( const deadlinePromise = new Promise((_, reject) => { deadlineTimer = setTimeout(() => { reject( - createUpdateError( + createPushyError( ERROR_DOWNLOAD_FAILED, 'Download phase deadline exceeded', ), diff --git a/harmony/pushy/src/main/ets/Texts.ts b/harmony/pushy/src/main/ets/Texts.ts new file mode 100644 index 00000000..c2faa9fc --- /dev/null +++ b/harmony/pushy/src/main/ets/Texts.ts @@ -0,0 +1,28 @@ +// Encoded text shared by the Harmony module. No imports, so any file can use it +// without forming an import cycle. + +// Decodes text produced by scripts/encode-native-text.ts (byte i XORed with +// (0x5A + 0x1D * i) & 0xFF), keeping service addresses and paths out of static +// string scans of the package. Not a secret. +export function revealText(hex: string): string { + let text = ''; + for (let i = 0; i < hex.length / 2; i++) { + const byte = parseInt(hex.substring(i * 2, i * 2 + 2), 16); + text += String.fromCharCode(byte ^ ((0x5a + 0x1d * i) & 0xff)); + } + return text; +} + +// Request path appended to an endpoint base. +export const QUERY_PATH: string = revealText('7514fcd4ad805d55263e08fc99'); + +// Protocol values, version-info flags and storage names, stored encoded. +export const STATUS_NONE: string = revealText('3418c1c1aa8a7c40'); +export const POLICY_NEXT_LAUNCH: string = revealText('2912e0ffab8e6c70323b1dedd3'); +export const STORAGE_DIR_NAME: string = revealText('0502e4d5af9f6d'); +export const PREFERENCES_NAME: string = revealText('2f07f0d0ba8e'); +export const PPK_DELTA_SUFFIX: string = revealText('7407e4dae09b69512137'); +export const APP_DELTA_SUFFIX: string = revealText('7416e4c1e09b69512137'); +export const BUNDLE_DELTA_ENTRY: string = revealText('3802fad5a28e264d232d11f6d8aade67596914e0ead8b0'); +export const DEVTOOLS_RESTART_EVENT: string = revealText('0832d8fe8faf'); +export const DEVTOOLS_RESTART_REASON: string = revealText('1218e0e3ab876744266d'); diff --git a/harmony/pushy/src/main/ets/UpdateContext.ts b/harmony/pushy/src/main/ets/UpdateContext.ts index ad9eea8f..f30a3861 100644 --- a/harmony/pushy/src/main/ets/UpdateContext.ts +++ b/harmony/pushy/src/main/ets/UpdateContext.ts @@ -14,9 +14,9 @@ import logger from './Logger'; import { KEY_CONFIG, KEY_RESP_CACHE, - markJsCheckCompleted, - scheduleNativeCheck, -} from './NativeCheckOrchestrator'; + recordJsRound, + scheduleNativeSync, +} from './SyncCoordinator'; import NativePatchCore, { STATE_OP_CLEAR_FIRST_TIME, STATE_OP_CLEAR_ROLLBACK_MARK, @@ -29,14 +29,15 @@ import NativePatchCore, { import { assertSafePathComponent } from './PathUtils'; import { ERROR_SWITCH_VERSION_FAILED, - createUpdateError, + createPushyError, getErrorMessage, - toUpdateError, + toPushyError, } from './ErrorCodes'; +import { APP_DELTA_SUFFIX, PPK_DELTA_SUFFIX, PREFERENCES_NAME, STORAGE_DIR_NAME } from './Texts'; export { isSafePathComponent } from './PathUtils'; -const TAG = 'UpdateContext'; +const TAG = 'PushyContext'; // 常规清理保留最近 3 天内触碰过的条目(续传 partial、staging 同样按 mtime)。 const CLEANUP_MAX_AGE_DAYS = 3; @@ -101,7 +102,7 @@ export class UpdateContext { private constructor(context: common.UIAbilityContext) { this.context = context; - this.rootDir = context.filesDir + '/_update'; + this.rootDir = `${context.filesDir}/${STORAGE_DIR_NAME}`; this.instanceId = `uc#${++UpdateContext.instanceCounter}`; try { @@ -145,7 +146,7 @@ export class UpdateContext { private initPreferences() { try { this.preferences = preferences.getPreferencesSync(this.context, { - name: 'update', + name: PREFERENCES_NAME, }); } catch (e) { // Fail fast: a missing preferences store means no state can be persisted, @@ -447,7 +448,7 @@ export class UpdateContext { logger.info( TAG, - `binary version changed, resetting update state id=${this.instanceId}`, + `binary version changed, resetting state id=${this.instanceId}`, ); UpdateContext.ignoreRollback = false; this.cleanUp(); @@ -477,7 +478,7 @@ export class UpdateContext { UpdateContext.nativeConfigGeneration += 1; this.preferences.putSync(KEY_CONFIG, config); this.preferences.deleteSync(KEY_RESP_CACHE); - markJsCheckCompleted(''); + recordJsRound(''); } // Flush even an equal value so a retry after a storage error can succeed. return this.flushPreferences('persist configuration'); @@ -614,7 +615,7 @@ export class UpdateContext { * 可插入 reset 的窗口(iOS/Android 用锁达到同一效果)。三项写入以一次 * flush 落盘,失败拒绝。返回是否提交成功。 */ - public async commitNativeCheckResult( + public async commitSyncResult( expectedGeneration: number, hash: string, hashInfoJson: string, @@ -646,7 +647,7 @@ export class UpdateContext { this.preferences.putSync(KEY_RESP_CACHE, responseCacheJson); } } finally { - flushed = this.endFlushBatch('commit native check result'); + flushed = this.endFlushBatch('commit sync result'); } await flushed; return true; @@ -668,7 +669,7 @@ export class UpdateContext { params.deadlineUptimeMs = deadlineUptimeMs; await this.executeTask(params); } catch (e) { - logger.error(TAG, `Failed to download full update: ${getErrorMessage(e)}`); + logger.error(TAG, `Failed to download full package: ${getErrorMessage(e)}`); throw e; } } @@ -685,7 +686,7 @@ export class UpdateContext { hash, ); params.originHash = assertSafePathComponent(originHash); - params.targetFile = `${this.rootDir}/${originHash}_${hash}.ppk.patch`; + params.targetFile = `${this.rootDir}/${originHash}_${hash}${PPK_DELTA_SUFFIX}`; params.unzipDirectory = `${this.rootDir}/${hash}`; params.originDirectory = `${this.rootDir}/${params.originHash}`; params.deadlineUptimeMs = deadlineUptimeMs; @@ -703,14 +704,14 @@ export class UpdateContext { url, hash, ); - params.targetFile = `${this.rootDir}/${hash}.app.patch`; + params.targetFile = `${this.rootDir}/${hash}${APP_DELTA_SUFFIX}`; params.unzipDirectory = `${this.rootDir}/${hash}`; params.deadlineUptimeMs = deadlineUptimeMs; return await this.executeTask(params); } catch (e) { logger.error( TAG, - `Failed to download package patch: ${getErrorMessage(e)}`, + `Failed to download package delta: ${getErrorMessage(e)}`, ); throw e; } @@ -732,7 +733,7 @@ export class UpdateContext { } } - // 原生冷启动检测(NativeCheckOrchestrator)用来跳过已就绪版本的重复下载 + // 原生冷启动检测(SyncCoordinator)用来跳过已就绪版本的重复下载 // ——alert 类策略下版本已下载但未激活,若不判在这里会每次冷启动重下一遍。 public hasDownloadedVersion(hash: string): boolean { try { @@ -752,7 +753,7 @@ export class UpdateContext { */ private assertActivatable(safeHash: string): boolean { if (!fileIo.accessSync(this.getBundlePath(safeHash))) { - throw createUpdateError( + throw createPushyError( ERROR_SWITCH_VERSION_FAILED, `Bundle version ${safeHash} not found.`, ); @@ -761,7 +762,7 @@ export class UpdateContext { this.readString('currentVersion') === safeHash || this.readString('lastVersion') === safeHash; if (!this.hasDownloadedVersion(safeHash) && !legacyActivated) { - throw createUpdateError( + throw createPushyError( ERROR_SWITCH_VERSION_FAILED, `Bundle version ${safeHash} is incomplete.`, ); @@ -801,7 +802,7 @@ export class UpdateContext { await this.flushPreferences(`switch version ${safeHash}`); } catch (e) { logger.error(TAG, `Failed to switch version: ${getErrorMessage(e)}`); - throw toUpdateError(e, ERROR_SWITCH_VERSION_FAILED); + throw toPushyError(e, ERROR_SWITCH_VERSION_FAILED); } } @@ -818,7 +819,7 @@ export class UpdateContext { public getBundleUrl() { UpdateContext.isUsingBundleUrl = true; this.trace('getBundleUrl:enter'); - let nativeCheckRolledBackVersion = ''; + let syncRolledBackVersion = ''; try { const stateBeforeLaunch = this.getStateSnapshot(); const launchState = NativePatchCore.runStateCore( @@ -828,7 +829,7 @@ export class UpdateContext { UpdateContext.ignoreRollback, true, ); - nativeCheckRolledBackVersion = launchState.rolledBackVersion || ''; + syncRolledBackVersion = launchState.rolledBackVersion || ''; if (launchState.didRollback) { // The crash-protection rollback: the new version never called // markSuccess. Keep this visible in release logs. @@ -865,24 +866,24 @@ export class UpdateContext { if (!fileIo.accessSync(bundleFile)) { logger.error(TAG, `Bundle version ${version} not found.`); version = this.rollBack(); - nativeCheckRolledBackVersion = this.rolledBackVersion(); + syncRolledBackVersion = this.rolledBackVersion(); continue; } UpdateContext.launchVersion = version; - nativeCheckRolledBackVersion = this.rolledBackVersion(); + syncRolledBackVersion = this.rolledBackVersion(); return bundleFile; } catch (e) { logger.error(TAG, `Failed to access bundle file: ${getErrorMessage(e)}`); version = this.rollBack(); - nativeCheckRolledBackVersion = this.rolledBackVersion(); + syncRolledBackVersion = this.rolledBackVersion(); } } - nativeCheckRolledBackVersion = this.rolledBackVersion(); + syncRolledBackVersion = this.rolledBackVersion(); return ''; } finally { // State corruption is exactly when the native rescue check is needed; // schedule even if state parsing/rollback throws before a normal exit. - scheduleNativeCheck(this, nativeCheckRolledBackVersion); + scheduleNativeSync(this, syncRolledBackVersion); } } @@ -936,7 +937,7 @@ export class UpdateContext { // 安装的二进制,每次(覆盖)安装 updateTime 都会变,每个安装只算一次。 private static readonly KEY_BUNDLE_HASH_CACHE = 'bundleHashCache'; - private getBundleUpdateTime(): number { + private getBundleModifiedTime(): number { try { const bundleInfo = bundleManager.getBundleInfoForSelfSync( this.getBundleFlags(), @@ -945,7 +946,7 @@ export class UpdateContext { } catch (error) { logger.error( TAG, - `Failed to get bundle update time: ${getErrorMessage(error)}`, + `Failed to get bundle modification time: ${getErrorMessage(error)}`, ); return 0; } @@ -962,7 +963,7 @@ export class UpdateContext { // debug 下 bundle 由 metro 提供,与 dev 删 buildTime 的行为对齐。 return ''; } - const cachePrefix = `${this.getPackageVersion()}|${this.getBundleUpdateTime()}|`; + const cachePrefix = `${this.getPackageVersion()}|${this.getBundleModifiedTime()}|`; const cached = this.readString(UpdateContext.KEY_BUNDLE_HASH_CACHE); if (cached.startsWith(cachePrefix)) { return cached.slice(cachePrefix.length); diff --git a/harmony/pushy/src/test/ErrorCodes.test.ets b/harmony/pushy/src/test/ErrorCodes.test.ets index 7954a61e..b42a0a42 100644 --- a/harmony/pushy/src/test/ErrorCodes.test.ets +++ b/harmony/pushy/src/test/ErrorCodes.test.ets @@ -1,39 +1,39 @@ import { describe, it, expect } from '@ohos/hypium'; import { ERROR_DOWNLOAD_FAILED, - ERROR_PATCH_FAILED, - UpdateError, - createUpdateError, + ERROR_DELTA_FAILED, + PushyError, + createPushyError, getErrorMessage, parseErrorCodePrefix, - toUpdateError, + toPushyError, } from '../main/ets/ErrorCodes'; // 稳定错误码:`code` 属性 + `[CODE] ` 消息前缀(RNOH 桥丢属性时 JS 解析前缀)。 export default function errorCodesTest() { - describe('ErrorCodes.createUpdateError', () => { + describe('ErrorCodes.createPushyError', () => { it('sets the code and prefixes the message', 0, () => { - const error = createUpdateError(ERROR_PATCH_FAILED, 'boom'); + const error = createPushyError(ERROR_DELTA_FAILED, 'boom'); expect(error instanceof Error).assertTrue(); - expect(error.code).assertEqual(ERROR_PATCH_FAILED); + expect(error.code).assertEqual(ERROR_DELTA_FAILED); expect(error.message).assertEqual('[PATCH_FAILED] boom'); }); }); - describe('ErrorCodes.toUpdateError', () => { + describe('ErrorCodes.toPushyError', () => { it('keeps an existing code and never double-prefixes', 0, () => { - const original = createUpdateError(ERROR_PATCH_FAILED, 'boom'); - const wrapped = toUpdateError(original, ERROR_DOWNLOAD_FAILED); + const original = createPushyError(ERROR_DELTA_FAILED, 'boom'); + const wrapped = toPushyError(original, ERROR_DOWNLOAD_FAILED); expect(wrapped === original).assertTrue(); expect(wrapped.message).assertEqual('[PATCH_FAILED] boom'); }); it('assigns the default code to plain errors and values', 0, () => { - const fromError = toUpdateError(new Error('plain'), ERROR_DOWNLOAD_FAILED); + const fromError = toPushyError(new Error('plain'), ERROR_DOWNLOAD_FAILED); expect(fromError.code).assertEqual(ERROR_DOWNLOAD_FAILED); expect(fromError.message).assertEqual('[DOWNLOAD_FAILED] plain'); - const fromString = toUpdateError('raw', ERROR_DOWNLOAD_FAILED); - expect(fromString instanceof UpdateError).assertTrue(); + const fromString = toPushyError('raw', ERROR_DOWNLOAD_FAILED); + expect(fromString instanceof PushyError).assertTrue(); expect(fromString.message).assertEqual('[DOWNLOAD_FAILED] raw'); }); }); diff --git a/harmony/pushy/src/test/InstallRecord.test.ets b/harmony/pushy/src/test/InstallRecord.test.ets index 96ca244d..148ca46a 100644 --- a/harmony/pushy/src/test/InstallRecord.test.ets +++ b/harmony/pushy/src/test/InstallRecord.test.ets @@ -10,7 +10,7 @@ import { parseInstallRecord, stagingDirectoryFor, } from '../main/ets/InstallRecord'; -import { ERROR_SWITCH_VERSION_FAILED, UpdateError } from '../main/ets/ErrorCodes'; +import { ERROR_SWITCH_VERSION_FAILED, PushyError } from '../main/ets/ErrorCodes'; // 两阶段安装的完成记录(cpp/patch_core/install_record.h 的 ArkTS 镜像): // 纯逻辑部分,不碰磁盘。 @@ -84,11 +84,11 @@ export default function installRecordTest() { describe('InstallRecord.expectedBundleSha256ForActivation', () => { it('throws SWITCH_VERSION_FAILED without a record', 0, () => { - let caught: UpdateError | null = null; + let caught: PushyError | null = null; try { expectedBundleSha256ForActivation(null, 'abc'); } catch (e) { - caught = e as UpdateError; + caught = e as PushyError; } expect(caught !== null).assertTrue(); expect(caught!.code).assertEqual(ERROR_SWITCH_VERSION_FAILED); diff --git a/harmony/pushy/src/test/check-constant-parity.js b/harmony/pushy/src/test/check-constant-parity.js index f71d68f7..b61cdd8e 100644 --- a/harmony/pushy/src/test/check-constant-parity.js +++ b/harmony/pushy/src/test/check-constant-parity.js @@ -20,7 +20,7 @@ const read = (relative) => fs.readFileSync(path.join(root, relative), 'utf8'); // LL suffix. function parseCppConstants(source) { const re = - /constexpr\s+(?:const\s+)?(?:long\s+long|int|char\s*\*)\s+k([A-Za-z0-9]+)\s*=\s*([^;]+);/g; + /(?:constexpr|inline)\s+(?:const\s+)?(?:long\s+long|int|char\s*\*)\s*(?:const\s+)?k([A-Za-z0-9]+)\s*=\s*([^;]+);/g; const values = new Map(); let match; while ((match = re.exec(source)) !== null) { @@ -40,8 +40,23 @@ function parseArkTsConstants(source) { return values; } +// Encoded text (obscured_text.h / Texts.ts, scripts/encode-native-text.ts): +// byte i XORed with (0x5A + 0x1D * i) & 0xFF. +function reveal(hex) { + let text = ''; + for (let i = 0; i < hex.length / 2; i++) { + const byte = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + text += String.fromCharCode(byte ^ ((0x5a + 0x1d * i) & 0xff)); + } + return text; +} + function evaluate(expression, name) { const text = expression.replace(/\s+/g, ' ').trim(); + const encoded = text.match(/^(?:text::RevealStatic|revealText)\((["'])([0-9a-f]+)\1\)$/); + if (encoded) { + return reveal(encoded[2]); + } const stringLiteral = text.match(/^(["'])((?:\\.|(?!\1).)*)\1$/); if (stringLiteral) { return stringLiteral[2]; diff --git a/harmony/types/librnupdate.d.ts b/harmony/types/librnpushy.d.ts similarity index 85% rename from harmony/types/librnupdate.d.ts rename to harmony/types/librnpushy.d.ts index 26dc62d5..e3a938f7 100644 --- a/harmony/types/librnupdate.d.ts +++ b/harmony/types/librnpushy.d.ts @@ -1,6 +1,6 @@ // Type stub for the native NAPI module. The real binding surface is typed in // pushy/src/main/ets/NativePatchCore.ts (NativePatchCoreBindings). -declare module 'librnupdate.so' { +declare module 'librnpushy.so' { const bindings: unknown; export default bindings; } diff --git a/ios/RCTPushy/RCTPushy.mm b/ios/RCTPushy/RCTPushy.mm index 5bbfd8c4..f6d881a0 100644 --- a/ios/RCTPushy/RCTPushy.mm +++ b/ios/RCTPushy/RCTPushy.mm @@ -64,7 +64,7 @@ static NSString *const keyHashInfo = @"REACTNATIVECN_PUSHY_HASH_"; static NSString *const keyFirstLoadMarked = @"REACTNATIVECN_PUSHY_FIRSTLOADMARKED_KEY"; static NSString *const keyRolledBackMarked = @"REACTNATIVECN_PUSHY_ROLLEDBACKMARKED_KEY"; -static NSString *const KeyPackageUpdatedMarked = @"REACTNATIVECN_PUSHY_ISPACKAGEUPDATEDMARKED_KEY"; +static NSString *const KeyPackageUpdatedMarked = RCTPushyRevealText("0832d5f29aa549710b0939daf88ca058790f3dded7e888b451640d2ec3f69099bb43711523cafa8ea7bb4372132a"); // bundleHash cache: "|" where cacheKey identifies the // installed binary (packageVersion + embedded bundle size + mtime). Recomputed // only when the key changes, i.e. once per install. @@ -84,7 +84,15 @@ // file def static NSString * const BUNDLE_FILE_NAME = @"index.bundlejs"; static NSString * const SOURCE_PATCH_NAME = @"__diff.json"; -static NSString * const BUNDLE_PATCH_NAME = @"index.bundlejs.patch"; +static NSString * const BUNDLE_PATCH_NAME = RCTPushyRevealText("3319f0d4b6c56a502c3b10fcdca0de7d4b3307e9"); +// Protocol strings and version-info flags read by JS, stored encoded +// (RCTPushyRevealText) so they do not appear as plain binary strings. +static NSString * const PushyOptionUrlKey = RCTPushyRevealText("2f07f0d0ba8e5d572e"); +static NSString * const PushyIpaDeltaSuffix = RCTPushyRevealText("741ee4d0e09b69512137"); +static NSString * const PushyPpkDeltaSuffix = RCTPushyRevealText("7407e4dae09b69512137"); +static NSString * const PushyInfoCrashHold = RCTPushyRevealText("3905f5c2a6b96d56212a19"); +static NSString * const PushyInfoForceBoot = RCTPushyRevealText("3c18e6d2aba9674a360d19ead5a695"); +static NSString * const PushyStatusNone = RCTPushyRevealText("3418c1c1aa8a7c40"); #define VERSION_COMPLETE_FILE_NAME_LITERAL ".pushy-complete" static NSString * const VERSION_COMPLETE_FILE_NAME = @VERSION_COMPLETE_FILE_NAME_LITERAL; @@ -211,7 +219,7 @@ static BOOL PushyHasCompletedVersionAtPath(NSString *versionDir, NSString *hash) if (shortfall == nil) { return nil; } - return PushyErrorWithCode(pushy::error_codes::kPatchFailed, shortfall); + return PushyErrorWithCode(pushy::error_codes::kDeltaFailed, shortfall); } // SSZipArchive delegate enforcing cpp/patch_core/archive_limits.h while the @@ -477,12 +485,12 @@ static void PushyWithStateLock(void (NS_NOESCAPE ^block)(void)) { return std::string([value UTF8String]); } -static NSError *PushyNSErrorFromStatus(const pushy::patch::Status &status) { +static NSError *PushyNSErrorFromStatus(const pushy::delta::Status &status) { return [NSError errorWithDomain:PushyErrorDomain code:-1 userInfo:@{ NSLocalizedDescriptionKey: [NSString stringWithUTF8String:status.message.c_str()], - PushyErrorCodeKey: PushyCode(pushy::error_codes::kPatchFailed), + PushyErrorCodeKey: PushyCode(pushy::error_codes::kDeltaFailed), }]; } @@ -568,12 +576,12 @@ static BOOL PushyJsonIsAbsent(id value) { // raise takes the whole app down (and trips the crash rescue) instead of // failing the patch. NO with `reason` set for a malformed manifest. static BOOL PushyPatchManifestFromJson(NSDictionary *json, - pushy::patch::PatchManifest *manifest, + pushy::delta::PatchManifest *manifest, NSString **reason) { id copies = json[@"copies"]; if (!PushyJsonIsAbsent(copies)) { if (![copies isKindOfClass:[NSDictionary class]]) { - *reason = @"patch manifest: copies is not an object"; + *reason = @"delta manifest: copies is not an object"; return NO; } // Content checksum per copy target ("copiesCrc", pdiff manifests @@ -588,13 +596,13 @@ static BOOL PushyPatchManifestFromJson(NSDictionary *json, for (id to in (NSDictionary *)copies) { id from = ((NSDictionary *)copies)[to]; if (![to isKindOfClass:[NSString class]] || ![from isKindOfClass:[NSString class]]) { - *reason = @"patch manifest: copies entry is not a string"; + *reason = @"delta manifest: copies entry is not a string"; return NO; } if ([from length] == 0) { from = to; } - pushy::patch::CopyOperation operation; + pushy::delta::CopyOperation operation; operation.from = PushyToStdString(from); operation.to = PushyToStdString(to); NSNumber *expectedCrc = copiesCrc[to]; @@ -611,12 +619,12 @@ static BOOL PushyPatchManifestFromJson(NSDictionary *json, id deletes = json[@"deletes"]; if (!PushyJsonIsAbsent(deletes)) { if (![deletes isKindOfClass:[NSDictionary class]] && ![deletes isKindOfClass:[NSArray class]]) { - *reason = @"patch manifest: deletes is not an object or array"; + *reason = @"delta manifest: deletes is not an object or array"; return NO; } for (id path in deletes) { if (![path isKindOfClass:[NSString class]]) { - *reason = @"patch manifest: deletes entry is not a string"; + *reason = @"delta manifest: deletes entry is not a string"; return NO; } manifest->deletes.push_back(PushyToStdString(path)); @@ -697,14 +705,14 @@ static void PushySwitchVersionLocked(NSString *hash) { } @interface RCTPushy () -- (void)downloadUpdate:(PushyType)type +- (void)fetchPackage:(PushyType)type options:(NSDictionary *)options resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject; -- (void)performUpdate:(PushyType)type +- (void)performFetch:(PushyType)type options:(NSDictionary *)options callback:(void (^)(NSError *error))callback; -- (NSError *)reloadBridgeWithReason:(NSString *)reason; +- (NSError *)restartBridgeWithReason:(NSString *)reason; - (void)unzipDownloadedPackage:(NSString *)zipFilePath hash:(NSString *)hash type:(PushyType)type @@ -714,7 +722,7 @@ - (void)finishDownloadedPackage:(NSString *)hash type:(PushyType)type originHash:(NSString *)originHash callback:(void (^)(NSError *error))callback; -- (void)applyPatchForHash:(NSString *)hash +- (void)applyDeltaForHash:(NSString *)hash type:(PushyType)type fromBundle:(NSString *)bundleOrigin source:(NSString *)sourceOrigin @@ -744,10 +752,10 @@ + (void)scheduleFromColdStart:(NSString *)launchRolledBackVersion; + (BOOL)restorePurgedLaunch:(NSString *)purgedVersion rolledBack:(NSString *)launchRolledBackVersion; #endif -+ (void)markJsCheckCompleted:(NSString *)config; ++ (void)recordJsRound:(NSString *)config; + (void)startRoundWithDeadline:(NSTimeInterval)deadlineUptime; + (void)runOnce:(NSString *)launchRolledBackVersion deadline:(NSTimeInterval)deadlineUptime; -+ (void)runRescueWithDeadline:(NSTimeInterval)deadlineUptime; ++ (void)runHoldRoundWithDeadline:(NSTimeInterval)deadlineUptime; + (BOOL)commitRoundWithGeneration:(uint64_t)generation hashInfo:(NSDictionary *)hashInfoEntry activate:(NSString *)hashToActivate @@ -766,8 +774,8 @@ + (BOOL)commitRoundWithGeneration:(uint64_t)generation // Flipped the moment a crash is being held. JS is dead from that point on, // so there is no second decision maker: the round force-activates whatever // it downloads (§11.3). -static std::atomic pushyCrashRescueActive{false}; -static std::atomic pushyRescueAttempted{false}; +static std::atomic pushyCrashHoldActive{false}; +static std::atomic pushyHoldAttempted{false}; // Set when this process's round is the tvOS purged-version restore. static std::atomic pushyPurgeRestoreActive{false}; // True while +bundleURL waits for that restore. Only then may the round @@ -778,7 +786,7 @@ + (BOOL)commitRoundWithGeneration:(uint64_t)generation // (and is left to JS) — never selected behind the packaged bundle's back. static bool pushyPurgeRestoreWindowOpen = false; static dispatch_semaphore_t pushyRoundDone; -static NSString *pushyLaunchRolledBackForRescue = nil; +static NSString *pushyLaunchRolledBackForHold = nil; // A version this process downloaded but left for JS to activate. If the // process then crashes, JS will never activate it — the crash handler // activates it directly (bounded local work, no network). The generation is @@ -796,27 +804,27 @@ + (BOOL)commitRoundWithGeneration:(uint64_t)generation 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 std::atomic pushySyncReady{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; +static const NSTimeInterval kPushyHoldTriggerUptime = 60; +static const NSTimeInterval kPushyHoldBudgetBackgroundThread = 10; // A held main thread freezes UI teardown; stay well under the watchdog. -static const NSTimeInterval kPushyRescueBudgetMainThread = 3.5; +static const NSTimeInterval kPushyHoldBudgetMainThread = 3.5; // The purged-version restore blocks +bundleURL, usually inside // application:didFinishLaunching: — stay clear of the launch watchdog. static const NSTimeInterval kPushyPurgeRestoreBudget = 12; -static void PushyMaybeHoldForRescue(void) { +static void PushyMaybeHoldProcess(void) { // Once per process; a second crashing thread passes straight through // instead of waiting behind the first (§11.3: prefer under-rescuing over // wedging the teardown). bool expected = false; - if (!pushyRescueAttempted.compare_exchange_strong(expected, true)) { + if (!pushyHoldAttempted.compare_exchange_strong(expected, true)) { return; } NSTimeInterval uptime = PushyMonotonicNow() - pushyProcessAnchorUptime; @@ -824,14 +832,14 @@ static void PushyMaybeHoldForRescue(void) { // Early crashes are the brick signature; a crash with an in-flight round // is worth finishing regardless of uptime. Everything else is an ordinary // crash whose UX must not be delayed. - if (uptime >= kPushyRescueTriggerUptime && !roundInFlight) { + if (uptime >= kPushyHoldTriggerUptime && !roundInFlight) { return; } NSTimeInterval budget = [NSThread isMainThread] - ? kPushyRescueBudgetMainThread - : kPushyRescueBudgetBackgroundThread; + ? kPushyHoldBudgetMainThread + : kPushyHoldBudgetBackgroundThread; NSTimeInterval deadline = PushyMonotonicNow() + budget; - NSLog(@"RCTPushy -- crash rescue: holding process for up to %.1fs " + NSLog(@"RCTPushy -- crash hold: holding process for up to %.1fs " @"(uptime %.1fs)", budget, uptime); // The rescue runs on its own queue and the dying thread only waits with a @@ -841,15 +849,15 @@ static void PushyMaybeHoldForRescue(void) { dispatch_semaphore_t done = dispatch_semaphore_create(0); dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ @try { - [RCTPushyOrchestrator runRescueWithDeadline:deadline]; + [RCTPushyOrchestrator runHoldRoundWithDeadline:deadline]; } @catch (NSException *exception) { - NSLog(@"RCTPushy -- crash rescue failed: %@", exception.reason); + NSLog(@"RCTPushy -- crash hold failed: %@", exception.reason); } dispatch_semaphore_signal(done); }); if (dispatch_semaphore_wait(done, dispatch_time(DISPATCH_TIME_NOW, (int64_t)(budget * NSEC_PER_SEC))) != 0) { - NSLog(@"RCTPushy -- crash rescue: budget exhausted, letting go"); + NSLog(@"RCTPushy -- crash hold: budget exhausted, letting go"); } } @@ -858,9 +866,9 @@ static void PushyMaybeHoldForRescue(void) { // process is still alive and JS will never run again — a natural, // false-positive-free window to finish the cold-start check. The previous // handler (crash reporters chain the same way) always runs afterwards. -static void PushyCrashRescueExceptionHandler(NSException *exception) { +static void PushyCrashHoldExceptionHandler(NSException *exception) { @try { - PushyMaybeHoldForRescue(); + PushyMaybeHoldProcess(); } @catch (NSException *inner) { // The dying process owes the previous handler its turn no matter // what the rescue did. @@ -870,11 +878,11 @@ static void PushyCrashRescueExceptionHandler(NSException *exception) { } } -static void PushyInstallCrashRescueHandler(void) { +static void PushyInstallCrashHoldHandler(void) { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ pushyPreviousExceptionHandler = NSGetUncaughtExceptionHandler(); - NSSetUncaughtExceptionHandler(&PushyCrashRescueExceptionHandler); + NSSetUncaughtExceptionHandler(&PushyCrashHoldExceptionHandler); }); } @@ -1137,14 +1145,14 @@ + (void)prepareBundleWithCompletion:(RCTPushyBundlePreparationCompletion)complet 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); + hostQueue = dispatch_queue_create("cn.reactnative.pushy.host-sync", DISPATCH_QUEUE_SERIAL); }); dispatch_async(hostQueue, ^{ NSDictionary *result; @try { result = [RCTPushyOrchestrator prepareBundle]; } @catch (NSException *exception) { - RCTLogWarn(@"RCTPushy -- native host check failed: %@", exception.reason); + RCTLogWarn(@"RCTPushy -- native host sync failed: %@", exception.reason); result = PushyHostResult(@"failed", @"internal_error", nil, NO); } if (completion != nil) { @@ -1286,7 +1294,7 @@ - (instancetype)init PushyRejectError(reject, PushyErrorWithCode(pushy::error_codes::kInvalidOptions, ERROR_OPTIONS)); return; } - [RCTPushyOrchestrator markJsCheckCompleted:config]; + [RCTPushyOrchestrator recordJsRound:config]; resolve(@true); } @@ -1328,21 +1336,21 @@ - (instancetype)init resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { - [self downloadUpdate:PushyTypeFullDownload options:options resolver:resolve rejecter:reject]; + [self fetchPackage:PushyTypeFullDownload options:options resolver:resolve rejecter:reject]; } RCT_EXPORT_METHOD(downloadPatchFromPackage:(NSDictionary *)options resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { - [self downloadUpdate:PushyTypePatchFromPackage options:options resolver:resolve rejecter:reject]; + [self fetchPackage:PushyTypePatchFromPackage options:options resolver:resolve rejecter:reject]; } RCT_EXPORT_METHOD(downloadPatchFromPpk:(NSDictionary *)options resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { - [self downloadUpdate:PushyTypePatchFromPpk options:options resolver:resolve rejecter:reject]; + [self fetchPackage:PushyTypePatchFromPpk options:options resolver:resolve rejecter:reject]; } RCT_EXPORT_METHOD(downloadAndInstallApk:(NSDictionary *)options @@ -1377,7 +1385,7 @@ - (instancetype)init return; } - NSError *reloadError = [self reloadBridgeWithReason:@"pushy reloadUpdate"]; + NSError *reloadError = [self restartBridgeWithReason:@"pushy restart"]; if (reloadError != nil) { PushyRejectError(reject, reloadError); return; @@ -1388,7 +1396,7 @@ - (instancetype)init RCT_EXPORT_METHOD(restartApp:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { - NSError *reloadError = [self reloadBridgeWithReason:@"pushy restartApp"]; + NSError *reloadError = [self restartBridgeWithReason:@"pushy restartApp"]; if (reloadError != nil) { PushyRejectError(reject, reloadError); return; @@ -1499,7 +1507,7 @@ - (instancetype)init // promise settles here, after the cleanup, so a failed wipe reaches // JS as RESET_FAILED (Android parity) instead of a warning nobody // aggregates. - pushy::patch::Status status = pushy::patch::CleanupOldEntries( + pushy::delta::Status status = pushy::delta::CleanupOldEntries( PushyToStdString([RCTPushy downloadDir]), std::vector{PushyToStdString(keepVersion)}, 0 @@ -1533,12 +1541,12 @@ -(void)stopObserving { hasListeners = NO; } -- (void)downloadUpdate:(PushyType)type +- (void)fetchPackage:(PushyType)type options:(NSDictionary *)options resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject { - [self performUpdate:type options:options callback:^(NSError *error) { + [self performFetch:type options:options callback:^(NSError *error) { if (error != nil) { if (error.userInfo[PushyErrorCodeKey] == nil) { // Unclassified (system/network) errors from the download @@ -1560,7 +1568,7 @@ - (void)downloadUpdate:(PushyType)type // here: it consumes one-shot launch state (first_time, the first-load mark, // ignoreRollback), and consuming it for a reload that never happens would // make the next cold start roll the freshly switched version back. -- (NSError *)reloadBridgeWithReason:(NSString *)reason +- (NSError *)restartBridgeWithReason:(NSString *)reason { #if PUSHY_HAS_RELOAD_COMMAND dispatch_async(dispatch_get_main_queue(), ^{ @@ -1575,7 +1583,7 @@ - (NSError *)reloadBridgeWithReason:(NSString *)reason RCTBridge *bridge = self.bridge; if (bridge == nil || object_getClass(bridge) == NSClassFromString(@"RCTBridgeProxy")) { return PushyErrorWithCode(pushy::error_codes::kRestartFailed, - @"no bridge available to reload (bridgeless host without RCTReloadCommand)"); + @"no bridge available to restart (bridgeless host without the RN restart command)"); } dispatch_async(dispatch_get_main_queue(), ^{ [bridge reload]; @@ -1584,9 +1592,9 @@ - (NSError *)reloadBridgeWithReason:(NSString *)reason #endif } -- (void)performUpdate:(PushyType)type options:(NSDictionary *)options callback:(void (^)(NSError *error))callback +- (void)performFetch:(PushyType)type options:(NSDictionary *)options callback:(void (^)(NSError *error))callback { - NSString *updateUrl = PushyOptionString(options, @"updateUrl"); + NSString *updateUrl = PushyOptionString(options, PushyOptionUrlKey); NSString *hash = PushyOptionString(options, @"hash"); if (PushyStringIsBlank(updateUrl) || !PushyIsSafePathComponent(hash)) { @@ -1632,7 +1640,7 @@ - (void)performUpdate:(PushyType)type options:(NSDictionary *)options callback:( } }; void (^deferredStart)(void) = ^{ - [self performUpdate:type options:options callback:callback]; + [self performFetch:type options:options callback:callback]; }; PushyDownloadRegistration registration = PushyRegisterDownload( hash, type, deadlineUptime, callback, progress, deferredStart); @@ -1669,7 +1677,7 @@ - (void)performUpdate:(PushyType)type options:(NSDictionary *)options callback:( // version directory in one atomic step. NSString *bundlePath = [staging stringByAppendingPathComponent:BUNDLE_FILE_NAME]; if (![fileManager fileExistsAtPath:bundlePath]) { - finalError = PushyErrorWithCode(pushy::error_codes::kPatchFailed, + finalError = PushyErrorWithCode(pushy::error_codes::kDeltaFailed, @"bundle missing after install"); } else { std::string bundleSha256 = pushy::digest::Sha256File(PushyToStdString(bundlePath)); @@ -1768,8 +1776,8 @@ - (void)unzipDownloadedPackage:(NSString *)zipFilePath } @catch (NSException *exception) { // An uncaught exception in a GCD block is fatal (and would // trip the crash rescue); a bad package is a PATCH_FAILED. - callback(PushyErrorWithCode(pushy::error_codes::kPatchFailed, - exception.reason ?: @"patch failed")); + callback(PushyErrorWithCode(pushy::error_codes::kDeltaFailed, + exception.reason ?: @"delta failed")); } }); }]; @@ -1782,7 +1790,7 @@ - (void)finishDownloadedPackage:(NSString *)hash { switch (type) { case PushyTypePatchFromPackage: - [self applyPatchForHash:hash + [self applyDeltaForHash:hash type:type fromBundle:[[RCTPushy binaryBundleURL] path] source:[[NSBundle mainBundle] resourcePath] @@ -1790,7 +1798,7 @@ - (void)finishDownloadedPackage:(NSString *)hash return; case PushyTypePatchFromPpk: { NSString *lastVersionDir = [[RCTPushy downloadDir] stringByAppendingPathComponent:originHash]; - [self applyPatchForHash:hash + [self applyDeltaForHash:hash type:type fromBundle:[lastVersionDir stringByAppendingPathComponent:BUNDLE_FILE_NAME] source:lastVersionDir @@ -1803,7 +1811,7 @@ - (void)finishDownloadedPackage:(NSString *)hash } } -- (void)applyPatchForHash:(NSString *)hash +- (void)applyDeltaForHash:(NSString *)hash type:(PushyType)type fromBundle:(NSString *)bundleOrigin source:(NSString *)sourceOrigin @@ -1818,13 +1826,13 @@ - (void)applyPatchForHash:(NSString *)hash NSString *destination = [unzipDir stringByAppendingPathComponent:BUNDLE_FILE_NAME]; long long manifestBytes = RCTPushyFileSize(sourcePatch); if (manifestBytes > pushy::archive_limits::kMaxManifestBytes) { - callback(PushyErrorWithCode(pushy::error_codes::kPatchFailed, - [NSString stringWithFormat:@"patch manifest too large: %lld bytes", manifestBytes])); + callback(PushyErrorWithCode(pushy::error_codes::kDeltaFailed, + [NSString stringWithFormat:@"delta manifest too large: %lld bytes", manifestBytes])); return; } NSData *data = [NSData dataWithContentsOfFile:sourcePatch]; if (data == nil) { - callback(PushyErrorWithCode(pushy::error_codes::kPatchFailed, @"missing patch manifest")); + callback(PushyErrorWithCode(pushy::error_codes::kDeltaFailed, @"missing delta manifest")); return; } @@ -1833,19 +1841,19 @@ - (void)applyPatchForHash:(NSString *)hash if (error != nil) { // Classify as a patch failure like the sibling manifest branches; // unclassified errors would otherwise be tagged DOWNLOAD_FAILED by the - // downloadUpdate fallback even though the download itself succeeded. - callback(PushyErrorWithCode(pushy::error_codes::kPatchFailed, error.localizedDescription)); + // fetchPackage fallback even though the download itself succeeded. + callback(PushyErrorWithCode(pushy::error_codes::kDeltaFailed, error.localizedDescription)); return; } if (![jsonObject isKindOfClass:[NSDictionary class]]) { - callback(PushyErrorWithCode(pushy::error_codes::kPatchFailed, @"invalid patch manifest")); + callback(PushyErrorWithCode(pushy::error_codes::kDeltaFailed, @"invalid delta manifest")); return; } NSDictionary *json = (NSDictionary *)jsonObject; - pushy::patch::PatchManifest manifest; + pushy::delta::PatchManifest manifest; NSString *manifestReason = nil; if (!PushyPatchManifestFromJson(json, &manifest, &manifestReason)) { - callback(PushyErrorWithCode(pushy::error_codes::kPatchFailed, manifestReason)); + callback(PushyErrorWithCode(pushy::error_codes::kDeltaFailed, manifestReason)); return; } @@ -1858,7 +1866,7 @@ - (void)applyPatchForHash:(NSString *)hash } pushy::archive_patch::ArchivePatchPlan plan; - pushy::patch::Status planStatus = pushy::archive_patch::BuildArchivePatchPlan( + pushy::delta::Status planStatus = pushy::archive_patch::BuildArchivePatchPlan( type == PushyTypePatchFromPackage ? pushy::archive_patch::ArchivePatchType::kPatchFromPackage : pushy::archive_patch::ArchivePatchType::kPatchFromPpk, @@ -1871,8 +1879,8 @@ - (void)applyPatchForHash:(NSString *)hash return; } - pushy::patch::FileSourcePatchOptions options; - pushy::patch::Status optionStatus = pushy::archive_patch::BuildFileSourcePatchOptions( + pushy::delta::FileSourcePatchOptions options; + pushy::delta::Status optionStatus = pushy::archive_patch::BuildFileSourcePatchOptions( plan, PushyToStdString(sourceOrigin), PushyToStdString(unzipDir), @@ -1901,7 +1909,7 @@ - (void)applyPatchForHash:(NSString *)hash } } - pushy::patch::Status status = pushy::patch::ApplyPatchFromFileSource(options); + pushy::delta::Status status = pushy::delta::ApplyPatchFromFileSource(options); if (!status.ok) { callback(PushyNSErrorFromStatus(status)); return; @@ -2025,7 +2033,7 @@ - (void)unzipFileAtPath:(NSString *)path long long archiveBytes = RCTPushyFileSize(path); NSError *preflight = nil; if (archiveBytes > pushy::archive_limits::kMaxArchiveBytes) { - preflight = PushyErrorWithCode(pushy::error_codes::kPatchFailed, + preflight = PushyErrorWithCode(pushy::error_codes::kDeltaFailed, [NSString stringWithFormat:@"archive too large: %lld bytes", archiveBytes]); } else { preflight = PushyEnsureFreeSpace(destination, MAX(0LL, archiveBytes) * 2); @@ -2063,15 +2071,15 @@ - (void)unzipFileAtPath:(NSString *)path NSError *unzipError = error; if (guard.violation != nil) { - unzipError = PushyErrorWithCode(pushy::error_codes::kPatchFailed, guard.violation); + unzipError = PushyErrorWithCode(pushy::error_codes::kDeltaFailed, guard.violation); } else if (!succeeded && unzipError == nil) { - unzipError = PushyErrorWithCode(pushy::error_codes::kPatchFailed, @"unzip failed"); + unzipError = PushyErrorWithCode(pushy::error_codes::kDeltaFailed, @"unzip failed"); } else if (unzipError != nil && unzipError.userInfo[PushyErrorCodeKey] == nil) { // SSZipArchive's own NSError (corrupt zip, bad magic, ...) has - // no stable code; without one, downloadUpdate's fallback would + // no stable code; without one, fetchPackage's fallback would // classify it as DOWNLOAD_FAILED even though the download // succeeded — keep the classification deterministic. - unzipError = PushyErrorWithCode(pushy::error_codes::kPatchFailed, + unzipError = PushyErrorWithCode(pushy::error_codes::kDeltaFailed, unzipError.localizedDescription ?: @"unzip failed"); } completionHandler(unzipError); @@ -2095,7 +2103,7 @@ - (void)clearInvalidFiles // process booted from: two switches without a restart would otherwise // evict the running bundle while its on-demand assets are still // being served. - pushy::patch::Status status = pushy::patch::CleanupOldEntries( + pushy::delta::Status status = pushy::delta::CleanupOldEntries( PushyToStdString(downloadDir), std::vector{ state.current_version, @@ -2114,9 +2122,9 @@ - (NSString *)zipExtension:(PushyType)type { switch (type) { case PushyTypePatchFromPackage: - return @".ipa.patch"; + return PushyIpaDeltaSuffix; case PushyTypePatchFromPpk: - return @".ppk.patch"; + return PushyPpkDeltaSuffix; case PushyTypeFullDownload: break; } @@ -2210,10 +2218,10 @@ + (NSString *)buildTime // NSAllowsArbitraryLoads. Same rule as RCTPushyDownloader. Returning nil // delivers the 3xx itself as the final response, which the status check in // PushyHttpRequest then treats as a failed endpoint. -@interface PushyCheckRequestRedirectGuard : NSObject +@interface PushyRequestRedirectGuard : NSObject @end -@implementation PushyCheckRequestRedirectGuard +@implementation PushyRequestRedirectGuard - (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task willPerformHTTPRedirection:(NSHTTPURLResponse *)response @@ -2255,7 +2263,7 @@ - (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task // final response. NSURLSession *session = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration] - delegate:[PushyCheckRequestRedirectGuard new] + delegate:[PushyRequestRedirectGuard new] delegateQueue:nil]; NSURLSessionDataTask *task = [session dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { @@ -2290,13 +2298,13 @@ - (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task return base; } -static BOOL PushyIsValidCheckResponse(NSString *responseText) { +static BOOL PushyIsValidResponse(NSString *responseText) { if (responseText == nil) { return NO; } - // Shared schema rule (update_flow_core::IsValidCheckResponse): a 200 with + // Shared schema rule (update_flow_core::IsValidResponse): a 200 with // `{"error": ...}` is a failed endpoint, not a verdict. - return updateflow::IsValidCheckResponse(PushyToStdString(responseText)) ? YES : NO; + return flowcore::IsValidResponse(PushyToStdString(responseText)) ? YES : NO; } @implementation RCTPushyOrchestrator @@ -2316,10 +2324,10 @@ + (void)persistConfiguration:(NSString *)config { pushyNativeConfigGeneration.fetch_add(1); [defaults setObject:config forKey:keyNativeConfig]; [defaults removeObjectForKey:keyNativeCheckCache]; - [self markJsCheckCompleted:nil]; + [self recordJsRound:nil]; }); - if (pushyNativeCheckReady.load() && [self hasRunnableConfig]) { - PushyInstallCrashRescueHandler(); + if (pushySyncReady.load() && [self hasRunnableConfig]) { + PushyInstallCrashHoldHandler(); } } @@ -2343,12 +2351,12 @@ + (void)prepareProcess:(NSString *)launchRolledBackVersion { pushyHostRoundGroup = dispatch_group_create(); dispatch_group_enter(pushyHostRoundGroup); pushyProcessAnchorUptime = PushyMonotonicNow(); - pushyLaunchRolledBackForRescue = [launchRolledBackVersion copy]; - pushyNativeCheckReady.store(true); + pushyLaunchRolledBackForHold = [launchRolledBackVersion copy]; + pushySyncReady.store(true); // The crash-hold rescue shares the orchestrator's rollout gate: no // persisted config, no handler (§11.3). if ([PushyDefaults() stringForKey:keyNativeConfig].length > 0) { - PushyInstallCrashRescueHandler(); + PushyInstallCrashHoldHandler(); } }); } @@ -2367,10 +2375,10 @@ + (void)scheduleFromColdStart:(NSString *)launchRolledBackVersion { [defaults objectForKey:keyNativeCheckIncomplete] != nil ? 0 : 5; dispatch_after(dispatch_time(DISPATCH_TIME_NOW, delaySeconds * NSEC_PER_SEC), dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{ - if ([self isJsCheckCompleted]) { + if ([self hasJsRound]) { // Not consuming the round: a later crash rescue may still // need it. - NSLog(@"RCTPushy -- native check skipped: JS check completed in this process"); + NSLog(@"RCTPushy -- native sync skipped: JS already queried in this process"); return; } [self startRoundWithDeadline:0]; @@ -2392,7 +2400,7 @@ + (BOOL)restorePurgedLaunch:(NSString *)purgedVersion rolledBack:(NSString *)launchRolledBackVersion { [self prepareProcess:launchRolledBackVersion]; if (![self hasRunnableConfig]) { - RCTLogWarn(@"RCTPushy -- version %@ was purged and no native check is " + RCTLogWarn(@"RCTPushy -- version %@ was purged and no native sync is " @"configured; launching the packaged bundle", purgedVersion); return NO; } @@ -2429,7 +2437,7 @@ + (NSDictionary *)prepareBundle { #if DEBUG return PushyHostResult(@"skipped", @"debug", nil, NO); #else - if (!pushyNativeCheckReady.load()) { + if (!pushySyncReady.load()) { return PushyHostResult(@"skipped", @"not_initialized", nil, NO); } NSString *configJson = [PushyDefaults() stringForKey:keyNativeConfig]; @@ -2468,7 +2476,7 @@ + (NSDictionary *)prepareBundle { #endif } -+ (void)markJsCheckCompleted:(NSString *)config { ++ (void)recordJsRound:(NSString *)config { @synchronized (RCTPushyOrchestrator.class) { pushyJsCompletedConfig = [config copy]; } @@ -2478,7 +2486,7 @@ + (void)markJsCheckCompleted:(NSString *)config { // exact config the native round would use (§10.3): the delayed round is // then a duplicate request. Only the scheduled round asks — the crash-rescue // path still runs, JS is dead by then. -+ (BOOL)isJsCheckCompleted { ++ (BOOL)hasJsRound { NSString *jsConfig; @synchronized (RCTPushyOrchestrator.class) { jsConfig = pushyJsCompletedConfig; @@ -2503,10 +2511,10 @@ + (void)startRoundWithDeadline:(NSTimeInterval)deadlineUptime { return; } @try { - [self runOnce:pushyLaunchRolledBackForRescue deadline:deadlineUptime]; + [self runOnce:pushyLaunchRolledBackForHold deadline:deadlineUptime]; } @catch (NSException *exception) { // The rescue path must never take the app down with it. - RCTLogWarn(@"RCTPushy -- native check crashed: %@", exception.reason); + RCTLogWarn(@"RCTPushy -- native sync crashed: %@", exception.reason); pushyHostRoundResult = PushyHostResult(@"failed", @"internal_error", nil, NO); } @finally { pushyRoundCompleted.store(true); @@ -2520,8 +2528,8 @@ + (void)startRoundWithDeadline:(NSTimeInterval)deadlineUptime { // process's round runs to completion within the budget, then activates a // downloaded-but-unactivated version if one exists — the last chance before // the process is gone. -+ (void)runRescueWithDeadline:(NSTimeInterval)deadlineUptime { - pushyCrashRescueActive.store(true); ++ (void)runHoldRoundWithDeadline:(NSTimeInterval)deadlineUptime { + pushyCrashHoldActive.store(true); [self startRoundWithDeadline:deadlineUptime]; if (pushyRoundStarted.load() && !pushyRoundCompleted.load()) { NSTimeInterval remaining = deadlineUptime - PushyMonotonicNow(); @@ -2552,7 +2560,7 @@ + (void)activatePendingVersion { // catch below anyway, or by cleanup). NSString *versionDir = [[RCTPushy downloadDir] stringByAppendingPathComponent:hash]; if (!PushyHasCompletedVersionAtPath(versionDir, hash)) { - NSLog(@"RCTPushy -- crash rescue: version %@ no longer on disk, dropping activation", hash); + NSLog(@"RCTPushy -- crash hold: version %@ no longer on disk, dropping activation", hash); return; } NSDictionary *hashInfoEntry = nil; @@ -2562,7 +2570,7 @@ + (void)activatePendingVersion { [NSJSONSerialization JSONObjectWithData:existingData options:0 error:nil]; if ([parsed isKindOfClass:[NSDictionary class]]) { NSMutableDictionary *merged = [parsed mutableCopy]; - merged[@"crashRescue"] = @YES; + merged[PushyInfoCrashHold] = @YES; hashInfoEntry = @{@"hash": hash, @"info": merged}; } BOOL committed = [self commitRoundWithGeneration:generation @@ -2577,9 +2585,9 @@ + (void)activatePendingVersion { @synchronized (self) { pushyUnactivatedHash = nil; } - NSLog(@"RCTPushy -- crash rescue: activated downloaded version %@", hash); + NSLog(@"RCTPushy -- crash hold: activated downloaded version %@", hash); } else { - NSLog(@"RCTPushy -- crash rescue: reset since download, dropping activation"); + NSLog(@"RCTPushy -- crash hold: reset since download, dropping activation"); } } @@ -2691,28 +2699,28 @@ + (void)runConfiguredRound:(const flowjson::Value &)config // shared with JS getBundleHash) on the ordinary delayed round. input.Set("bundleHash", flowjson::Value::String(PushyToStdString( - PushyBundleHashSync(!pushyCrashRescueActive.load())))); + PushyBundleHashSync(!pushyCrashHoldActive.load())))); std::string bodyJson = - flowjson::Stringify(updateflow::BuildCheckRequestBody(input)); + flowjson::Stringify(flowcore::BuildRequestBody(input)); NSString *body = [NSString stringWithUTF8String:bodyJson.c_str()]; if (body == nil) { - RCTLogWarn(@"RCTPushy -- native check: request body is not valid UTF-8"); + RCTLogWarn(@"RCTPushy -- native sync: request body is not valid UTF-8"); pushyHostRoundResult = PushyHostResult(@"failed", @"invalid_request", nil, NO); return; } - NSString *responseText = [self runCheckRequest:config + NSString *responseText = [self runQueryRequest:config appKey:appKey body:body deadline:deadlineUptime]; if (responseText == nil) { - RCTLogInfo(@"RCTPushy -- native check: no endpoint reachable, giving up until next launch"); + RCTLogInfo(@"RCTPushy -- native sync: no endpoint reachable, giving up until next launch"); return; } long long responseAtSeconds = (long long)[[NSDate date] timeIntervalSince1970]; - flowjson::Value decision = updateflow::HandleCheckResponse( + flowjson::Value decision = flowcore::HandleResponse( PushyToStdString(responseText), identity, false, config.Get("afterDownload").AsString()); if (decision.Get("action").AsString() != "download") { @@ -2725,9 +2733,9 @@ + (void)runConfiguredRound:(const flowjson::Value &)config responseAt:responseAtSeconds activated:NULL]; pushyHostRoundResult = committed - ? PushyHostResult(@"noUpdate", PushyFromStdString(decision.Get("reason").AsString()), nil, NO) + ? PushyHostResult(PushyStatusNone, PushyFromStdString(decision.Get("reason").AsString()), nil, NO) : PushyHostResult(@"cancelled", @"reset", nil, NO); - RCTLogInfo(@"RCTPushy -- native check: nothing to do (%s)", + RCTLogInfo(@"RCTPushy -- native sync: nothing to do (%s)", decision.Get("reason").AsString().c_str()); return; } @@ -2774,10 +2782,10 @@ + (void)runConfiguredRound:(const flowjson::Value &)config // survives to markSuccess. Only the server-sent directive counts — a // silent-strategy activation is ordinary delivery. if (info.Get("config").Get("forceBoot").Truthy()) { - versionInfo[@"forceBootRescue"] = @YES; + versionInfo[PushyInfoForceBoot] = @YES; } - if (pushyCrashRescueActive.load()) { - versionInfo[@"crashRescue"] = @YES; + if (pushyCrashHoldActive.load()) { + versionInfo[PushyInfoCrashHold] = @YES; } // Silent strategies or a server-marked forceBoot version (per-version // remote override — the brick rescue) activate for the next launch; @@ -2786,7 +2794,7 @@ + (void)runConfiguredRound:(const flowjson::Value &)config // forever (§11.3). Or the launch is blocked on reinstalling a version // tvOS purged: JS has not started, and the whole point is to boot it — // commitRoundWithGeneration drops that one once the restore window closed. - BOOL activate = decision.Get("activate").Truthy() || pushyCrashRescueActive.load() + BOOL activate = decision.Get("activate").Truthy() || pushyCrashHoldActive.load() || pushyPurgeRestoreActive.load(); BOOL activated = NO; BOOL committed = [self commitRoundWithGeneration:resetGeneration @@ -2799,12 +2807,12 @@ + (void)runConfiguredRound:(const flowjson::Value &)config activated:&activated]; activate = activated; if (!committed) { - RCTLogInfo(@"RCTPushy -- native check: reset during round, dropping result"); + RCTLogInfo(@"RCTPushy -- native sync: reset during round, dropping result"); } else if (activate) { @synchronized (self) { pushyUnactivatedHash = nil; } - RCTLogInfo(@"RCTPushy -- native check: downloaded %@ and set for next launch", hash); + RCTLogInfo(@"RCTPushy -- native sync: downloaded %@ and set for next launch", hash); } else { // Remembered so a crash later in this process can still activate it // (activatePendingVersion) — JS never will. @@ -2812,7 +2820,7 @@ + (void)runConfiguredRound:(const flowjson::Value &)config pushyUnactivatedHash = [hash copy]; pushyUnactivatedGeneration = resetGeneration; } - RCTLogInfo(@"RCTPushy -- native check: downloaded %@, activation left to JS", hash); + RCTLogInfo(@"RCTPushy -- native sync: downloaded %@, activation left to JS", hash); } pushyHostRoundResult = committed ? PushyHostResult(@"downloaded", @"", hash, activate) @@ -2863,7 +2871,7 @@ + (BOOL)commitRoundWithGeneration:(uint64_t)generation marked[@"purgeRestore"] = @YES; info = marked; } - } else if (!pushyCrashRescueActive.load()) { + } else if (!pushyCrashHoldActive.load()) { activation = nil; } } @@ -2899,20 +2907,20 @@ + (BOOL)commitRoundWithGeneration:(uint64_t)generation // more round. No hedged race on purpose — this path is latency-insensitive. // Per-request timeout, capped to the crash-rescue budget when one is active. // <= 0 means the budget is gone and the round must stop issuing requests. -static NSTimeInterval PushyCheckRequestTimeout(NSTimeInterval deadlineUptime) { +static NSTimeInterval PushyQueryRequestTimeout(NSTimeInterval deadlineUptime) { if (deadlineUptime <= 0) { return 10; } return MIN(10, deadlineUptime - PushyMonotonicNow()); } -+ (NSString *)runCheckRequest:(const flowjson::Value &)config ++ (NSString *)runQueryRequest:(const flowjson::Value &)config appKey:(NSString *)appKey body:(NSString *)body deadline:(NSTimeInterval)deadlineUptime { double sample = arc4random() / 4294967296.0; flowjson::Value ordered = - updateflow::OrderEndpointCandidates(config.Get("endpoints"), sample); + flowcore::OrderEndpointCandidates(config.Get("endpoints"), sample); NSMutableSet *tried = [NSMutableSet set]; const NSUInteger maxHttpAttempts = 8; NSUInteger httpAttempts = 0; @@ -2926,14 +2934,14 @@ + (NSString *)runCheckRequest:(const flowjson::Value &)config return nil; } [tried addObject:base]; - NSTimeInterval timeout = PushyCheckRequestTimeout(deadlineUptime); + NSTimeInterval timeout = PushyQueryRequestTimeout(deadlineUptime); if (timeout <= 0) { return nil; } NSString *response = PushyHttpRequest( - [NSString stringWithFormat:@"%@/checkUpdate/%@", base, appKey], + [NSString stringWithFormat:@"%@%@%@", base, RCTPushyQueryPath(), appKey], @"POST", body, timeout); - if (PushyIsValidCheckResponse(response)) { + if (PushyIsValidResponse(response)) { return response; } } @@ -2945,7 +2953,7 @@ + (NSString *)runCheckRequest:(const flowjson::Value &)config if (httpAttempts++ >= maxHttpAttempts) { return nil; } - NSTimeInterval listTimeout = PushyCheckRequestTimeout(deadlineUptime); + NSTimeInterval listTimeout = PushyQueryRequestTimeout(deadlineUptime); if (listTimeout <= 0) { return nil; } @@ -2968,14 +2976,14 @@ + (NSString *)runCheckRequest:(const flowjson::Value &)config return nil; } [tried addObject:base]; - NSTimeInterval timeout = PushyCheckRequestTimeout(deadlineUptime); + NSTimeInterval timeout = PushyQueryRequestTimeout(deadlineUptime); if (timeout <= 0) { return nil; } NSString *response = PushyHttpRequest( - [NSString stringWithFormat:@"%@/checkUpdate/%@", base, appKey], + [NSString stringWithFormat:@"%@%@%@", base, RCTPushyQueryPath(), appKey], @"POST", body, timeout); - if (PushyIsValidCheckResponse(response)) { + if (PushyIsValidResponse(response)) { return response; } } @@ -2987,12 +2995,12 @@ + (NSString *)runCheckRequest:(const flowjson::Value &)config + (BOOL)performAttempts:(const flowjson::Value &)attempts hash:(NSString *)hash originHash:(NSString *)originHash - deadline:(NSTimeInterval)rescueDeadline { + deadline:(NSTimeInterval)holdDeadline { RCTPushy *engine = [self engine]; // Crash-rescue budget caps every phase; 0 keeps the normal 10min windows. NSTimeInterval incrementalDeadline = PushyMonotonicNow() + 600; - if (rescueDeadline > 0) { - incrementalDeadline = MIN(incrementalDeadline, rescueDeadline); + if (holdDeadline > 0) { + incrementalDeadline = MIN(incrementalDeadline, holdDeadline); } NSTimeInterval fullDeadline = 0; for (const auto &attempt : attempts.elements()) { @@ -3007,8 +3015,8 @@ + (BOOL)performAttempts:(const flowjson::Value &)attempts if (isFullAttempt && fullDeadline == 0) { // diff/pdiff cannot starve the last-resort full download. fullDeadline = PushyMonotonicNow() + 600; - if (rescueDeadline > 0) { - fullDeadline = MIN(fullDeadline, rescueDeadline); + if (holdDeadline > 0) { + fullDeadline = MIN(fullDeadline, holdDeadline); } } NSTimeInterval deadline = isFullAttempt ? fullDeadline : incrementalDeadline; @@ -3026,7 +3034,7 @@ + (BOOL)performAttempts:(const flowjson::Value &)attempts } NSMutableDictionary *options = [@{ - @"updateUrl": url, + PushyOptionUrlKey: url, @"hash": hash, @"deadlineUptime": @(deadline), } mutableCopy]; @@ -3035,13 +3043,13 @@ + (BOOL)performAttempts:(const flowjson::Value &)attempts } dispatch_semaphore_t sem = dispatch_semaphore_create(0); __block NSError *resultError = nil; - [engine performUpdate:pushyType options:options callback:^(NSError *error) { + [engine performFetch:pushyType options:options callback:^(NSError *error) { resultError = error; dispatch_semaphore_signal(sem); }]; if (dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, (int64_t)(remaining * NSEC_PER_SEC))) != 0) { - RCTLogWarn(@"RCTPushy -- native check: %s attempt timed out", type.c_str()); + RCTLogWarn(@"RCTPushy -- native sync: %s attempt timed out", type.c_str()); if (isFullAttempt) { return NO; } @@ -3050,7 +3058,7 @@ + (BOOL)performAttempts:(const flowjson::Value &)attempts if (resultError == nil) { return YES; } - RCTLogInfo(@"RCTPushy -- native check: %s attempt failed: %@", + RCTLogInfo(@"RCTPushy -- native sync: %s attempt failed: %@", type.c_str(), resultError.localizedDescription); } } diff --git a/ios/RCTPushy/RCTPushyConfiguration.h b/ios/RCTPushy/RCTPushyConfiguration.h index 8231b68c..c7d00150 100644 --- a/ios/RCTPushy/RCTPushyConfiguration.h +++ b/ios/RCTPushy/RCTPushyConfiguration.h @@ -3,3 +3,11 @@ // Validates and snapshots host options without changing any update state. FOUNDATION_EXPORT NSString * _Nullable RCTPushyNormalizeConfiguration( NSDictionary * _Nonnull options, NSError * _Nullable * _Nullable error); + +// Decodes text produced by scripts/encode-native-text.ts (byte i XORed with +// (0x5A + 0x1D * i) & 0xFF), keeping service addresses and paths out of static +// string scans of the binary. Not a secret. +FOUNDATION_EXPORT NSString * _Nonnull RCTPushyRevealText(const char * _Nonnull hex); + +// Request path appended to an endpoint base. +FOUNDATION_EXPORT NSString * _Nonnull RCTPushyQueryPath(void); diff --git a/ios/RCTPushy/RCTPushyConfiguration.mm b/ios/RCTPushy/RCTPushyConfiguration.mm index 2133b2cc..2714837d 100644 --- a/ios/RCTPushy/RCTPushyConfiguration.mm +++ b/ios/RCTPushy/RCTPushyConfiguration.mm @@ -1,5 +1,30 @@ #import "RCTPushyConfiguration.h" +// Read through a volatile so the optimizer cannot fold the decode of a constant +// argument back into a plain string in the binary. +static volatile unsigned char PushyTextKeyBase = 0x5A; + +NSString *RCTPushyRevealText(const char *hex) { + size_t length = strlen(hex) / 2; + NSMutableString *text = [NSMutableString stringWithCapacity:length]; + unsigned char base = PushyTextKeyBase; + for (size_t i = 0; i < length; i++) { + char pair[3] = {hex[i * 2], hex[i * 2 + 1], 0}; + unsigned char byte = (unsigned char)strtoul(pair, NULL, 16); + [text appendFormat:@"%c", (char)(byte ^ (unsigned char)(base + 0x1D * i))]; + } + return text; +} + +NSString *RCTPushyQueryPath(void) { + static NSString *path; + static dispatch_once_t once; + dispatch_once(&once, ^{ + path = RCTPushyRevealText("7514fcd4ad805d55263e08fc99"); + }); + return path; +} + static void PushyConfigInvalid(NSString *message) { @throw [NSException exceptionWithName:NSInvalidArgumentException reason:[@"Invalid native configuration: " stringByAppendingString:message] @@ -63,16 +88,18 @@ static void PushyConfigInvalid(NSString *message) { 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"], + : @[RCTPushyRevealText("3203e0c1bdd1270a372f18f8c2b6de7f4f2607f5b3d5b9817b592947e5cdefbc8a7e"), + RCTPushyRevealText("3203e0c1bdd1270a372f18f8c2b6de7f4f2607f5f0daac9c644a620ae88ca1ad93")], @"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"]), + : @[RCTPushyRevealText("3203e0c1bdd1270a253608fcd3fd9362476817f4f0d5a1996342631be3c2a3a9d779552507fdcde8928a6f512f5ce2ccbdc869404d2f1de79daa826d562c0913eec4fa9b7d4426"), + RCTPushyRevealText("3203e0c1bdd1270a213b12b7dca09468462e12f3b0d5bd813d482446f4c6a1be8e79552507fdcda68cd06e5c3710e480a4867048483e55e0c2ab8d7d43030d1ce9c3b183214e2601f2f0d5b782601e2719e8ca")]), @"queryUrls", NO); NSString *afterDownload = options[@"afterDownload"] ? PushyConfigString(options[@"afterDownload"], @"afterDownload", NO) : @"none"; - if (![@[@"none", @"setNeedUpdate"] containsObject:afterDownload]) { - PushyConfigInvalid(@"afterDownload must be none or setNeedUpdate"); + NSString *nextLaunch = RCTPushyRevealText("2912e0ffab8e6c70323b1dedd3"); + if (![@[@"none", nextLaunch] containsObject:afterDownload]) { + PushyConfigInvalid([@"afterDownload must be none or " stringByAppendingString:nextLaunch]); } id disabled = options[@"disabled"] ?: @NO; if (![disabled isKindOfClass:NSNumber.class] || CFGetTypeID((__bridge CFTypeRef)disabled) != CFBooleanGetTypeID()) { diff --git a/react-native-update.podspec b/react-native-update.podspec index 98643ce1..3d27a6a1 100644 --- a/react-native-update.podspec +++ b/react-native-update.podspec @@ -166,9 +166,12 @@ Pod::Spec.new do |s| s.dependency 'ExpoModulesCore' end - s.subspec 'RCTPushy' do |ss| - ss.source_files = ['ios/RCTPushy/*.{h,m,mm}', - 'cpp/update_flow_core/flow_json.{h,cpp}', + # Shared C/C++ core. Built with hidden visibility so none of its symbols are + # exported when the pod is linked as a dynamic framework. Per-file flags: the + # Objective-C classes in the RCTPushy subspec keep default visibility, which + # the host app and the Expo module need to link against RCTPushy. + s.subspec 'Core' do |ss| + ss.source_files = ['cpp/update_flow_core/flow_json.{h,cpp}', 'cpp/update_flow_core/update_flow_core.{h,cpp}', 'cpp/patch_core/archive_patch_core.{h,cpp}', 'cpp/patch_core/digest.{h,cpp}', @@ -181,6 +184,15 @@ Pod::Spec.new do |s| 'android/jni/HDiffPatch/file_for_patch.{h,c}', 'android/jni/lzma/C/LzmaDec.{h,c}', 'android/jni/lzma/C/Lzma2Dec.{h,c}'] + ss.private_header_files = ['cpp/**/*.h', 'android/jni/**/*.h'] + # NDEBUG matches the prebuilt Android library and keeps third-party assertion + # text (file and expression strings) out of the binary. + ss.compiler_flags = '-DNDEBUG -fvisibility=hidden -fvisibility-inlines-hidden' + end + + s.subspec 'RCTPushy' do |ss| + ss.dependency 'react-native-update/Core' + ss.source_files = ['ios/RCTPushy/*.{h,m,mm}'] ss.public_header_files = ['ios/RCTPushy/*.h'] end diff --git a/scripts/build-android-so.sh b/scripts/build-android-so.sh index 75ccd1ab..c9747437 100644 --- a/scripts/build-android-so.sh +++ b/scripts/build-android-so.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # -# Builds android/lib//librnupdate.so for all ABIs and verifies the +# Builds android/lib//librnpushy.so for all ABIs and verifies the # exported JNI symbols. # # NDK resolution order (CP-8): $ANDROID_NDK_HOME > $ANDROID_NDK_ROOT > diff --git a/scripts/check-harmony-types.js b/scripts/check-harmony-types.js index 365e9952..04264061 100644 --- a/scripts/check-harmony-types.js +++ b/scripts/check-harmony-types.js @@ -98,7 +98,7 @@ const config = { strict: true, forceConsistentCasingInFileNames: true, paths: { - // @rnoh and librnupdate.so are stubbed in types/ (see comments there); + // @rnoh and librnpushy.so are stubbed in types/ (see comments there); // @ohos/@kit resolve to the real SDK declarations. '@ohos.*': [path.join(sdkEts, 'api', '@ohos.*')], '@kit.*': [path.join(sdkEts, 'kits', '@kit.*')], diff --git a/scripts/check-packlist.js b/scripts/check-packlist.js index 4b06cada..d78924d5 100644 --- a/scripts/check-packlist.js +++ b/scripts/check-packlist.js @@ -89,8 +89,8 @@ for (const file of required) { } } for (const abi of requiredAbis) { - if (!set.has(`android/lib/${abi}/librnupdate.so`)) { - problems.push(`missing: android/lib/${abi}/librnupdate.so`); + if (!set.has(`android/lib/${abi}/librnpushy.so`)) { + problems.push(`missing: android/lib/${abi}/librnpushy.so`); } } diff --git a/scripts/encode-native-text.ts b/scripts/encode-native-text.ts new file mode 100644 index 00000000..d630fa84 --- /dev/null +++ b/scripts/encode-native-text.ts @@ -0,0 +1,38 @@ +#!/usr/bin/env bun +// Encodes ASCII text for the native "reveal" helpers so service addresses and +// request paths are not stored as plain strings in shipped binaries: +// Android HttpUtils.reveal(hex) +// iOS RCTPushyRevealText(hex) +// Harmony revealText(hex) +// Byte i is XORed with (0x5A + 0x1D * i) & 0xFF and written as two hex digits. +// This only keeps the text out of static string scans; it is not a secret. +// +// Usage: bun scripts/encode-native-text.ts "https://example.com/api" ... + +export const encodeNativeText = (text: string): string => { + let hex = ''; + for (let i = 0; i < text.length; i++) { + const code = text.charCodeAt(i); + if (code > 0x7f) { + throw new Error(`only ASCII text is supported: ${JSON.stringify(text)}`); + } + const byte = code ^ ((0x5a + 0x1d * i) & 0xff); + hex += byte.toString(16).padStart(2, '0'); + } + return hex; +}; + +export const decodeNativeText = (hex: string): string => { + let text = ''; + for (let i = 0; i < hex.length / 2; i++) { + const byte = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16); + text += String.fromCharCode(byte ^ ((0x5a + 0x1d * i) & 0xff)); + } + return text; +}; + +if (import.meta.main) { + for (const text of process.argv.slice(2)) { + console.log(`${encodeNativeText(text)} // ${text}`); + } +} diff --git a/scripts/test-ios-purge-restore.sh b/scripts/test-ios-purge-restore.sh index 87c036a4..1ced56d9 100755 --- a/scripts/test-ios-purge-restore.sh +++ b/scripts/test-ios-purge-restore.sh @@ -35,6 +35,7 @@ build() { xcrun clang++ -std=c++17 -fobjc-arc -fblocks $WARNING_FLAGS $SANITIZE_FLAGS \ -I"$ROOT_DIR" -I"$destination" \ "$TEST_DIR/purge_restore_test.mm" "$ROOT_DIR/cpp/patch_core/state_core.cpp" \ + "$ROOT_DIR/ios/RCTPushy/RCTPushyConfiguration.mm" \ -framework Foundation -o "$destination/purge_restore_test" } diff --git a/scripts/tests/ios-purge-restore/extract.py b/scripts/tests/ios-purge-restore/extract.py index 0d42ea8e..b94a5c5e 100644 --- a/scripts/tests/ios-purge-restore/extract.py +++ b/scripts/tests/ios-purge-restore/extract.py @@ -75,7 +75,7 @@ def declaration(source: str, name: str) -> tuple[int, str]: 'keyHashInfo', 'keyFirstLoadMarked', 'keyRolledBackMarked', 'KeyPackageUpdatedMarked', 'keyNativeCheckCache', 'BUNDLE_FILE_NAME', 'pushyStateLock', 'ignoreRollback', 'pushyIsUsingBundleUrl', - 'pushyResetGeneration', 'pushyLaunchVersion', 'pushyCrashRescueActive', + 'pushyResetGeneration', 'pushyLaunchVersion', 'pushyCrashHoldActive', 'pushyPurgeRestoreActive', 'pushyPurgeRestoreWindowOpen', 'pushyHostRoundResult', 'kPushyPurgeRestoreBudget', ] diff --git a/scripts/tests/ios-purge-restore/purge_restore_test.mm b/scripts/tests/ios-purge-restore/purge_restore_test.mm index 01bdd804..184cae9a 100644 --- a/scripts/tests/ios-purge-restore/purge_restore_test.mm +++ b/scripts/tests/ios-purge-restore/purge_restore_test.mm @@ -12,6 +12,7 @@ #include "cpp/patch_core/state_core.h" #include "cpp/patch_core/patch_core.h" #include "cpp/patch_core/error_codes.h" +#include "ios/RCTPushy/RCTPushyConfiguration.h" #undef TARGET_OS_TV #define TARGET_OS_TV 1 @@ -143,7 +144,7 @@ static void PushyRejectError(RCTPromiseRejectBlock reject, NSError *error) { // Filesystem cleanup is not under test. Keep the real declaration/return type; // the real reset body (including its asynchronous completion) still executes. -namespace pushy { namespace patch { +namespace pushy { namespace delta { Status CleanupOldEntries(const std::string&, const std::vector&, int max_age_days, std::time_t) { Expect(max_age_days == 0, "reset requests a full cleanup"); @@ -337,7 +338,7 @@ static void SetUp(Order selected) { ignoreRollback.store(false); pushyIsUsingBundleUrl.store(false); pushyLaunchVersion = nil; - pushyCrashRescueActive.store(false); + pushyCrashHoldActive.store(false); pushyPurgeRestoreActive.store(false); pushyPurgeRestoreWindowOpen = false; pushyHostRoundResult = nil; diff --git a/scripts/verify-android-so.js b/scripts/verify-android-so.js index 0d2cbf85..f3b7cc33 100644 --- a/scripts/verify-android-so.js +++ b/scripts/verify-android-so.js @@ -2,7 +2,7 @@ /** * Verify the prebuilt Android native libraries shipped in android/lib/ are * present for every ABI and export the JNI symbols the Java layer binds. This - * guards against publishing an npm package whose committed librnupdate.so is + * guards against publishing an npm package whose committed librnpushy.so is * stale/missing after a cpp/patch_core change (which would crash consumers at * runtime with UnsatisfiedLinkError while CI stays green). * @@ -27,21 +27,10 @@ const ABI_MACHINE = { x86_64: 0x3e, // EM_X86_64 }; -// JNI entry points the Java `native` declarations bind to. Keep in sync with -// the native methods in android/src/main/java/cn/reactnative/modules/update/. -const REQUIRED_SYMBOLS = [ - 'Java_cn_reactnative_modules_update_DownloadTask_applyPatchFromFileSource', - 'Java_cn_reactnative_modules_update_DownloadTask_cleanupOldEntries', - 'Java_cn_reactnative_modules_update_DownloadTask_buildArchivePatchPlan', - 'Java_cn_reactnative_modules_update_DownloadTask_buildCopyGroups', - 'Java_cn_reactnative_modules_update_UpdateContext_syncStateWithBinaryVersion', - 'Java_cn_reactnative_modules_update_UpdateContext_runStateCore', - 'Java_cn_reactnative_modules_update_NativeUpdateCore_getSupportedDiffVersion', - 'Java_cn_reactnative_modules_update_NativeUpdateFlow_buildCheckRequestBody', - 'Java_cn_reactnative_modules_update_NativeUpdateFlow_orderEndpointCandidates', - 'Java_cn_reactnative_modules_update_NativeUpdateFlow_handleCheckResponse', - 'Java_cn_reactnative_modules_update_NativeUpdateFlow_isValidCheckResponse', -]; +// Native methods are registered from JNI_OnLoad (cpp/patch_core/ +// jni_registration.cpp), so JNI_OnLoad must be the library's only JNI export. +// A leftover Java_* export means a stale .so built before that change. +const REQUIRED_SYMBOLS = ['JNI_OnLoad']; const SHT_DYNSYM = 11; const SHN_UNDEF = 0; @@ -158,7 +147,7 @@ function readDynamicSymbols(buffer, expectedMachine) { let failed = false; for (const abi of ABIS) { - const soPath = path.join(LIB_DIR, abi, 'librnupdate.so'); + const soPath = path.join(LIB_DIR, abi, 'librnpushy.so'); let stat; try { stat = fs.statSync(soPath); @@ -205,6 +194,16 @@ for (const abi of ABIS) { } } + const staticJni = [...symbols].filter((symbol) => symbol.startsWith('Java_')); + if (staticJni.length) { + for (const symbol of staticJni) { + console.error( + `error: ${soPath} still exports ${symbol}; natives must be registered in JNI_OnLoad (stale .so? rebuild with 'npm run build:so')` + ); + } + failed = true; + } + const missing = REQUIRED_SYMBOLS.filter((symbol) => !symbols.has(symbol)); if (missing.length) { for (const symbol of missing) { @@ -214,7 +213,7 @@ for (const abi of ABIS) { } failed = true; } else { - console.log(`ok: ${abi} librnupdate.so exports all required symbols`); + console.log(`ok: ${abi} librnpushy.so exports all required symbols`); } } diff --git a/src/__tests__/jniRegistration.test.ts b/src/__tests__/jniRegistration.test.ts new file mode 100644 index 00000000..dd81fb82 --- /dev/null +++ b/src/__tests__/jniRegistration.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from 'bun:test'; +import { readdirSync, readFileSync } from 'node:fs'; +import { decodeNativeText } from '../../scripts/encode-native-text'; + +// JNI_OnLoad binds Android native methods from an encoded name/signature table +// (cpp/patch_core/jni_registration.cpp). A drifted entry would silently leave +// a method unbound until its first call, so the table must match every Java +// `native` declaration exactly. +const root = new URL('../../', import.meta.url); +const javaDir = 'android/src/main/java/cn/reactnative/modules/update/'; +const javaPackage = 'cn/reactnative/modules/update/'; + +const descriptor = (type: string): string => { + const array = type.endsWith('[]'); + const base = array ? type.slice(0, -2) : type; + const primitives: Record = { + boolean: 'Z', + int: 'I', + long: 'J', + double: 'D', + float: 'F', + void: 'V', + }; + const element = + primitives[base] ?? + (base === 'String' ? 'Ljava/lang/String;' : `L${javaPackage}${base};`); + return array ? `[${element}` : element; +}; + +const javaNatives = () => + readdirSync(new URL(javaDir, root)) + .filter((file) => file.endsWith('.java')) + .flatMap((file) => { + const source = readFileSync(new URL(javaDir + file, root), 'utf8') + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/\/\/.*$/gm, ''); + return [ + ...source.matchAll(/\bnative\s+([\w[\]]+)\s+(\w+)\s*\(([^)]*)\)\s*;/g), + ].map((match) => { + const params = match[3] + .split(',') + .map((param) => param.trim()) + .filter(Boolean) + .map((param) => descriptor(param.split(/\s+/)[0])); + return `${javaPackage}${file.replace('.java', '')}.${match[2]}${`(${params.join('')})`}${descriptor(match[1])}`; + }); + }) + .sort(); + +const registered = () => { + const source = readFileSync( + new URL('cpp/patch_core/jni_registration.cpp', root), + 'utf8' + ); + const entries: string[] = []; + let currentClass = ''; + for (const match of source.matchAll( + /\{"([0-9a-f]+)",\s*(?:\/\/[^\n]*)?\s*\{|\{"([0-9a-f]+)",\s*"([0-9a-f]+)",/g + )) { + if (match[1]) { + currentClass = decodeNativeText(match[1]); + } else { + entries.push( + `${currentClass}.${decodeNativeText(match[2])}${decodeNativeText(match[3])}` + ); + } + } + return entries.sort(); +}; + +describe('JNI registration table', () => { + test('matches every Java native declaration', () => { + const natives = javaNatives(); + expect(natives.length).toBe(11); + expect(registered()).toEqual(natives); + }); + + test('stores no plain class or method names', () => { + const source = readFileSync( + new URL('cpp/patch_core/jni_registration.cpp', root), + 'utf8' + ).replace(/\/\/.*$/gm, ''); + expect(source).not.toContain('cn/reactnative'); + expect(source).not.toMatch(/"[A-Za-z]{4,}"/); + }); +}); diff --git a/src/__tests__/nativeConfiguration.test.ts b/src/__tests__/nativeConfiguration.test.ts index 859b809c..4da9cded 100644 --- a/src/__tests__/nativeConfiguration.test.ts +++ b/src/__tests__/nativeConfiguration.test.ts @@ -20,7 +20,7 @@ const clientSource = runtimeSource('../client.ts'); interface ConfigStore { setNativeConfig: (config: string) => Promise; getResetGeneration: () => number; - commitNativeCheckResult: ( + commitSyncResult: ( generation: number, hash: string, info: string, @@ -50,7 +50,7 @@ function storeHarness() { util: { generateRandomUUID: () => 'native-installation-id' }, KEY_CONFIG: 'nativeConfig', KEY_RESP_CACHE: 'nativeCheckResp', - markJsCheckCompleted: (config: string) => clearedSignals.push(config), + recordJsRound: (config: string) => clearedSignals.push(config), logger: { error() {} }, getErrorMessage: (error: unknown) => String(error), } @@ -181,13 +181,7 @@ describe('actual native configuration store', () => { await h.store.setNativeConfig('A'); expect(h.store.getResetGeneration()).toBe(oldGeneration + 2); expect( - await h.store.commitNativeCheckResult( - oldGeneration, - 'old', - '{}', - true, - 'stale' - ) + await h.store.commitSyncResult(oldGeneration, 'old', '{}', true, 'stale') ).toBe(false); expect(h.values.has('hash_old')).toBe(false); expect(h.values.get('uuid')).toBe('native-installation-id'); diff --git a/src/__tests__/nativeHostApi.test.ts b/src/__tests__/nativeHostApi.test.ts index 12230f17..2b9eb771 100644 --- a/src/__tests__/nativeHostApi.test.ts +++ b/src/__tests__/nativeHostApi.test.ts @@ -6,13 +6,14 @@ import { BundlePreparationRound, bundlePreparationResult, } from '../../harmony/pushy/src/main/ets/BundlePreparationResult'; +import { STATUS_NONE } from '../../harmony/pushy/src/main/ets/Texts'; // 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', + '../../harmony/pushy/src/main/ets/SyncCoordinator.ts', import.meta.url ), 'utf8' @@ -62,13 +63,13 @@ function harness() { getBundleUrl: () => { throw new Error('host checks must not resolve the launch bundle again'); }, - commitNativeCheckResult: async (generation: number) => { + commitSyncResult: async (generation: number) => { state.commits += 1; return generation === state.generation && state.commitOK; }, }; const runtime = runInNewContext( - `${javascript}\nrunCheckRequest = mockCheck;\nperformAttempts = mockDownload;\n({ check: prepareBundleNative, schedule: scheduleNativeCheck });`, + `${javascript}\nrunQueryRequest = mockCheck;\nperformAttempts = mockDownload;\n({ check: prepareBundleNative, schedule: scheduleNativeSync });`, { BundlePreparationRound, bundlePreparationResult, @@ -77,10 +78,11 @@ function harness() { setTimeout: (callback: () => void) => timers.push(callback), isSafePathComponent: (hash: string) => /^[a-zA-Z0-9_-]+$/.test(hash), getErrorMessage: (error: unknown) => String(error), + STATUS_NONE, NativePatchCore: { getSupportedDiffVersion: () => 2, - buildCheckRequestBody: (input: string) => input, - handleCheckResponse: (response: string) => response, + buildRequestBody: (input: string) => input, + handleResponse: (response: string) => response, }, mockCheck: async () => { state.checks += 1; diff --git a/src/__tests__/nativeHostApiNaming.test.ts b/src/__tests__/nativeHostApiNaming.test.ts index b0308d60..e29aba68 100644 --- a/src/__tests__/nativeHostApiNaming.test.ts +++ b/src/__tests__/nativeHostApiNaming.test.ts @@ -9,9 +9,16 @@ const source = (path: string) => readFileSync(new URL(path, root), 'utf8'); // These are forbidden host API identifiers, not backend paths or JS bridge names. const oldNames = /\b(?:PushyNativeUpdate|NativeUpdateResult|NativeUpdateConfig|RCTPushyNativeUpdateCompletion|RCTPushyNativeConfigurationCompletion|RCTPushyNormalizeNativeConfig|checkAndUpdate(?:WithCompletion|Native)?)\b/; -// Any other "native update" wording in shipped native code. NativeUpdateCore and -// NativeUpdateFlow predate the host APIs and are bound by JNI symbol names. -const nativeUpdateWording = /NativeUpdate(?!Core|Flow)|native update/i; +// Any other "native update" wording in shipped native code. +const nativeUpdateWording = /NativeUpdate|native update/i; +// Names added with the native round (10.51+) that were renamed to neutral +// wording. Checked against code only: comments never reach a binary. +const roundNames = + /\b(?:NativeCheckOrchestrator|CrashRescue|updateflow|scheduleNativeCheck|isJsCheckCompleted|commitNativeCheckResult\w*|runCheckRequest|runRescue\w*|softReload|ReloadEventEmitter|\w*PatchInputs|FlowCheckInput|\w*(?:Check|Rescue)(?:Request|Response|Result|Budget|Deadline|Active|Attempted)\w*|pushyNativeCheckReady|nativeCheckRolledBackVersion)\b/; +const roundWording = + /native check|crash rescue|host-check|native-check|crash-rescue/i; +const stripComments = (text: string) => + text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:"'\\])\/\/.*$/gm, '$1'); const shippedNativeSources = (dir: string): string[] => readdirSync(new URL(dir, root), { recursive: true, withFileTypes: true }) @@ -31,7 +38,7 @@ describe('native host API naming', () => { const entry = source(`${android}PushyRuntime.java`); expect(entry).toContain('public final class PushyRuntime'); expect(entry).toContain('public static void prepareBundle('); - expect(entry).toContain('NativeCheckOrchestrator.prepareBundle('); + expect(entry).toContain('SyncCoordinator.prepareBundle('); expect(entry).toContain('void onComplete(BundlePreparationResult result)'); expect(source(`${android}BundlePreparationResult.java`)).toContain( 'class BundlePreparationResult' @@ -105,4 +112,21 @@ describe('native host API naming', () => { }); expect(offenders).toEqual([]); }); + + test('shipped native code uses neutral names for the native round', () => { + const paths = [ + 'ios/', + 'android/src/main/', + 'harmony/pushy/src/main/', + 'cpp/patch_core/', + 'cpp/update_flow_core/', + ].flatMap(shippedNativeSources); + const offenders = paths.flatMap((path) => + stripComments(source(path)) + .split('\n') + .filter((line) => roundNames.test(line) || roundWording.test(line)) + .map((line) => `${path}: ${line.trim()}`) + ); + expect(offenders).toEqual([]); + }); }); diff --git a/src/__tests__/nativeTextEncoding.test.ts b/src/__tests__/nativeTextEncoding.test.ts new file mode 100644 index 00000000..682e76c8 --- /dev/null +++ b/src/__tests__/nativeTextEncoding.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, test } from 'bun:test'; +import { readdirSync, readFileSync } from 'node:fs'; +import { normalizePushyConfiguration } from '../../harmony/pushy/src/main/ets/PushyConfiguration'; +import { QUERY_PATH, revealText } from '../../harmony/pushy/src/main/ets/Texts'; +import { + decodeNativeText, + encodeNativeText, +} from '../../scripts/encode-native-text'; + +const root = new URL('../../', import.meta.url); +const source = (path: string) => readFileSync(new URL(path, root), 'utf8'); + +// Service addresses and the request path must only appear encoded in shipped +// native code (see scripts/encode-native-text.ts). +const plainTexts = [ + '/checkUpdate/', + 'https://update.react-native.cn/api', + 'https://update.reactnative.cn/api', + 'https://gitee.com/sunnylqm/react-native-pushy/raw/master/endpoints.json', + 'https://cdn.jsdelivr.net/gh/reactnativecn/react-native-update@master/endpoints.json', +]; + +const shippedNativeSources = (dir: string): string[] => + readdirSync(new URL(dir, root), { recursive: true, withFileTypes: true }) + .filter( + (entry) => + entry.isFile() && /\.(?:h|m|mm|java|kt|ts|ets|cpp)$/.test(entry.name) + ) + .map((entry) => + `${entry.parentPath}/${entry.name}`.slice( + new URL(dir, root).pathname.length - dir.length + ) + ) + .filter((path) => !/\/tests?\//.test(path)); + +const paths = [ + 'ios/', + 'android/src/main/', + 'harmony/pushy/src/main/', + 'cpp/patch_core/', + 'cpp/update_flow_core/', +].flatMap(shippedNativeSources); + +const stripComments = (text: string) => + text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:"'\\])\/\/.*$/gm, '$1'); + +const encodedIn = (text: string) => + [ + ...text.matchAll( + /\b(?:reveal|RCTPushyRevealText|revealText|RevealStatic|Reveal)\(\s*["']([0-9a-f]+)["']\s*\)/g + ), + ].map((match) => match[1]); + +describe('native text encoding', () => { + test('encoding round-trips', () => { + for (const text of plainTexts) { + expect(decodeNativeText(encodeNativeText(text))).toBe(text); + expect(revealText(encodeNativeText(text))).toBe(text); + } + }); + + test('each platform encodes the service addresses and path', () => { + const platforms = { + android: paths.filter((path) => path.startsWith('android/')), + ios: paths.filter((path) => path.startsWith('ios/')), + harmony: paths.filter((path) => path.startsWith('harmony/')), + }; + for (const [platform, files] of Object.entries(platforms)) { + const decoded = files + .flatMap((path) => encodedIn(source(path))) + .map(decodeNativeText) + .sort(); + for (const text of plainTexts) { + expect({ platform, text, found: decoded.includes(text) }).toEqual({ + platform, + text, + found: true, + }); + } + } + }); + + test('shipped native code has no plain service addresses or paths', () => { + const offenders = paths.flatMap((path) => { + const code = stripComments(source(path)); + return plainTexts + .filter((text) => code.includes(text)) + .map((text) => `${path}: ${text}`); + }); + expect(offenders).toEqual([]); + }); + + test('Harmony resolves the path and default addresses at runtime', () => { + expect(QUERY_PATH).toBe('/checkUpdate/'); + const config = JSON.parse(normalizePushyConfiguration({ appKey: 'k' })); + expect(config.endpoints).toEqual(plainTexts.slice(1, 3)); + expect(config.queryUrls).toEqual(plainTexts.slice(3)); + }); + + // Every string literal in shipped native code, comments excluded: none may + // carry update/patch/rescue/reload wording except the entries below, which + // are fixed by a contract that cannot change without breaking callers. + test('shipped native string literals carry no update wording', () => { + const allowed = [ + // Public host API constant (Java switch/case needs a compile-time constant). + 'android/src/main/java/cn/reactnative/modules/update/BundlePreparationResult.java: noUpdate', + // Public ArkTS option type (compile-time only). + 'harmony/pushy/src/main/ets/PushyConfiguration.ts: setNeedUpdate', + // JS bridge method names registered with RNOH. + 'harmony/pushy/src/main/cpp/PushyTurboModule.cpp: reloadUpdate', + 'harmony/pushy/src/main/cpp/PushyTurboModule.cpp: setNeedUpdate', + 'harmony/pushy/src/main/cpp/PushyTurboModule.cpp: downloadPatchFromPpk', + 'harmony/pushy/src/main/cpp/PushyTurboModule.cpp: downloadPatchFromPackage', + 'harmony/pushy/src/main/cpp/PushyTurboModule.cpp: downloadFullUpdate', + ]; + const wording = /update|patch|rescue|reload|hotfix/i; + const literal = + /@?"((?:[^"\\\n]|\\.)*)"|'((?:[^'\\\n]|\\.)*)'|`((?:[^`\\\n]|\\.)*)`/g; + const offenders = paths.flatMap((path) => + stripComments(source(path)) + .split('\n') + .filter( + (line) => + !/^\s*(?:#\s*(?:import|include)|import\b|}\s*from\b)/.test(line) + ) + .filter((line) => !/__has_include/.test(line)) + .flatMap((line) => + [...line.matchAll(literal)] + .map((match) => + (match[1] ?? match[2] ?? match[3] ?? '').replace( + /\$\{[^}]*\}/g, + '' + ) + ) + .filter((value) => wording.test(value)) + .map((value) => `${path}: ${value}`) + ) + .filter((entry) => !allowed.includes(entry)) + ); + expect(offenders).toEqual([]); + }); +});