react-native-update 10.48.0 → 10.48.1

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.
@@ -18,15 +18,6 @@
18
18
  #define _check(v,errorType) do{ \
19
19
  if (!(v)){ if (result==kHPatch_ok) result=errorType; if (!_isInClear){ goto _clear; }; } }while(0)
20
20
 
21
- int hpatch_getInfo_by_mem(hpatch_singleCompressedDiffInfo* out_patinfo,
22
- const uint8_t* pat,size_t patsize){
23
- hpatch_TStreamInput patStream;
24
- mem_as_hStreamInput(&patStream,pat,pat+patsize);
25
- if (!getSingleCompressedDiffInfo(out_patinfo,&patStream,0))
26
- return kHPatch_error_info;//data error;
27
- return kHPatch_ok; //ok
28
- }
29
-
30
21
  static hpatch_TDecompress* getDecompressPlugin(const char* compressType){
31
22
  #ifdef _CompressPlugin_zlib
32
23
  if (zlibDecompressPlugin.is_can_open(compressType))
@@ -96,17 +87,6 @@ _clear:
96
87
  return result;
97
88
  }
98
89
 
99
- int hpatch_by_mem(const uint8_t* old,size_t oldsize,uint8_t* newBuf,size_t newsize,
100
- const uint8_t* pat,size_t patsize,const hpatch_singleCompressedDiffInfo* patInfo){
101
- hpatch_TStreamInput oldStream;
102
- hpatch_TStreamInput patStream;
103
- hpatch_TStreamOutput newStream;
104
- mem_as_hStreamInput(&oldStream,old,old+oldsize);
105
- mem_as_hStreamInput(&patStream,pat,pat+patsize);
106
- mem_as_hStreamOutput(&newStream,newBuf,newBuf+newsize);
107
- return hpatch_by_stream(&oldStream,hpatch_FALSE,&patStream,&newStream,patInfo);
108
- }
109
-
110
90
  // HDIFF13(diffStream 产物,v2 轨道的大 bundle patch):流式应用,
111
91
  // 内存 = 解压缓存 + IO 缓冲(+ old ≤ 8MB 时的整载优化),与 single 路径同级。
