From b71c6f1e4eb473c199f3fb4e322db8b98f69f5e6 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Fri, 16 Oct 2026 12:17:47 +0800 Subject: [PATCH 1/2] build: generate the cross-platform constants and binding tables from one source Values every platform must agree on were copied by hand into C++, Java, ArkTS and JS and held together by regex checkers, and several groups had no guard at all (state operation codes, archive patch types, round statuses, storage keys, the ABI and vendored-source lists). scripts/contract.ts is now the single source of those values, and scripts/generate-contract.ts rewrites every `` region from it: error codes (C++, Java, ArkTS, src/error.ts), archive limits, install record constants, the query budget, state operations, archive patch types (C++, Java and ArkTS task types, iOS PushyType), the Android/Harmony storage keys, the round statuses, the Android ABIs and the vendored decoder sources (Android.mk, CMakeLists.txt, test-patch-core.sh, check-packlist.js). Binding tables are generated from the calling-side declarations: the Harmony PUSHY_METHODS table and the old-architecture NativePushySpec from src/NativePushy.ts, the JNI registration table and prototypes from the Java `native` declarations, the NAPI exports from NativePatchCore.ts; the contract only names the C++ function behind each. `bun lint` runs the generator with --check instead of check-constant-parity.js (removed); check-native-spec-parity.js keeps only what no generator can write, the per-platform implementations. Generated values are byte-identical to the hand-kept ones (the JNI table included); the method table is reordered to spec order. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/lint.yml | 7 +- android/jni/Android.mk | 2 + android/jni/Application.mk | 4 +- .../modules/update/ArchiveLimits.java | 5 +- .../update/BundlePreparationResult.java | 2 + .../modules/update/DownloadTaskParams.java | 8 +- .../modules/update/ErrorCodes.java | 8 +- .../modules/update/InstallRecord.java | 4 +- .../modules/update/SyncCoordinator.java | 14 +- .../modules/update/UpdateContext.java | 4 +- .../modules/update/NativePushySpec.java | 2 + cpp/android_jni/jni_natives.h | 20 +- cpp/android_jni/jni_registration.cpp | 2 + cpp/patch_core/archive_limits.h | 8 +- cpp/patch_core/archive_patch_core.h | 2 + cpp/patch_core/error_codes.h | 22 +- cpp/patch_core/install_record.h | 8 +- cpp/patch_core/state_ops.h | 2 + cpp/update_flow_core/native_round.h | 6 +- harmony/pushy/src/main/cpp/CMakeLists.txt | 2 + .../pushy/src/main/cpp/PushyTurboModule.cpp | 42 +- harmony/pushy/src/main/cpp/pushy.cpp | 4 +- harmony/pushy/src/main/ets/ArchiveLimits.ts | 6 +- .../pushy/src/main/ets/DownloadTaskParams.ts | 8 +- harmony/pushy/src/main/ets/ErrorCodes.ts | 5 +- harmony/pushy/src/main/ets/InstallRecord.ts | 4 +- harmony/pushy/src/main/ets/NativePatchCore.ts | 6 + harmony/pushy/src/main/ets/SyncCoordinator.ts | 15 +- ios/RCTPushy/RCTPushyInternal.h | 2 + package.json | 3 +- scripts/check-constant-parity.js | 263 ------- scripts/check-native-spec-parity.js | 45 +- scripts/check-packlist.js | 6 + scripts/contract.ts | 308 +++++++++ scripts/generate-contract.ts | 645 ++++++++++++++++++ scripts/test-patch-core.sh | 2 + scripts/verify-android-so.js | 2 + src/__tests__/jniRegistration.test.ts | 9 +- src/endpoint.ts | 2 + src/error.ts | 10 +- 40 files changed, 1132 insertions(+), 387 deletions(-) delete mode 100644 scripts/check-constant-parity.js create mode 100644 scripts/contract.ts create mode 100644 scripts/generate-contract.ts diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 0d090c94..e118ce48 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -29,10 +29,9 @@ jobs: run: bun install --frozen-lockfile # `bun lint` ends with check-native-spec-parity.js (every - # src/NativePushy.ts method must be exported by all four native - # bindings, with the right sync/async kind on Harmony) and - # check-constant-parity.js (the C++ header constants must match their - # Java/ArkTS/JS mirrors). + # src/NativePushy.ts method must be implemented by all three native + # modules) and generate-contract.ts --check (every region generated from + # scripts/contract.ts and the calling-side declarations is current). - name: Run lint run: bun lint diff --git a/android/jni/Android.mk b/android/jni/Android.mk index b656e9bc..09a0f46f 100644 --- a/android/jni/Android.mk +++ b/android/jni/Android.mk @@ -18,10 +18,12 @@ Shared_Files := \ # HDiffPatch / lzma submodules: only the decoder sources hpatch.c uses. Hdp_Files := \ +# (scripts/contract.ts; bun scripts/generate-contract.ts) HDiffPatch/libHDiffPatch/HPatch/patch.c \ HDiffPatch/file_for_patch.c \ lzma/C/LzmaDec.c \ lzma/C/Lzma2Dec.c +# LOCAL_SRC_FILES := \ $(patsubst $(LOCAL_PATH)/%,%,$(Shared_Files)) \ diff --git a/android/jni/Application.mk b/android/jni/Application.mk index 2fa2fbc8..008a1b0b 100644 --- a/android/jni/Application.mk +++ b/android/jni/Application.mk @@ -12,5 +12,7 @@ APP_CPPFLAGS += -std=c++17 -Oz -fexceptions -fno-rtti -fno-unwind-tables -fno-as APP_LDFLAGS += -Wl,--gc-sections -Wl,--exclude-libs,ALL APP_LDFLAGS += -Wl,--icf=all APP_BUILD_SCRIPT := Android.mk -APP_ABI := armeabi-v7a arm64-v8a x86 x86_64 +# (scripts/contract.ts; bun scripts/generate-contract.ts) +APP_ABI := arm64-v8a armeabi-v7a x86 x86_64 +# APP_STL := c++_static diff --git a/android/src/main/java/cn/reactnative/modules/update/ArchiveLimits.java b/android/src/main/java/cn/reactnative/modules/update/ArchiveLimits.java index 964d1f3e..edf9edbf 100644 --- a/android/src/main/java/cn/reactnative/modules/update/ArchiveLimits.java +++ b/android/src/main/java/cn/reactnative/modules/update/ArchiveLimits.java @@ -5,15 +5,16 @@ * itself (entry count, sizes, ratios, reserved names, the unknown-length * probe plan) is the shared core's (cpp/patch_core/archive_limits.h, over * JNI); the constants below are the ones this side still compares against - * itself, mirrored by hand (scripts/check-constant-parity.js asserts they - * match the header). + * itself, generated from scripts/contract.ts like the header. */ final class ArchiveLimits { + // (scripts/contract.ts; bun scripts/generate-contract.ts) static final long MAX_ARCHIVE_BYTES = 512L * 1024 * 1024; static final long MAX_TOTAL_UNCOMPRESSED_BYTES = 2048L * 1024 * 1024; static final long MAX_ENTRY_BYTES = 512L * 1024 * 1024; static final long MAX_MANIFEST_BYTES = 16L * 1024 * 1024; static final long FREE_DISK_MARGIN_BYTES = 64L * 1024 * 1024; + // private ArchiveLimits() { } diff --git a/android/src/main/java/cn/reactnative/modules/update/BundlePreparationResult.java b/android/src/main/java/cn/reactnative/modules/update/BundlePreparationResult.java index e472a836..66f01552 100644 --- a/android/src/main/java/cn/reactnative/modules/update/BundlePreparationResult.java +++ b/android/src/main/java/cn/reactnative/modules/update/BundlePreparationResult.java @@ -5,11 +5,13 @@ * mean that the running React Native instance has been reloaded. */ public final class BundlePreparationResult { + // (scripts/contract.ts; bun scripts/generate-contract.ts) public static final String SKIPPED = "skipped"; public static final String NO_UPDATE = "noUpdate"; public static final String DOWNLOADED = "downloaded"; public static final String FAILED = "failed"; public static final String CANCELLED = "cancelled"; + // private final String status; private final String reason; diff --git a/android/src/main/java/cn/reactnative/modules/update/DownloadTaskParams.java b/android/src/main/java/cn/reactnative/modules/update/DownloadTaskParams.java index f1c3408e..0ed44f39 100644 --- a/android/src/main/java/cn/reactnative/modules/update/DownloadTaskParams.java +++ b/android/src/main/java/cn/reactnative/modules/update/DownloadTaskParams.java @@ -8,9 +8,11 @@ class DownloadTaskParams { static final int TASK_TYPE_CLEANUP = 0; //Keep hash & originHash - static final int TASK_TYPE_PATCH_FULL = 1; - static final int TASK_TYPE_PATCH_FROM_APK = 2; - static final int TASK_TYPE_PATCH_FROM_PPK = 3; + // (scripts/contract.ts; bun scripts/generate-contract.ts) + static final int TASK_TYPE_PATCH_FULL = 1; + static final int TASK_TYPE_PATCH_FROM_APK = 2; + static final int TASK_TYPE_PATCH_FROM_PPK = 3; + // static final int TASK_TYPE_PLAIN_DOWNLOAD = 4; static final int DEFAULT_MAX_AGE_DAYS = 3; 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 7459272e..b8052ba6 100644 --- a/android/src/main/java/cn/reactnative/modules/update/ErrorCodes.java +++ b/android/src/main/java/cn/reactnative/modules/update/ErrorCodes.java @@ -4,11 +4,12 @@ * Stable, machine-readable error codes used as the promise rejection code so * the JS layer and user loggers can aggregate errors across platforms. * - * MUST stay in sync with cpp/patch_core/error_codes.h (the single source of - * truth) and src/error.ts (UpdateErrorCode). Messages are free-form; only the - * codes are part of the contract. + * The codes of cpp/patch_core/error_codes.h and src/error.ts + * (UpdateErrorCode), all generated from scripts/contract.ts. Messages are + * free-form; only the codes are part of the contract. */ final class ErrorCodes { + // (scripts/contract.ts; bun scripts/generate-contract.ts) static final String INVALID_OPTIONS = "INVALID_OPTIONS"; static final String DOWNLOAD_FAILED = "DOWNLOAD_FAILED"; // Encoded (see Texts.reveal): the value is "PATCH_FAILED". @@ -22,6 +23,7 @@ final class ErrorCodes { static final String UNSUPPORTED_PLATFORM = "UNSUPPORTED_PLATFORM"; static final String APK_INSTALL_PERMISSION_REQUIRED = "APK_INSTALL_PERMISSION_REQUIRED"; static final String APK_INSTALL_FAILED = "APK_INSTALL_FAILED"; + // private ErrorCodes() { } diff --git a/android/src/main/java/cn/reactnative/modules/update/InstallRecord.java b/android/src/main/java/cn/reactnative/modules/update/InstallRecord.java index 2aa373d8..5543851f 100644 --- a/android/src/main/java/cn/reactnative/modules/update/InstallRecord.java +++ b/android/src/main/java/cn/reactnative/modules/update/InstallRecord.java @@ -13,8 +13,10 @@ * isInstalled / recordedBundleSha256, which only stat and read the record. */ final class InstallRecord { - // Mirrors install_record.h kStagingSuffix (scripts/check-constant-parity.js). + // install_record.h kStagingSuffix, generated from scripts/contract.ts. + // (scripts/contract.ts; bun scripts/generate-contract.ts) static final String STAGING_SUFFIX = ".staging"; + // /** The JS bundle inside a version directory. */ static final String BUNDLE_FILE_NAME = "index.bundlejs"; diff --git a/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java b/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java index b1948732..df028e90 100644 --- a/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java +++ b/android/src/main/java/cn/reactnative/modules/update/SyncCoordinator.java @@ -34,15 +34,19 @@ final class SyncCoordinator { // Version-info flag read by JS (metadata.ts), stored encoded. private static final String INFO_CRASH_HOLD = Texts.reveal("3905f5c2a6b96d56212a19"); + // Shared with Harmony. The response cache lives in its own + // SharedPreferences file (UpdateContext.getNativeCheckCache). + // (scripts/contract.ts; bun scripts/generate-contract.ts) + // The persisted native check config (JS syncNativeConfig or the host). static final String KEY_CONFIG = "nativeConfig"; - // Raw response cache for the JS side to reuse (§10.3), scoped to the - // exact logical request and native config that produced it. Stored in - // its own SharedPreferences file (UpdateContext.getNativeCheckCache). + // Raw response cache for the JS side to reuse (§10.3), scoped to the exact + // logical request and native config that produced it. static final String KEY_RESP_CACHE = "nativeCheckResp"; // Set when a round starts, cleared when it ends (§11.4). Residue on the - // next launch means the previous process died mid-round (a crash rescue - // was truncated): that launch resumes immediately instead of waiting 5s. + // next launch means the previous process died mid-round: that launch + // resumes immediately instead of waiting 5s. static final String KEY_ROUND_INCOMPLETE = "nativeCheckIncomplete"; + // // The check response (and a remote queryUrls list) is a small JSON // document; anything bigger is a broken or hijacked endpoint. private static final long MAX_QUERY_RESPONSE_BYTES = 1024 * 1024; 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 c8eec65e..0009c523 100644 --- a/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +++ b/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java @@ -57,12 +57,14 @@ public class UpdateContext { private String bundleHash; private java.util.ArrayList pendingBundleHashListeners = new java.util.ArrayList<>(); + // (scripts/contract.ts; bun scripts/generate-contract.ts) private static final int STATE_OP_SWITCH_VERSION = 1; private static final int STATE_OP_MARK_SUCCESS = 2; private static final int STATE_OP_ROLLBACK = 3; - // 4 (clear first time) exists in the state core but has no Android caller. + // 4 (ClearFirstTime) has no caller on this side. private static final int STATE_OP_CLEAR_ROLLBACK_MARK = 5; private static final int STATE_OP_RESOLVE_LAUNCH = 6; + // static final String KEY_FIRST_LOAD_MARKED = "firstLoadMarked"; // Bumped by resetToPackagedBundle. The cold-start check runs for minutes // and may already hold a decision when the app resets to the packaged diff --git a/android/src/oldarch/cn/reactnative/modules/update/NativePushySpec.java b/android/src/oldarch/cn/reactnative/modules/update/NativePushySpec.java index 9c329d0e..13aeac0c 100644 --- a/android/src/oldarch/cn/reactnative/modules/update/NativePushySpec.java +++ b/android/src/oldarch/cn/reactnative/modules/update/NativePushySpec.java @@ -31,6 +31,7 @@ public final Map getConstants() { return getTypedExportedConstants(); } + // (scripts/contract.ts; bun scripts/generate-contract.ts) public abstract void setLocalHashInfo(String hash, String info, Promise promise); public abstract void getLocalHashInfo(String hash, Promise promise); @@ -66,4 +67,5 @@ public final Map getConstants() { public abstract void addListener(String eventName); public abstract void removeListeners(double count); + // } diff --git a/cpp/android_jni/jni_natives.h b/cpp/android_jni/jni_natives.h index 46d29b15..a44990b6 100644 --- a/cpp/android_jni/jni_natives.h +++ b/cpp/android_jni/jni_natives.h @@ -15,18 +15,19 @@ namespace jni_natives { // then throw a RuntimeException instead of defaulting fields. bool InitResultClassCache(JNIEnv* env); -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); +// (scripts/contract.ts; bun scripts/generate-contract.ts) +jint SupportedDiffVersion(JNIEnv* env, jclass); +jobject SyncStateWithBinaryVersion(JNIEnv* env, jclass, jstring package_version, jstring build_time, jobject state); +jobject RunStateCore(JNIEnv* env, jclass, jint operation, jobject state, jstring string_arg, jboolean flag_a, jboolean flag_b); jobjectArray ApplyArchiveDelta(JNIEnv* env, jclass, jint patch_type, jobjectArray entry_names, jbyteArray manifest_json, jstring source_root, jstring target_root, jstring origin_bundle_path, jstring bundle_patch_path, jstring bundle_output_path, jboolean host_copies_resources); void CleanupOldEntries(JNIEnv* env, jclass, jstring root_dir, jobjectArray keep_names, jint max_age_days, jstring trash_dir); -jstring FlowPrepareRound(JNIEnv* env, jclass, jstring configJson, jstring envJson, jdouble randomSample); -jstring FlowNextQueryStep(JNIEnv* env, jclass, jstring stateJson, jstring responseText); -jstring FlowStepRound(JNIEnv* env, jclass, jstring stateJson, jstring eventJson); -jstring FlowHandleResponse(JNIEnv* env, jclass, jstring responseText, jstring configJson, jstring envJson, jstring requestBody, jlong responseAtSeconds); +jstring FlowPrepareRound(JNIEnv* env, jclass, jstring config_json, jstring env_json, jdouble random_sample); +jstring FlowNextQueryStep(JNIEnv* env, jclass, jstring state_json, jstring response_text); +jstring FlowStepRound(JNIEnv* env, jclass, jstring state_json, jstring event_json); +jstring FlowHandleResponse(JNIEnv* env, jclass, jstring response_text, jstring config_json, jstring env_json, jstring request_body, jlong response_at_seconds); jboolean FlowConfigsEquivalent(JNIEnv* env, jclass, jstring a, jstring b); -jstring FlowRunnableConfig(JNIEnv* env, jclass, jstring configJson); -jstring FlowNormalizeConfiguration(JNIEnv* env, jclass, jstring optionsJson); +jstring FlowRunnableConfig(JNIEnv* env, jclass, jstring config_json); +jstring FlowNormalizeConfiguration(JNIEnv* env, jclass, jstring options_json); jboolean InstallRecordIsComplete(JNIEnv* env, jclass, jstring version_dir, jstring version_hash, jstring bundle_file_name); jstring InstallRecordCheckActivation(JNIEnv* env, jclass, jstring version_dir, jstring version_hash, jstring bundle_file_name, jboolean grandfathered); jstring InstallRecordSeal(JNIEnv* env, jclass, jstring staging_dir, jstring version_hash, jstring bundle_file_name, jstring artifact_sha256, jstring known_bundle_sha256); @@ -34,6 +35,7 @@ jstring InstallRecordPromote(JNIEnv* env, jclass, jstring staging_dir, jstring v jstring InstallRecordBundleSha256(JNIEnv* env, jclass, jstring version_dir); jlong ArchiveCheckEntries(JNIEnv* env, jclass, jlong archive_bytes, jobjectArray names, jlongArray sizes, jlongArray compressed_sizes); jlongArray ArchivePlanFreeSpaceProbe(JNIEnv* env, jclass, jlong known_total, jlong written_before, jlong chunk_bytes, jlong reserved_until); +// } // namespace jni_natives } // namespace pushy diff --git a/cpp/android_jni/jni_registration.cpp b/cpp/android_jni/jni_registration.cpp index a3758944..0ffd9bfc 100644 --- a/cpp/android_jni/jni_registration.cpp +++ b/cpp/android_jni/jni_registration.cpp @@ -28,6 +28,7 @@ struct NativeClass { const std::vector& NativeClasses() { static const std::vector classes = { + // (scripts/contract.ts; bun scripts/generate-contract.ts) {"3919bbc3ab8a6b512c3e08f0c0b6df60452311edfbc8f780624b2d1de38c8ebc8e7e42342de4daa0", // NativeCore { {"3d12e0e2bb9b784a302b19fdf2ba966b7c2216f2f7d4b6", "725edd", @@ -84,6 +85,7 @@ const std::vector& NativeClasses() { {"2a1bf5df88996d40112f1dfad38382624822", "723ddefb84c2536f", reinterpret_cast(&pushy::jni_natives::ArchivePlanFreeSpaceProbe)}, }}, + // }; return classes; } diff --git a/cpp/patch_core/archive_limits.h b/cpp/patch_core/archive_limits.h index 10e3bc5f..9a5d6c06 100644 --- a/cpp/patch_core/archive_limits.h +++ b/cpp/patch_core/archive_limits.h @@ -23,9 +23,9 @@ // and CheckExtractedDirectory after, over NAPI (pushy.cpp). // The constants a host still compares against itself (download caps, the // manifest cap, the free-space margin, the streamed-bytes backstop) are -// mirrored in android/.../ArchiveLimits.java and -// harmony/pushy/src/main/ets/ArchiveLimits.ts; -// scripts/check-constant-parity.js (part of `bun lint`) asserts they match. +// generated into android/.../ArchiveLimits.java and +// harmony/pushy/src/main/ets/ArchiveLimits.ts from scripts/contract.ts, like +// the values below. // Keep the core internal to whichever binary links it (see podspec Core). #pragma GCC visibility push(hidden) @@ -35,6 +35,7 @@ namespace archive_limits { using delta::Status; +// (scripts/contract.ts; bun scripts/generate-contract.ts) // Downloaded archive (Content-Length up front, streamed bytes as backstop). constexpr long long kMaxArchiveBytes = 512LL * 1024 * 1024; // Sum of every entry's uncompressed size. @@ -58,6 +59,7 @@ constexpr long long kFreeDiskMarginBytes = 64LL * 1024 * 1024; // probe reserving at least this many bytes ahead (a single larger chunk // reserves its own size), so no write can ever eat into the margin. constexpr long long kUnknownLengthFreeSpaceProbeBytes = 8LL * 1024 * 1024; +// // Whether any component of an archive path ('/' or '\\' separated) starts // with install_record::kReservedPrefix. Those names belong to the SDK (the diff --git a/cpp/patch_core/archive_patch_core.h b/cpp/patch_core/archive_patch_core.h index ac852cec..cb8c0309 100644 --- a/cpp/patch_core/archive_patch_core.h +++ b/cpp/patch_core/archive_patch_core.h @@ -14,9 +14,11 @@ namespace pushy { namespace archive_patch { enum class ArchivePatchType { + // (scripts/contract.ts; bun scripts/generate-contract.ts) kFull = 1, kPatchFromPackage = 2, kPatchFromPpk = 3, + // }; enum class EntryAction { diff --git a/cpp/patch_core/error_codes.h b/cpp/patch_core/error_codes.h index 33bd8013..8edf8f9a 100644 --- a/cpp/patch_core/error_codes.h +++ b/cpp/patch_core/error_codes.h @@ -1,17 +1,14 @@ #ifndef PUSHY_PATCH_CORE_ERROR_CODES_H_ #define PUSHY_PATCH_CORE_ERROR_CODES_H_ -// Single source of truth for the stable, machine-readable error codes shared -// by every platform. Native modules reject promises with one of these codes -// so the JS layer (src/error.ts UpdateErrorCode) and user loggers can -// aggregate errors across platforms and locales. +// The stable, machine-readable error codes shared by every platform. Native +// modules reject promises with one of these codes so the JS layer +// (src/error.ts UpdateErrorCode) and user loggers can aggregate errors across +// platforms and locales. // -// Mirrors that cannot include this header MUST stay in sync by hand -// (scripts/check-constant-parity.js, part of `bun lint`, asserts they do): -// - src/error.ts (UpdateErrorCode, JS layer) -// - android/.../ErrorCodes.java (Java constants) -// - harmony/pushy/src/main/ets/ErrorCodes.ts -// iOS (ios/RCTPushy/*.mm) includes this header directly. +// Generated, like its copies in src/error.ts, android/.../ErrorCodes.java and +// harmony/pushy/src/main/ets/ErrorCodes.ts, from scripts/contract.ts. iOS +// (ios/RCTPushy/*.mm) includes this header directly. // // Human-readable messages are NOT part of this contract: they may differ per // platform and locale. Only the codes are stable. @@ -24,12 +21,14 @@ namespace pushy { namespace error_codes { +// (scripts/contract.ts; bun scripts/generate-contract.ts) // Method options missing or malformed (blank hash/url, wrong types). 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. -inline const char* const kDeltaFailed = text::RevealStatic("0a36c0f286b44e640b1339dd"); +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. @@ -52,6 +51,7 @@ constexpr const char* kApkInstallPermissionRequired = "APK_INSTALL_PERMISSION_REQUIRED"; // PackageInstaller could not create, write, commit, or continue an APK session. constexpr const char* kApkInstallFailed = "APK_INSTALL_FAILED"; +// } // namespace error_codes } // namespace pushy diff --git a/cpp/patch_core/install_record.h b/cpp/patch_core/install_record.h index f724ce8f..466d21d1 100644 --- a/cpp/patch_core/install_record.h +++ b/cpp/patch_core/install_record.h @@ -31,9 +31,9 @@ // This is the only implementation: Android (InstallRecord.java over JNI, // cpp/android_jni/install_record_jni.cpp), iOS (RCTPushyInstallRecord.mm) // and Harmony (InstallRecord.ts over NAPI, pushy.cpp) call it. The bundle -// file name is the caller's, since it differs per platform. Constants a -// host still mirrors are listed in scripts/check-constant-parity.js -// (part of `bun lint`), which asserts they match. +// file name is the caller's, since it differs per platform. The constants +// (and the staging suffix the hosts build their unpack directory from) are +// generated from scripts/contract.ts. // Keep the core internal to whichever binary links it (see podspec Core). #pragma GCC visibility push(hidden) @@ -43,6 +43,7 @@ namespace install_record { using delta::Status; +// (scripts/contract.ts; bun scripts/generate-contract.ts) constexpr int kSchema = 1; constexpr const char* kFileName = ".pushy-complete"; // Every name starting with this prefix, at any depth of a version directory, @@ -52,6 +53,7 @@ constexpr const char* kReservedPrefix = ".pushy-"; constexpr const char* kStagingSuffix = ".staging"; // A record is a few hundred bytes; anything larger is not one. constexpr long long kMaxRecordBytes = 64 * 1024; +// // A record file's contents. struct Record { diff --git a/cpp/patch_core/state_ops.h b/cpp/patch_core/state_ops.h index 735afadf..7929e2ef 100644 --- a/cpp/patch_core/state_ops.h +++ b/cpp/patch_core/state_ops.h @@ -15,12 +15,14 @@ namespace pushy { namespace state_ops { enum class StateOperation { + // (scripts/contract.ts; bun scripts/generate-contract.ts) kSwitchVersion = 1, kMarkSuccess = 2, kRollback = 3, kClearFirstTime = 4, kClearRollbackMark = 5, kResolveLaunch = 6, + // }; } // namespace state_ops diff --git a/cpp/update_flow_core/native_round.h b/cpp/update_flow_core/native_round.h index 32f3daa0..212e7321 100644 --- a/cpp/update_flow_core/native_round.h +++ b/cpp/update_flow_core/native_round.h @@ -32,9 +32,11 @@ namespace flowcore { // Total HTTP requests one query may issue: endpoint queries and remote list // fetches alike. The JS check's endpoint fallback keeps the same budget for -// its endpoint queries (src/endpoint.ts MAX_ENDPOINT_REQUESTS, -// scripts/check-constant-parity.js). +// its endpoint queries (src/endpoint.ts MAX_ENDPOINT_REQUESTS; both generated +// from scripts/contract.ts). +// (scripts/contract.ts; bun scripts/generate-contract.ts) constexpr int kMaxQueryRequests = 8; +// // {"body": "", "query": }. The body is BuildRequestBody over the config and env: a non-empty diff --git a/harmony/pushy/src/main/cpp/CMakeLists.txt b/harmony/pushy/src/main/cpp/CMakeLists.txt index 9a492418..75cf2c3a 100644 --- a/harmony/pushy/src/main/cpp/CMakeLists.txt +++ b/harmony/pushy/src/main/cpp/CMakeLists.txt @@ -32,10 +32,12 @@ file(GLOB CORE_SOURCES CONFIGURE_DEPENDS set(HDP_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/pushy.cpp ${CORE_SOURCES} + # (scripts/contract.ts; bun scripts/generate-contract.ts) ${HDIFFPATCH_DIR}/libHDiffPatch/HPatch/patch.c ${HDIFFPATCH_DIR}/file_for_patch.c ${LZMA_DIR}/C/LzmaDec.c ${LZMA_DIR}/C/Lzma2Dec.c + # ) add_library(rnpushy SHARED diff --git a/harmony/pushy/src/main/cpp/PushyTurboModule.cpp b/harmony/pushy/src/main/cpp/PushyTurboModule.cpp index 5fb03eaa..b97a4568 100644 --- a/harmony/pushy/src/main/cpp/PushyTurboModule.cpp +++ b/harmony/pushy/src/main/cpp/PushyTurboModule.cpp @@ -17,26 +17,28 @@ using namespace rnoh; // unhandled ArkTS rejection, so JS would report e.g. setNeedUpdate as // successful while nothing was switched. scripts/check-native-spec-parity.js // enforces both rules against this table. -#define PUSHY_METHODS(X) \ - X(getConstants, 0, SYNC) \ - X(addListener, 1, SYNC) \ - X(removeListeners, 1, SYNC) \ - X(setLocalHashInfo, 2, ASYNC) \ - X(getLocalHashInfo, 1, ASYNC) \ - X(setUuid, 1, ASYNC) \ - X(reloadUpdate, 1, ASYNC) \ - X(restartApp, 0, ASYNC) \ - X(setNeedUpdate, 1, ASYNC) \ - X(markSuccess, 0, ASYNC) \ - X(downloadPatchFromPpk, 1, ASYNC) \ - X(downloadPatchFromPackage, 1, ASYNC) \ - X(downloadFullUpdate, 1, ASYNC) \ - X(downloadAndInstallApk, 1, ASYNC) \ - X(syncNativeConfig, 1, ASYNC) \ - X(getNativeCheckCache, 0, ASYNC) \ - X(markJsCheckCompleted, 1, ASYNC) \ - X(getBundleHash, 0, ASYNC) \ - X(resetToPackagedBundle, 0, ASYNC) +// (scripts/contract.ts; bun scripts/generate-contract.ts) +#define PUSHY_METHODS(X) \ + X(getConstants, 0, SYNC) \ + X(setLocalHashInfo, 2, ASYNC) \ + X(getLocalHashInfo, 1, ASYNC) \ + X(setUuid, 1, ASYNC) \ + X(syncNativeConfig, 1, ASYNC) \ + X(getNativeCheckCache, 0, ASYNC) \ + X(markJsCheckCompleted, 1, ASYNC) \ + X(reloadUpdate, 1, ASYNC) \ + X(restartApp, 0, ASYNC) \ + X(setNeedUpdate, 1, ASYNC) \ + X(markSuccess, 0, ASYNC) \ + X(getBundleHash, 0, ASYNC) \ + X(resetToPackagedBundle, 0, ASYNC) \ + X(downloadPatchFromPpk, 1, ASYNC) \ + X(downloadPatchFromPackage, 1, ASYNC) \ + X(downloadFullUpdate, 1, ASYNC) \ + X(downloadAndInstallApk, 1, ASYNC) \ + X(addListener, 1, SYNC) \ + X(removeListeners, 1, SYNC) +// // ArkTSTurboModule entry point per kind. #define PUSHY_INVOKE_SYNC call diff --git a/harmony/pushy/src/main/cpp/pushy.cpp b/harmony/pushy/src/main/cpp/pushy.cpp index ae8306eb..6546da53 100644 --- a/harmony/pushy/src/main/cpp/pushy.cpp +++ b/harmony/pushy/src/main/cpp/pushy.cpp @@ -1443,6 +1443,7 @@ static napi_value FlowNormalizeConfiguration(napi_env env, } napi_value Init(napi_env env, napi_value exports) { + // (scripts/contract.ts; bun scripts/generate-contract.ts) if (!ExportFunction(env, exports, "syncStateWithBinaryVersion", Guarded) || !ExportFunction(env, exports, "runStateCore", Guarded) || !ExportFunction(env, exports, "applyArchiveDelta", Guarded) || @@ -1455,8 +1456,8 @@ napi_value Init(napi_env env, napi_value exports) { !ExportFunction(env, exports, "promoteStaging", Guarded) || !ExportFunction(env, exports, "checkActivation", Guarded) || !ExportFunction(env, exports, "checkUncompressedSize", Guarded) || - !ExportFunction(env, exports, "planFreeSpaceProbe", Guarded) || !ExportFunction(env, exports, "checkExtractedDirectory", Guarded) || + !ExportFunction(env, exports, "planFreeSpaceProbe", Guarded) || !ExportFunction(env, exports, "crc32", Guarded) || !ExportFunction(env, exports, "getSupportedDiffVersion", Guarded) || !ExportFunction(env, exports, "prepareRound", Guarded) || @@ -1466,6 +1467,7 @@ napi_value Init(napi_env env, napi_value exports) { !ExportFunction(env, exports, "configsEquivalent", Guarded) || !ExportFunction(env, exports, "runnableConfig", Guarded) || !ExportFunction(env, exports, "normalizeConfiguration", Guarded)) { + // return nullptr; } return exports; diff --git a/harmony/pushy/src/main/ets/ArchiveLimits.ts b/harmony/pushy/src/main/ets/ArchiveLimits.ts index 9591a935..16fc6447 100644 --- a/harmony/pushy/src/main/ets/ArchiveLimits.ts +++ b/harmony/pushy/src/main/ets/ArchiveLimits.ts @@ -9,11 +9,13 @@ import NativePatchCore from './NativePatchCore'; // 解压资源上限的计数规则(条目数、单条/总解压量、压缩比、保留名、符号链接、 // 未知长度下载的磁盘探测计划)只在 cpp/patch_core/archive_limits.h 里实现, -// 三端共用(这里经 NAPI)。下面只镜像本端仍需自己比较的常量, -// scripts/check-constant-parity.js 断言与头文件一致。 +// 三端共用(这里经 NAPI)。下面只是本端仍需自己比较的常量,与头文件一样 +// 由 scripts/contract.ts 生成。 +// (scripts/contract.ts; bun scripts/generate-contract.ts) export const MAX_ARCHIVE_BYTES = 512 * 1024 * 1024; export const MAX_MANIFEST_BYTES = 16 * 1024 * 1024; export const FREE_DISK_MARGIN_BYTES = 64 * 1024 * 1024; +// /** * 解压前的整包校验(zlib.getOriginalSize 给出的归档解压后总字节数):总解压 diff --git a/harmony/pushy/src/main/ets/DownloadTaskParams.ts b/harmony/pushy/src/main/ets/DownloadTaskParams.ts index 6c1c94b9..d0a817ce 100644 --- a/harmony/pushy/src/main/ets/DownloadTaskParams.ts +++ b/harmony/pushy/src/main/ets/DownloadTaskParams.ts @@ -3,9 +3,11 @@ */ export class DownloadTaskParams { // 任务类型常量 - static readonly TASK_TYPE_PATCH_FULL: number = 1; // 全量补丁 - static readonly TASK_TYPE_PATCH_FROM_APP: number = 2; // 从APP补丁 - static readonly TASK_TYPE_PATCH_FROM_PPK: number = 3; // 从PPK补丁 + // (scripts/contract.ts; bun scripts/generate-contract.ts) + static readonly TASK_TYPE_PATCH_FULL: number = 1; + static readonly TASK_TYPE_PATCH_FROM_APP: number = 2; + static readonly TASK_TYPE_PATCH_FROM_PPK: number = 3; + // type: number = DownloadTaskParams.TASK_TYPE_PATCH_FULL; // 任务类型 url: string = ''; // 下载URL diff --git a/harmony/pushy/src/main/ets/ErrorCodes.ts b/harmony/pushy/src/main/ets/ErrorCodes.ts index 611ca654..6b5552c4 100644 --- a/harmony/pushy/src/main/ets/ErrorCodes.ts +++ b/harmony/pushy/src/main/ets/ErrorCodes.ts @@ -1,5 +1,4 @@ -// ArkTS mirror of cpp/patch_core/error_codes.h — keep in sync by hand -// (scripts/check-constant-parity.js asserts the values match). +// The codes of cpp/patch_core/error_codes.h, generated from scripts/contract.ts. // 与 Android/iOS 共用的稳定错误码:JS 层(src/error.ts)按 code 聚合遥测, // 消息文案各平台可不同,只有码是契约。 // @@ -9,6 +8,7 @@ import { revealText } from './Texts'; +// (scripts/contract.ts; bun scripts/generate-contract.ts) export const ERROR_INVALID_OPTIONS = 'INVALID_OPTIONS'; export const ERROR_DOWNLOAD_FAILED = 'DOWNLOAD_FAILED'; export const ERROR_DELTA_FAILED = revealText('0a36c0f286b44e640b1339dd'); @@ -22,6 +22,7 @@ export const ERROR_UNSUPPORTED_PLATFORM = 'UNSUPPORTED_PLATFORM'; export const ERROR_APK_INSTALL_PERMISSION_REQUIRED = 'APK_INSTALL_PERMISSION_REQUIRED'; export const ERROR_APK_INSTALL_FAILED = 'APK_INSTALL_FAILED'; +// /** * 带稳定错误码的 Error:`code` 是属性,消息同时带 `[CODE] ` 前缀(桥丢属性时 diff --git a/harmony/pushy/src/main/ets/InstallRecord.ts b/harmony/pushy/src/main/ets/InstallRecord.ts index 35af5704..b6e204c4 100644 --- a/harmony/pushy/src/main/ets/InstallRecord.ts +++ b/harmony/pushy/src/main/ets/InstallRecord.ts @@ -10,8 +10,10 @@ import { // 实现):解压/打补丁全在 staging 目录里进行,最后写入记录,再一次 rename 成 // 版本目录;激活前重新校验 bundle 摘要。这里只拼路径、把原生结果映射成 // 稳定错误码。 -// STAGING_SUFFIX 镜像 kStagingSuffix(scripts/check-constant-parity.js 断言)。 +// STAGING_SUFFIX 即 kStagingSuffix,由 scripts/contract.ts 生成。 +// (scripts/contract.ts; bun scripts/generate-contract.ts) export const STAGING_SUFFIX = '.staging'; +// export const HARMONY_BUNDLE_FILE_NAME = 'bundle.harmony.js'; export function stagingDirectoryFor(versionDir: string): string { diff --git a/harmony/pushy/src/main/ets/NativePatchCore.ts b/harmony/pushy/src/main/ets/NativePatchCore.ts index 008fb685..b92b943e 100644 --- a/harmony/pushy/src/main/ets/NativePatchCore.ts +++ b/harmony/pushy/src/main/ets/NativePatchCore.ts @@ -1,13 +1,19 @@ import NativeCore from 'librnpushy.so'; +// (scripts/contract.ts; bun scripts/generate-contract.ts) export const STATE_OP_SWITCH_VERSION = 1; export const STATE_OP_MARK_SUCCESS = 2; export const STATE_OP_ROLLBACK = 3; +// 4 (ClearFirstTime) has no caller on this side. export const STATE_OP_CLEAR_ROLLBACK_MARK = 5; export const STATE_OP_RESOLVE_LAUNCH = 6; +// +// (scripts/contract.ts; bun scripts/generate-contract.ts) +export const ARCHIVE_PATCH_TYPE_FULL = 1; export const ARCHIVE_PATCH_TYPE_FROM_PACKAGE = 2; export const ARCHIVE_PATCH_TYPE_FROM_PPK = 3; +// export interface StateCoreResult { packageVersion?: string; diff --git a/harmony/pushy/src/main/ets/SyncCoordinator.ts b/harmony/pushy/src/main/ets/SyncCoordinator.ts index 92bccc63..cf1ac752 100644 --- a/harmony/pushy/src/main/ets/SyncCoordinator.ts +++ b/harmony/pushy/src/main/ets/SyncCoordinator.ts @@ -17,14 +17,19 @@ import { PushyError, getErrorMessage } from './ErrorCodes'; // 鸿蒙的 debug 门控由触发点天然承担:dev 走 MetroJSBundleProvider, // PushyFileJSBundleProvider.getBundleUrl 不会被调用。 const TAG = 'NativeSync'; +// 与 Android 共用的存储键。鸿蒙不做 crash-hold(errorManager 的同步回调驱动 +// 不了 ArkTS 异步网络 IO),续传 + 零延迟标记是收敛砖机的全部机制。 +// (scripts/contract.ts; bun scripts/generate-contract.ts) +// The persisted native check config (JS syncNativeConfig or the host). export const KEY_CONFIG = 'nativeConfig'; -// 供 JS 侧复用的原始响应缓存(§10.3),同时记录请求与配置指纹以限定命中范围。 +// Raw response cache for the JS side to reuse (§10.3), scoped to the exact +// logical request and native config that produced it. export const KEY_RESP_CACHE = 'nativeCheckResp'; -// 轮次开始落盘、结束清除(§11.4)。下次启动读到残留 = 上个进程死于轮中 -// (砖机的签名),该次启动跳过 5s 延迟立即续传。鸿蒙本版不做 crash-hold -// (errorManager 的同步回调驱动不了 ArkTS 异步网络 IO),续传 + 零延迟 -// 标记是收敛砖机的全部机制。 +// Set when a round starts, cleared when it ends (§11.4). Residue on the +// next launch means the previous process died mid-round: that launch +// resumes immediately instead of waiting 5s. export const KEY_ROUND_INCOMPLETE = 'nativeCheckIncomplete'; +// const START_DELAY_MS = 5000; // 本次启动的事实,交给共享内核(cpp/update_flow_core/native_round.h)。 diff --git a/ios/RCTPushy/RCTPushyInternal.h b/ios/RCTPushy/RCTPushyInternal.h index a5d0257c..9d18d4c3 100644 --- a/ios/RCTPushy/RCTPushyInternal.h +++ b/ios/RCTPushy/RCTPushyInternal.h @@ -32,9 +32,11 @@ #endif typedef NS_ENUM(NSInteger, PushyType) { + // (scripts/contract.ts; bun scripts/generate-contract.ts) PushyTypeFullDownload = 1, PushyTypePatchFromPackage = 2, PushyTypePatchFromPpk = 3, + // }; #pragma mark - RCTPushyState.mm diff --git a/package.json b/package.json index 5ad204bd..86caf06d 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,8 @@ ], "scripts": { "prepack": "bun submodule && bun lint", - "lint": "biome check src && tsc --noEmit && node scripts/check-harmony-types.js && node scripts/check-native-spec-parity.js && node scripts/check-constant-parity.js && node scripts/check-readme-options.js", + "lint": "biome check src && tsc --noEmit && node scripts/check-harmony-types.js && node scripts/check-native-spec-parity.js && bun scripts/generate-contract.ts --check && node scripts/check-readme-options.js", + "contract": "bun scripts/generate-contract.ts", "lint:harmony": "node scripts/check-harmony-types.js", "submodule": "git submodule update --init --recursive", "test": "bun test src/__tests__", diff --git a/scripts/check-constant-parity.js b/scripts/check-constant-parity.js deleted file mode 100644 index 292871e1..00000000 --- a/scripts/check-constant-parity.js +++ /dev/null @@ -1,263 +0,0 @@ -#!/usr/bin/env node -// Asserts that the "hand-kept mirrors" of the shared C++ headers (ArkTS, -// Java, JS) still carry the same values. Plain Node, no SDK: runs in -// `bun lint` on every PR so a limit or code bumped in cpp/patch_core cannot -// silently drift on Harmony, Android or the JS layer. -// -// cpp/patch_core/archive_limits.h <-> harmony/.../ArchiveLimits.ts -// android/.../ArchiveLimits.java -// (only the caps a host still -// compares against itself) -// cpp/patch_core/install_record.h <-> harmony/.../InstallRecord.ts -// android/.../InstallRecord.java -// (only the constants a pair lists -// under `only`) -// cpp/patch_core/error_codes.h <-> harmony/.../ErrorCodes.ts -// android/.../ErrorCodes.java -// src/error.ts (every native code must -// be a known UpdateErrorCode) -// cpp/update_flow_core/native_round.h <-> src/endpoint.ts -// (the request budget of one check) -'use strict'; - -const fs = require('fs'); -const path = require('path'); - -const root = path.resolve(__dirname, '..'); -const read = (relative) => fs.readFileSync(path.join(root, relative), 'utf8'); - -function collect(re, source, nameGroup, valueGroup) { - const values = new Map(); - let match; - while ((match = re.exec(source)) !== null) { - const name = match[nameGroup]; - values.set(name, evaluate(match[valueGroup].trim(), name)); - } - return values; -} - -// `constexpr kName = ;` — the value is either a string literal or -// an integer expression built from literals, `*`, `+`, parentheses and the -// LL suffix. -const parseCppConstants = (source) => - collect( - /(?:constexpr|inline)\s+(?:const\s+)?(?:long\s+long|int|char\s*\*)\s*(?:const\s+)?k([A-Za-z0-9]+)\s*=\s*([^;]+);/g, - source, - 1, - 2 - ); - -// `export const NAME = ;` -const parseArkTsConstants = (source) => - collect( - /export\s+const\s+([A-Z][A-Z0-9_]*)\s*(?::\s*[a-z]+)?\s*=\s*([^;]+);/g, - source, - 1, - 2 - ); - -// `static final NAME = ;` -const parseJavaConstants = (source) => - collect( - /static\s+final\s+(?:long|int|String)\s+([A-Z][A-Z0-9_]*)\s*=\s*([^;]+);/g, - source, - 1, - 2 - ); - -// Every quoted UPPER_SNAKE string literal, whatever the surrounding shape -// (union type, Set, `as const` array): the JS layer only has to *know* each -// native code; it also carries JS-only codes of its own. -function parseJsStringLiterals(source) { - const re = /(["'`])([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+)\1/g; - const values = new Map(); - let match; - while ((match = re.exec(source)) !== null) { - values.set(match[2], match[2]); - } - return values; -} - -// Encoded text (obscured_text.h / Texts.ts / Texts.java, -// 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|Texts\.reveal|reveal)\((["'])([0-9a-f]+)\1\)$/ - ); - if (encoded) { - return reveal(encoded[2]); - } - const stringLiteral = text.match(/^(["'])((?:\\.|(?!\1).)*)\1$/); - if (stringLiteral) { - return stringLiteral[2]; - } - // C++ `LL` / Java `L` integer suffixes. - const numeric = text.replace(/(\d)L{1,2}\b/g, '$1'); - if (!/^[\d\s()*+]+$/.test(numeric)) { - throw new Error(`check-constant-parity: cannot evaluate ${name} = ${text}`); - } - // Only digits and arithmetic operators survive the regex above. - return Function(`"use strict"; return (${numeric});`)(); -} - -// kMaxArchiveBytes -> MAX_ARCHIVE_BYTES -function screamingSnake(name) { - return name.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toUpperCase(); -} - -const javaDir = 'android/src/main/java/cn/reactnative/modules/update'; -// Must always be parsed from the header (guards the parser itself: a regex -// regression would otherwise silently drop the pair's coverage). -const archiveLimitsRequired = ['UnknownLengthFreeSpaceProbeBytes', 'MaxArchiveBytes']; -// The accounting itself lives in archive_limits.cpp; each host mirrors only -// the caps it still compares against on its own (download, manifest, -// streamed-bytes backstop, free-space margin). -const archiveLimitsMirroredByArkTs = [ - 'MaxArchiveBytes', - 'MaxManifestBytes', - 'FreeDiskMarginBytes', -]; -const archiveLimitsMirroredByJava = [ - 'MaxArchiveBytes', - 'MaxTotalUncompressedBytes', - 'MaxEntryBytes', - 'MaxManifestBytes', - 'FreeDiskMarginBytes', -]; -const errorCodesRequired = ['DeltaFailed', 'InvalidOptions']; -// The record logic itself lives in install_record.cpp; the hosts only mirror -// the staging suffix they still build their unpack directory from. -const installRecordMirrored = ['StagingSuffix']; - -const pairs = [ - { - header: 'cpp/patch_core/archive_limits.h', - mirror: 'harmony/pushy/src/main/ets/ArchiveLimits.ts', - parse: parseArkTsConstants, - mirrorName: screamingSnake, - required: archiveLimitsRequired, - only: archiveLimitsMirroredByArkTs, - }, - { - header: 'cpp/patch_core/archive_limits.h', - mirror: `${javaDir}/ArchiveLimits.java`, - parse: parseJavaConstants, - mirrorName: screamingSnake, - required: archiveLimitsRequired, - only: archiveLimitsMirroredByJava, - }, - { - header: 'cpp/patch_core/install_record.h', - mirror: 'harmony/pushy/src/main/ets/InstallRecord.ts', - parse: parseArkTsConstants, - mirrorName: screamingSnake, - only: installRecordMirrored, - }, - { - header: 'cpp/patch_core/install_record.h', - mirror: `${javaDir}/InstallRecord.java`, - parse: parseJavaConstants, - mirrorName: screamingSnake, - only: installRecordMirrored, - }, - { - header: 'cpp/patch_core/error_codes.h', - mirror: 'harmony/pushy/src/main/ets/ErrorCodes.ts', - parse: parseArkTsConstants, - mirrorName: (cppName) => `ERROR_${screamingSnake(cppName)}`, - required: errorCodesRequired, - // Every ERROR_* export must exist in the header too (no invented codes). - mirrorPrefix: 'ERROR_', - }, - { - header: 'cpp/patch_core/error_codes.h', - mirror: `${javaDir}/ErrorCodes.java`, - parse: parseJavaConstants, - mirrorName: screamingSnake, - required: errorCodesRequired, - // Every constant in the class must exist in the header too. - mirrorPrefix: '', - }, - { - // The JS check's endpoint fallback keeps the native round's budget. - header: 'cpp/update_flow_core/native_round.h', - mirror: 'src/endpoint.ts', - parse: parseArkTsConstants, - mirrorName: () => 'MAX_ENDPOINT_REQUESTS', - only: ['MaxQueryRequests'], - }, - { - header: 'cpp/patch_core/error_codes.h', - mirror: 'src/error.ts', - parse: parseJsStringLiterals, - required: errorCodesRequired, - // The literal *is* the code: look each one up by the header's value. - byValue: true, - }, -]; - -const problems = []; -let checked = 0; - -for (const pair of pairs) { - const cpp = parseCppConstants(read(pair.header)); - const mirror = pair.parse(read(pair.mirror)); - if (cpp.size === 0) { - problems.push(`${pair.header}: no constexpr constants parsed`); - continue; - } - for (const name of [...(pair.required || []), ...(pair.only || [])]) { - if (!cpp.has(name)) { - problems.push(`${pair.header}: k${name} not parsed (required)`); - } - } - for (const [cppName, cppValue] of cpp) { - if (pair.only && !pair.only.includes(cppName)) { - continue; - } - const mirrorName = pair.byValue ? cppValue : pair.mirrorName(cppName); - checked += 1; - if (!mirror.has(mirrorName)) { - problems.push( - `${pair.mirror}: missing ${mirrorName} (k${cppName} in ${pair.header})` - ); - continue; - } - const mirrorValue = mirror.get(mirrorName); - if (mirrorValue !== cppValue) { - problems.push( - `${pair.mirror}: ${mirrorName} = ${JSON.stringify(mirrorValue)} but ` + - `${pair.header} k${cppName} = ${JSON.stringify(cppValue)}` - ); - } - } - if (pair.mirrorPrefix !== undefined) { - const known = new Set(Array.from(cpp.keys()).map(pair.mirrorName)); - for (const name of mirror.keys()) { - if (name.startsWith(pair.mirrorPrefix) && !known.has(name)) { - problems.push(`${pair.mirror}: ${name} has no counterpart in ${pair.header}`); - } - } - } -} - -if (problems.length > 0) { - console.error(`check-constant-parity: ${problems.length} problem(s)`); - for (const problem of problems) { - console.error(` - ${problem}`); - } - process.exit(1); -} -console.log( - `check-constant-parity: ${checked} constants match across ${pairs.length} header/mirror pairs` -); diff --git a/scripts/check-native-spec-parity.js b/scripts/check-native-spec-parity.js index b91e1a69..7948109c 100644 --- a/scripts/check-native-spec-parity.js +++ b/scripts/check-native-spec-parity.js @@ -1,11 +1,12 @@ #!/usr/bin/env node // Guards the JS <-> native method contract against drift. // -// src/NativePushy.ts is the single spec. Every method it declares must be -// exposed by all four native bindings, and the Harmony C++ method table must -// register Promise-returning methods as ASYNC (a Promise handed through a -// SYNC registration reaches JS as an empty object and its rejection is lost). -// A method missing on one platform is `undefined` on the JS side, and the JS +// src/NativePushy.ts is the single spec. The tables derived from it — the +// Harmony PUSHY_METHODS rows (Promise methods ASYNC) and the old-architecture +// NativePushySpec — are generated (scripts/generate-contract.ts); what no +// generator can write is each module's implementation, checked here: every +// spec method must be implemented on Android, iOS and Harmony. A method +// missing on one platform is `undefined` on the JS side, and the JS // feature-detects then treat the whole capability as "old native" and skip it // silently — exactly how the native cold-start check shipped dark on Harmony // for three releases (docs/NATIVE_CHECK_FOLLOWUPS.md, 2026-08-13). @@ -22,7 +23,7 @@ const methodRe = /^\s{2}([a-zA-Z]+)\(([^)]*)\)\s*:\s*([^;]+);/gm; const methods = []; let m; while ((m = methodRe.exec(specBody)) !== null) { - methods.push({ name: m[1], returns: m[3].trim() }); + methods.push({ name: m[1] }); } if (methods.length < 10) { console.error('check-native-spec-parity: failed to parse src/NativePushy.ts'); @@ -33,28 +34,20 @@ if (methods.length < 10) { const plumbing = new Set(['addListener', 'removeListeners']); // One UpdateModule serves both Android architectures. The old bridge finds -// its methods through @ReactMethod on UpdateModule itself; under the old -// architecture it extends the hand-written NativePushySpec stand-in, which -// must declare every method the codegen spec does. +// its methods through @ReactMethod on UpdateModule itself. const androidModule = read( 'android/src/main/java/cn/reactnative/modules/update/UpdateModule.java' ); -const oldarchSpec = read( - 'android/src/oldarch/cn/reactnative/modules/update/NativePushySpec.java' -); const ios = read('ios/RCTPushy/RCTPushy.mm'); const harmonyCpp = read('harmony/pushy/src/main/cpp/PushyTurboModule.cpp'); const harmonyTs = read('harmony/pushy/src/main/ets/PushyTurboModule.ts'); -// The Harmony method table: `X(name, argCount, SYNC|ASYNC)` rows of the -// PUSHY_METHODS X-macro in PushyTurboModule.cpp. +// The (generated) Harmony method table: `X(name, argCount, SYNC|ASYNC)` rows +// of the PUSHY_METHODS X-macro in PushyTurboModule.cpp. const harmonyTable = new Map(); const problems = []; const tableRe = /\bX\(\s*([A-Za-z]+)\s*,\s*(\d+)\s*,\s*(SYNC|ASYNC)\s*\)/g; while ((m = tableRe.exec(harmonyCpp)) !== null) { - if (harmonyTable.has(m[1])) { - problems.push(`harmony/cpp: ${m[1]} listed twice in PUSHY_METHODS`); - } harmonyTable.set(m[1], m[3]); } if (harmonyTable.size < 10) { @@ -67,7 +60,7 @@ if (harmonyTable.size < 10) { const has = (source, re) => re.test(source); -for (const { name, returns } of methods) { +for (const { name } of methods) { if (plumbing.has(name)) { continue; } @@ -77,10 +70,6 @@ for (const { name, returns } of methods) { if (!has(androidModule, javaRe)) { problems.push(`android: ${name} not exported with @ReactMethod`); } - const specRe = new RegExp(`public\\s+abstract\\s+\\S+\\s+${name}\\s*\\(`); - if (!has(oldarchSpec, specRe)) { - problems.push(`android/oldarch: ${name} missing from NativePushySpec`); - } if ( !has(ios, new RegExp(`RCT_(EXPORT|REMAP)_METHOD\\(\\s*${name}\\b`)) && !has(ios, new RegExp(`^-\\s*\\([^)]*\\)\\s*${name}\\b`, 'm')) @@ -88,16 +77,6 @@ for (const { name, returns } of methods) { problems.push(`ios: ${name} not exported`); } const kind = harmonyTable.get(name); - if (!kind) { - problems.push(`harmony/cpp: ${name} not in the PUSHY_METHODS table`); - } - const isPromise = /^Promise 0) { } console.log( `check-native-spec-parity: ${methods.length} spec methods present on ` + - 'android (old+new arch), ios and harmony' + 'android, ios and harmony' ); diff --git a/scripts/check-packlist.js b/scripts/check-packlist.js index 6165e7f8..4c6a0a0a 100644 --- a/scripts/check-packlist.js +++ b/scripts/check-packlist.js @@ -78,10 +78,12 @@ const required = [ 'harmony/pushy.har', // Submodule sources hpatch.c decodes with (lint CI checks out // without submodules; the publish jobs check them out). + // (scripts/contract.ts; bun scripts/generate-contract.ts) 'android/jni/HDiffPatch/libHDiffPatch/HPatch/patch.c', 'android/jni/HDiffPatch/file_for_patch.c', 'android/jni/lzma/C/LzmaDec.c', 'android/jni/lzma/C/Lzma2Dec.c', + // // The submodules ship only these sources and headers, so their // licenses must ship with them. 'android/jni/HDiffPatch/LICENSE', @@ -89,7 +91,9 @@ const required = [ ] : []), ]; +// (scripts/contract.ts; bun scripts/generate-contract.ts) const requiredAbis = ['arm64-v8a', 'armeabi-v7a', 'x86', 'x86_64']; +// const json = execFileSync( 'npm', @@ -150,10 +154,12 @@ function compilePackedSources() { execFileSync('tar', ['-xzf', path.join(dir, path.basename(tarball)), '-C', dir]); const root = path.join(dir, 'package'); const sources = [ + // (scripts/contract.ts; bun scripts/generate-contract.ts) 'android/jni/HDiffPatch/libHDiffPatch/HPatch/patch.c', 'android/jni/HDiffPatch/file_for_patch.c', 'android/jni/lzma/C/LzmaDec.c', 'android/jni/lzma/C/Lzma2Dec.c', + // ...fs .readdirSync(path.join(root, 'cpp/hpatch')) .filter((name) => name.endsWith('.c')) diff --git a/scripts/contract.ts b/scripts/contract.ts new file mode 100644 index 00000000..0c3570b2 --- /dev/null +++ b/scripts/contract.ts @@ -0,0 +1,308 @@ +// The single source of the values every platform must agree on. Edit here, +// then run `bun scripts/generate-contract.ts`: it rewrites each +// `` … `` region in the C++ headers, the +// Java and ArkTS mirrors, the JS layer and the build files. `bun lint` runs +// it with --check, so a region edited by hand (or a value changed here +// without regenerating) fails CI. +// +// The binding tables are generated from the declarations on the calling side +// instead: the Harmony method table and the old-architecture spec from +// src/NativePushy.ts, the JNI registration table and prototypes from the +// Java `native` declarations, the NAPI exports from NativePatchCore.ts. This +// file only adds what those declarations cannot say (which C++ function +// implements each one). + +export interface ErrorCode { + /** C++ name without the k prefix; Java/ArkTS use its SCREAMING_SNAKE. */ + name: string; + value: string; + doc: string; + /** Stored encoded in native code (the value has update wording). */ + encoded?: boolean; +} + +// cpp/patch_core/error_codes.h. Stable rejection codes of the native modules: +// the JS layer and user loggers aggregate on them; messages are free-form. +export const errorCodes: ErrorCode[] = [ + { + name: 'InvalidOptions', + value: 'INVALID_OPTIONS', + doc: 'Method options missing or malformed (blank hash/url, wrong types).', + }, + { + name: 'DownloadFailed', + value: 'DOWNLOAD_FAILED', + doc: 'Native download failed (network error, bad HTTP status, truncated body).', + }, + { + name: 'DeltaFailed', + value: 'PATCH_FAILED', + encoded: true, + doc: 'Unzip or hdiff patch application failed.', + }, + { + name: 'FileOperationFailed', + value: 'FILE_OPERATION_FAILED', + doc: 'Local file or state persistence operation failed.', + }, + { + name: 'SwitchVersionFailed', + value: 'SWITCH_VERSION_FAILED', + doc: 'switchVersion / setNeedUpdate state transition failed.', + }, + { + name: 'MarkSuccessFailed', + value: 'MARK_SUCCESS_FAILED', + doc: 'markSuccess state transition failed.', + }, + { + name: 'RestartFailed', + value: 'RESTART_FAILED', + doc: 'reloadUpdate / restartApp failed.', + }, + { + name: 'ResetFailed', + value: 'RESET_FAILED', + doc: + 'resetToPackagedBundle failed (state wipe or cleanup could not run), or the ' + + 'installed native module predates the method (JS-layer detection).', + }, + { + name: 'InvalidHashInfo', + value: 'INVALID_HASH_INFO', + doc: 'Stored or provided hash info is not a valid JSON object.', + }, + { + name: 'UnsupportedPlatform', + value: 'UNSUPPORTED_PLATFORM', + doc: + 'The method is not supported on this platform (e.g. downloadAndInstallApk ' + + 'outside Android).', + }, + { + name: 'ApkInstallPermissionRequired', + value: 'APK_INSTALL_PERMISSION_REQUIRED', + doc: + 'Android full-package installation requires the user to trust the app as an ' + + 'install source before PackageInstaller can accept a session.', + }, + { + name: 'ApkInstallFailed', + value: 'APK_INSTALL_FAILED', + doc: 'PackageInstaller could not create, write, commit, or continue an APK session.', + }, +]; + +export type Host = 'java' | 'arkts'; + +export interface Limit { + name: string; + /** Factors multiplied together (kept readable: 512 * 1024 * 1024). */ + value: number[]; + doc?: string; + /** Hosts that still compare against the value themselves. */ + hosts?: Host[]; +} + +// cpp/patch_core/archive_limits.h: damage bounds for a corrupt or hostile +// package. The accounting lives in archive_limits.cpp; hosts mirror only the +// caps they compare against on their own. +export const archiveLimits: Limit[] = [ + { + name: 'MaxArchiveBytes', + value: [512, 1024, 1024], + doc: 'Downloaded archive (Content-Length up front, streamed bytes as backstop).', + hosts: ['java', 'arkts'], + }, + { + name: 'MaxTotalUncompressedBytes', + value: [2048, 1024, 1024], + doc: "Sum of every entry's uncompressed size.", + hosts: ['java'], + }, + { + name: 'MaxEntryBytes', + value: [512, 1024, 1024], + doc: "A single entry's uncompressed size.", + hosts: ['java'], + }, + { + name: 'MaxEntries', + value: [20000], + doc: 'Number of entries in one archive.', + }, + { + name: 'MaxCompressionRatio', + value: [100], + doc: + 'Uncompressed/compressed ratio, of each entry and of the archive as a whole,\n' + + 'checked only above kRatioCheckMinBytes of expansion (tiny highly\n' + + 'compressible files are legitimate).', + }, + { name: 'RatioCheckMinBytes', value: [1, 1024, 1024] }, + { + name: 'MaxManifestBytes', + value: [16, 1024, 1024], + doc: '__diff.json is parsed fully in memory.', + hosts: ['java', 'arkts'], + }, + { + name: 'FreeDiskMarginBytes', + value: [64, 1024, 1024], + doc: 'Free disk required beyond the bytes about to be written.', + hosts: ['java', 'arkts'], + }, + { + name: 'UnknownLengthFreeSpaceProbeBytes', + value: [8, 1024, 1024], + doc: + 'A download whose length is unknown up front (chunked / encoded body) can\n' + + 'only reserve the margin when the response arrives; the disk is re-probed\n' + + 'before any write that would run past the bytes reserved so far, each\n' + + 'probe reserving at least this many bytes ahead (a single larger chunk\n' + + 'reserves its own size), so no write can ever eat into the margin.', + }, +]; + +// cpp/patch_core/install_record.h. The hosts build their unpack directory +// from the staging suffix. +export const installRecord = { + schema: 1, + fileName: '.pushy-complete', + reservedPrefix: '.pushy-', + stagingSuffix: '.staging', + maxRecordBytes: [64, 1024], +}; + +// cpp/update_flow_core/native_round.h kMaxQueryRequests: HTTP requests one +// query may issue (endpoint queries and remote list fetches alike). The JS +// check's endpoint fallback keeps the same budget (src/endpoint.ts). +export const maxQueryRequests = 8; + +// cpp/patch_core/state_ops.h. Android has no caller of ClearFirstTime. +export const stateOperations: { name: string; value: number; hosts: Host[] }[] = [ + { name: 'SwitchVersion', value: 1, hosts: ['java', 'arkts'] }, + { name: 'MarkSuccess', value: 2, hosts: ['java', 'arkts'] }, + { name: 'Rollback', value: 3, hosts: ['java', 'arkts'] }, + { name: 'ClearFirstTime', value: 4, hosts: [] }, + { name: 'ClearRollbackMark', value: 5, hosts: ['java', 'arkts'] }, + { name: 'ResolveLaunch', value: 6, hosts: ['java', 'arkts'] }, +]; + +// cpp/patch_core/archive_patch_core.h ArchivePatchType; also the hosts' task +// types and the iOS PushyType, which cross the boundary as these numbers +// (each side keeps its historical name: the package is the APK / the app). +export const archivePatchTypes = [ + { name: 'Full', value: 1, java: 'FULL', arkts: 'FULL', objc: 'FullDownload' }, + { + name: 'PatchFromPackage', + value: 2, + java: 'FROM_APK', + arkts: 'FROM_APP', + objc: 'PatchFromPackage', + }, + { + name: 'PatchFromPpk', + value: 3, + java: 'FROM_PPK', + arkts: 'FROM_PPK', + objc: 'PatchFromPpk', + }, +]; + +// Storage keys the Android and Harmony orchestrators share (iOS uses its own +// REACTNATIVECN_PUSHY_* names). +export const syncStorageKeys = [ + { + name: 'Config', + value: 'nativeConfig', + doc: 'The persisted native check config (JS syncNativeConfig or the host).', + }, + { + name: 'RespCache', + value: 'nativeCheckResp', + doc: + 'Raw response cache for the JS side to reuse (§10.3), scoped to the exact\n' + + 'logical request and native config that produced it.', + }, + { + name: 'RoundIncomplete', + value: 'nativeCheckIncomplete', + doc: + 'Set when a round starts, cleared when it ends (§11.4). Residue on the\n' + + 'next launch means the previous process died mid-round: that launch\n' + + 'resumes immediately instead of waiting 5s.', + }, +]; + +// The host-facing result of a native round (prepareBundle). +export const roundStatuses = [ + { name: 'Skipped', value: 'skipped' }, + { name: 'NoUpdate', value: 'noUpdate' }, + { name: 'Downloaded', value: 'downloaded' }, + { name: 'Failed', value: 'failed' }, + { name: 'Cancelled', value: 'cancelled' }, +]; + +// Every Android ABI the prebuilt library ships for. +export const androidAbis = ['arm64-v8a', 'armeabi-v7a', 'x86', 'x86_64']; + +// The vendored HDiffPatch / lzma decoder sources hpatch.c needs, compiled by +// every platform build (the shared cores themselves are globbed by directory). +export const vendoredSources = [ + 'android/jni/HDiffPatch/libHDiffPatch/HPatch/patch.c', + 'android/jni/HDiffPatch/file_for_patch.c', + 'android/jni/lzma/C/LzmaDec.c', + 'android/jni/lzma/C/Lzma2Dec.c', +]; + +// Java `native` method -> the C++ function in cpp/android_jni that implements +// it. The descriptor and the prototype come from the Java declaration. +export const jniFunctions: Record = { + 'NativeCore.getSupportedDiffVersion': 'SupportedDiffVersion', + 'UpdateContext.syncStateWithBinaryVersion': 'SyncStateWithBinaryVersion', + 'UpdateContext.runStateCore': 'RunStateCore', + 'DownloadTask.applyArchiveDelta': 'ApplyArchiveDelta', + 'DownloadTask.cleanupOldEntries': 'CleanupOldEntries', + 'FlowBridge.prepareRound': 'FlowPrepareRound', + 'FlowBridge.nextQueryStep': 'FlowNextQueryStep', + 'FlowBridge.stepRound': 'FlowStepRound', + 'FlowBridge.handleResponse': 'FlowHandleResponse', + 'FlowBridge.configsEquivalent': 'FlowConfigsEquivalent', + 'FlowBridge.runnableConfig': 'FlowRunnableConfig', + 'FlowBridge.normalizeConfiguration': 'FlowNormalizeConfiguration', + 'InstallRecord.isComplete': 'InstallRecordIsComplete', + 'InstallRecord.checkActivation': 'InstallRecordCheckActivation', + 'InstallRecord.sealStaging': 'InstallRecordSeal', + 'InstallRecord.promoteStaging': 'InstallRecordPromote', + 'InstallRecord.bundleSha256Of': 'InstallRecordBundleSha256', + 'ArchiveLimits.checkEntries': 'ArchiveCheckEntries', + 'ArchiveLimits.planFreeSpaceProbe': 'ArchivePlanFreeSpaceProbe', +}; + +// NativePatchCore.ts binding -> the NAPI callback in pushy.cpp. +export const napiFunctions: Record = { + syncStateWithBinaryVersion: 'SyncStateWithBinaryVersion', + runStateCore: 'RunStateCore', + applyArchiveDelta: 'ApplyArchiveDelta', + cleanupOldEntries: 'CleanupOldEntries', + sha256HexFileAsync: 'Sha256HexFileAsync', + sha256HexRawFdAsync: 'Sha256HexRawFdAsync', + isInstallComplete: 'InstallRecordIsComplete', + recordedBundleSha256: 'InstallRecordBundleSha256', + sealInstallRecord: 'SealInstallRecord', + promoteStaging: 'PromoteStaging', + checkActivation: 'CheckActivation', + checkUncompressedSize: 'CheckUncompressedSize', + planFreeSpaceProbe: 'PlanFreeSpaceProbe', + checkExtractedDirectory: 'CheckExtractedDirectory', + crc32: 'Crc32', + getSupportedDiffVersion: 'GetSupportedDiffVersion', + prepareRound: 'FlowPrepareRound', + nextQueryStep: 'FlowNextQueryStep', + stepRound: 'FlowStepRound', + handleResponse: 'FlowHandleResponse', + configsEquivalent: 'FlowConfigsEquivalent', + runnableConfig: 'FlowRunnableConfig', + normalizeConfiguration: 'FlowNormalizeConfiguration', +}; diff --git a/scripts/generate-contract.ts b/scripts/generate-contract.ts new file mode 100644 index 00000000..d1857874 --- /dev/null +++ b/scripts/generate-contract.ts @@ -0,0 +1,645 @@ +#!/usr/bin/env bun +// Rewrites every `` region from scripts/contract.ts and the +// calling-side declarations (see the header of contract.ts). +// bun scripts/generate-contract.ts rewrite the regions +// bun scripts/generate-contract.ts --check fail when any region is stale +import { readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { + androidAbis, + archiveLimits, + archivePatchTypes, + errorCodes, + type Host, + installRecord, + jniFunctions, + maxQueryRequests, + napiFunctions, + roundStatuses, + stateOperations, + syncStorageKeys, + vendoredSources, +} from './contract'; +import { encodeNativeText } from './encode-native-text'; + +const root = resolve(import.meta.dir, '..'); +const read = (path: string) => readFileSync(join(root, path), 'utf8'); + +const javaDir = 'android/src/main/java/cn/reactnative/modules/update'; +const javaPackage = 'cn/reactnative/modules/update/'; +const ets = 'harmony/pushy/src/main/ets'; + +// kMaxArchiveBytes -> MAX_ARCHIVE_BYTES +const snake = (name: string) => + name.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toUpperCase(); + +// `//`-style comment lines, wrapped at 80 columns including `indent`. +const comment = (doc: string | undefined, indent = 0, prefix = '// ') => { + if (!doc) { + return []; + } + const width = 80 - indent - prefix.length; + const lines: string[] = []; + for (const paragraph of doc.split('\n')) { + let line = ''; + for (const word of paragraph.split(' ')) { + if (line && line.length + 1 + word.length > width) { + lines.push(prefix + line); + line = word; + } else { + line = line ? `${line} ${word}` : word; + } + } + lines.push(prefix + line); + } + return lines; +}; + +// ` = ;`, the value moved to its own line past 80 columns. +const assign = (head: string, value: string, indent: number, continuation: number) => { + const line = `${head} = ${value};`; + return indent + line.length <= 80 + ? [line] + : [`${head} =`, `${' '.repeat(continuation)}${value};`]; +}; + +const product = (factors: number[], suffix: string) => + factors.length === 1 + ? String(factors[0]) + : [`${factors[0]}${suffix}`, ...factors.slice(1)].join(' * '); + +const hostsOf = (hosts: Host[] | undefined, host: Host) => + (hosts ?? []).includes(host); + +// --- Declarations on the calling side -------------------------------------- + +interface SpecMethod { + name: string; + params: { name: string; java: string }[]; + promise: boolean; +} + +// src/NativePushy.ts: the codegen spec every native module implements. +// A small bracket-matching reader over `interface Spec`, enough for its +// shape: `getConstants: () => {...};` and `name(params): Return;` members. +function specMethods(): SpecMethod[] { + const source = read('src/NativePushy.ts') + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/\/\/.*$/gm, ''); + const start = source.indexOf('interface Spec'); + if (start < 0) { + throw new Error('src/NativePushy.ts: no Spec interface'); + } + const open = source.indexOf('{', start); + // The index just past the bracket that closes the one at `from`. + const matching = (from: number) => { + let depth = 0; + for (let i = from; i < source.length; i++) { + const c = source[i]; + if (c === '{' || c === '(' || c === '<') { + depth++; + } else if (c === '}' || c === ')' || (c === '>' && source[i - 1] !== '=')) { + depth--; + if (depth === 0) { + return i + 1; + } + } + } + throw new Error('src/NativePushy.ts: unbalanced brackets'); + }; + const body = source.slice(open + 1, matching(open) - 1); + // Splits on commas outside any bracket. + const topLevel = (text: string) => { + const parts: string[] = []; + let depth = 0; + let part = ''; + for (const c of text) { + if (c === '{' || c === '(' || c === '<') depth++; + if (c === '}' || c === ')' || c === '>') depth--; + if (c === ',' && depth === 0) { + parts.push(part); + part = ''; + } else { + part += c; + } + } + parts.push(part); + return parts.map((p) => p.trim()).filter(Boolean); + }; + const javaType = (type: string, where: string) => { + if (type === 'string') return 'String'; + if (type === 'number') return 'double'; + if (type === 'boolean') return 'boolean'; + if (type.startsWith('{')) return 'ReadableMap'; + throw new Error(`${where}: unsupported type ${type}`); + }; + const methods: SpecMethod[] = []; + const member = /^\s*(\w+)\s*(\(|:)/gm; + let match: RegExpExecArray | null; + while ((match = member.exec(body)) !== null) { + const name = match[1]; + if (match[2] === ':') { + if (name !== 'getConstants') { + throw new Error(`src/NativePushy.ts: unexpected member ${name}`); + } + methods.push({ name, params: [], promise: false }); + const brace = body.indexOf('{', member.lastIndex); + member.lastIndex = brace + bodyMatching(body, brace); + continue; + } + const paramsStart = member.lastIndex - 1; + const paramsEnd = paramsStart + bodyMatching(body, paramsStart); + const params = topLevel(body.slice(paramsStart + 1, paramsEnd - 1)).map( + (param) => { + const colon = param.indexOf(':'); + const paramName = param.slice(0, colon).trim(); + return { + name: paramName, + java: javaType(param.slice(colon + 1).trim(), `${name}(${paramName})`), + }; + } + ); + const end = body.indexOf(';', paramsEnd); + const returns = body.slice(body.indexOf(':', paramsEnd) + 1, end).trim(); + methods.push({ name, params, promise: returns.startsWith('Promise<') }); + member.lastIndex = end + 1; + } + return methods; +} + +// Length from `from` (an opening bracket) to just past its closing bracket. +function bodyMatching(text: string, from: number) { + let depth = 0; + for (let i = from; i < text.length; i++) { + const c = text[i]; + if (c === '{' || c === '(') { + depth++; + } else if (c === '}' || c === ')') { + depth--; + if (depth === 0) { + return i + 1 - from; + } + } + } + throw new Error('src/NativePushy.ts: unbalanced brackets'); +} + +interface JavaNative { + className: string; + name: string; + returns: string; + params: { type: string; name: string }[]; +} + +// Every Java `native` declaration of the update package. +function javaNatives(): JavaNative[] { + const natives: JavaNative[] = []; + for (const file of readdirSync(join(root, javaDir)).sort()) { + if (!file.endsWith('.java')) { + continue; + } + const source = read(`${javaDir}/${file}`) + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/\/\/.*$/gm, ''); + for (const match of source.matchAll( + /\bnative\s+([\w[\]]+)\s+(\w+)\s*\(([^)]*)\)\s*(?:throws\s+[\w.,\s]+)?;/g + )) { + natives.push({ + className: file.replace('.java', ''), + name: match[2], + returns: match[1], + params: match[3] + .split(',') + .map((param) => param.trim()) + .filter(Boolean) + .map((param) => { + const [type, name] = param.split(/\s+/); + return { type, name }; + }), + }); + } + } + return natives; +} + +// `interface NativePatchCoreBindings` method names, in declaration order. +function napiBindings(): string[] { + const source = read(`${ets}/NativePatchCore.ts`); + const start = source.indexOf('interface NativePatchCoreBindings'); + const body = source.slice(start, source.indexOf('\n}', start)); + return [...body.matchAll(/^ {2}(\w+)\(/gm)].map((match) => match[1]); +} + +// --- Generated regions ----------------------------------------------------- + +type Regions = Record>; +const regions: Regions = {}; +const region = (file: string, name: string, lines: string[]) => { + regions[file] ??= {}; + regions[file][name] = lines; +}; + +// Error codes. +region( + 'cpp/patch_core/error_codes.h', + 'error-codes', + errorCodes.flatMap(({ name, value, doc, encoded }) => [ + ...comment(doc), + ...(encoded + ? assign( + `inline const char* const k${name}`, + `text::RevealStatic("${encodeNativeText(value)}")`, + 0, + 4 + ) + : assign(`constexpr const char* k${name}`, `"${value}"`, 0, 4)), + ]) +); +region( + `${javaDir}/ErrorCodes.java`, + 'error-codes', + errorCodes.flatMap(({ name, value, encoded }) => + encoded + ? [ + `// Encoded (see Texts.reveal): the value is "${value}".`, + `static final String ${snake(name)} = Texts.reveal("${encodeNativeText(value)}");`, + ] + : [`static final String ${snake(name)} = "${value}";`] + ) +); +region( + `${ets}/ErrorCodes.ts`, + 'error-codes', + errorCodes.flatMap(({ name, value, encoded }) => + assign( + `export const ERROR_${snake(name)}`, + encoded ? `revealText('${encodeNativeText(value)}')` : `'${value}'`, + 0, + 2 + ) + ) +); +region( + 'src/error.ts', + 'native-error-codes', + errorCodes.map(({ value }) => `'${value}',`) +); + +// Archive limits. +region( + 'cpp/patch_core/archive_limits.h', + 'limits', + archiveLimits.flatMap(({ name, value, doc }) => [ + ...comment(doc), + `constexpr long long k${name} = ${product(value, 'LL')};`, + ]) +); +region( + `${javaDir}/ArchiveLimits.java`, + 'limits', + archiveLimits + .filter(({ hosts }) => hostsOf(hosts, 'java')) + .map(({ name, value }) => `static final long ${snake(name)} = ${product(value, 'L')};`) +); +region( + `${ets}/ArchiveLimits.ts`, + 'limits', + archiveLimits + .filter(({ hosts }) => hostsOf(hosts, 'arkts')) + .map(({ name, value }) => `export const ${snake(name)} = ${product(value, '')};`) +); + +// Install record. +region('cpp/patch_core/install_record.h', 'constants', [ + `constexpr int kSchema = ${installRecord.schema};`, + `constexpr const char* kFileName = "${installRecord.fileName}";`, + '// Every name starting with this prefix, at any depth of a version directory,', + '// belongs to the SDK (the record, its temp file): no archive may ship one', + '// (archive_limits::IsReservedEntryName).', + `constexpr const char* kReservedPrefix = "${installRecord.reservedPrefix}";`, + `constexpr const char* kStagingSuffix = "${installRecord.stagingSuffix}";`, + '// A record is a few hundred bytes; anything larger is not one.', + `constexpr long long kMaxRecordBytes = ${product(installRecord.maxRecordBytes, '')};`, +]); +region(`${javaDir}/InstallRecord.java`, 'staging-suffix', [ + `static final String STAGING_SUFFIX = "${installRecord.stagingSuffix}";`, +]); +region(`${ets}/InstallRecord.ts`, 'staging-suffix', [ + `export const STAGING_SUFFIX = '${installRecord.stagingSuffix}';`, +]); + +// Query budget. +region('cpp/update_flow_core/native_round.h', 'query-budget', [ + `constexpr int kMaxQueryRequests = ${maxQueryRequests};`, +]); +region('src/endpoint.ts', 'query-budget', [ + `export const MAX_ENDPOINT_REQUESTS = ${maxQueryRequests};`, +]); + +// State operations. +region( + 'cpp/patch_core/state_ops.h', + 'state-operations', + stateOperations.map(({ name, value }) => `k${name} = ${value},`) +); +const stateOpHost = (host: Host, declare: (name: string, value: number) => string) => + stateOperations.map(({ name, value, hosts }) => + hosts.includes(host) + ? declare(`STATE_OP_${snake(name)}`, value) + : `// ${value} (${name}) has no caller on this side.` + ); +region( + `${javaDir}/UpdateContext.java`, + 'state-operations', + stateOpHost('java', (name, value) => `private static final int ${name} = ${value};`) +); +region( + `${ets}/NativePatchCore.ts`, + 'state-operations', + stateOpHost('arkts', (name, value) => `export const ${name} = ${value};`) +); + +// Archive patch types. +region( + 'cpp/patch_core/archive_patch_core.h', + 'archive-patch-types', + archivePatchTypes.map(({ name, value }) => `k${name} = ${value},`) +); +region( + `${ets}/NativePatchCore.ts`, + 'archive-patch-types', + archivePatchTypes.map( + ({ name, value }) => + `export const ARCHIVE_PATCH_TYPE_${snake(name).replace(/^PATCH_/, '')} = ${value};` + ) +); +region( + `${javaDir}/DownloadTaskParams.java`, + 'archive-patch-types', + archivePatchTypes.map( + ({ java, value }) => `static final int TASK_TYPE_PATCH_${java} = ${value};` + ) +); +region( + `${ets}/DownloadTaskParams.ts`, + 'archive-patch-types', + archivePatchTypes.map( + ({ arkts, value }) => + `static readonly TASK_TYPE_PATCH_${arkts}: number = ${value};` + ) +); +region( + 'ios/RCTPushy/RCTPushyInternal.h', + 'archive-patch-types', + archivePatchTypes.map(({ objc, value }) => `PushyType${objc} = ${value},`) +); + +// Sync storage keys. +region( + `${javaDir}/SyncCoordinator.java`, + 'storage-keys', + syncStorageKeys.flatMap(({ name, value, doc }) => [ + ...comment(doc, 4), + `static final String KEY_${snake(name)} = "${value}";`, + ]) +); +region( + `${ets}/SyncCoordinator.ts`, + 'storage-keys', + syncStorageKeys.flatMap(({ name, value, doc }) => [ + ...comment(doc), + `export const KEY_${snake(name)} = '${value}';`, + ]) +); + +// Round statuses. +region( + `${javaDir}/BundlePreparationResult.java`, + 'statuses', + roundStatuses.map( + ({ name, value }) => `public static final String ${snake(name)} = "${value}";` + ) +); + +// Android ABIs. +region('android/jni/Application.mk', 'abis', [`APP_ABI := ${androidAbis.join(' ')}`]); +const quotedList = (values: string[]) => values.map((value) => `'${value}',`); +region('scripts/check-packlist.js', 'abis', [ + `const requiredAbis = [${androidAbis.map((abi) => `'${abi}'`).join(', ')}];`, +]); +region('scripts/verify-android-so.js', 'abis', [ + `const ABIS = [${androidAbis.map((abi) => `'${abi}'`).join(', ')}];`, +]); + +// Vendored decoder sources. +region( + 'android/jni/Android.mk', + 'vendored-sources', + vendoredSources.map( + (path, i) => + `\t${path.replace('android/jni/', '')}${i < vendoredSources.length - 1 ? ' \\' : ''}` + ) +); +region( + 'harmony/pushy/src/main/cpp/CMakeLists.txt', + 'vendored-sources', + vendoredSources.map((path) => + path + .replace('android/jni/HDiffPatch', '${HDIFFPATCH_DIR}') + .replace('android/jni/lzma', '${LZMA_DIR}') + ) +); +region('scripts/check-packlist.js', 'vendored-sources', quotedList(vendoredSources)); +region('scripts/check-packlist.js', 'vendored-compile', quotedList(vendoredSources)); +region( + 'scripts/test-patch-core.sh', + 'vendored-sources', + vendoredSources.map((path) => { + const object = path.split('/').pop()?.replace(/\.c$/, '.o'); + return `cc -Wall -Wextra $SANITIZE_FLAGS $COMMON_INCLUDES -c "$ROOT_DIR/${path}" -o "$BUILD_DIR/${object}"`; + }) +); + +// Harmony method table and the old-architecture spec, from src/NativePushy.ts. +const spec = specMethods(); +const plumbing = new Set(['getConstants', 'addListener', 'removeListeners']); +const tableRows = spec.map(({ name, params, promise }) => { + if (!promise && !plumbing.has(name)) { + throw new Error(`src/NativePushy.ts: ${name} must return a Promise`); + } + return `X(${name}, ${params.length}, ${promise ? 'ASYNC' : 'SYNC'})`; +}); +const tableWidth = Math.max(...tableRows.map((row) => row.length)) + 1; +region('harmony/pushy/src/main/cpp/PushyTurboModule.cpp', 'pushy-methods', [ + `${'#define PUSHY_METHODS(X)'.padEnd(tableWidth + 2)}\\`, + ...tableRows.map( + (row, i) => ` ${i < tableRows.length - 1 ? `${row.padEnd(tableWidth)}\\` : row}` + ), +]); +region( + 'android/src/oldarch/cn/reactnative/modules/update/NativePushySpec.java', + 'spec-methods', + spec + .filter(({ name }) => name !== 'getConstants') + .flatMap(({ name, params, promise }, i) => [ + ...(i > 0 ? [''] : []), + `public abstract void ${name}(${[ + ...params.map((param) => `${param.java} ${param.name}`), + ...(promise ? ['Promise promise'] : []), + ].join(', ')});`, + ]) +); + +// JNI registration table and prototypes, from the Java natives. +const natives = javaNatives(); +const nativeKeys = natives.map(({ className, name }) => `${className}.${name}`); +for (const key of nativeKeys) { + if (!jniFunctions[key]) { + throw new Error(`${key}: no C++ function in scripts/contract.ts jniFunctions`); + } +} +for (const key of Object.keys(jniFunctions)) { + if (!nativeKeys.includes(key)) { + throw new Error(`scripts/contract.ts jniFunctions: ${key} is not a Java native`); + } +} +const descriptor = (type: string): string => { + const array = type.endsWith('[]'); + const base = array ? type.slice(0, -2) : type; + const primitives: Record = { + boolean: 'Z', + byte: 'B', + 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 jniType = (type: string): string => { + const scalars: Record = { + boolean: 'jboolean', + byte: 'jbyte', + int: 'jint', + long: 'jlong', + double: 'jdouble', + float: 'jfloat', + void: 'void', + String: 'jstring', + 'boolean[]': 'jbooleanArray', + 'byte[]': 'jbyteArray', + 'int[]': 'jintArray', + 'long[]': 'jlongArray', + 'double[]': 'jdoubleArray', + }; + return scalars[type] ?? (type.endsWith('[]') ? 'jobjectArray' : 'jobject'); +}; +const lowerSnake = (name: string) => + name.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase(); +const ordered = Object.keys(jniFunctions).map( + (key) => natives[nativeKeys.indexOf(key)] +); +region( + 'cpp/android_jni/jni_natives.h', + 'prototypes', + ordered.map( + ({ className, name, returns, params }) => + `${jniType(returns)} ${jniFunctions[`${className}.${name}`]}(${[ + 'JNIEnv* env', + 'jclass', + ...params.map((param) => `${jniType(param.type)} ${lowerSnake(param.name)}`), + ].join(', ')});` + ) +); +const classes = [...new Set(ordered.map(({ className }) => className))]; +region( + 'cpp/android_jni/jni_registration.cpp', + 'natives', + classes.flatMap((className) => [ + `{"${encodeNativeText(`${javaPackage}${className}`)}", // ${className}`, + ' {', + ...ordered + .filter((native) => native.className === className) + .flatMap(({ name, returns, params }) => [ + ` {"${encodeNativeText(name)}", "${encodeNativeText( + `(${params.map((param) => descriptor(param.type)).join('')})${descriptor(returns)}` + )}",`, + ` reinterpret_cast(&pushy::jni_natives::${jniFunctions[`${className}.${name}`]})},`, + ]), + ' }},', + ]) +); + +// NAPI exports, from NativePatchCore.ts. +const bindings = napiBindings(); +for (const name of bindings) { + if (!napiFunctions[name]) { + throw new Error(`NativePatchCore.ts ${name}: no NAPI callback in scripts/contract.ts`); + } +} +for (const name of Object.keys(napiFunctions)) { + if (!bindings.includes(name)) { + throw new Error(`scripts/contract.ts napiFunctions: ${name} is not a NativePatchCore binding`); + } +} +region( + 'harmony/pushy/src/main/cpp/pushy.cpp', + 'exports', + bindings.map( + (name, i) => + `${i === 0 ? 'if (!' : ' !'}ExportFunction(env, exports, "${name}", Guarded<${napiFunctions[name]}>)${ + i === bindings.length - 1 ? ') {' : ' ||' + }` + ) +); + +// --- Apply ----------------------------------------------------------------- + +const check = process.argv.includes('--check'); +const stale: string[] = []; +for (const [file, named] of Object.entries(regions)) { + const original = read(file); + const lines = original.split('\n'); + for (const [name, content] of Object.entries(named)) { + const open = lines.findIndex((line) => line.includes(``)); + const close = lines.findIndex((line) => line.includes(``)); + if (open < 0 || close < open) { + throw new Error(`${file}: missing markers`); + } + const indent = lines[open].match(/^\s*/)?.[0] ?? ''; + const body = content.map((line) => + line === '' || line.startsWith('\t') ? line : indent + line + ); + lines.splice(open + 1, close - open - 1, ...body); + } + const updated = lines.join('\n'); + if (updated !== original) { + stale.push(file); + if (!check) { + writeFileSync(join(root, file), updated); + } + } +} + +const total = Object.values(regions).reduce( + (sum, named) => sum + Object.keys(named).length, + 0 +); +if (check && stale.length > 0) { + console.error( + 'generate-contract: stale generated regions (run `bun scripts/generate-contract.ts`):' + ); + for (const file of stale) { + console.error(` - ${file}`); + } + process.exit(1); +} +console.log( + check + ? `generate-contract: ${total} generated regions are up to date` + : `generate-contract: ${stale.length ? `rewrote ${stale.join(', ')}` : 'nothing to change'}` +); diff --git a/scripts/test-patch-core.sh b/scripts/test-patch-core.sh index ef008964..c7db3fe1 100755 --- a/scripts/test-patch-core.sh +++ b/scripts/test-patch-core.sh @@ -25,10 +25,12 @@ COMMON_INCLUDES=" # hpatch.c and the vendored HDiffPatch / lzma C sources are compiled with # -Wall -Wextra only (no -Werror). cc -Wall -Wextra $SANITIZE_FLAGS $COMMON_INCLUDES -c "$ROOT_DIR/cpp/hpatch/hpatch.c" -o "$BUILD_DIR/hpatch.o" +# (scripts/contract.ts; bun scripts/generate-contract.ts) cc -Wall -Wextra $SANITIZE_FLAGS $COMMON_INCLUDES -c "$ROOT_DIR/android/jni/HDiffPatch/libHDiffPatch/HPatch/patch.c" -o "$BUILD_DIR/patch.o" cc -Wall -Wextra $SANITIZE_FLAGS $COMMON_INCLUDES -c "$ROOT_DIR/android/jni/HDiffPatch/file_for_patch.c" -o "$BUILD_DIR/file_for_patch.o" cc -Wall -Wextra $SANITIZE_FLAGS $COMMON_INCLUDES -c "$ROOT_DIR/android/jni/lzma/C/LzmaDec.c" -o "$BUILD_DIR/LzmaDec.o" cc -Wall -Wextra $SANITIZE_FLAGS $COMMON_INCLUDES -c "$ROOT_DIR/android/jni/lzma/C/Lzma2Dec.c" -o "$BUILD_DIR/Lzma2Dec.o" +# # Every patch core source, picked up by directory like the platform builds # (tests/ is a subdirectory and stays out), plus the flow core's JSON parser diff --git a/scripts/verify-android-so.js b/scripts/verify-android-so.js index 0fd82189..b1018bcf 100644 --- a/scripts/verify-android-so.js +++ b/scripts/verify-android-so.js @@ -17,7 +17,9 @@ const path = require('path'); const { findWording } = require('./check-binary-strings'); const LIB_DIR = path.resolve(__dirname, '..', 'android', 'lib'); +// (scripts/contract.ts; bun scripts/generate-contract.ts) const ABIS = ['arm64-v8a', 'armeabi-v7a', 'x86', 'x86_64']; +// // ELF e_machine per ABI, so a symbol-complete .so placed in the wrong ABI // directory (mixed-up artifacts) still fails verification. diff --git a/src/__tests__/jniRegistration.test.ts b/src/__tests__/jniRegistration.test.ts index ea80eed1..cf361901 100644 --- a/src/__tests__/jniRegistration.test.ts +++ b/src/__tests__/jniRegistration.test.ts @@ -4,9 +4,10 @@ import { URL } from 'node:url'; import { decodeNativeText } from '../../scripts/encode-native-text'; // JNI_OnLoad binds Android native methods from an encoded name/signature table -// (cpp/android_jni/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. +// (cpp/android_jni/jni_registration.cpp), generated from the Java `native` +// declarations (scripts/generate-contract.ts). A drifted entry would silently +// leave a method unbound until its first call; this decodes the generated +// table independently of the generator and compares. const root = new URL('../../', import.meta.url); const javaDir = 'android/src/main/java/cn/reactnative/modules/update/'; const javaPackage = 'cn/reactnative/modules/update/'; @@ -75,7 +76,7 @@ const registered = () => { describe('JNI registration table', () => { test('matches every Java native declaration', () => { const natives = javaNatives(); - expect(natives.length).toBe(19); + expect(natives.length).toBeGreaterThan(0); expect(registered()).toEqual(natives); }); diff --git a/src/endpoint.ts b/src/endpoint.ts index 9aa46ad6..7caf5279 100644 --- a/src/endpoint.ts +++ b/src/endpoint.ts @@ -40,7 +40,9 @@ export const DEFAULT_HEDGE_DELAY_MS = 250; * hostile) list would make every device fan out to all of its entries on * each failed check. */ +// (scripts/contract.ts; bun scripts/generate-contract.ts) export const MAX_ENDPOINT_REQUESTS = 8; +// /** * Hedged race over the candidate endpoints: the first candidate starts diff --git a/src/error.ts b/src/error.ts index 04cf0082..aa3418a5 100644 --- a/src/error.ts +++ b/src/error.ts @@ -16,9 +16,6 @@ const UPDATE_ERROR_CODES = [ // A 2xx check response that is not a check verdict (schema gate). 'INVALID_RESPONSE', 'CHECK_FAILED', - 'DOWNLOAD_FAILED', - 'SWITCH_VERSION_FAILED', - 'MARK_SUCCESS_FAILED', 'APK_INSTALL_PENDING', 'APK_DOWNLOAD_FAILED', // A throw from a user-provided hook (e.g. beforeReload) — not an update @@ -27,16 +24,21 @@ const UPDATE_ERROR_CODES = [ // A second client/provider in the same process — a hard integration error; // the SDK is a process-level singleton. 'SINGLETON_VIOLATION', - // Native codes (see cpp/patch_core/error_codes.h) + // Native codes (cpp/patch_core/error_codes.h) + // (scripts/contract.ts; bun scripts/generate-contract.ts) 'INVALID_OPTIONS', + 'DOWNLOAD_FAILED', 'PATCH_FAILED', 'FILE_OPERATION_FAILED', + 'SWITCH_VERSION_FAILED', + 'MARK_SUCCESS_FAILED', 'RESTART_FAILED', 'RESET_FAILED', 'INVALID_HASH_INFO', 'UNSUPPORTED_PLATFORM', 'APK_INSTALL_PERMISSION_REQUIRED', 'APK_INSTALL_FAILED', + // ] as const; /** A stable error code (see UPDATE_ERROR_CODES). */ From 0e381728e36404e9339d32039999ba51e1bbf53b Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Fri, 16 Oct 2026 12:23:56 +0800 Subject: [PATCH 2/2] fix(build): keep the whole Hdp_Files assignment in its generated region A comment line inside the backslash continuation ended the assignment and turned the source lines into recipe lines (ndk-build: recipe commences before first target). Co-Authored-By: Claude Opus 5.5 --- android/jni/Android.mk | 2 +- scripts/generate-contract.ts | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/android/jni/Android.mk b/android/jni/Android.mk index 09a0f46f..dbb6fa28 100644 --- a/android/jni/Android.mk +++ b/android/jni/Android.mk @@ -17,8 +17,8 @@ Shared_Files := \ $(sort $(wildcard $(CPP_DIR)/hpatch/*.c)) # HDiffPatch / lzma submodules: only the decoder sources hpatch.c uses. -Hdp_Files := \ # (scripts/contract.ts; bun scripts/generate-contract.ts) +Hdp_Files := \ HDiffPatch/libHDiffPatch/HPatch/patch.c \ HDiffPatch/file_for_patch.c \ lzma/C/LzmaDec.c \ diff --git a/scripts/generate-contract.ts b/scripts/generate-contract.ts index d1857874..838e6558 100644 --- a/scripts/generate-contract.ts +++ b/scripts/generate-contract.ts @@ -432,14 +432,14 @@ region('scripts/verify-android-so.js', 'abis', [ ]); // Vendored decoder sources. -region( - 'android/jni/Android.mk', - 'vendored-sources', - vendoredSources.map( +// The whole assignment: a comment line would end a backslash continuation. +region('android/jni/Android.mk', 'vendored-sources', [ + 'Hdp_Files := \\', + ...vendoredSources.map( (path, i) => `\t${path.replace('android/jni/', '')}${i < vendoredSources.length - 1 ? ' \\' : ''}` - ) -); + ), +]); region( 'harmony/pushy/src/main/cpp/CMakeLists.txt', 'vendored-sources',