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..dbb6fa28 100644
--- a/android/jni/Android.mk
+++ b/android/jni/Android.mk
@@ -17,11 +17,13 @@ Shared_Files := \
$(sort $(wildcard $(CPP_DIR)/hpatch/*.c))
# HDiffPatch / lzma submodules: only the decoder sources hpatch.c uses.
+# (scripts/contract.ts; bun scripts/generate-contract.ts)
Hdp_Files := \
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..838e6558
--- /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.
+// 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',
+ 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). */