react-native-update 10.44.0 → 10.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/android/jni/Android.mk +0 -1
  2. package/android/lib/arm64-v8a/librnupdate.so +0 -0
  3. package/android/lib/armeabi-v7a/librnupdate.so +0 -0
  4. package/android/lib/x86/librnupdate.so +0 -0
  5. package/android/lib/x86_64/librnupdate.so +0 -0
  6. package/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +10 -1
  7. package/android/src/main/java/cn/reactnative/modules/update/ErrorCodes.java +24 -0
  8. package/android/src/main/java/cn/reactnative/modules/update/StateSerialRunner.java +2 -1
  9. package/android/src/main/java/cn/reactnative/modules/update/UiThreadRunner.java +2 -1
  10. package/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +19 -4
  11. package/android/src/main/java/cn/reactnative/modules/update/UpdateEventEmitter.java +13 -1
  12. package/android/src/main/java/cn/reactnative/modules/update/UpdateModuleImpl.java +15 -15
  13. package/cpp/patch_core/error_codes.h +43 -0
  14. package/cpp/patch_core/patch_core.cpp +16 -1
  15. package/cpp/patch_core/patch_core.h +6 -0
  16. package/cpp/patch_core/tests/patch_core_test.cpp +77 -0
  17. package/harmony/pushy/src/main/cpp/pushy.cpp +1 -93
  18. package/harmony/pushy/src/main/ets/DownloadTask.ts +13 -10
  19. package/harmony/pushy/src/main/ets/NativePatchCore.ts +0 -4
  20. package/harmony/pushy/src/main/ets/UpdateContext.ts +20 -2
  21. package/harmony/pushy.har +0 -0
  22. package/ios/RCTPushy/RCTPushy.mm +223 -132
  23. package/ios/RCTPushy/RCTPushyDownloader.mm +22 -1
  24. package/package.json +7 -3
  25. package/src/client.ts +151 -53
  26. package/src/context.ts +21 -7
  27. package/src/core.ts +8 -2
  28. package/src/endpoint.ts +4 -2
  29. package/src/error.ts +68 -0
  30. package/src/index.ts +10 -1
  31. package/src/locales/en.ts +5 -1
  32. package/src/locales/zh.ts +3 -1
  33. package/src/provider.tsx +101 -54
  34. package/src/type.ts +6 -0
  35. package/src/utils.ts +56 -16
  36. package/android/jni/DownloadTask.c +0 -56
  37. package/android/jni/cn_reactnative_modules_update_DownloadTask.h +0 -21
@@ -20,7 +20,6 @@ Hdp_Files := \
20
20
  lzma/C/Lzma2Dec.c
21
21
 
22
22
  LOCAL_SRC_FILES := \
23
- DownloadTask.c \
24
23
  ../../cpp/patch_core/archive_patch_core.cpp \
25
24
  ../../cpp/patch_core/patch_core.cpp \
26
25
  ../../cpp/patch_core/patch_core_android.cpp \
