@absolutejs/absolute 0.20.0-beta.84 → 0.20.0-beta.86

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.
@@ -157,17 +157,18 @@ var fileUrl = (manifestUrl, releaseId, path) => {
157
157
  throw new TypeError("Mobile update asset escaped its signed release origin.");
158
158
  return result;
159
159
  };
160
- var readChunks = async (reader, maximum, chunks = [], received = 0) => {
160
+ var readChunks = async (reader, maximum, onChunk, chunks = [], received = 0) => {
161
161
  const result = await reader.read();
162
162
  if (result.done)
163
163
  return { chunks, received };
164
164
  const total = received + result.value.byteLength;
165
165
  if (total > maximum)
166
166
  throw new TypeError("Mobile update response exceeds its signed size.");
167
+ await onChunk?.(result.value, received);
167
168
  chunks.push(result.value);
168
- return readChunks(reader, maximum, chunks, total);
169
+ return readChunks(reader, maximum, onChunk, chunks, total);
169
170
  };
170
- var readBounded = async (response, maximum) => {
171
+ var readBounded = async (response, maximum, onChunk) => {
171
172
  const declared = Number(response.headers.get("content-length"));
172
173
  if (Number.isFinite(declared) && declared > maximum)
173
174
  throw new TypeError("Mobile update response exceeds its signed size.");
@@ -175,8 +176,9 @@ var readBounded = async (response, maximum) => {
175
176
  return new Uint8Array;
176
177
  const reader = response.body.getReader();
177
178
  let result;
179
+ const chunks = [];
178
180
  try {
179
- result = await readChunks(reader, maximum);
181
+ result = await readChunks(reader, maximum, onChunk, chunks);
180
182
  } catch (error) {
181
183
  await reader.cancel().catch(() => {
182
184
  return;
@@ -185,7 +187,7 @@ var readBounded = async (response, maximum) => {
185
187
  }
186
188
  const contents = new Uint8Array(result.received);
187
189
  let offset = 0;
188
- for (const chunk of result.chunks) {
190
+ for (const chunk of chunks) {
189
191
  contents.set(chunk, offset);
190
192
  offset += chunk.byteLength;
191
193
  }
@@ -198,6 +200,30 @@ var requestHeaders = (config) => ({
198
200
  "x-absolute-mobile-release": config.currentReleaseId,
199
201
  "x-absolute-mobile-runtime": config.runtimeFingerprint
200
202
  });
203
+ var healthUrl = (manifestUrl) => new URL("./health", manifestUrl);
204
+ var reportAbsoluteMobileUpdateHealth = async (config, input, request = globalThis.fetch) => {
205
+ const manifestUrl = exactManifestUrl(config.manifestUrl);
206
+ const headers = new Headers(requestHeaders(config));
207
+ headers.set("content-type", "application/json");
208
+ headers.set("x-absolute-mobile-health-token", input.healthToken);
209
+ const response = await request(healthUrl(manifestUrl), {
210
+ body: JSON.stringify({
211
+ kind: input.kind,
212
+ ...input.reason ? { reason: input.reason } : {},
213
+ releaseId: input.releaseId,
214
+ ...input.transfer ? { transfer: input.transfer } : {}
215
+ }),
216
+ cache: "no-store",
217
+ credentials: "omit",
218
+ headers,
219
+ keepalive: true,
220
+ method: "POST",
221
+ redirect: "error",
222
+ signal: AbortSignal.timeout(15000)
223
+ });
224
+ if (response.status !== 202)
225
+ throw new TypeError(`Mobile update health report failed with HTTP ${response.status}.`);
226
+ };
201
227
  var requireCompatible = (manifest, config) => {
202
228
  if (manifest.appId !== config.appId)
203
229
  throw new TypeError("Mobile update belongs to another app.");
@@ -206,49 +232,172 @@ var requireCompatible = (manifest, config) => {
206
232
  if (manifest.runtimeFingerprint !== config.runtimeFingerprint)
207
233
  throw new TypeError("Mobile update requires a different native runtime.");
208
234
  };
235
+ var networkConcurrency = (requested) => {
236
+ const bounded = Math.max(1, Math.min(6, Math.floor(requested ?? 3)));
237
+ const navigatorValue = Reflect.get(globalThis, "navigator");
238
+ const connection = typeof navigatorValue === "object" && navigatorValue !== null ? Reflect.get(navigatorValue, "connection") : undefined;
239
+ if (typeof connection !== "object" || connection === null)
240
+ return bounded;
241
+ if (Reflect.get(connection, "saveData") === true)
242
+ return 1;
243
+ const effectiveType = Reflect.get(connection, "effectiveType");
244
+ if (effectiveType === "slow-2g" || effectiveType === "2g")
245
+ return 1;
246
+ if (effectiveType === "3g")
247
+ return Math.min(2, bounded);
248
+ return bounded;
249
+ };
250
+ var combine = (prefix, suffix) => {
251
+ const result = new Uint8Array(prefix.byteLength + suffix.byteLength);
252
+ result.set(prefix);
253
+ result.set(suffix, prefix.byteLength);
254
+ return result;
255
+ };
256
+ var validContentRange = (value, start, total) => value === `bytes ${start}-${total - 1}/${total}`;
257
+ var combineSignals = (signals) => {
258
+ const nativeAny = Reflect.get(AbortSignal, "any");
259
+ if (typeof nativeAny === "function")
260
+ return Reflect.apply(nativeAny, AbortSignal, [signals]);
261
+ const controller = new AbortController;
262
+ const abort = () => controller.abort();
263
+ if (signals.some((signal) => signal.aborted))
264
+ abort();
265
+ else
266
+ signals.forEach((signal) => signal.addEventListener("abort", abort, { once: true }));
267
+ return controller.signal;
268
+ };
209
269
  var createAbsoluteMobileUpdateClient = (options) => {
210
270
  const manifestUrl = exactManifestUrl(options.config.manifestUrl);
211
271
  const request = options.fetch ?? globalThis.fetch;
212
- const downloadFiles = async (manifest, index = 0, transfer = {
213
- downloadedBytes: 0,
214
- downloadedFiles: 0,
215
- reusedBytes: 0,
216
- reusedFiles: 0,
217
- totalBytes: manifest.files.reduce((total, file) => total + file.bytes, 0),
218
- totalFiles: manifest.files.length
219
- }) => {
220
- const file = manifest.files[index];
221
- if (!file)
222
- return transfer;
223
- const reusable = await options.store.readReusable?.(file);
224
- if (reusable && reusable.byteLength === file.bytes && await options.verifier.digest(reusable) === file.sha256) {
225
- await options.store.write(file, reusable);
226
- return downloadFiles(manifest, index + 1, {
227
- ...transfer,
228
- reusedBytes: transfer.reusedBytes + reusable.byteLength,
229
- reusedFiles: transfer.reusedFiles + 1
272
+ const downloadFiles = async (manifest) => {
273
+ const startedAt = performance.now();
274
+ const transfer = {
275
+ avoidedBytes: 0,
276
+ completedFiles: 0,
277
+ downloadedBytes: 0,
278
+ downloadedFiles: 0,
279
+ durationMs: 0,
280
+ resumedBytes: 0,
281
+ resumedFiles: 0,
282
+ reusedBytes: 0,
283
+ reusedFiles: 0,
284
+ throughputBytesPerSecond: 0,
285
+ totalBytes: manifest.files.reduce((total, file) => total + file.bytes, 0),
286
+ totalFiles: manifest.files.length
287
+ };
288
+ const updateTiming = () => {
289
+ transfer.durationMs = Math.max(0, performance.now() - startedAt);
290
+ transfer.avoidedBytes = transfer.reusedBytes + transfer.resumedBytes;
291
+ transfer.throughputBytesPerSecond = transfer.durationMs > 0 ? Math.round(transfer.downloadedBytes * 1000 / transfer.durationMs) : transfer.downloadedBytes;
292
+ };
293
+ const progress = () => {
294
+ updateTiming();
295
+ try {
296
+ options.onProgress?.({
297
+ ...transfer,
298
+ kind: "download-progress"
299
+ });
300
+ } catch {}
301
+ };
302
+ const controller = new AbortController;
303
+ let next = 0;
304
+ let firstError;
305
+ const downloadFile = async (file) => {
306
+ const staged = await options.store.readStaged?.(file);
307
+ if (staged?.byteLength === file.bytes && await options.verifier.digest(staged) === file.sha256) {
308
+ transfer.resumedBytes += staged.byteLength;
309
+ transfer.resumedFiles += 1;
310
+ transfer.completedFiles += 1;
311
+ progress();
312
+ return;
313
+ }
314
+ const reusable = await options.store.readReusable?.(file);
315
+ if (reusable?.byteLength === file.bytes && await options.verifier.digest(reusable) === file.sha256) {
316
+ await options.store.write(file, reusable);
317
+ transfer.reusedBytes += reusable.byteLength;
318
+ transfer.reusedFiles += 1;
319
+ transfer.completedFiles += 1;
320
+ progress();
321
+ return;
322
+ }
323
+ const candidate = await options.store.readPartial?.(file);
324
+ if (candidate?.byteLength === file.bytes && await options.verifier.digest(candidate) === file.sha256) {
325
+ await options.store.write(file, candidate);
326
+ transfer.resumedBytes += candidate.byteLength;
327
+ transfer.resumedFiles += 1;
328
+ transfer.completedFiles += 1;
329
+ progress();
330
+ return;
331
+ }
332
+ const partial = candidate && candidate.byteLength > 0 && candidate.byteLength < file.bytes ? candidate : new Uint8Array;
333
+ const headers = new Headers;
334
+ if (partial.byteLength > 0) {
335
+ headers.set("if-range", `"${file.sha256}"`);
336
+ headers.set("range", `bytes=${partial.byteLength}-`);
337
+ }
338
+ const asset = await request(fileUrl(manifestUrl, manifest.releaseId, file.path), {
339
+ cache: "no-store",
340
+ credentials: "omit",
341
+ headers,
342
+ redirect: "error",
343
+ signal: combineSignals([
344
+ controller.signal,
345
+ AbortSignal.timeout(30000)
346
+ ])
230
347
  });
231
- }
232
- const asset = await request(fileUrl(manifestUrl, manifest.releaseId, file.path), {
233
- cache: "no-store",
234
- credentials: "omit",
235
- redirect: "error",
236
- signal: AbortSignal.timeout(30000)
237
- });
238
- if (!asset.ok)
239
- throw new TypeError(`Mobile update asset ${file.path} failed with HTTP ${asset.status}.`);
240
- const contents = await readBounded(asset, file.bytes);
241
- const downloadedBytes = transfer.downloadedBytes + contents.byteLength;
242
- if (contents.byteLength !== file.bytes || downloadedBytes > ABSOLUTE_MOBILE_UPDATE_MAX_TOTAL_BYTES)
243
- throw new TypeError(`Mobile update asset ${file.path} has an invalid size.`);
244
- if (await options.verifier.digest(contents) !== file.sha256)
245
- throw new TypeError(`Mobile update asset ${file.path} failed integrity verification.`);
246
- await options.store.write(file, contents);
247
- return downloadFiles(manifest, index + 1, {
248
- ...transfer,
249
- downloadedBytes,
250
- downloadedFiles: transfer.downloadedFiles + 1
251
- });
348
+ if (!asset.ok)
349
+ throw new TypeError(`Mobile update asset ${file.path} failed with HTTP ${asset.status}.`);
350
+ const ranged = asset.status === 206;
351
+ if (ranged && (partial.byteLength === 0 || !validContentRange(asset.headers.get("content-range"), partial.byteLength, file.bytes)))
352
+ throw new TypeError(`Mobile update asset ${file.path} returned an invalid byte range.`);
353
+ const prefix = ranged ? partial : new Uint8Array;
354
+ if (ranged) {
355
+ transfer.resumedBytes += partial.byteLength;
356
+ transfer.resumedFiles += 1;
357
+ }
358
+ const downloaded = await readBounded(asset, file.bytes - prefix.byteLength, options.store.appendPartial ? async (chunk, offset) => {
359
+ await options.store.appendPartial?.(file, chunk, prefix.byteLength + offset);
360
+ transfer.downloadedBytes += chunk.byteLength;
361
+ progress();
362
+ } : undefined);
363
+ if (!options.store.appendPartial) {
364
+ transfer.downloadedBytes += downloaded.byteLength;
365
+ progress();
366
+ }
367
+ if (transfer.downloadedBytes > ABSOLUTE_MOBILE_UPDATE_MAX_TOTAL_BYTES)
368
+ throw new TypeError("Mobile update exceeds the maximum transfer size.");
369
+ const contents = combine(prefix, downloaded);
370
+ if (contents.byteLength !== file.bytes)
371
+ throw new TypeError(`Mobile update asset ${file.path} has an invalid size.`);
372
+ if (await options.verifier.digest(contents) !== file.sha256)
373
+ throw new TypeError(`Mobile update asset ${file.path} failed integrity verification.`);
374
+ await options.store.write(file, contents);
375
+ transfer.downloadedFiles += 1;
376
+ transfer.completedFiles += 1;
377
+ progress();
378
+ };
379
+ const worker = async () => {
380
+ if (firstError)
381
+ return;
382
+ const index = next++;
383
+ const file = manifest.files[index];
384
+ if (!file)
385
+ return;
386
+ try {
387
+ await downloadFile(file);
388
+ } catch (error) {
389
+ firstError ??= error;
390
+ controller.abort();
391
+ }
392
+ await worker();
393
+ };
394
+ await Promise.all(Array.from({
395
+ length: Math.min(networkConcurrency(options.concurrency), manifest.files.length)
396
+ }, () => worker()));
397
+ if (firstError)
398
+ throw firstError;
399
+ updateTiming();
400
+ return transfer;
252
401
  };
253
402
  const check = async (download = false) => {
254
403
  const response = await request(manifestUrl, {
@@ -270,39 +419,67 @@ var createAbsoluteMobileUpdateClient = (options) => {
270
419
  throw new TypeError("Mobile update manifest is not valid JSON.");
271
420
  }
272
421
  const manifest = parseAbsoluteMobileUpdateManifest(manifestValue);
422
+ const healthToken = response.headers.get("x-absolute-mobile-health-token");
273
423
  requireCompatible(manifest, options.config);
274
424
  if (!await options.verifier.verify(manifest))
275
425
  throw new TypeError("Mobile update signature verification failed.");
276
426
  if (manifest.releaseId === options.config.currentReleaseId)
277
427
  return { kind: "current" };
278
428
  if (options.config.blockedReleaseIds?.includes(manifest.releaseId))
279
- return { kind: "quarantined", releaseId: manifest.releaseId };
429
+ return {
430
+ ...healthToken ? { healthToken } : {},
431
+ kind: "quarantined",
432
+ releaseId: manifest.releaseId
433
+ };
280
434
  if (!download)
281
- return { kind: "update-available", manifest };
435
+ return {
436
+ ...healthToken ? { healthToken } : {},
437
+ kind: "update-available",
438
+ manifest
439
+ };
282
440
  await options.store.begin(manifest);
283
441
  let transfer;
284
442
  try {
285
443
  transfer = await downloadFiles(manifest);
286
444
  await options.store.commit(manifest);
287
445
  } catch (error) {
288
- await options.store.abort(manifest.releaseId);
446
+ if (options.store.suspend)
447
+ await options.store.suspend(manifest.releaseId);
448
+ else
449
+ await options.store.abort(manifest.releaseId);
450
+ if (healthToken)
451
+ reportAbsoluteMobileUpdateHealth(options.config, {
452
+ healthToken,
453
+ kind: "download-failed",
454
+ releaseId: manifest.releaseId
455
+ }, request).catch(() => {
456
+ return;
457
+ });
289
458
  throw error;
290
459
  }
291
- return { kind: "downloaded", manifest, transfer };
460
+ return {
461
+ ...healthToken ? { healthToken } : {},
462
+ kind: "downloaded",
463
+ manifest,
464
+ transfer
465
+ };
292
466
  };
293
467
  return {
294
468
  check,
295
469
  activate: (releaseId) => options.store.activate(releaseId),
296
- download: () => check(true)
470
+ download: () => check(true),
471
+ report: (input) => reportAbsoluteMobileUpdateHealth(options.config, input, request)
297
472
  };
298
473
  };
299
474
 
300
475
  // src/mobile/shellUpdate.ts
301
476
  var STATE_KEY = "absolute.mobile.update.state.v1";
477
+ var STAGING_KEY = "absolute.mobile.update.staging.v1";
302
478
  var INSTALLATION_KEY = "absolute.mobile.update.installation.v1";
303
479
  var RESULT_KEY = Symbol.for("absolutejs.mobile.update.result");
304
480
  var RESULTS_KEY = Symbol.for("absolutejs.mobile.update.results");
305
481
  var ROOT = "NoCloud/ionic_built_snapshots";
482
+ var STAGING_ROOT = "NoCloud/absolute_update_staging";
306
483
  var watchdog = registerPlugin("AbsoluteMobileUpdateWatchdog");
307
484
  var base64Bytes = (value) => Uint8Array.from(atob(value), (character) => character.charCodeAt(0));
308
485
  var arrayBuffer = (value) => new Uint8Array(value).buffer;
@@ -348,12 +525,15 @@ var readState = async () => {
348
525
  releaseId: recovery.releaseId
349
526
  };
350
527
  return {
528
+ ...text("activeHealthToken") ? { activeHealthToken: text("activeHealthToken") } : {},
351
529
  ...text("activeRelease") ? { activeRelease: text("activeRelease") } : {},
352
530
  ...text("pendingRelease") ? { pendingRelease: text("pendingRelease") } : {},
531
+ ...text("pendingHealthToken") ? { pendingHealthToken: text("pendingHealthToken") } : {},
353
532
  ...number("pendingStartedAt") === undefined ? {} : { pendingStartedAt: number("pendingStartedAt") },
354
533
  ...text("previousPath") ? { previousPath: text("previousPath") } : {},
355
534
  ...quarantinedReleases.length > 0 ? { quarantinedReleases } : {},
356
535
  ...text("readyRelease") ? { readyRelease: text("readyRelease") } : {},
536
+ ...text("readyHealthToken") ? { readyHealthToken: text("readyHealthToken") } : {},
357
537
  ...validRecovery ? { recovery: validRecovery } : {}
358
538
  };
359
539
  } catch {
@@ -361,6 +541,25 @@ var readState = async () => {
361
541
  }
362
542
  };
363
543
  var writeState = (state) => Preferences.set({ key: STATE_KEY, value: JSON.stringify(state) });
544
+ var clearStagingRoot = () => Filesystem.rmdir({
545
+ directory: Directory.Library,
546
+ path: STAGING_ROOT,
547
+ recursive: true
548
+ }).catch(() => {
549
+ return;
550
+ });
551
+ var readStaging = async () => {
552
+ const { value } = await Preferences.get({ key: STAGING_KEY });
553
+ if (!value)
554
+ return;
555
+ try {
556
+ return parseAbsoluteMobileUpdateManifest(JSON.parse(value));
557
+ } catch {
558
+ await Preferences.remove({ key: STAGING_KEY });
559
+ await clearStagingRoot();
560
+ return;
561
+ }
562
+ };
364
563
  var installationId = async () => {
365
564
  const existing = await Preferences.get({ key: INSTALLATION_KEY });
366
565
  if (existing.value && /^[a-f0-9-]{36}$/u.test(existing.value))
@@ -379,6 +578,7 @@ var webView = () => {
379
578
  };
380
579
  var currentServerBasePath = () => new Promise((resolve) => webView().getServerBasePath(resolve));
381
580
  var releasePath = (releaseId) => `${ROOT}/${releaseId}`;
581
+ var partialPath = (releaseId, file) => `${STAGING_ROOT}/${releaseId}/${file.path}.part`;
382
582
  var releaseNativePath = async (releaseId) => {
383
583
  const { uri } = await Filesystem.getUri({
384
584
  directory: Directory.Library,
@@ -396,6 +596,15 @@ var removeRelease = async (releaseId) => {
396
596
  return;
397
597
  });
398
598
  };
599
+ var removeStaging = async (releaseId) => {
600
+ await Filesystem.rmdir({
601
+ directory: Directory.Library,
602
+ path: `${STAGING_ROOT}/${releaseId}`,
603
+ recursive: true
604
+ }).catch(() => {
605
+ return;
606
+ });
607
+ };
399
608
  var filesystemBytes = async (data) => typeof data === "string" ? base64Bytes(data) : new Uint8Array(await data.arrayBuffer());
400
609
  var createStore = () => {
401
610
  let staging;
@@ -403,9 +612,14 @@ var createStore = () => {
403
612
  abort: async (releaseId) => {
404
613
  staging = undefined;
405
614
  await removeRelease(releaseId);
615
+ await removeStaging(releaseId);
616
+ const persisted = await readStaging();
617
+ if (persisted?.releaseId === releaseId)
618
+ await Preferences.remove({ key: STAGING_KEY });
406
619
  const state = await readState();
407
620
  if (state.readyRelease === releaseId || state.pendingRelease === releaseId)
408
621
  await writeState({
622
+ activeHealthToken: state.activeHealthToken,
409
623
  activeRelease: state.activeRelease,
410
624
  quarantinedReleases: state.quarantinedReleases,
411
625
  recovery: state.recovery
@@ -418,7 +632,9 @@ var createStore = () => {
418
632
  const path = await releaseNativePath(releaseId);
419
633
  const previousPath = await currentServerBasePath();
420
634
  await writeState({
635
+ activeHealthToken: state.activeHealthToken,
421
636
  activeRelease: state.activeRelease,
637
+ pendingHealthToken: state.readyHealthToken,
422
638
  pendingRelease: releaseId,
423
639
  pendingStartedAt: Date.now(),
424
640
  previousPath,
@@ -433,19 +649,59 @@ var createStore = () => {
433
649
  });
434
650
  await removeRelease(releaseId);
435
651
  await writeState({
652
+ activeHealthToken: state.activeHealthToken,
436
653
  activeRelease: state.activeRelease,
437
654
  quarantinedReleases: state.quarantinedReleases
438
655
  });
439
656
  throw error;
440
657
  }
441
658
  },
659
+ appendPartial: async (file, contents, offset) => {
660
+ if (!staging || !staging.files.some((candidate) => candidate.path === file.path))
661
+ throw new TypeError("Mobile update partial write is outside its staging transaction.");
662
+ const path = partialPath(staging.releaseId, file);
663
+ if (offset === 0) {
664
+ await Filesystem.writeFile({
665
+ data: bytesBase64(contents),
666
+ directory: Directory.Library,
667
+ path,
668
+ recursive: true
669
+ });
670
+ return;
671
+ }
672
+ const stat = await Filesystem.stat({
673
+ directory: Directory.Library,
674
+ path
675
+ });
676
+ if (stat.size !== offset)
677
+ throw new TypeError("Mobile update partial checkpoint changed unexpectedly.");
678
+ await Filesystem.appendFile({
679
+ data: bytesBase64(contents),
680
+ directory: Directory.Library,
681
+ path
682
+ });
683
+ },
442
684
  begin: async (manifest) => {
443
- await removeRelease(manifest.releaseId);
685
+ const persisted = await readStaging();
686
+ const resume = persisted?.releaseId === manifest.releaseId && persisted.signature.keyId === manifest.signature.keyId && persisted.signature.value === manifest.signature.value;
687
+ if (persisted && !resume)
688
+ await Promise.all([
689
+ removeRelease(persisted.releaseId),
690
+ removeStaging(persisted.releaseId)
691
+ ]);
692
+ if (!resume) {
693
+ await removeRelease(manifest.releaseId);
694
+ await removeStaging(manifest.releaseId);
695
+ }
444
696
  await Filesystem.mkdir({
445
697
  directory: Directory.Library,
446
698
  path: releasePath(manifest.releaseId),
447
699
  recursive: true
448
700
  });
701
+ await Preferences.set({
702
+ key: STAGING_KEY,
703
+ value: JSON.stringify(manifest)
704
+ });
449
705
  staging = manifest;
450
706
  },
451
707
  commit: async (manifest) => {
@@ -453,12 +709,24 @@ var createStore = () => {
453
709
  throw new TypeError("Mobile update staging transaction changed identity.");
454
710
  const state = await readState();
455
711
  await writeState({
712
+ activeHealthToken: state.activeHealthToken,
456
713
  activeRelease: state.activeRelease,
457
714
  quarantinedReleases: state.quarantinedReleases,
458
715
  readyRelease: manifest.releaseId
459
716
  });
717
+ await removeStaging(manifest.releaseId);
718
+ await Preferences.remove({ key: STAGING_KEY });
460
719
  staging = undefined;
461
720
  },
721
+ readPartial: async (file) => {
722
+ if (!staging)
723
+ return null;
724
+ const result = await Filesystem.readFile({
725
+ directory: Directory.Library,
726
+ path: partialPath(staging.releaseId, file)
727
+ }).catch(() => null);
728
+ return result ? filesystemBytes(result.data).catch(() => null) : null;
729
+ },
462
730
  readReusable: async (file) => {
463
731
  const state = await readState();
464
732
  if (!state.activeRelease)
@@ -469,6 +737,19 @@ var createStore = () => {
469
737
  }).catch(() => null);
470
738
  return result ? filesystemBytes(result.data).catch(() => null) : null;
471
739
  },
740
+ readStaged: async (file) => {
741
+ if (!staging)
742
+ return null;
743
+ const result = await Filesystem.readFile({
744
+ directory: Directory.Library,
745
+ path: `${releasePath(staging.releaseId)}/${file.path}`
746
+ }).catch(() => null);
747
+ return result ? filesystemBytes(result.data).catch(() => null) : null;
748
+ },
749
+ suspend: async (releaseId) => {
750
+ if (staging?.releaseId === releaseId)
751
+ staging = undefined;
752
+ },
472
753
  write: async (file, contents) => {
473
754
  if (!staging || !staging.files.some((candidate) => candidate.path === file.path))
474
755
  throw new TypeError("Mobile update write is outside its staging transaction.");
@@ -512,29 +793,56 @@ var removePriorRelease = async (prior, active) => {
512
793
  if (prior && prior !== active)
513
794
  await removeRelease(prior);
514
795
  };
515
- var reconcilePendingRelease = async (store, state) => {
796
+ var reconcilePendingRelease = async (store, state, report) => {
516
797
  if (!state.pendingRelease)
517
798
  return state;
518
799
  const pendingPath = await releaseNativePath(state.pendingRelease);
519
800
  const currentPath = await currentServerBasePath();
520
801
  if (currentPath !== pendingPath) {
521
802
  const failed = state.pendingRelease;
803
+ if (state.pendingHealthToken)
804
+ report({
805
+ healthToken: state.pendingHealthToken,
806
+ kind: "rolled-back",
807
+ releaseId: failed
808
+ });
522
809
  await store.abort(failed);
523
810
  emitUpdateResult({ kind: "rolled-back", releaseId: failed });
524
811
  return readState();
525
812
  }
526
813
  webView().persistServerBasePath();
527
- const next = { activeRelease: state.pendingRelease };
814
+ const next = {
815
+ activeHealthToken: state.pendingHealthToken,
816
+ activeRelease: state.pendingRelease
817
+ };
528
818
  await writeState(next);
529
819
  await watchdog.confirm({ releaseId: state.pendingRelease });
820
+ if (state.pendingHealthToken)
821
+ report({
822
+ healthToken: state.pendingHealthToken,
823
+ kind: "activated",
824
+ releaseId: state.pendingRelease
825
+ });
530
826
  await removePriorRelease(state.activeRelease, next.activeRelease);
531
827
  emitUpdateResult({ kind: "activated", releaseId: next.activeRelease });
532
828
  return next;
533
829
  };
534
- var consumeNativeRecovery = async (state) => {
830
+ var consumeNativeRecovery = async (state, report) => {
535
831
  if (!state.recovery)
536
832
  return state;
537
- const { recovery, ...next } = state;
833
+ const {
834
+ pendingHealthToken,
835
+ readyHealthToken: _readyHealthToken,
836
+ recovery,
837
+ ...next
838
+ } = state;
839
+ if (pendingHealthToken)
840
+ report({
841
+ healthToken: pendingHealthToken,
842
+ kind: "rolled-back",
843
+ reason: recovery.reason,
844
+ releaseId: recovery.releaseId
845
+ });
538
846
  emitUpdateResult({
539
847
  durationMs: Math.round(recovery.durationMs),
540
848
  kind: "rolled-back",
@@ -545,34 +853,66 @@ var consumeNativeRecovery = async (state) => {
545
853
  return next;
546
854
  };
547
855
  var installAbsoluteMobileShellUpdates = async (manifest) => {
548
- if (!manifest.updates)
856
+ const { updates } = manifest;
857
+ if (!updates)
549
858
  return;
550
859
  const store = createStore();
551
- const recovered = await consumeNativeRecovery(await readState());
552
- const state = await reconcilePendingRelease(store, recovered);
860
+ const identity = await installationId();
861
+ const clientConfig = (state2) => ({
862
+ appId: manifest.appId,
863
+ blockedReleaseIds: state2.quarantinedReleases ?? [],
864
+ channel: updates.channel,
865
+ currentReleaseId: state2.activeRelease ?? `embedded:${manifest.appBuild}`,
866
+ installationId: identity,
867
+ manifestUrl: updates.manifestUrl,
868
+ runtimeFingerprint: manifest.nativeRuntime
869
+ });
870
+ const reportFor = (state2) => async (evidence) => {
871
+ await reportAbsoluteMobileUpdateHealth(clientConfig(state2), evidence).catch(() => {
872
+ return;
873
+ });
874
+ };
875
+ const initial = await readState();
876
+ const recovered = await consumeNativeRecovery(initial, reportFor(initial));
877
+ const state = await reconcilePendingRelease(store, recovered, reportFor(recovered));
553
878
  const client = createAbsoluteMobileUpdateClient({
554
- config: {
555
- appId: manifest.appId,
556
- blockedReleaseIds: state.quarantinedReleases ?? [],
557
- channel: manifest.updates.channel,
558
- currentReleaseId: state.activeRelease ?? `embedded:${manifest.appBuild}`,
559
- installationId: await installationId(),
560
- manifestUrl: manifest.updates.manifestUrl,
561
- runtimeFingerprint: manifest.nativeRuntime
562
- },
879
+ config: clientConfig(state),
563
880
  store,
564
- verifier: createVerifier(manifest.updates.publicKeys)
881
+ verifier: createVerifier(updates.publicKeys),
882
+ onProgress: (progress) => emitUpdateResult(progress)
565
883
  });
566
884
  const activateDownloaded = async (result) => {
567
885
  if (result.kind !== "downloaded")
568
886
  return;
887
+ if (result.healthToken) {
888
+ const ready = await readState();
889
+ if (ready.readyRelease === result.manifest.releaseId)
890
+ await writeState({
891
+ ...ready,
892
+ readyHealthToken: result.healthToken
893
+ });
894
+ client.report({
895
+ healthToken: result.healthToken,
896
+ kind: "downloaded",
897
+ releaseId: result.manifest.releaseId,
898
+ transfer: result.transfer
899
+ }).catch(() => {
900
+ return;
901
+ });
902
+ }
569
903
  emitUpdateResult({
904
+ avoidedBytes: result.transfer.avoidedBytes,
905
+ completedFiles: result.transfer.completedFiles,
570
906
  downloadedBytes: result.transfer.downloadedBytes,
571
907
  downloadedFiles: result.transfer.downloadedFiles,
908
+ durationMs: Math.round(result.transfer.durationMs),
572
909
  kind: "downloaded",
573
910
  releaseId: result.manifest.releaseId,
911
+ resumedBytes: result.transfer.resumedBytes,
912
+ resumedFiles: result.transfer.resumedFiles,
574
913
  reusedBytes: result.transfer.reusedBytes,
575
914
  reusedFiles: result.transfer.reusedFiles,
915
+ throughputBytesPerSecond: result.transfer.throughputBytesPerSecond,
576
916
  totalBytes: result.transfer.totalBytes,
577
917
  totalFiles: result.transfer.totalFiles
578
918
  });
@@ -580,6 +920,14 @@ var installAbsoluteMobileShellUpdates = async (manifest) => {
580
920
  };
581
921
  client.download().then((result) => {
582
922
  if (result.kind === "quarantined") {
923
+ if (result.healthToken)
924
+ client.report({
925
+ healthToken: result.healthToken,
926
+ kind: "quarantined",
927
+ releaseId: result.releaseId
928
+ }).catch(() => {
929
+ return;
930
+ });
583
931
  emitUpdateResult({
584
932
  kind: "quarantined",
585
933
  releaseId: result.releaseId