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.
- package/android/jni/hpatch.c +0 -20
- package/android/jni/hpatch.h +0 -6
- package/android/lib/arm64-v8a/librnupdate.so +0 -0
- package/android/lib/armeabi-v7a/librnupdate.so +0 -0
- package/android/lib/x86/librnupdate.so +0 -0
- package/android/lib/x86_64/librnupdate.so +0 -0
- package/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +10 -2
- package/android/src/main/java/cn/reactnative/modules/update/UpdateContext.java +20 -5
- package/android/src/main/java/cn/reactnative/modules/update/UpdateEventEmitter.java +12 -3
- package/android/src/main/java/cn/reactnative/modules/update/UpdateModuleSupport.java +11 -1
- package/cpp/patch_core/hbc_transform.cpp +5 -1
- package/cpp/patch_core/hbc_transform_wire.cpp +6 -0
- package/cpp/patch_core/tests/hbc_transform_test.cpp +156 -1
- package/harmony/pushy/src/main/cpp/CMakeLists.txt +33 -41
- package/harmony/pushy/src/main/cpp/pushy.cpp +13 -5
- package/harmony/pushy/src/main/ets/DownloadTask.ts +13 -2
- package/harmony/pushy/src/main/ets/UpdateContext.ts +43 -12
- package/harmony/pushy.har +0 -0
- package/ios/RCTPushy/RCTPushy.mm +34 -6
- package/ios/RCTPushy/RCTPushyDownloader.mm +11 -2
- package/package.json +1 -1
- package/src/client.ts +122 -39
- package/src/error.ts +50 -5
- package/src/provider.tsx +23 -15
- package/src/telemetry.ts +6 -1
- package/src/type.ts +1 -0
package/android/jni/hpatch.c
CHANGED
|
@@ -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,
|
package/android/jni/hpatch.h
CHANGED
|
@@ -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
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
private boolean
|
|
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
|
|
309
|
-
* downloaded
|
|
310
|
-
*
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
41
|
+
add_library(rnupdate SHARED
|
|
42
|
+
${HDP_SOURCES}
|
|
43
|
+
)
|
|
51
44
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
301
|
-
|
|
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
|
-
*
|
|
398
|
+
* 已下载版本——仅保留当前运行版本的目录(静默 reset 不能破坏运行中 bundle
|
|
399
|
+
* 的按需资源加载)。uuid 保留 —— 它标识安装实例、用于灰度分桶,reset 不应改变。
|
|
378
400
|
*/
|
|
379
401
|
public resetToPackagedBundle(): void {
|
|
380
402
|
this.trace('resetToPackagedBundle:before');
|
|
381
|
-
|
|
403
|
+
// 实时读取二进制身份(与 Android/iOS 对齐),而非 preferences 快照:
|
|
404
|
+
// meta.json 读取失败时快照可能为空,reset 持久化空值会让下次启动误判
|
|
405
|
+
// binary 变更多做一轮 cleanUp+persist。
|
|
382
406
|
const resetState: StateCoreResult = {
|
|
383
|
-
packageVersion:
|
|
384
|
-
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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
package/ios/RCTPushy/RCTPushy.mm
CHANGED
|
@@ -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
|
-
|
|
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
|
|
526
|
-
// downloaded
|
|
527
|
-
//
|
|
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
|
|
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).
|
|
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 =
|
|
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
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 {
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
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
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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
|
-
|
|
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
|
-
|
|
950
|
-
|
|
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
|
-
|
|
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
|
|
54
|
-
* comparing the caught error to the original still match; non-Error
|
|
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
|
-
|
|
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
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
|
|
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
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
if (!e
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
|
|
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':
|