112
92
  static int hpatch_v13_by_stream(const hpatch_TStreamInput* old,hpatch_BOOL isLoadOldAllToMem,
@@ -30,12 +30,6 @@ enum {
30
30
  kHPatch_error_new_size =-15,
31
31
  };
32
32
 
33
- int hpatch_getInfo_by_mem(hpatch_singleCompressedDiffInfo* out_patinfo,
34
- const uint8_t* pat,size_t patsize);
35
-
36
- //patInfo can NULL
37
- int hpatch_by_mem(const uint8_t* old,size_t oldsize, uint8_t* newBuf,size_t newsize,
38
- const uint8_t* pat,size_t patsize,const hpatch_singleCompressedDiffInfo* patInfo);
39
33
  int hpatch_by_file(const char* oldfile, const char* newfile, const char* patchfile);
40
34
 
41
35
  #ifdef __cplusplus
Binary file
Binary file
@@ -85,12 +85,15 @@ class DownloadTask implements Runnable {
85
85
  }
86
86
 
87
87
  private void postProgress(final long received, final long total) {
88
+ // Cross-platform progress contract: unknown length is reported as
89
+ // total=0, never a raw -1 (OkHttp's contentLength for chunked/gzip).
90
+ final long normalizedTotal = total > 0 ? total : 0;
88
91
  mainHandler.post(new Runnable() {
89
92
  @Override
90
93
  public void run() {
91
94
  WritableMap progress = Arguments.createMap();
92
95
  progress.putDouble("received", received);
93
- progress.putDouble("total", total);
96
+ progress.putDouble("total", normalizedTotal);
94
97
  progress.putString("hash", hash);
95
98
  UpdateEventEmitter.sendEvent("RCTPushyDownloadProgress", progress);
96
99
  }
@@ -136,6 +139,7 @@ class DownloadTask implements Runnable {
136
139
  int percentage = (int) (received * 100.0 / contentLength + 0.5);
137
140
  if (percentage > currentPercentage) {
138
141
  currentPercentage = percentage;
142
+ lastPostedBytes = received;
139
143
  postProgress(received, contentLength);
140
144
  }
141
145
  } else if (received - lastPostedBytes >= PROGRESS_BYTES_THRESHOLD) {
@@ -149,7 +153,11 @@ class DownloadTask implements Runnable {
149
153
  if (contentLength >= 0 && received != contentLength) {
150
154
  throw new IOException("Unexpected eof while reading downloaded update");
151
155
  }
152
- postProgress(received, contentLength);
156
+ // Final progress event, skipped when the loop already posted this
157
+ // exact value (known length reaching 100% posts it in-loop).
158
+ if (received != lastPostedBytes) {
159
+ postProgress(received, contentLength);
160
+ }
153
161
  }
154
162
 
155
163
  }
@@ -26,8 +26,16 @@ public class UpdateContext {
26
26
  private final SharedPreferences sp;
27
27
 
28
28
  private ReactInstanceManager reactInstanceManager;
29
- private boolean isUsingBundleUrl;
30
- private boolean ignoreRollback;
29
+ // Written on the launch path, read from executor/JS threads.
30
+ private volatile boolean isUsingBundleUrl;
31
+ private volatile boolean ignoreRollback;
32
+ // The version whose bundle this process actually loaded (resolved in
33
+ // getBundleUrl). resetToPackagedBundle must not delete its directory:
34
+ // update assets (images/fonts) are read from it on demand at runtime, so
35
+ // wiping it under a silent (no-restart) reset would break every image the
36
+ // running app has not loaded yet. Volatile: written during launch, read
37
+ // from the state serial executor.
38
+ private volatile String launchVersion;
31
39
  private static final int STATE_OP_SWITCH_VERSION = 1;
32
40
  private static final int STATE_OP_MARK_SUCCESS = 2;
33
41
  private static final int STATE_OP_ROLLBACK = 3;
@@ -305,9 +313,11 @@ public class UpdateContext {
305
313
 
306
314
  /**
307
315
  * Reset to the bundle packaged in the binary: wipe the whole update state
308
- * (so the next launch resolves to the built-in bundle) and delete every
309
- * downloaded version. Only the client uuid survives it identifies the
310
- * install for gray release bucketing and must not change on reset.
316
+ * (so the next launch resolves to the built-in bundle) and delete the
317
+ * downloaded versions, keeping only the directory of the version this
318
+ * process is running from (a silent reset must not break its on-demand
319
+ * asset loads). Only the client uuid survives — it identifies the install
320
+ * for gray release bucketing and must not change on reset.
311
321
  */
312
322
  public void resetToPackagedBundle() {
313
323
  StateCoreResult resetState = new StateCoreResult();
@@ -329,6 +339,10 @@ public class UpdateContext {
329
339
  DownloadTaskParams params = new DownloadTaskParams();
330
340
  params.type = DownloadTaskParams.TASK_TYPE_CLEANUP;
331
341
  params.maxAgeDays = 0;
342
+ // Keep the directory of the version this process is running from (a
343
+ // silent reset would otherwise break its on-demand asset loads); the
344
+ // orphaned directory is removed by the next regular cleanup.
345
+ params.hash = launchVersion;
332
346
  params.unzipDirectory = rootDir;
333
347
  enqueue(params);
334
348
  }
@@ -435,6 +449,7 @@ public class UpdateContext {
435
449
  currentVersion = this.rollBack();
436
450
  continue;
437
451
  }
452
+ launchVersion = currentVersion;
438
453
  return bundleFile.toString();
439
454
  }
440
455
 
@@ -1,5 +1,6 @@
1
1
  package cn.reactnative.modules.update;
2
2
 
3
+ import android.util.Log;
3
4
  import androidx.annotation.Nullable;
4
5
  import com.facebook.react.bridge.ReactApplicationContext;
5
6
  import com.facebook.react.modules.core.DeviceEventManagerModule;
@@ -28,9 +29,17 @@ final class UpdateEventEmitter {
28
29
  return;
29
30
  }
30
31
 
31
- reactContext
32
- .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
33
- .emit(eventName, params);
32
+ try {
33
+ reactContext
34
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
35
+ .emit(eventName, params);
36
+ } catch (RuntimeException e) {
37
+ // The instance can be torn down between the check above and the
38
+ // emit (this SDK itself triggers reloads); sendEvent runs on the
39
+ // main thread, so a throw here would crash the app for a lost
40
+ // progress tick.
41
+ Log.w("pushy", "sendEvent " + eventName + " failed: " + e.getMessage());
42
+ }
34
43
  }
35
44
 
36
45
  @SuppressWarnings("deprecation")
@@ -5,6 +5,7 @@ import static androidx.core.content.FileProvider.getUriForFile;
5
5
  import android.content.Intent;
6
6
  import android.net.Uri;
7
7
  import android.os.Build;
8
+ import android.util.Log;
8
9
  import com.facebook.react.bridge.ReactApplicationContext;
9
10
  import java.io.File;
10
11
  import java.util.HashMap;
@@ -35,7 +36,16 @@ final class UpdateModuleSupport {
35
36
  }
36
37
 
37
38
  constants.put("uuid", updateContext.getKv("uuid"));
38
- constants.put("supportedDiffVersion", NativeUpdateCore.supportedDiffVersion());
39
+ int supportedDiffVersion = 0;
40
+ try {
41
+ supportedDiffVersion = NativeUpdateCore.supportedDiffVersion();
42
+ } catch (UnsatisfiedLinkError e) {
43
+ // A mismatched librnupdate.so (stale manual copy / build cache)
44
+ // must not crash startup via getConstants; 0 simply means "no v2
45
+ // diff track" and the server degrades gracefully.
46
+ Log.e("pushy", "supportedDiffVersion missing from librnupdate.so", e);
47
+ }
48
+ constants.put("supportedDiffVersion", supportedDiffVersion);
39
49
  return constants;
40
50
  }
41
51
 
@@ -95,7 +95,11 @@ bool TransformHbcInPlace(
95
95
  }
96
96
  for (uint32_t f = 0; f < s.deltaFieldCount; ++f) {
97
97
  const HbcDeltaField& field = s.deltaFields[f];
98
- if (field.bits < 1 || field.bits > 32 || field.bit + field.bits > 32 ||
98
+ // bit 必须单独设上界:bit=0xFFFFFFFF bit+bits uint32 求和会回绕
99
+ // 绕过 >32 检查,改写阶段的 << bit 就成了移位量 ≥32 的 UB。
100
+ // bits≥1 时合法 bit 必 ≤31。
101
+ if (field.bits < 1 || field.bits > 32 || field.bit > 31 ||
102
+ field.bit + field.bits > 32 ||
99
103
  static_cast<uint64_t>(field.byte) + 4 > s.entrySize) {
100
104
  return false;
101
105
  }
@@ -151,6 +151,12 @@ bool ParseDeltaField(Cursor* c, HbcDeltaField* out) {
151
151
  !ParseUInt(c, &out->bits)) {
152
152
  return false;
153
153
  }
154
+ // 语义范围在解析层就拒绝(与 TransformHbcInPlace 的校验双保险):
155
+ // 位域必须落在一个 32 位字内,bit>31 的值没有任何合法用途。
156
+ if (out->bits < 1 || out->bits > 32 || out->bit > 31 ||
157
+ out->bit + out->bits > 32) {
158
+ return false;
159
+ }
154
160
  return Consume(c, ']');
155
161
  }
156
162
 
@@ -2,8 +2,11 @@
2
2
  // 1) 与 JS 参考实现(react-native-update-cli src/utils/hbcTransform.ts)
3
3
  // 做 golden 对拍——fixtures/*.t.hbc 由 JS 实现生成;
4
4
  // 2) 可逆性 property check;
5
- // 3) 非法输入/非法描述表拒绝(buffer 必须保持原样)
5
+ // 3) 非法输入/非法描述表拒绝(buffer 必须保持原样);
6
+ // 4) wire 解析器(ParseHbcTransformMeta/BuildLayout)的正向/负向直测——
7
+ // 它解析的是随 patch 下发的不可信输入,拒绝分支必须逐条有覆盖。
6
8
  #include "../hbc_transform.h"
9
+ #include "../hbc_transform_wire.h"
7
10
 
8
11
  #include <cstdio>
9
12
  #include <cstring>
@@ -13,9 +16,12 @@
13
16
 
14
17
  namespace {
15
18
 
19
+ using pushy::hbc::BuildLayout;
16
20
  using pushy::hbc::HbcDeltaField;
17
21
  using pushy::hbc::HbcLayoutDesc;
18
22
  using pushy::hbc::HbcSectionDesc;
23
+ using pushy::hbc::HbcTransformMeta;
24
+ using pushy::hbc::ParseHbcTransformMeta;
19
25
  using pushy::hbc::TransformHbcInPlace;
20
26
 
21
27
  int g_failures = 0;
@@ -193,6 +199,154 @@ void TestRejections(const std::string& dir) {
193
199
  const HbcLayoutDesc badLayout = {19, bad, 1};
194
200
  CHECK(!TransformHbcInPlace(buf.data(), buf.size(), badLayout, false));
195
201
  }
202
+ // CP2-1 回归:bit=0xFFFFFFFF 时 bit+bits 的 uint32 求和回绕为 0,
203
+ // 曾绕过 >32 检查进入改写阶段(移位量 ≥32 的 UB)。必须拒绝且不改字节。
204
+ {
205
+ std::vector<uint8_t> buf = good;
206
+ const std::vector<uint8_t> before = buf;
207
+ const HbcDeltaField badField[] = {{0, 0xffffffffu, 1}};
208
+ const HbcSectionDesc bad[] = {{5, 4, badField, 1}};
209
+ const HbcLayoutDesc badLayout = {19, bad, 1};
210
+ CHECK(!TransformHbcInPlace(buf.data(), buf.size(), badLayout, false));
211
+ CHECK(buf == before);
212
+ }
213
+ // bit=31 本身合法,但 bit+bits=33 不回绕也必须拒绝
214
+ {
215
+ std::vector<uint8_t> buf = good;
216
+ const HbcDeltaField badField[] = {{0, 31, 2}};
217
+ const HbcSectionDesc bad[] = {{5, 4, badField, 1}};
218
+ const HbcLayoutDesc badLayout = {19, bad, 1};
219
+ CHECK(!TransformHbcInPlace(buf.data(), buf.size(), badLayout, false));
220
+ }
221
+ // 非法描述表:bits=0
222
+ {
223
+ std::vector<uint8_t> buf = good;
224
+ const HbcDeltaField badField[] = {{0, 0, 0}};
225
+ const HbcSectionDesc bad[] = {{5, 4, badField, 1}};
226
+ const HbcLayoutDesc badLayout = {19, bad, 1};
227
+ CHECK(!TransformHbcInPlace(buf.data(), buf.size(), badLayout, false));
228
+ }
229
+ }
230
+
231
+ // ---- wire 解析器直测 ----
232
+
233
+ std::string ReadTextFileOrDie(const std::string& path) {
234
+ const std::vector<uint8_t> raw = ReadFileOrDie(path);
235
+ return std::string(raw.begin(), raw.end());
236
+ }
237
+
238
+ void TestWireParser(const std::string& dir) {
239
+ // 正向:真实 fixture 解析 + BuildLayout 后与手写 kLayoutV96 等效
240
+ // (对 v96.hbc 变换产物与 golden 逐字节一致 = 端到端等效性证明)
241
+ {
242
+ HbcTransformMeta meta;
243
+ CHECK(ParseHbcTransformMeta(ReadTextFileOrDie(dir + "v96.meta.json"), &meta));
244
+ CHECK(meta.v == 1);
245
+ CHECK(meta.hbcVersion == 96);
246
+ CHECK(meta.headerCountFields == 19);
247
+ CHECK(meta.sections.size() == 15);
248
+ std::vector<HbcSectionDesc> scratch;
249
+ const HbcLayoutDesc layout = BuildLayout(meta, &scratch);
250
+ std::vector<uint8_t> buf = ReadFileOrDie(dir + "v96.hbc");
251
+ CHECK(TransformHbcInPlace(buf.data(), buf.size(), layout, false));
252
+ CHECK(buf == ReadFileOrDie(dir + "v96.t.hbc"));
253
+ }
254
+
255
+ const std::string minimalLayout =
256
+ R"("layout":{"counts":19,"sections":[[5,4,[]]]})";
257
+ const std::string minimal =
258
+ R"({"v":1,"hbcVersion":96,)" + minimalLayout + "}";
259
+
260
+ // 正向:最小合法输入;未知键(各种值形态)被跳过;边界位域 [0,31,1] 合法
261
+ {
262
+ HbcTransformMeta meta;
263
+ CHECK(ParseHbcTransformMeta(minimal, &meta));
264
+ CHECK(ParseHbcTransformMeta(
265
+ R"({"v":1,"hbcVersion":96,"future":{"a":[1,"s\"x",true,null],"b":-1.5e3},)" +
266
+ minimalLayout + "}",
267
+ &meta));
268
+ CHECK(ParseHbcTransformMeta(
269
+ R"({"v":1,"hbcVersion":96,"layout":{"counts":19,"sections":[[5,4,[[0,31,1]]]]}})",
270
+ &meta));
271
+ CHECK(meta.sections[0].deltaFields[0].bit == 31);
272
+ }
273
+
274
+ // 负向:每条拒绝分支单测,解析必须返回 false
275
+ const char* rejects[] = {
276
+ // 非 JSON / 结构破损
277
+ "not json",
278
+ "",
279
+ "{",
280
+ R"({"v":1,"hbcVersion":96})", // 缺 layout
281
+ R"({"v":1,"layout":{"counts":19,"sections":[[5,4,[]]]}})", // 缺 hbcVersion
282
+ R"({"hbcVersion":96,"layout":{"counts":19,"sections":[[5,4,[]]]}})", // 缺 v
283
+ R"({"v":1,"hbcVersion":96,"layout":{"counts":19}})", // 缺 sections
284
+ R"({"v":1,"hbcVersion":96,"layout":{"sections":[[5,4,[]]]}})", // 缺 counts
285
+ R"({"v":1,"hbcVersion":96,"layout":{"counts":19,"sections":[]}})", // 空 sections
286
+ R"({"v":1,"hbcVersion":96,"layout":{"counts":0,"sections":[[5,4,[]]]}})", // counts=0
287
+ R"({"v":1,"hbcVersion":96,"layout":[1,2]})", // layout 非对象
288
+ // 尾部多余内容
289
+ R"({"v":1,"hbcVersion":96,"layout":{"counts":19,"sections":[[5,4,[]]]}} x)",
290
+ // 键含转义(本格式的键不允许反斜杠;raw string 里 \x 就是两个字符)
291
+ R"({"v\x":1,"v":1,"hbcVersion":96,"layout":{"counts":19,"sections":[[5,4,[]]]}})",
292
+ // ParseUInt 溢出(>0xffffffff)与负数
293
+ R"({"v":4294967296,"hbcVersion":96,"layout":{"counts":19,"sections":[[5,4,[]]]}})",
294
+ R"({"v":-1,"hbcVersion":96,"layout":{"counts":19,"sections":[[5,4,[]]]}})",
295
+ // 位域语义:CP2-1 的 bit 溢出、bit+bits>32、bits=0、bits=33
296
+ R"({"v":1,"hbcVersion":96,"layout":{"counts":19,"sections":[[5,4,[[0,4294967295,1]]]]}})",
297
+ R"({"v":1,"hbcVersion":96,"layout":{"counts":19,"sections":[[5,4,[[0,31,2]]]]}})",
298
+ R"({"v":1,"hbcVersion":96,"layout":{"counts":19,"sections":[[5,4,[[0,0,0]]]]}})",
299
+ R"({"v":1,"hbcVersion":96,"layout":{"counts":19,"sections":[[5,4,[[0,0,33]]]]}})",
300
+ // delta field 元组长度不对
301
+ R"({"v":1,"hbcVersion":96,"layout":{"counts":19,"sections":[[5,4,[[0,0]]]]}})",
302
+ };
303
+ for (const char* json : rejects) {
304
+ HbcTransformMeta meta;
305
+ if (ParseHbcTransformMeta(json, &meta)) {
306
+ std::fprintf(stderr, "FAIL: wire parser accepted: %s\n", json);
307
+ ++g_failures;
308
+ }
309
+ }
310
+
311
+ // 负向:输入尺寸上限(64KB)
312
+ {
313
+ std::string huge = minimal;
314
+ huge.insert(1, std::string(65 * 1024, ' '));
315
+ HbcTransformMeta meta;
316
+ CHECK(!ParseHbcTransformMeta(huge, &meta));
317
+ }
318
+ // 负向:未知键的值嵌套深度超限(kMaxDepth=8)
319
+ {
320
+ std::string deep = R"({"v":1,"hbcVersion":96,"x":)";
321
+ for (int i = 0; i < 12; ++i) deep += '[';
322
+ deep += '1';
323
+ for (int i = 0; i < 12; ++i) deep += ']';
324
+ deep += ',' + minimalLayout + "}";
325
+ HbcTransformMeta meta;
326
+ CHECK(!ParseHbcTransformMeta(deep, &meta));
327
+ }
328
+ // 负向:sections 超过 64 个
329
+ {
330
+ std::string many = R"({"v":1,"hbcVersion":96,"layout":{"counts":19,"sections":[)";
331
+ for (int i = 0; i < 65; ++i) {
332
+ if (i) many += ',';
333
+ many += "[5,4,[]]";
334
+ }
335
+ many += "]}}";
336
+ HbcTransformMeta meta;
337
+ CHECK(!ParseHbcTransformMeta(many, &meta));
338
+ }
339
+ // 负向:单段 deltaFields 超过 8 个
340
+ {
341
+ std::string many = R"({"v":1,"hbcVersion":96,"layout":{"counts":19,"sections":[[5,64,[)";
342
+ for (int i = 0; i < 9; ++i) {
343
+ if (i) many += ',';
344
+ many += "[0,0,8]";
345
+ }
346
+ many += "]]]}}";
347
+ HbcTransformMeta meta;
348
+ CHECK(!ParseHbcTransformMeta(many, &meta));
349
+ }
196
350
  }
197
351
 
198
352
  } // namespace
@@ -210,6 +364,7 @@ int main(int argc, char** argv) {
210
364
  TestGoldenPair(fixturesDir, "v98.hbc", "v98.t.hbc", kLayoutV98);
211
365
  TestGoldenPair(fixturesDir, "v98b.hbc", "v98b.t.hbc", kLayoutV98Late);
212
366
  TestRejections(fixturesDir);
367
+ TestWireParser(fixturesDir);
213
368
 
214
369
  // 变体互斥:19 槽布局作用于 20 槽文件(或反之)必须被结构校验拒绝
215
370
  {
@@ -5,12 +5,6 @@ set(CMAKE_C_STANDARD 11)
5
5
  set(CMAKE_CXX_STANDARD 17)
6
6
  set(CMAKE_CXX_STANDARD_REQUIRED ON)
7
7
 
8
- set(PUSHY_MODULE_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/../../..)
9
- set(PUSHY_ABI ${CMAKE_OHOS_ARCH_ABI})
10
- if(NOT PUSHY_ABI)
11
- set(PUSHY_ABI ${OHOS_ARCH})
12
- endif()
13
- set(PREBUILT_RNUPDATE_PATH ${PUSHY_MODULE_ROOT}/libs/${PUSHY_ABI}/librnupdate.so)
14
8
  set(CMAKE_VERBOSE_MAKEFILE on)
15
9
 
16
10
  set(REPO_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/../../../../../)
@@ -24,44 +18,42 @@ if(NOT EXISTS ${PATCH_CORE_DIR}/patch_core.cpp)
24
18
  set(PATCH_CORE_DIR ${STAGED_NATIVE_DIR}/patch_core)
25
19
  endif()
26
20
 
27
- if(EXISTS ${PREBUILT_RNUPDATE_PATH})
28
- add_library(rnupdate SHARED IMPORTED GLOBAL)
29
- set_target_properties(rnupdate PROPERTIES IMPORTED_LOCATION ${PREBUILT_RNUPDATE_PATH})
30
- else()
31
- # When building the HAR itself, native sources are compiled from the repo checkout.
32
- set(HDIFFPATCH_DIR ${ANDROID_JNI_DIR}/HDiffPatch)
33
- set(LZMA_DIR ${ANDROID_JNI_DIR}/lzma)
34
- set(HDP_SOURCES
35
- ${CMAKE_CURRENT_SOURCE_DIR}/pushy.cpp
36
- ${PATCH_CORE_DIR}/archive_patch_core.cpp
37
- ${PATCH_CORE_DIR}/hbc_transform.cpp
38
- ${PATCH_CORE_DIR}/hbc_transform_wire.cpp
39
- ${PATCH_CORE_DIR}/patch_core.cpp
40
- ${PATCH_CORE_DIR}/state_core.cpp
41
- ${ANDROID_JNI_DIR}/hpatch.c
42
- ${HDIFFPATCH_DIR}/libHDiffPatch/HPatch/patch.c
43
- ${HDIFFPATCH_DIR}/file_for_patch.c
44
- ${LZMA_DIR}/C/LzmaDec.c
45
- ${LZMA_DIR}/C/Lzma2Dec.c
46
- )
21
+ # Always compile from source. A prebuilt-import branch used to live here, but
22
+ # it had no symbol verification: a stale libs/<abi>/librnupdate.so missing a
23
+ # newly added export (e.g. getSupportedDiffVersion) would silently ship and
24
+ # crash getConstants at runtime.
25
+ set(HDIFFPATCH_DIR ${ANDROID_JNI_DIR}/HDiffPatch)
26
+ set(LZMA_DIR ${ANDROID_JNI_DIR}/lzma)
27
+ set(HDP_SOURCES
28
+ ${CMAKE_CURRENT_SOURCE_DIR}/pushy.cpp
29
+ ${PATCH_CORE_DIR}/archive_patch_core.cpp
30
+ ${PATCH_CORE_DIR}/hbc_transform.cpp
31
+ ${PATCH_CORE_DIR}/hbc_transform_wire.cpp
32
+ ${PATCH_CORE_DIR}/patch_core.cpp
33
+ ${PATCH_CORE_DIR}/state_core.cpp
34
+ ${ANDROID_JNI_DIR}/hpatch.c
35
+ ${HDIFFPATCH_DIR}/libHDiffPatch/HPatch/patch.c
36
+ ${HDIFFPATCH_DIR}/file_for_patch.c
37
+ ${LZMA_DIR}/C/LzmaDec.c
38
+ ${LZMA_DIR}/C/Lzma2Dec.c
39
+ )
47
40
 
48
- add_library(rnupdate SHARED
49
- ${HDP_SOURCES}
50
- )
41
+ add_library(rnupdate SHARED
42
+ ${HDP_SOURCES}
43
+ )
51
44
 
52
- target_include_directories(rnupdate PRIVATE
53
- ${CMAKE_CURRENT_SOURCE_DIR}
54
- ${PATCH_CORE_DIR}
55
- ${ANDROID_JNI_DIR}
56
- ${HDIFFPATCH_DIR}
57
- ${HDIFFPATCH_DIR}/libHDiffPatch/HPatch
58
- ${LZMA_DIR}/C
59
- )
45
+ target_include_directories(rnupdate PRIVATE
46
+ ${CMAKE_CURRENT_SOURCE_DIR}
47
+ ${PATCH_CORE_DIR}
48
+ ${ANDROID_JNI_DIR}
49
+ ${HDIFFPATCH_DIR}
50
+ ${HDIFFPATCH_DIR}/libHDiffPatch/HPatch
51
+ ${LZMA_DIR}/C
52
+ )
60
53
 
61
- target_link_libraries(rnupdate PUBLIC
62
- libace_napi.z.so
63
- )
64
- endif()
54
+ target_link_libraries(rnupdate PUBLIC
55
+ libace_napi.z.so
56
+ )
65
57
 
66
58
  if(TARGET rnoh)
67
59
  add_library(rnoh_pushy SHARED
@@ -70,7 +70,9 @@ std::string GetString(napi_env env, napi_value value, bool* ok) {
70
70
  return std::string();
71
71
  }
72
72
 
73
- std::string result(length, '\0');
73
+ // length + 1 so NAPI's terminating '\0' lands inside owned storage instead
74
+ // of the string's past-the-end terminator slot (formally UB to write).
75
+ std::string result(length + 1, '\0');
74
76
  size_t written = 0;
75
77
  if (napi_get_value_string_utf8(
76
78
  env, value, result.data(), length + 1, &written) != napi_ok) {
@@ -738,9 +740,13 @@ napi_value ApplyPatchFromFileSource(napi_env env, napi_callback_info info) {
738
740
  auto* w = static_cast<ApplyPatchWork*>(data);
739
741
  w->status = pushy::patch::ApplyPatchFromFileSource(w->options);
740
742
  },
741
- [](napi_env cb_env, napi_status, void* data) {
743
+ [](napi_env cb_env, napi_status status, void* data) {
742
744
  auto* w = static_cast<ApplyPatchWork*>(data);
743
- if (w->status.ok) {
745
+ if (status != napi_ok) {
746
+ // Cancelled/aborted before execute ran: w->status is
747
+ // meaningless; still settle the promise so it never hangs.
748
+ RejectDeferredWithMessage(cb_env, w->deferred, "async work aborted");
749
+ } else if (w->status.ok) {
744
750
  napi_value undefined_value = nullptr;
745
751
  napi_get_undefined(cb_env, &undefined_value);
746
752
  napi_resolve_deferred(cb_env, w->deferred, undefined_value);
@@ -826,9 +832,11 @@ napi_value CleanupOldEntries(napi_env env, napi_callback_info info) {
826
832
  w->status = pushy::patch::CleanupOldEntries(
827
833
  w->root_dir, w->keep_current, w->keep_previous, w->max_age_days);
828
834
  },
829
- [](napi_env cb_env, napi_status, void* data) {
835
+ [](napi_env cb_env, napi_status status, void* data) {
830
836
  auto* w = static_cast<CleanupWork*>(data);
831
- if (w->status.ok) {
837
+ if (status != napi_ok) {
838
+ RejectDeferredWithMessage(cb_env, w->deferred, "async work aborted");
839
+ } else if (w->status.ok) {
832
840
  napi_value undefined_value = nullptr;
833
841
  napi_get_undefined(cb_env, &undefined_value);
834
842
  napi_resolve_deferred(cb_env, w->deferred, undefined_value);
@@ -395,8 +395,15 @@ export class DownloadTask {
395
395
  resolve();
396
396
  })
397
397
  .catch(async error => {
398
- await closeWriter();
398
+ // reject 必须先于 closeWriter:此时 watchdog 已清除,若 close
399
+ // 也失败(同一磁盘故障的常见连锁)而 reject 未执行,下载 Promise
400
+ // 将永久挂起——正是 HM-2 要消灭的症状。
399
401
  reject(error);
402
+ try {
403
+ await closeWriter();
404
+ } catch (closeErr: any) {
405
+ console.error('closeWriter failed after write error:', closeErr);
406
+ }
400
407
  });
401
408
  });
402
409
  });
@@ -461,7 +468,6 @@ export class DownloadTask {
461
468
  },
462
469
  );
463
470
 
464
- refreshWatchdog();
465
471
  const responseCode = await httpRequest.requestInStream(params.url, {
466
472
  method: http.RequestMethod.GET,
467
473
  readTimeout: 60000,
@@ -474,6 +480,11 @@ export class DownloadTask {
474
480
  throw Error(`Server error: ${responseCode}`);
475
481
  }
476
482
 
483
+ // watchdog 到这里才首次启动:requestInStream 阶段已有 connect/readTimeout
484
+ // 覆盖;若提早启动,连接超过 60s 时 inactivityPromise 会在 race 尚无订阅者
485
+ // 时 reject(unhandled rejection),且 promise 一经 reject 无法复活——
486
+ // 即使随后数据正常流入,race 也必然以 "Download stalled" 失败。
487
+ refreshWatchdog();
477
488
  await Promise.race([dataEndPromise, inactivityPromise]);
478
489
  const stats = await fileIo.stat(params.targetFile);
479
490
  const fileSize = stats.size;
@@ -27,6 +27,10 @@ export class UpdateContext {
27
27
  private static DEBUG: boolean = false;
28
28
  private static isUsingBundleUrl: boolean = false;
29
29
  private static ignoreRollback: boolean = false;
30
+ // 本进程实际加载的热更版本(getBundleUrl 解析成功时记录)。
31
+ // resetToPackagedBundle 不能删它的目录:热更包内的图片等资源是运行时按需
32
+ // 读盘的,静默(不重启)reset 若删掉会导致后续所有未加载过的资源失败。
33
+ private static launchVersion: string = '';
30
34
  private static cachedPackageVersion: string = '';
31
35
  private static cachedBuildTime: string = '';
32
36
  // 单例:确保 bundle provider 与 TurboModule 共用同一份 preferences 内存状态,
@@ -296,9 +300,26 @@ export class UpdateContext {
296
300
  return params;
297
301
  }
298
302
 
303
+ // 串行化下载/补丁任务与破坏性清理(reset 的全量删除):Android 靠单线程
304
+ // download executor 天然串行,Harmony 的 NAPI 任务跑在 libuv worker 池上,
305
+ // 若不排队,reset 的 RemovePathRecursively 可能与正在写入的解压/打补丁并发,
306
+ // 产出"bundle 在、资源半删"的目录且可能被后续 switchVersion 激活。
307
+ private taskChain: Promise<void> = Promise.resolve();
308
+
309
+ private enqueueSerialTask<T>(job: () => Promise<T>): Promise<T> {
310
+ const run = this.taskChain.then(job);
311
+ this.taskChain = run.then(
312
+ () => undefined,
313
+ () => undefined,
314
+ );
315
+ return run;
316
+ }
317
+
299
318
  private async executeTask(params: DownloadTaskParams): Promise<void> {
300
- const downloadTask = new DownloadTask(this.context);
301
- await downloadTask.execute(params);
319
+ await this.enqueueSerialTask(() => {
320
+ const downloadTask = new DownloadTask(this.context);
321
+ return downloadTask.execute(params);
322
+ });
302
323
  }
303
324
 
304
325
  public syncStateWithBinaryVersion(
@@ -374,14 +395,17 @@ export class UpdateContext {
374
395
 
375
396
  /**
376
397
  * 恢复到二进制内置包:清空整个更新状态机(下次启动即回内置 bundle)并删除
377
- * 全部已下载版本。仅保留 uuid —— 它标识安装实例、用于灰度分桶,reset 不应改变。
398
+ * 已下载版本——仅保留当前运行版本的目录(静默 reset 不能破坏运行中 bundle
399
+ * 的按需资源加载)。uuid 保留 —— 它标识安装实例、用于灰度分桶,reset 不应改变。
378
400
  */
379
401
  public resetToPackagedBundle(): void {
380
402
  this.trace('resetToPackagedBundle:before');
381
- const state = this.getStateSnapshot();
403
+ // 实时读取二进制身份(与 Android/iOS 对齐),而非 preferences 快照:
404
+ // meta.json 读取失败时快照可能为空,reset 持久化空值会让下次启动误判
405
+ // binary 变更多做一轮 cleanUp+persist。
382
406
  const resetState: StateCoreResult = {
383
- packageVersion: state.packageVersion,
384
- buildTime: state.buildTime,
407
+ packageVersion: this.getPackageVersion(),
408
+ buildTime: this.getBuildTime(),
385
409
  currentVersion: '',
386
410
  lastVersion: '',
387
411
  firstTime: false,
@@ -409,12 +433,18 @@ export class UpdateContext {
409
433
  this.persistState(resetState, { clearFirstLoadMarker: true });
410
434
  UpdateContext.ignoreRollback = false;
411
435
 
412
- // maxAgeDays=0 且不保留任何版本:全量删除下载目录内容(后台线程,尽力而为)
413
- NativePatchCore.cleanupOldEntries(this.rootDir, '', '', 0).catch(
414
- (error: Object) => {
415
- console.error('reset cleanup failed:', error);
416
- },
417
- );
436
+ // maxAgeDays=0:删除下载目录内容,仅保留当前运行版本的目录(残留目录由
437
+ // 下次常规清理回收)。挂到串行任务链尾,避免与在飞的解压/打补丁并发。
438
+ this.enqueueSerialTask(() =>
439
+ NativePatchCore.cleanupOldEntries(
440
+ this.rootDir,
441
+ UpdateContext.launchVersion,
442
+ '',
443
+ 0,
444
+ ),
445
+ ).catch((error: Object) => {
446
+ console.error('reset cleanup failed:', error);
447
+ });
418
448
  this.trace('resetToPackagedBundle:after');
419
449
  }
420
450
 
@@ -558,6 +588,7 @@ export class UpdateContext {
558
588
  version = this.rollBack();
559
589
  continue;
560
590
  }
591
+ UpdateContext.launchVersion = version;
561
592
  return bundleFile;
562
593
  } catch (e) {
563
594
  console.error('Failed to access bundle file:', e);
package/harmony/pushy.har CHANGED
Binary file
@@ -64,6 +64,12 @@ typedef NS_ENUM(NSInteger, PushyType) {
64
64
  };
65
65
 
66
66
  static std::atomic<bool> ignoreRollback{false};
67
+ // The version whose bundle this process actually loaded (resolved in
68
+ // +bundleURL). resetToPackagedBundle must not delete its directory: update
69
+ // assets (images/fonts) are read from it on demand at runtime, so wiping it
70
+ // under a silent (no-restart) reset would break every image the running app
71
+ // has not loaded yet. Guarded by the state lock.
72
+ static NSString *pushyLaunchVersion = nil;
67
73
 
68
74
  // Serializes every read-modify-write of the persisted update state. The state
69
75
  // machine itself is a pure function (state_core), but callers run on different
@@ -305,6 +311,7 @@ RCT_EXPORT_MODULE(RCTPushy);
305
311
  [visitedVersions addObject:loadVersion];
306
312
  NSString *bundlePath = [[downloadDir stringByAppendingPathComponent:loadVersion] stringByAppendingPathComponent:BUNDLE_FILE_NAME];
307
313
  if ([[NSFileManager defaultManager] fileExistsAtPath:bundlePath isDirectory:NULL]) {
314
+ pushyLaunchVersion = loadVersion;
308
315
  resolvedURL = [NSURL fileURLWithPath:bundlePath];
309
316
  return;
310
317
  } else {
@@ -380,7 +387,16 @@ RCT_EXPORT_MODULE(RCTPushy);
380
387
  {
381
388
  self = [super init];
382
389
  if (self) {
383
- _fileQueue = dispatch_queue_create("cn.reactnative.pushy.file", DISPATCH_QUEUE_SERIAL);
390
+ // One process-wide serial queue, not per-instance: a bridge reload can
391
+ // briefly keep two RCTPushy instances alive, and destructive file work
392
+ // (resetToPackagedBundle's full cleanup) must stay serialized with the
393
+ // other instance's unzip/patch jobs.
394
+ static dispatch_queue_t sharedFileQueue;
395
+ static dispatch_once_t onceToken;
396
+ dispatch_once(&onceToken, ^{
397
+ sharedFileQueue = dispatch_queue_create("cn.reactnative.pushy.file", DISPATCH_QUEUE_SERIAL);
398
+ });
399
+ _fileQueue = sharedFileQueue;
384
400
  }
385
401
  return self;
386
402
  }
@@ -522,11 +538,15 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
522
538
  rejecter:(RCTPromiseRejectBlock)reject)
523
539
  {
524
540
  // Reset to the bundle packaged in the binary: wipe the whole update state
525
- // (so the next launch resolves to the built-in bundle) and delete every
526
- // downloaded version. Only the client uuid survives it identifies the
527
- // install for gray release bucketing and must not change on reset.
541
+ // (so the next launch resolves to the built-in bundle) and delete the
542
+ // downloaded versions, keeping only the directory of the version this
543
+ // process is running from (a silent reset must not break its on-demand
544
+ // asset loads). Only the client uuid survives — it identifies the install
545
+ // for gray release bucketing and must not change on reset.
546
+ __block NSString *keepVersion = nil;
528
547
  PushyWithStateLock(^{
529
548
  NSUserDefaults *defaults = PushyDefaults();
549
+ keepVersion = pushyLaunchVersion;
530
550
 
531
551
  // A default-constructed State is exactly the reset state (no current /
532
552
  // last version, first_time=false, first_time_ok=true); keep the binary
@@ -548,10 +568,11 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
548
568
  });
549
569
 
550
570
  dispatch_async(_fileQueue, ^{
551
- // maxAgeDays=0 and no versions to keep: remove every downloaded entry.
571
+ // maxAgeDays=0: remove every downloaded entry except the running
572
+ // version's directory (cleaned up by the next regular cleanup).
552
573
  pushy::patch::Status status = pushy::patch::CleanupOldEntries(
553
574
  PushyToStdString([RCTPushy downloadDir]),
554
- "",
575
+ PushyToStdString(keepVersion),
555
576
  "",
556
577
  0
557
578
  );
@@ -883,6 +904,13 @@ RCT_EXPORT_METHOD(resetToPackagedBundle:(RCTPromiseResolveBlock)resolve
883
904
  NSError *unzipError = error;
884
905
  if (!succeeded && unzipError == nil) {
885
906
  unzipError = PushyErrorWithCode(pushy::error_codes::kPatchFailed, @"unzip failed");
907
+ } else if (unzipError != nil && unzipError.userInfo[PushyErrorCodeKey] == nil) {
908
+ // SSZipArchive's own NSError (corrupt zip, bad magic, ...) has
909
+ // no stable code; without one, downloadUpdate's fallback would
910
+ // classify it as DOWNLOAD_FAILED even though the download
911
+ // succeeded — keep the classification deterministic.
912
+ unzipError = PushyErrorWithCode(pushy::error_codes::kPatchFailed,
913
+ unzipError.localizedDescription ?: @"unzip failed");
886
914
  }
887
915
  completionHandler(unzipError);
888
916
  }];
@@ -45,9 +45,12 @@ completionHandler:(void (^)(NSString *path, NSError *error))completionHandler
45
45
 
46
46
  NSURLSessionConfiguration *sessionConfig = [NSURLSessionConfiguration defaultSessionConfiguration];
47
47
  // Avoid hanging forever on a stalled connection (default resource timeout
48
- // is 7 days). These are generous enough for large bundles on slow networks.
48
+ // is 7 days). The 30s idle timeout matches Android's readTimeout and is
49
+ // what actually catches a stalled transfer; the total-duration cap matches
50
+ // Android's 10min callTimeout — 300s made a 30MB full package on a slow
51
+ // (<100KB/s) network fail on iOS while succeeding on Android.
49
52
  sessionConfig.timeoutIntervalForRequest = 30;
50
- sessionConfig.timeoutIntervalForResource = 300;
53
+ sessionConfig.timeoutIntervalForResource = 600;
51
54
  self.session = [NSURLSession sessionWithConfiguration:sessionConfig
52
55
  delegate:self
53
56
  delegateQueue:nil];
@@ -89,6 +92,12 @@ totalBytesExpectedToWrite:(int64_t)totalBytesExpectedToWrite
89
92
  // Normalize an unknown total (NSURLSessionTransferSizeUnknown == -1) to 0 so
90
93
  // the JS side does not compute a negative/NaN percentage.
91
94
  long long total = totalBytesExpectedToWrite > 0 ? totalBytesExpectedToWrite : 0;
95
+ if (totalBytesWritten > total) {
96
+ // Encoded responses (gzip): the expected total counts compressed bytes
97
+ // while written counts decompressed ones, so the percentage would run
98
+ // past 100%. Treat the total as unknown instead.
99
+ total = 0;
100
+ }
92
101
  if (total > 0) {
93
102
  int percentage = (int)((totalBytesWritten * 100.0 / total) + 0.5);
94
103
  if (percentage <= self.lastReportedPercentage) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-update",
3
- "version": "10.48.0",
3
+ "version": "10.48.1",
4
4
  "description": "react-native hot update",
5
5
  "main": "src/index",
6
6
  "types": "src/index.ts",
package/src/client.ts CHANGED
@@ -40,7 +40,12 @@ import {
40
40
  testUrls,
41
41
  } from './utils';
42
42
  import i18n from './i18n';
43
- import { toUpdateError, UpdateError, UpdateErrorCode } from './error';
43
+ import {
44
+ asUpdateErrorCode,
45
+ toUpdateError,
46
+ UpdateError,
47
+ UpdateErrorCode,
48
+ } from './error';
44
49
  import { dedupeEndpoints, executeEndpointFallback } from './endpoint';
45
50
  import {
46
51
  resolveServerEventHash,
@@ -104,6 +109,10 @@ const defaultClientOptions: ClientOptions = {
104
109
  export const sharedState: {
105
110
  progressHandlers: Record<string, EmitterSubscription>;
106
111
  downloadingTasks: Record<string, Promise<string | undefined>>;
112
+ // Progress callbacks per hash: concurrent downloadUpdate callers of the
113
+ // same hash each register theirs here instead of the second one being
114
+ // silently dropped by the in-flight dedup.
115
+ progressCallbacks: Record<string, Set<(data: ProgressData) => void>>;
107
116
  downloadedHash?: string;
108
117
  toHash?: string;
109
118
  apkStatus: 'downloading' | 'downloaded' | null;
@@ -112,6 +121,7 @@ export const sharedState: {
112
121
  } = {
113
122
  progressHandlers: {},
114
123
  downloadingTasks: {},
124
+ progressCallbacks: {},
115
125
  downloadedHash: undefined,
116
126
  apkStatus: null,
117
127
  marked: false,
@@ -325,6 +335,7 @@ export class Pushy {
325
335
  }
326
336
  };
327
337
  private errorListeners = new Set<UpdateErrorListener>();
338
+ private emittedErrors = new WeakSet<Error>();
328
339
  /**
329
340
  * Subscribe to every error the client reports (regardless of throwError).
330
341
  * Returns an unsubscribe function.
@@ -335,6 +346,15 @@ export class Pushy {
335
346
  this.errorListeners.delete(listener);
336
347
  };
337
348
  };
349
+ /**
350
+ * Whether this exact error object already went through emitError (and was
351
+ * therefore delivered to onError subscribers). Lets UI layers decide if a
352
+ * caught error still needs surfacing — checking `e.code` is not enough,
353
+ * since axios/system errors carry their own code without ever entering the
354
+ * pipeline.
355
+ */
356
+ wasEmitted = (e: unknown): boolean =>
357
+ e instanceof Error && this.emittedErrors.has(e);
338
358
  /**
339
359
  * Single exit point for errors: reports to the logger (with the stable
340
360
  * code) and notifies onError listeners. Whether to also throw stays with
@@ -348,7 +368,15 @@ export class Pushy {
348
368
  data,
349
369
  }: { message?: string; data?: Record<string, string | number> } = {},
350
370
  ) => {
351
- this.report({ type, message, code: error.code, data });
371
+ this.emittedErrors.add(error);
372
+ this.report({
373
+ type,
374
+ message,
375
+ code: error.code,
376
+ // Structured context from the error (e.g. HTTP status) reaches the
377
+ // logger; explicit data wins on key conflicts.
378
+ data: error.extra ? { ...error.extra, ...data } : data,
379
+ });
352
380
  for (const listener of this.errorListeners) {
353
381
  try {
354
382
  listener(error, type);
@@ -502,7 +530,10 @@ export class Pushy {
502
530
  }
503
531
  } catch (e) {
504
532
  sharedState.applyingUpdate = false;
505
- const err = toUpdateError(e, 'SWITCH_VERSION_FAILED');
533
+ // A throw from the user's beforeReload hook is business-code failure,
534
+ // not an update-pipeline one: give it a distinct code so telemetry
535
+ // excludes it from the server-side patch-health stats.
536
+ const err = toUpdateError(e, 'USER_HOOK_ERROR');
506
537
  this.emitError(err, 'errorSwitchVersion', {
507
538
  data: { newVersion: hash },
508
539
  });
@@ -563,9 +594,20 @@ export class Pushy {
563
594
  this.lastChecking &&
564
595
  now - this.lastChecking < 1000 * 5
565
596
  ) {
566
- const result = await this.lastRespJson;
567
- this.notifyAfterCheckUpdate({ status: 'completed', result });
568
- return result;
597
+ try {
598
+ const result = await this.lastRespJson;
599
+ this.notifyAfterCheckUpdate({ status: 'completed', result });
600
+ return result;
601
+ } catch (e: any) {
602
+ // The shared in-flight check failed. Its initiating call reports it
603
+ // through emitError/throw; this call must still honor its own
604
+ // contract — afterCheckUpdate always fires and throwError applies —
605
+ // without double-reporting the same error.
606
+ const err = toUpdateError(e, 'CHECK_FAILED');
607
+ this.notifyAfterCheckUpdate({ status: 'error', error: err });
608
+ this.throwIfEnabled(err);
609
+ return undefined;
610
+ }
569
611
  }
570
612
  this.lastChecking = now;
571
613
  const fetchBody: Record<string, any> = {
@@ -655,6 +697,10 @@ export class Pushy {
655
697
  const existingTask = sharedState.downloadingTasks[hash];
656
698
  if (existingTask) {
657
699
  log(`download for hash ${hash} already in progress, reusing it`);
700
+ // The second caller's progress callback must still fire.
701
+ if (onDownloadProgress) {
702
+ sharedState.progressCallbacks[hash]?.add(onDownloadProgress);
703
+ }
658
704
  return existingTask;
659
705
  }
660
706
  const task = this.performDownload(updateInfo, onDownloadProgress);
@@ -682,38 +728,42 @@ export class Pushy {
682
728
  if (!hash) {
683
729
  return;
684
730
  }
685
- if (sharedState.progressHandlers[hash]) {
686
- return;
687
- }
688
731
  const patchStartTime = Date.now();
732
+ // One native listener per hash dispatching to a callback set, so
733
+ // concurrent callers deduped onto this task can each observe progress
734
+ // (they register via downloadUpdate).
735
+ const progressCallbacks = new Set<(data: ProgressData) => void>();
689
736
  if (onDownloadProgress) {
690
- const wrapProgress = (data: ProgressData) => {
691
- onDownloadProgress({
692
- ...data,
693
- progress: computeProgress(data.received, data.total),
694
- });
737
+ progressCallbacks.add(onDownloadProgress);
738
+ }
739
+ sharedState.progressCallbacks[hash] = progressCallbacks;
740
+ const dispatchProgress = (data: ProgressData) => {
741
+ const callbacks = sharedState.progressCallbacks[hash];
742
+ if (!callbacks || callbacks.size === 0) {
743
+ return;
744
+ }
745
+ const payload = {
746
+ ...data,
747
+ progress: computeProgress(data.received, data.total),
695
748
  };
696
- // @ts-expect-error harmony not in existing platforms
697
- if (Platform.OS === 'harmony') {
698
- sharedState.progressHandlers[hash] = DeviceEventEmitter.addListener(
699
- 'RCTPushyDownloadProgress',
700
- (progressData: ProgressData) => {
701
- if (progressData.hash === hash) {
702
- wrapProgress(progressData);
703
- }
704
- },
705
- );
706
- } else {
707
- sharedState.progressHandlers[hash] =
708
- pushyNativeEventEmitter.addListener(
709
- 'RCTPushyDownloadProgress',
710
- (progressData: ProgressData) => {
711
- if (progressData.hash === hash) {
712
- wrapProgress(progressData);
713
- }
714
- },
715
- );
749
+ callbacks.forEach(callback => callback(payload));
750
+ };
751
+ const onNativeProgress = (progressData: ProgressData) => {
752
+ if (progressData.hash === hash) {
753
+ dispatchProgress(progressData);
716
754
  }
755
+ };
756
+ // @ts-expect-error harmony not in existing platforms
757
+ if (Platform.OS === 'harmony') {
758
+ sharedState.progressHandlers[hash] = DeviceEventEmitter.addListener(
759
+ 'RCTPushyDownloadProgress',
760
+ onNativeProgress,
761
+ );
762
+ } else {
763
+ sharedState.progressHandlers[hash] = pushyNativeEventEmitter.addListener(
764
+ 'RCTPushyDownloadProgress',
765
+ onNativeProgress,
766
+ );
717
767
  }
718
768
  const maxRetries = Math.max(0, Math.floor(this.options.maxRetries ?? 3));
719
769
  let succeeded = '';
@@ -804,7 +854,14 @@ export class Pushy {
804
854
  message: e.message,
805
855
  });
806
856
  errorMessages.push(errorMessage);
807
- lastError = Error(errorMessage);
857
+ // Keep the i18n message for display, but preserve the native
858
+ // rejection's stable code (e.g. PATCH_FAILED vs DOWNLOAD_FAILED —
859
+ // telemetry classifies on it) and the original error as cause.
860
+ lastError = new UpdateError(
861
+ errorMessage,
862
+ asUpdateErrorCode(e?.code) ?? 'DOWNLOAD_FAILED',
863
+ { cause: e },
864
+ );
808
865
  log(errorMessage);
809
866
  }
810
867
  } else if (!url && strategy.devNoopWhenNoUrl && __DEV__) {
@@ -820,6 +877,7 @@ export class Pushy {
820
877
  sharedState.progressHandlers[hash].remove();
821
878
  delete sharedState.progressHandlers[hash];
822
879
  }
880
+ delete sharedState.progressCallbacks[hash];
823
881
  if (!succeeded) {
824
882
  const message = errorMessages.join(';');
825
883
  if (lastError) {
@@ -946,10 +1004,22 @@ export class Pushy {
946
1004
  }
947
1005
  };
948
1006
  restartApp = async () => {
949
- if (!(await this.runBeforeReload({ type: 'restartApp' }))) {
950
- return;
1007
+ try {
1008
+ if (!(await this.runBeforeReload({ type: 'restartApp' }))) {
1009
+ return;
1010
+ }
1011
+ } catch (e) {
1012
+ const err = toUpdateError(e, 'USER_HOOK_ERROR');
1013
+ this.emitError(err, 'errorRestart');
1014
+ throw err;
1015
+ }
1016
+ try {
1017
+ return await PushyModule.restartApp();
1018
+ } catch (e) {
1019
+ const err = toUpdateError(e, 'RESTART_FAILED');
1020
+ this.emitError(err, 'errorRestart');
1021
+ throw err;
951
1022
  }
952
- return PushyModule.restartApp();
953
1023
  };
954
1024
  /**
955
1025
  * Reset to the bundle packaged in the binary: wipes every downloaded update
@@ -965,6 +1035,11 @@ export class Pushy {
965
1035
  resetToPackagedBundle = async (options?: {
966
1036
  restart?: boolean;
967
1037
  }): Promise<boolean> => {
1038
+ if (!assertWeb()) {
1039
+ // On web PushyModule is a Proxy of noops, so the feature-detect below
1040
+ // would report a false success.
1041
+ return false;
1042
+ }
968
1043
  if (typeof PushyModule.resetToPackagedBundle !== 'function') {
969
1044
  // The JS layer can arrive via hot update onto an older binary whose
970
1045
  // native module predates this method.
@@ -987,10 +1062,18 @@ export class Pushy {
987
1062
  // The downloaded versions are gone; drop JS bookkeeping referring to them
988
1063
  // so a stale downloadedHash cannot be switched to.
989
1064
  sharedState.downloadedHash = undefined;
1065
+ sharedState.toHash = undefined;
990
1066
  sharedState.marked = false;
991
1067
  this.report({ type: 'reset' });
992
1068
  if (options?.restart) {
993
- await this.restartApp();
1069
+ try {
1070
+ await this.restartApp();
1071
+ } catch (e: any) {
1072
+ // The reset itself succeeded and the restart failure was already
1073
+ // reported through the pipeline; the boolean must still say "state
1074
+ // is reset".
1075
+ log('restart after reset failed:', e?.message || e);
1076
+ }
994
1077
  }
995
1078
  return true;
996
1079
  };
package/src/error.ts CHANGED
@@ -21,6 +21,9 @@ export type UpdateErrorCode =
21
21
  | 'STORAGE_PERMISSION_REJECTED'
22
22
  | 'STORAGE_PERMISSION_ERROR'
23
23
  | 'APK_DOWNLOAD_FAILED'
24
+ // A throw from a user-provided hook (e.g. beforeReload) — not an update
25
+ // pipeline failure, and excluded from server-side patch-health telemetry.
26
+ | 'USER_HOOK_ERROR'
24
27
  // Native codes (see cpp/patch_core/error_codes.h)
25
28
  | 'INVALID_OPTIONS'
26
29
  | 'PATCH_FAILED'
@@ -30,6 +33,41 @@ export type UpdateErrorCode =
30
33
  | 'INVALID_HASH_INFO'
31
34
  | 'UNSUPPORTED_PLATFORM';
32
35
 
36
+ const KNOWN_CODES = new Set<string>([
37
+ 'MODULE_NOT_LOADED',
38
+ 'APPKEY_REQUIRED',
39
+ 'NO_ENDPOINTS',
40
+ 'HTTP_STATUS',
41
+ 'CHECK_FAILED',
42
+ 'DOWNLOAD_FAILED',
43
+ 'SWITCH_VERSION_FAILED',
44
+ 'MARK_SUCCESS_FAILED',
45
+ 'APK_INSTALL_PENDING',
46
+ 'STORAGE_PERMISSION_REJECTED',
47
+ 'STORAGE_PERMISSION_ERROR',
48
+ 'APK_DOWNLOAD_FAILED',
49
+ 'USER_HOOK_ERROR',
50
+ 'INVALID_OPTIONS',
51
+ 'PATCH_FAILED',
52
+ 'FILE_OPERATION_FAILED',
53
+ 'RESTART_FAILED',
54
+ 'RESET_FAILED',
55
+ 'INVALID_HASH_INFO',
56
+ 'UNSUPPORTED_PLATFORM',
57
+ ]);
58
+
59
+ /**
60
+ * Narrow an arbitrary `code` property (axios' ERR_NETWORK, Node's
61
+ * ECONNREFUSED, RN's default EUNSPECIFIED, ...) to our stable set; anything
62
+ * else is treated as absent so it never leaks into telemetry aggregation.
63
+ */
64
+ export const asUpdateErrorCode = (
65
+ code: unknown,
66
+ ): UpdateErrorCode | undefined =>
67
+ typeof code === 'string' && KNOWN_CODES.has(code)
68
+ ? (code as UpdateErrorCode)
69
+ : undefined;
70
+
33
71
  export class UpdateError extends Error {
34
72
  code: UpdateErrorCode;
35
73
  cause?: unknown;
@@ -50,9 +88,11 @@ export class UpdateError extends Error {
50
88
 
51
89
  /**
52
90
  * Attach a code to an unknown thrown value. An existing Error keeps its
53
- * identity (message, stack, and any code already assigned upstream) so callers
54
- * comparing the caught error to the original still match; non-Error values are
55
- * wrapped.
91
+ * identity (message, stack, and any known code already assigned upstream) so
92
+ * callers comparing the caught error to the original still match; non-Error
93
+ * values are wrapped. A foreign `code` (axios/system errors) is overwritten
94
+ * with ours; a frozen/sealed Error that rejects the assignment is wrapped
95
+ * instead (identity is lost only in that edge case).
56
96
  */
57
97
  export const toUpdateError = (
58
98
  e: unknown,
@@ -60,8 +100,13 @@ export const toUpdateError = (
60
100
  ): UpdateError => {
61
101
  if (e instanceof Error) {
62
102
  const err = e as UpdateError;
63
- if (!err.code) {
64
- err.code = code;
103
+ if (!asUpdateErrorCode(err.code)) {
104
+ try {
105
+ err.code = code;
106
+ } catch {}
107
+ if (err.code !== code) {
108
+ return new UpdateError(err.message, code, { cause: err });
109
+ }
65
110
  }
66
111
  return err;
67
112
  }
package/src/provider.tsx CHANGED
@@ -152,10 +152,12 @@ export const UpdateProvider = ({
152
152
  ]);
153
153
  return true;
154
154
  } catch (e: any) {
155
- // Client pipeline errors carry a code and were already surfaced via
156
- // the onError subscription; errors thrown by user hooks
157
- // (afterDownloadUpdate) bypass the pipeline and are surfaced here.
158
- if (!e?.code) {
155
+ // Client pipeline errors were already surfaced via the onError
156
+ // subscription; errors thrown by user hooks (afterDownloadUpdate)
157
+ // bypass the pipeline and are surfaced here. Asking the client
158
+ // instead of checking `e.code` matters: axios/system errors carry
159
+ // their own code without ever entering the pipeline.
160
+ if (!client.wasEmitted(e)) {
159
161
  setLastError(e);
160
162
  alertError(client.t('update_failed'), e.message);
161
163
  }
@@ -186,10 +188,10 @@ export const UpdateProvider = ({
186
188
  try {
187
189
  rootInfo = await client.checkUpdate(extra);
188
190
  } catch (e: any) {
189
- // Client pipeline errors carry a code and were already surfaced via
190
- // the onError subscription; errors thrown by user hooks
191
- // (beforeCheckUpdate) bypass the pipeline and are surfaced here.
192
- if (!e?.code) {
191
+ // Client pipeline errors were already surfaced via the onError
192
+ // subscription; errors thrown by user hooks (beforeCheckUpdate)
193
+ // bypass the pipeline and are surfaced here (see wasEmitted).
194
+ if (!client.wasEmitted(e)) {
193
195
  setLastError(e);
194
196
  alertError(client.t('error_update_check_failed'), e.message);
195
197
  }
@@ -403,15 +405,21 @@ export const UpdateProvider = ({
403
405
  if (!url) {
404
406
  return;
405
407
  }
406
- const params = new URL(url).searchParams;
407
- const payload = {
408
- type: params.get('type'),
409
- data: params.get('data'),
410
- };
411
- parseTestPayload(payload);
408
+ try {
409
+ const params = new URL(url).searchParams;
410
+ const payload = {
411
+ type: params.get('type'),
412
+ data: params.get('data'),
413
+ };
414
+ parseTestPayload(payload);
415
+ } catch (e: any) {
416
+ // A malformed deep link (new URL throws) must not become an
417
+ // unhandled rejection / a throw inside the 'url' event handler.
418
+ log('parseLinking: invalid url', e?.message || e);
419
+ }
412
420
  };
413
421
 
414
- Linking.getInitialURL().then(parseLinking);
422
+ Linking.getInitialURL().then(parseLinking).catch(noop);
415
423
  const linkingHandler = ({ url }: { url: string }) => {
416
424
  parseLinking(url);
417
425
  };
package/src/telemetry.ts CHANGED
@@ -43,7 +43,12 @@ export const resolveServerEventType = (
43
43
  case 'errorUpdate':
44
44
  return code === 'PATCH_FAILED' ? 'patch_fail' : 'download_fail';
45
45
  case 'errorSwitchVersion':
46
- return 'patch_fail';
46
+ // Activation failures are patch-health signals, but a throw from a
47
+ // user hook (beforeReload) or a restart-mechanics failure is not —
48
+ // those must not poison the stats driving the rollback safety net.
49
+ return code === 'USER_HOOK_ERROR' || code === 'RESTART_FAILED'
50
+ ? undefined
51
+ : 'patch_fail';
47
52
  case 'markSuccess':
48
53
  return 'mark_success';
49
54
  case 'rollback':
package/src/type.ts CHANGED
@@ -57,6 +57,7 @@ export type EventType =
57
57
  | 'errorMarkSuccess'
58
58
  | 'reset'
59
59
  | 'errorReset'
60
+ | 'errorRestart'
60
61
  | 'errorSwitchVersion'
61
62
  | 'downloadingApk'
62
63
  | 'rejectStoragePermission'