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). */