Binary file
Binary file
@@ -33,7 +33,16 @@ class DownloadTask implements Runnable {
33
33
  // events on percentage change, so throttle by bytes to avoid flooding the
34
34
  // bridge (e.g. a 20MB chunked download would otherwise emit ~5000 events).
35
35
  private static final long PROGRESS_BYTES_THRESHOLD = 256 * 1024;
36
- private static final OkHttpClient HTTP_CLIENT = new OkHttpClient();
36
+ // Explicit timeouts: the default client has no call timeout, so a
37
+ // slow-dripping connection could occupy the single-threaded download
38
+ // executor indefinitely and starve queued tasks. The call timeout is a
39
+ // generous upper bound sized for large full-package downloads.
40
+ private static final OkHttpClient HTTP_CLIENT = new OkHttpClient.Builder()
41
+ .connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
42
+ .readTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
43
+ .writeTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
44
+ .callTimeout(10, java.util.concurrent.TimeUnit.MINUTES)
45
+ .build();
37
46
 
38
47
  static {
39
48
  NativeUpdateCore.ensureLoaded();
@@ -0,0 +1,24 @@
1
+ package cn.reactnative.modules.update;
2
+
3
+ /**
4
+ * Stable, machine-readable error codes used as the promise rejection code so
5
+ * the JS layer and user loggers can aggregate errors across platforms.
6
+ *
7
+ * MUST stay in sync with cpp/patch_core/error_codes.h (the single source of
8
+ * truth) and src/error.ts (UpdateErrorCode). Messages are free-form; only the
9
+ * codes are part of the contract.
10
+ */
11
+ final class ErrorCodes {
12
+ static final String INVALID_OPTIONS = "INVALID_OPTIONS";
13
+ static final String DOWNLOAD_FAILED = "DOWNLOAD_FAILED";
14
+ static final String PATCH_FAILED = "PATCH_FAILED";
15
+ static final String FILE_OPERATION_FAILED = "FILE_OPERATION_FAILED";
16
+ static final String SWITCH_VERSION_FAILED = "SWITCH_VERSION_FAILED";
17
+ static final String MARK_SUCCESS_FAILED = "MARK_SUCCESS_FAILED";
18
+ static final String RESTART_FAILED = "RESTART_FAILED";
19
+ static final String INVALID_HASH_INFO = "INVALID_HASH_INFO";
20
+ static final String UNSUPPORTED_PLATFORM = "UNSUPPORTED_PLATFORM";
21
+
22
+ private ErrorCodes() {
23
+ }
24
+ }
@@ -43,6 +43,7 @@ final class StateSerialRunner {
43
43
 
44
44
  static void run(
45
45
  @Nullable final Promise promise,
46
+ final String errorCode,
46
47
  final String operationName,
47
48
  final Operation operation
48
49
  ) {
@@ -53,7 +54,7 @@ final class StateSerialRunner {
53
54
  operation.run();
54
55
  } catch (Throwable error) {
55
56
  if (promise != null) {
56
- promise.reject(operationName + " failed", error);
57
+ promise.reject(errorCode, operationName + " failed", error);
57
58
  } else {
58
59
  Log.e(UpdateContext.TAG, operationName + " failed", error);
59
60
  }
@@ -15,6 +15,7 @@ final class UiThreadRunner {
15
15
 
16
16
  static void run(
17
17
  @Nullable final Promise promise,
18
+ final String errorCode,
18
19
  final String operationName,
19
20
  final Operation operation
20
21
  ) {
@@ -25,7 +26,7 @@ final class UiThreadRunner {
25
26
  operation.run();
26
27
  } catch (Throwable error) {
27
28
  if (promise != null) {
28
- promise.reject(operationName + " failed", error);
29
+ promise.reject(errorCode, operationName + " failed", error);
29
30
  } else {
30
31
  Log.e(UpdateContext.TAG, operationName + " failed", error);
31
32
  }
@@ -207,14 +207,16 @@ public class UpdateContext {
207
207
  }
208
208
 
209
209
  private void persistEditor(SharedPreferences.Editor editor, String reason) {
210
- if (!editor.commit() && DEBUG) {
211
- Log.w(TAG, "Failed to persist update state for " + reason);
210
+ // A lost state write can mean a missed rollback or a version switch
211
+ // that silently never happens, so this must be visible in release too.
212
+ if (!editor.commit()) {
213
+ Log.e(TAG, "Failed to persist update state for " + reason);
212
214
  }
213
215
  }
214
216
 
215
217
  public void switchVersion(String hash) {
216
218
  if (!new File(rootDir, hash+"/index.bundlejs").exists()) {
217
- throw new Error("Bundle version " + hash + " not found.");
219
+ throw new IllegalStateException("Bundle version " + hash + " not found.");
218
220
  }
219
221
  StateCoreResult currentState = getStateSnapshot();
220
222
  StateCoreResult nextState = runStateCore(
@@ -367,6 +369,13 @@ public class UpdateContext {
367
369
  ignoreRollback,
368
370
  true
369
371
  );
372
+ if (launchState.didRollback) {
373
+ // The crash-protection rollback: the new version never called
374
+ // markSuccess. Keep this visible in release logs.
375
+ Log.e(TAG, "Version " + currentState.currentVersion
376
+ + " was not marked as successful, rolling back to "
377
+ + launchState.currentVersion);
378
+ }
370
379
  if (launchState.didRollback || launchState.consumedFirstTime) {
371
380
  SharedPreferences.Editor editor = sp.edit();
372
381
  applyState(editor, launchState);
@@ -385,7 +394,11 @@ public class UpdateContext {
385
394
  return defaultAssetsUrl;
386
395
  }
387
396
 
388
- while (currentVersion != null) {
397
+ // Guard the rollback chain against cycles: a corrupted state returning
398
+ // an already-visited version would otherwise spin this loop forever on
399
+ // the main thread.
400
+ java.util.HashSet<String> visitedVersions = new java.util.HashSet<>();
401
+ while (currentVersion != null && visitedVersions.add(currentVersion)) {
389
402
  File bundleFile = new File(rootDir, currentVersion+"/index.bundlejs");
390
403
  if (!bundleFile.exists()) {
391
404
  Log.e(TAG, "Bundle version " + currentVersion + " not found.");
@@ -407,6 +420,8 @@ public class UpdateContext {
407
420
  false,
408
421
  false
409
422
  );
423
+ Log.e(TAG, "Rolling back version " + currentState.currentVersion
424
+ + " to " + nextState.currentVersion);
410
425
  SharedPreferences.Editor editor = sp.edit();
411
426
  applyState(editor, nextState);
412
427
  persistEditor(editor, "rollback");
@@ -24,7 +24,7 @@ final class UpdateEventEmitter {
24
24
 
25
25
  static void sendEvent(String eventName, WritableMap params) {
26
26
  ReactApplicationContext reactContext = getReactContext();
27
- if (reactContext == null || !reactContext.hasActiveCatalystInstance()) {
27
+ if (reactContext == null || !hasActiveInstance(reactContext)) {
28
28
  return;
29
29
  }
30
30
 
@@ -32,4 +32,16 @@ final class UpdateEventEmitter {
32
32
  .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
33
33
  .emit(eventName, params);
34
34
  }
35
+
36
+ @SuppressWarnings("deprecation")
37
+ private static boolean hasActiveInstance(ReactApplicationContext reactContext) {
38
+ try {
39
+ // hasActiveCatalystInstance() is always false in bridgeless mode, which
40
+ // silently drops every progress event on the new architecture.
41
+ return reactContext.hasActiveReactInstance();
42
+ } catch (NoSuchMethodError e) {
43
+ // RN < 0.68 has no hasActiveReactInstance(); fall back for old peers.
44
+ return reactContext.hasActiveCatalystInstance();
45
+ }
46
+ }
35
47
  }
@@ -41,7 +41,7 @@ public class UpdateModuleImpl {
41
41
 
42
42
  @Override
43
43
  public void onDownloadFailed(Throwable error) {
44
- promise.reject(error);
44
+ promise.reject(ErrorCodes.DOWNLOAD_FAILED, error);
45
45
  }
46
46
  });
47
47
  }
@@ -64,7 +64,7 @@ public class UpdateModuleImpl {
64
64
 
65
65
  @Override
66
66
  public void onDownloadFailed(Throwable error) {
67
- promise.reject(error);
67
+ promise.reject(ErrorCodes.DOWNLOAD_FAILED, error);
68
68
  }
69
69
  });
70
70
  }
@@ -84,7 +84,7 @@ public class UpdateModuleImpl {
84
84
 
85
85
  @Override
86
86
  public void onDownloadFailed(Throwable error) {
87
- promise.reject(error);
87
+ promise.reject(ErrorCodes.DOWNLOAD_FAILED, error);
88
88
  }
89
89
  });
90
90
  }
@@ -107,11 +107,11 @@ public class UpdateModuleImpl {
107
107
 
108
108
  @Override
109
109
  public void onDownloadFailed(Throwable error) {
110
- promise.reject(error);
110
+ promise.reject(ErrorCodes.DOWNLOAD_FAILED, error);
111
111
  }
112
112
  });
113
113
  } catch (Exception e) {
114
- promise.reject("downloadPatchFromPpk failed: " + e.getMessage());
114
+ promise.reject(ErrorCodes.INVALID_OPTIONS, "downloadPatchFromPpk failed: " + e.getMessage(), e);
115
115
  }
116
116
  }
117
117
 
@@ -130,7 +130,7 @@ public class UpdateModuleImpl {
130
130
  @Nullable final String hash,
131
131
  final Promise promise
132
132
  ) {
133
- UiThreadRunner.run(promise, "restartApp", new UiThreadRunner.Operation() {
133
+ UiThreadRunner.run(promise, ErrorCodes.RESTART_FAILED, "restartApp", new UiThreadRunner.Operation() {
134
134
  @Override
135
135
  public void run() throws Throwable {
136
136
  ReactReloadManager.restartApp(updateContext, reactContext, hash);
@@ -149,7 +149,7 @@ public class UpdateModuleImpl {
149
149
  final Promise promise
150
150
  ) {
151
151
  final String hash = options.getString("hash");
152
- StateSerialRunner.run(promise, "switchVersionLater", new StateSerialRunner.Operation() {
152
+ StateSerialRunner.run(promise, ErrorCodes.SWITCH_VERSION_FAILED, "switchVersionLater", new StateSerialRunner.Operation() {
153
153
  @Override
154
154
  public void run() {
155
155
  setNeedUpdateInternal(updateContext, hash);
@@ -160,7 +160,7 @@ public class UpdateModuleImpl {
160
160
 
161
161
  public static void setNeedUpdate(final UpdateContext updateContext, final ReadableMap options) {
162
162
  final String hash = options.getString("hash");
163
- StateSerialRunner.run(null, "switchVersionLater", new StateSerialRunner.Operation() {
163
+ StateSerialRunner.run(null, ErrorCodes.SWITCH_VERSION_FAILED, "switchVersionLater", new StateSerialRunner.Operation() {
164
164
  @Override
165
165
  public void run() {
166
166
  setNeedUpdateInternal(updateContext, hash);
@@ -173,7 +173,7 @@ public class UpdateModuleImpl {
173
173
  }
174
174
 
175
175
  public static void markSuccess(final UpdateContext updateContext, final Promise promise) {
176
- StateSerialRunner.run(promise, "markSuccess", new StateSerialRunner.Operation() {
176
+ StateSerialRunner.run(promise, ErrorCodes.MARK_SUCCESS_FAILED, "markSuccess", new StateSerialRunner.Operation() {
177
177
  @Override
178
178
  public void run() {
179
179
  markSuccessInternal(updateContext);
@@ -183,7 +183,7 @@ public class UpdateModuleImpl {
183
183
  }
184
184
 
185
185
  public static void markSuccess(final UpdateContext updateContext) {
186
- StateSerialRunner.run(null, "markSuccess", new StateSerialRunner.Operation() {
186
+ StateSerialRunner.run(null, ErrorCodes.MARK_SUCCESS_FAILED, "markSuccess", new StateSerialRunner.Operation() {
187
187
  @Override
188
188
  public void run() {
189
189
  markSuccessInternal(updateContext);
@@ -200,7 +200,7 @@ public class UpdateModuleImpl {
200
200
  final String uuid,
201
201
  final Promise promise
202
202
  ) {
203
- StateSerialRunner.run(promise, "setUuid", new StateSerialRunner.Operation() {
203
+ StateSerialRunner.run(promise, ErrorCodes.FILE_OPERATION_FAILED, "setUuid", new StateSerialRunner.Operation() {
204
204
  @Override
205
205
  public void run() {
206
206
  setUuidInternal(updateContext, uuid);
@@ -210,7 +210,7 @@ public class UpdateModuleImpl {
210
210
  }
211
211
 
212
212
  public static void setUuid(final UpdateContext updateContext, final String uuid) {
213
- StateSerialRunner.run(null, "setUuid", new StateSerialRunner.Operation() {
213
+ StateSerialRunner.run(null, ErrorCodes.FILE_OPERATION_FAILED, "setUuid", new StateSerialRunner.Operation() {
214
214
  @Override
215
215
  public void run() {
216
216
  setUuidInternal(updateContext, uuid);
@@ -235,7 +235,7 @@ public class UpdateModuleImpl {
235
235
  final String info,
236
236
  final Promise promise
237
237
  ) {
238
- StateSerialRunner.run(promise, "setLocalHashInfo", new StateSerialRunner.Operation() {
238
+ StateSerialRunner.run(promise, ErrorCodes.INVALID_HASH_INFO, "setLocalHashInfo", new StateSerialRunner.Operation() {
239
239
  @Override
240
240
  public void run() {
241
241
  setLocalHashInfoInternal(updateContext, hash, info);
@@ -249,7 +249,7 @@ public class UpdateModuleImpl {
249
249
  final String hash,
250
250
  final String info
251
251
  ) {
252
- StateSerialRunner.run(null, "setLocalHashInfo", new StateSerialRunner.Operation() {
252
+ StateSerialRunner.run(null, ErrorCodes.INVALID_HASH_INFO, "setLocalHashInfo", new StateSerialRunner.Operation() {
253
253
  @Override
254
254
  public void run() {
255
255
  setLocalHashInfoInternal(updateContext, hash, info);
@@ -264,7 +264,7 @@ public class UpdateModuleImpl {
264
264
  ) {
265
265
  String value = updateContext.getKv("hash_" + hash);
266
266
  if (!isValidHashInfo(value)) {
267
- promise.reject("getLocalHashInfo failed: invalid json string");
267
+ promise.reject(ErrorCodes.INVALID_HASH_INFO, "getLocalHashInfo failed: invalid json string");
268
268
  return;
269
269
  }
270
270
 
@@ -0,0 +1,43 @@
1
+ #ifndef PUSHY_PATCH_CORE_ERROR_CODES_H_
2
+ #define PUSHY_PATCH_CORE_ERROR_CODES_H_
3
+
4
+ // Single source of truth for the stable, machine-readable error codes shared
5
+ // by every platform. Native modules reject promises with one of these codes
6
+ // so the JS layer (src/error.ts UpdateErrorCode) and user loggers can
7
+ // aggregate errors across platforms and locales.
8
+ //
9
+ // Mirrors that cannot include this header MUST stay in sync by hand:
10
+ // - src/error.ts (UpdateErrorCode union, JS layer)
11
+ // - android/.../ErrorCodes.java (Java constants)
12
+ // iOS (RCTPushy.mm) includes this header directly.
13
+ //
14
+ // Human-readable messages are NOT part of this contract: they may differ per
15
+ // platform and locale. Only the codes are stable.
16
+
17
+ namespace pushy {
18
+ namespace error_codes {
19
+
20
+ // Method options missing or malformed (blank hash/url, wrong types).
21
+ constexpr const char* kInvalidOptions = "INVALID_OPTIONS";
22
+ // Native download failed (network error, bad HTTP status, truncated body).
23
+ constexpr const char* kDownloadFailed = "DOWNLOAD_FAILED";
24
+ // Unzip or hdiff patch application failed.
25
+ constexpr const char* kPatchFailed = "PATCH_FAILED";
26
+ // Local file or state persistence operation failed.
27
+ constexpr const char* kFileOperationFailed = "FILE_OPERATION_FAILED";
28
+ // switchVersion / setNeedUpdate state transition failed.
29
+ constexpr const char* kSwitchVersionFailed = "SWITCH_VERSION_FAILED";
30
+ // markSuccess state transition failed.
31
+ constexpr const char* kMarkSuccessFailed = "MARK_SUCCESS_FAILED";
32
+ // reloadUpdate / restartApp failed.
33
+ constexpr const char* kRestartFailed = "RESTART_FAILED";
34
+ // Stored or provided hash info is not a valid JSON object.
35
+ constexpr const char* kInvalidHashInfo = "INVALID_HASH_INFO";
36
+ // The method is not supported on this platform (e.g. downloadAndInstallApk
37
+ // outside Android).
38
+ constexpr const char* kUnsupportedPlatform = "UNSUPPORTED_PLATFORM";
39
+
40
+ } // namespace error_codes
41
+ } // namespace pushy
42
+
43
+ #endif // PUSHY_PATCH_CORE_ERROR_CODES_H_
@@ -8,7 +8,6 @@
8
8
  #include <sys/types.h>
9
9
  #include <unistd.h>
10
10
 
11
- #include <set>
12
11
  #include <vector>
13
12
 
14
13
  extern "C" {
@@ -17,6 +16,11 @@ extern "C" {
17
16
 
18
17
  namespace pushy {
19
18
  namespace patch {
19
+
20
+ namespace internal {
21
+ bool g_disable_hard_links = false;
22
+ } // namespace internal
23
+
20
24
  namespace {
21
25
 
22
26
  constexpr size_t kCopyBufferSize = 16 * 1024;
@@ -202,6 +206,17 @@ Status CopyFile(const std::string& from, const std::string& to, bool overwrite)
202
206
  }
203
207
  }
204
208
 
209
+ // Prefer a hard link over copying bytes: unchanged files between versions
210
+ // are identical, so linking is O(1) per file, writes nothing to flash, and
211
+ // shares disk blocks. Version directories are immutable once created (patch
212
+ // outputs are always written as new files), so sharing the inode with the
213
+ // source is safe. Fall back to a byte copy whenever linking is not possible
214
+ // (cross-device source such as the installed app bundle, EPERM, EMLINK, or
215
+ // filesystems without hard-link support).
216
+ if (!internal::g_disable_hard_links && link(from.c_str(), to.c_str()) == 0) {
217
+ return Status::Ok();
218
+ }
219
+
205
220
  FILE* source = std::fopen(from.c_str(), "rb");
206
221
  if (!source) {
207
222
  return MakeErrnoStatus("Failed to open source file " + from);
@@ -64,5 +64,11 @@ Status CleanupOldEntries(
64
64
 
65
65
  bool IsSafeRelativePath(const std::string& path);
66
66
 
67
+ namespace internal {
68
+ // Test-only escape hatch: forces file copies to take the byte-copy fallback
69
+ // instead of hard-linking, so tests can cover both paths on one filesystem.
70
+ extern bool g_disable_hard_links;
71
+ } // namespace internal
72
+
67
73
  } // namespace patch
68
74
  } // namespace pushy
@@ -175,6 +175,81 @@ void TestApplyPatchFromFileSourceMergesAndCopies() {
175
175
  Expect(!Exists(JoinPath(target, "assets/delete.txt")), "deleted asset should not be copied");
176
176
  }
177
177
 
178
+ void TestApplyPatchMergeHardLinksUnchangedFiles() {
179
+ TempDir temp;
180
+ const std::string source = JoinPath(temp.path, "origin");
181
+ const std::string target = JoinPath(temp.path, "target");
182
+ const std::string patch = JoinPath(temp.path, "bundle.patch");
183
+
184
+ WriteFile(JoinPath(source, "index.bundlejs"), "old bundle");
185
+ WriteFile(JoinPath(source, "assets/keep.txt"), "keep");
186
+ WriteFile(patch, "unused patch");
187
+
188
+ FakeBundlePatcher patcher("patched bundle");
189
+ FileSourcePatchOptions options;
190
+ options.source_root = source;
191
+ options.target_root = target;
192
+ options.origin_bundle_path = JoinPath(source, "index.bundlejs");
193
+ options.bundle_patch_path = patch;
194
+ options.bundle_output_path = JoinPath(target, "index.bundlejs");
195
+ options.manifest.copies.push_back(CopyOperation{"assets/keep.txt", "assets/copied.txt"});
196
+
197
+ Status status = ApplyPatchFromFileSource(options, patcher);
198
+ Expect(status.ok, status.message);
199
+
200
+ // Unchanged files must share the source inode (hard link) instead of being
201
+ // byte-copied: same filesystem, so the fast path has to kick in.
202
+ struct stat source_stat;
203
+ struct stat merged_stat;
204
+ struct stat copied_stat;
205
+ Expect(stat(JoinPath(source, "assets/keep.txt").c_str(), &source_stat) == 0, "stat source");
206
+ Expect(stat(JoinPath(target, "assets/keep.txt").c_str(), &merged_stat) == 0, "stat merged");
207
+ Expect(stat(JoinPath(target, "assets/copied.txt").c_str(), &copied_stat) == 0, "stat copied");
208
+ Expect(merged_stat.st_ino == source_stat.st_ino, "merged file should hard-link the source");
209
+ Expect(copied_stat.st_ino == source_stat.st_ino, "manifest copy should hard-link the source");
210
+ Expect(source_stat.st_nlink >= 3, "source should have three names");
211
+ ExpectEq(ReadFile(JoinPath(target, "assets/keep.txt")), "keep", "merged content mismatch");
212
+
213
+ // The patched bundle must be a fresh file, never a link into the source dir.
214
+ struct stat bundle_stat;
215
+ struct stat origin_stat;
216
+ Expect(stat(JoinPath(target, "index.bundlejs").c_str(), &bundle_stat) == 0, "stat bundle");
217
+ Expect(stat(JoinPath(source, "index.bundlejs").c_str(), &origin_stat) == 0, "stat origin bundle");
218
+ Expect(bundle_stat.st_ino != origin_stat.st_ino, "patched bundle must not link the origin");
219
+ }
220
+
221
+ void TestApplyPatchMergeFallsBackToByteCopy() {
222
+ TempDir temp;
223
+ const std::string source = JoinPath(temp.path, "origin");
224
+ const std::string target = JoinPath(temp.path, "target");
225
+ const std::string patch = JoinPath(temp.path, "bundle.patch");
226
+
227
+ WriteFile(JoinPath(source, "index.bundlejs"), "old bundle");
228
+ WriteFile(JoinPath(source, "assets/keep.txt"), "keep");
229
+ WriteFile(patch, "unused patch");
230
+
231
+ FakeBundlePatcher patcher("patched bundle");
232
+ FileSourcePatchOptions options;
233
+ options.source_root = source;
234
+ options.target_root = target;
235
+ options.origin_bundle_path = JoinPath(source, "index.bundlejs");
236
+ options.bundle_patch_path = patch;
237
+ options.bundle_output_path = JoinPath(target, "index.bundlejs");
238
+
239
+ pushy::patch::internal::g_disable_hard_links = true;
240
+ Status status = ApplyPatchFromFileSource(options, patcher);
241
+ pushy::patch::internal::g_disable_hard_links = false;
242
+ Expect(status.ok, status.message);
243
+
244
+ struct stat source_stat;
245
+ struct stat merged_stat;
246
+ Expect(stat(JoinPath(source, "assets/keep.txt").c_str(), &source_stat) == 0, "stat source");
247
+ Expect(stat(JoinPath(target, "assets/keep.txt").c_str(), &merged_stat) == 0, "stat merged");
248
+ Expect(merged_stat.st_ino != source_stat.st_ino, "fallback should produce an independent copy");
249
+ Expect(merged_stat.st_nlink == 1, "fallback copy should have a single name");
250
+ ExpectEq(ReadFile(JoinPath(target, "assets/keep.txt")), "keep", "fallback content mismatch");
251
+ }
252
+
178
253
  void TestApplyPatchFromFileSourceCanLimitMergeSubdir() {
179
254
  TempDir temp;
180
255
  const std::string source = JoinPath(temp.path, "origin");
@@ -537,6 +612,8 @@ void TestStateCoreSwitchToSameVersion() {
537
612
  int main() {
538
613
  const std::vector<std::pair<std::string, void (*)()>> tests = {
539
614
  {"ApplyPatchFromFileSourceMergesAndCopies", TestApplyPatchFromFileSourceMergesAndCopies},
615
+ {"ApplyPatchMergeHardLinksUnchangedFiles", TestApplyPatchMergeHardLinksUnchangedFiles},
616
+ {"ApplyPatchMergeFallsBackToByteCopy", TestApplyPatchMergeFallsBackToByteCopy},
540
617
  {"ApplyPatchFromFileSourceCanLimitMergeSubdir", TestApplyPatchFromFileSourceCanLimitMergeSubdir},
541
618
  {"ApplyPatchFromFileSourceRejectsUnsafePaths", TestApplyPatchFromFileSourceRejectsUnsafePaths},
542
619
  {"CleanupOldEntriesRemovesOnlyExpiredPaths", TestCleanupOldEntriesRemovesOnlyExpiredPaths},
@@ -11,7 +11,6 @@
11
11
  #include "state_ops.h"
12
12
 
13
13
  extern "C" {
14
- #include "hpatch.h"
15
14
  }
16
15
 
17
16
  namespace {
@@ -407,96 +406,6 @@ pushy::patch::PatchManifest BuildManifest(
407
406
  return manifest;
408
407
  }
409
408
 
410
- napi_value HdiffPatch(napi_env env, napi_callback_info info) {
411
- napi_value args[2] = {nullptr, nullptr};
412
- size_t argc = 2;
413
- if (!GetArgCount(env, info, &argc, args) || argc < 2) {
414
- ThrowError(env, "Wrong number of arguments");
415
- return nullptr;
416
- }
417
-
418
- bool is_typed_array = false;
419
- if (napi_is_typedarray(env, args[0], &is_typed_array) != napi_ok || !is_typed_array) {
420
- ThrowError(env, "First argument must be a TypedArray");
421
- return nullptr;
422
- }
423
-
424
- uint8_t* origin_ptr = nullptr;
425
- size_t origin_length = 0;
426
- if (napi_get_typedarray_info(
427
- env,
428
- args[0],
429
- nullptr,
430
- &origin_length,
431
- reinterpret_cast<void**>(&origin_ptr),
432
- nullptr,
433
- nullptr) != napi_ok) {
434
- ThrowError(env, "Failed to get origin buffer");
435
- return nullptr;
436
- }
437
-
438
- if (napi_is_typedarray(env, args[1], &is_typed_array) != napi_ok || !is_typed_array) {
439
- ThrowError(env, "Second argument must be a TypedArray");
440
- return nullptr;
441
- }
442
-
443
- uint8_t* patch_ptr = nullptr;
444
- size_t patch_length = 0;
445
- if (napi_get_typedarray_info(
446
- env,
447
- args[1],
448
- nullptr,
449
- &patch_length,
450
- reinterpret_cast<void**>(&patch_ptr),
451
- nullptr,
452
- nullptr) != napi_ok) {
453
- ThrowError(env, "Failed to get patch buffer");
454
- return nullptr;
455
- }
456
-
457
- hpatch_singleCompressedDiffInfo patch_info;
458
- if (!((origin_length == 0) || origin_ptr) || !patch_ptr || patch_length == 0) {
459
- ThrowError(env, "Corrupt patch");
460
- return nullptr;
461
- }
462
- if (kHPatch_ok != hpatch_getInfo_by_mem(&patch_info, patch_ptr, patch_length)) {
463
- ThrowError(env, "Error info in hpatch");
464
- return nullptr;
465
- }
466
- if (origin_length != patch_info.oldDataSize) {
467
- ThrowError(env, "Error oldDataSize in hpatch");
468
- return nullptr;
469
- }
470
-
471
- size_t new_size = static_cast<size_t>(patch_info.newDataSize);
472
- if (sizeof(size_t) != sizeof(hpatch_StreamPos_t) &&
473
- new_size != patch_info.newDataSize) {
474
- ThrowError(env, "Error newDataSize in hpatch");
475
- return nullptr;
476
- }
477
-
478
- void* output_data = nullptr;
479
- napi_value result = nullptr;
480
- if (napi_create_arraybuffer(env, new_size, &output_data, &result) != napi_ok) {
481
- ThrowError(env, "Failed to create result buffer");
482
- return nullptr;
483
- }
484
-
485
- if (kHPatch_ok != hpatch_by_mem(
486
- origin_ptr,
487
- origin_length,
488
- static_cast<uint8_t*>(output_data),
489
- new_size,
490
- patch_ptr,
491
- patch_length,
492
- &patch_info)) {
493
- ThrowError(env, "hpatch");
494
- return nullptr;
495
- }
496
-
497
- return result;
498
- }
499
-
500
409
  napi_value SyncStateWithBinaryVersion(napi_env env, napi_callback_info info) {
501
410
  napi_value args[3] = {nullptr, nullptr, nullptr};
502
411
  size_t argc = 3;
@@ -968,8 +877,7 @@ bool ExportFunction(
968
877
  } // namespace
969
878
 
970
879
  napi_value Init(napi_env env, napi_value exports) {
971
- if (!ExportFunction(env, exports, "hdiffPatch", HdiffPatch) ||
972
- !ExportFunction(env, exports, "syncStateWithBinaryVersion", SyncStateWithBinaryVersion) ||
880
+ if (!ExportFunction(env, exports, "syncStateWithBinaryVersion", SyncStateWithBinaryVersion) ||
973
881
  !ExportFunction(env, exports, "runStateCore", RunStateCore) ||
974
882
  !ExportFunction(env, exports, "buildArchivePatchPlan", BuildArchivePatchPlan) ||
975
883
  !ExportFunction(env, exports, "buildCopyGroups", BuildCopyGroups) ||