@otakit/capacitor-updater 2.3.2 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +46 -5
  2. package/android/build.gradle +16 -0
  3. package/android/src/main/java/com/otakit/updater/BundleCrypto.java +38 -17
  4. package/android/src/main/java/com/otakit/updater/BundleInfo.java +10 -0
  5. package/android/src/main/java/com/otakit/updater/BundleStore.java +134 -86
  6. package/android/src/main/java/com/otakit/updater/CheckFailure.java +53 -0
  7. package/android/src/main/java/com/otakit/updater/DeltaAssembler.java +46 -41
  8. package/android/src/main/java/com/otakit/updater/DeviceEventClient.java +125 -26
  9. package/android/src/main/java/com/otakit/updater/DocumentReadyBridge.java +71 -0
  10. package/android/src/main/java/com/otakit/updater/DownloadRetry.java +144 -0
  11. package/android/src/main/java/com/otakit/updater/EventOutbox.java +174 -0
  12. package/android/src/main/java/com/otakit/updater/FileDownloader.java +80 -0
  13. package/android/src/main/java/com/otakit/updater/ForegroundDeadline.java +84 -0
  14. package/android/src/main/java/com/otakit/updater/HashUtils.java +26 -0
  15. package/android/src/main/java/com/otakit/updater/ManifestClient.java +12 -9
  16. package/android/src/main/java/com/otakit/updater/ManifestKeyConfig.java +47 -0
  17. package/android/src/main/java/com/otakit/updater/ManifestVerifier.java +22 -4
  18. package/android/src/main/java/com/otakit/updater/SDKVersion.java +9 -0
  19. package/android/src/main/java/com/otakit/updater/UpdateOwner.java +27 -0
  20. package/android/src/main/java/com/otakit/updater/UpdaterCoordinator.java +154 -59
  21. package/android/src/main/java/com/otakit/updater/UpdaterPlugin.java +284 -209
  22. package/android/src/main/java/com/otakit/updater/WebViewActivation.java +26 -0
  23. package/dist/esm/definitions.d.ts +3 -3
  24. package/dist/esm/definitions.d.ts.map +1 -1
  25. package/ios/Sources/UpdaterPlugin/BundleCrypto.swift +35 -3
  26. package/ios/Sources/UpdaterPlugin/BundleInfo.swift +13 -0
  27. package/ios/Sources/UpdaterPlugin/BundleStore.swift +103 -77
  28. package/ios/Sources/UpdaterPlugin/CheckFailure.swift +41 -0
  29. package/ios/Sources/UpdaterPlugin/DeltaAssembler.swift +29 -17
  30. package/ios/Sources/UpdaterPlugin/DeviceEventClient.swift +12 -10
  31. package/ios/Sources/UpdaterPlugin/DocumentReadyBridge.swift +43 -0
  32. package/ios/Sources/UpdaterPlugin/DownloadRetry.swift +51 -0
  33. package/ios/Sources/UpdaterPlugin/Downloader.swift +97 -39
  34. package/ios/Sources/UpdaterPlugin/EventOutbox.swift +183 -0
  35. package/ios/Sources/UpdaterPlugin/ForegroundDeadline.swift +94 -0
  36. package/ios/Sources/UpdaterPlugin/HashUtils.swift +16 -0
  37. package/ios/Sources/UpdaterPlugin/ManifestClient.swift +2 -4
  38. package/ios/Sources/UpdaterPlugin/ManifestKeyConfig.swift +20 -0
  39. package/ios/Sources/UpdaterPlugin/ManifestVerifier.swift +7 -1
  40. package/ios/Sources/UpdaterPlugin/SDKVersion.swift +4 -0
  41. package/ios/Sources/UpdaterPlugin/UpdateOwner.swift +12 -0
  42. package/ios/Sources/UpdaterPlugin/UpdaterCoordinator.swift +146 -110
  43. package/ios/Sources/UpdaterPlugin/UpdaterPlugin.swift +206 -202
  44. package/ios/Tests/UpdaterPluginTests/BundleCryptoTests.swift +80 -0
  45. package/ios/Tests/UpdaterPluginTests/BundlePersistenceTests.swift +102 -0
  46. package/ios/Tests/UpdaterPluginTests/CheckFailureTests.swift +55 -0
  47. package/ios/Tests/UpdaterPluginTests/DeltaCacheIntegrityTests.swift +69 -0
  48. package/ios/Tests/UpdaterPluginTests/DocumentReadyBridgeTests.swift +102 -0
  49. package/ios/Tests/UpdaterPluginTests/DownloadIntegrityTests.swift +40 -0
  50. package/ios/Tests/UpdaterPluginTests/DownloadRetryTests.swift +189 -0
  51. package/ios/Tests/UpdaterPluginTests/EventDeliveryTests.swift +56 -0
  52. package/ios/Tests/UpdaterPluginTests/EventOutboxTests.swift +86 -0
  53. package/ios/Tests/UpdaterPluginTests/ForegroundDeadlineTests.swift +144 -0
  54. package/ios/Tests/UpdaterPluginTests/ManifestKeyConfigTests.swift +48 -0
  55. package/ios/Tests/UpdaterPluginTests/UpdateOwnerTests.swift +34 -0
  56. package/ios/Tests/UpdaterPluginTests/UpdaterCoordinatorTests.swift +394 -0
  57. package/package.json +7 -3
@@ -1,6 +1,11 @@
1
1
  import Foundation
2
2
 
3
3
  final class UpdaterCoordinator {
4
+ struct Trial: Equatable {
5
+ let bundleId: String
6
+ let activationId = UUID().uuidString
7
+ }
8
+
4
9
  struct StateSnapshot {
5
10
  let current: BundleInfo
6
11
  let fallback: BundleInfo
@@ -15,6 +20,8 @@ final class UpdaterCoordinator {
15
20
  let channel: String?
16
21
  let releaseId: String?
17
22
  let detail: String?
23
+ var attemptId: String? = nil
24
+ var phase: String? = nil
18
25
  }
19
26
 
20
27
  enum LatestManifestClassification {
@@ -25,14 +32,14 @@ final class UpdaterCoordinator {
25
32
 
26
33
  struct StartupPreparation {
27
34
  let activationPath: String?
28
- let trialBundleId: String?
35
+ let trial: Trial?
29
36
  let cleanupBundleIds: [String]
30
37
  let eventPayload: DeviceEventPayload?
31
38
  }
32
39
 
33
40
  struct ApplyPreparation {
34
41
  let activationPath: String?
35
- let trialBundleId: String?
42
+ let trial: Trial?
36
43
  let cleanupBundleIds: [String]
37
44
 
38
45
  var didApply: Bool {
@@ -56,6 +63,7 @@ final class UpdaterCoordinator {
56
63
  private let operationLock = NSLock()
57
64
  private let stateLock = NSLock()
58
65
  private var operationInProgress = false
66
+ private var activeTrial: Trial?
59
67
 
60
68
  init(store: BundleStore) {
61
69
  self.store = store
@@ -110,18 +118,19 @@ final class UpdaterCoordinator {
110
118
 
111
119
  func pruneIncompatibleBundles(
112
120
  isCompatibleRuntime: @escaping (BundleInfo) -> Bool
113
- ) -> [String] {
114
- withStateLock {
121
+ ) throws -> [String] {
122
+ try withStateLock {
123
+ _ = try store.protectedBundleIds()
115
124
  var cleanupBundleIds = Set<String>()
116
125
 
117
126
  for bundle in store.listDownloadedBundles() where !isCompatibleRuntime(bundle) {
118
- detachBundleReferencesLocked(bundleId: bundle.id)
127
+ try detachBundleReferencesLocked(bundleId: bundle.id)
119
128
  cleanupBundleIds.insert(bundle.id)
120
129
  }
121
130
 
122
131
  if let failed = store.getLastFailedBundle(),
123
132
  !isCompatibleRuntime(failed) {
124
- store.setLastFailedBundle(nil)
133
+ try store.setLastFailedBundle(nil)
125
134
  }
126
135
 
127
136
  return Array(cleanupBundleIds)
@@ -130,15 +139,17 @@ final class UpdaterCoordinator {
130
139
 
131
140
  func normalizeStartupState(
132
141
  isBundleUsable: @escaping (BundleInfo) -> Bool
133
- ) -> StartupPreparation {
134
- withStateLock {
142
+ ) throws -> StartupPreparation {
143
+ try withStateLock {
144
+ _ = try store.protectedBundleIds()
145
+ activeTrial = nil
135
146
  var cleanupBundleIds = Set<String>()
136
- clearStaleBundlePointersLocked()
137
- normalizeStagedPointerLocked(
147
+ try clearStaleBundlePointersLocked()
148
+ try normalizeStagedPointerLocked(
138
149
  isBundleUsable: isBundleUsable,
139
150
  cleanupBundleIds: &cleanupBundleIds
140
151
  )
141
- normalizeFallbackPointerLocked(
152
+ try normalizeFallbackPointerLocked(
142
153
  isBundleUsable: isBundleUsable,
143
154
  cleanupBundleIds: &cleanupBundleIds
144
155
  )
@@ -147,7 +158,7 @@ final class UpdaterCoordinator {
147
158
  let current = store.getCurrentBundle()
148
159
  if !current.isBuiltin,
149
160
  current.status == .trial {
150
- let rollback = rollbackLocked(
161
+ let rollback = try rollbackLocked(
151
162
  reason: "app_restarted_before_notify",
152
163
  isBundleUsable: isBundleUsable,
153
164
  cleanupBundleIds: &cleanupBundleIds
@@ -157,24 +168,25 @@ final class UpdaterCoordinator {
157
168
 
158
169
  var normalizedCurrent = store.getCurrentBundle()
159
170
  if !normalizedCurrent.isBuiltin,
160
- !isBundleUsable(normalizedCurrent) {
171
+ normalizedCurrent.status == .error || !isBundleUsable(normalizedCurrent) {
161
172
  cleanupBundleIds.insert(normalizedCurrent.id)
162
- normalizedCurrent = restoreFallbackOrBuiltinLocked(
173
+ normalizedCurrent = try restoreFallbackOrBuiltinLocked(
163
174
  isBundleUsable: isBundleUsable,
164
- cleanupBundleIds: &cleanupBundleIds
175
+ cleanupBundleIds: &cleanupBundleIds,
176
+ excluding: normalizedCurrent.id
165
177
  )
166
178
  }
167
179
 
168
- var trialBundleId: String?
180
+ var trial: Trial?
169
181
  if !normalizedCurrent.isBuiltin,
170
182
  normalizedCurrent.status == .pending {
171
- normalizedCurrent = updateStatusLocked(normalizedCurrent, status: .trial)
172
- trialBundleId = normalizedCurrent.id
183
+ normalizedCurrent = try updateStatusLocked(normalizedCurrent, status: .trial)
184
+ trial = beginTrialLocked(bundleId: normalizedCurrent.id)
173
185
  }
174
186
 
175
187
  return StartupPreparation(
176
188
  activationPath: normalizedCurrent.isBuiltin ? nil : normalizedCurrent.path,
177
- trialBundleId: trialBundleId,
189
+ trial: trial,
178
190
  cleanupBundleIds: Array(cleanupBundleIds),
179
191
  eventPayload: eventPayload
180
192
  )
@@ -225,10 +237,15 @@ final class UpdaterCoordinator {
225
237
 
226
238
  func stageDownloadedBundle(_ bundle: BundleInfo) throws -> [String] {
227
239
  try withStateLock {
240
+ guard !bundle.isBuiltin, !(try store.protectedBundleIds()).contains(bundle.id) else {
241
+ throw NSError(domain: "OtaKit", code: 1, userInfo: [
242
+ NSLocalizedDescriptionKey: "Cannot overwrite a referenced bundle installation"
243
+ ])
244
+ }
228
245
  var cleanupBundleIds = Set<String>()
229
246
  let previousStagedId = store.getStagedBundleId()
230
247
  try store.saveBundle(bundle)
231
- store.setStagedBundleId(bundle.id)
248
+ try store.setStagedBundleId(bundle.id)
232
249
 
233
250
  if let previousStagedId,
234
251
  previousStagedId != bundle.id,
@@ -245,10 +262,15 @@ final class UpdaterCoordinator {
245
262
  func prepareApplyStaged(
246
263
  isCompatibleRuntime: @escaping (BundleInfo) -> Bool,
247
264
  isBundleUsable: @escaping (BundleInfo) -> Bool
248
- ) -> ApplyPreparation {
249
- withStateLock {
265
+ ) throws -> ApplyPreparation {
266
+ try withStateLock {
250
267
  var cleanupBundleIds = Set<String>()
251
- guard var staged = stagedBundleLocked(
268
+ let previousCurrent = store.getCurrentBundle()
269
+ // Keep the staged update until the running trial has a definite outcome.
270
+ guard previousCurrent.status != .trial else {
271
+ return ApplyPreparation(activationPath: nil, trial: nil, cleanupBundleIds: [])
272
+ }
273
+ guard var staged = try stagedBundleLocked(
252
274
  cleanInvalid: true,
253
275
  isCompatibleRuntime: isCompatibleRuntime,
254
276
  isUsable: isBundleUsable,
@@ -256,48 +278,48 @@ final class UpdaterCoordinator {
256
278
  ) else {
257
279
  return ApplyPreparation(
258
280
  activationPath: nil,
259
- trialBundleId: nil,
281
+ trial: nil,
260
282
  cleanupBundleIds: Array(cleanupBundleIds)
261
283
  )
262
284
  }
263
285
 
264
- let previousCurrent = store.getCurrentBundle()
265
- if previousCurrent.isBuiltin {
266
- store.setFallbackBundleId(nil)
267
- } else {
268
- store.setFallbackBundleId(previousCurrent.id)
269
- }
270
-
271
- store.setCurrentBundleId(staged.id)
272
- store.setStagedBundleId(nil)
273
-
274
- var trialBundleId: String?
286
+ let fallbackId = previousCurrent.isBuiltin ? nil :
287
+ (previousCurrent.status == .success ? previousCurrent.id : store.getFallbackBundleId())
275
288
  if staged.status == .pending {
276
- staged = updateStatusLocked(staged, status: .trial)
277
- trialBundleId = staged.id
278
- } else if staged.status == .trial {
279
- trialBundleId = staged.id
289
+ staged = try updateStatusLocked(staged, status: .trial)
280
290
  }
291
+ // Persist trial metadata before publishing the pointer; keep all pointers in one write.
292
+ try store.setCoreState(
293
+ currentId: staged.id, fallbackId: fallbackId, stagedId: nil,
294
+ lastFailed: store.getLastFailedBundle()
295
+ )
296
+ let trial = staged.status == .trial ? beginTrialLocked(bundleId: staged.id) : nil
281
297
 
282
298
  return ApplyPreparation(
283
299
  activationPath: staged.path,
284
- trialBundleId: trialBundleId,
300
+ trial: trial,
285
301
  cleanupBundleIds: Array(cleanupBundleIds)
286
302
  )
287
303
  }
288
304
  }
289
305
 
290
- func prepareNotifyAppReady() -> NotifyReadyPreparation {
291
- withStateLock {
306
+ func prepareNotifyAppReady(activationId: String?) throws -> NotifyReadyPreparation {
307
+ try withStateLock {
292
308
  let current = store.getCurrentBundle()
293
309
  guard !current.isBuiltin,
294
- current.status == .trial else {
310
+ activeTrial?.bundleId == current.id,
311
+ let activationId, activeTrial?.activationId == activationId,
312
+ current.status == .trial ||
313
+ (current.status == .success && store.getFallbackBundleId() != current.id) else {
295
314
  return NotifyReadyPreparation(eventPayload: nil, cleanupBundleIds: [])
296
315
  }
297
316
 
298
317
  let oldFallbackId = store.getFallbackBundleId()
299
- _ = updateStatusLocked(current, status: .success)
300
- store.setFallbackBundleId(current.id)
318
+ if current.status == .trial {
319
+ _ = try updateStatusLocked(current, status: .success)
320
+ }
321
+ try store.setFallbackBundleId(current.id)
322
+ activeTrial = nil
301
323
 
302
324
  var cleanupBundleIds = Set<String>()
303
325
  if let oldFallbackId,
@@ -312,7 +334,9 @@ final class UpdaterCoordinator {
312
334
  runtimeVersion: current.runtimeVersion,
313
335
  channel: current.channel,
314
336
  releaseId: current.releaseId,
315
- detail: nil
337
+ detail: nil,
338
+ attemptId: current.attemptId,
339
+ phase: "readiness"
316
340
  ),
317
341
  cleanupBundleIds: Array(cleanupBundleIds)
318
342
  )
@@ -320,12 +344,21 @@ final class UpdaterCoordinator {
320
344
  }
321
345
 
322
346
  func prepareRollback(
347
+ expectedTrial: Trial,
323
348
  reason: String,
324
349
  isBundleUsable: @escaping (BundleInfo) -> Bool
325
- ) -> RollbackPreparation {
326
- withStateLock {
350
+ ) throws -> RollbackPreparation {
351
+ try withStateLock {
352
+ let current = store.getCurrentBundle()
353
+ guard activeTrial == expectedTrial,
354
+ current.id == expectedTrial.bundleId,
355
+ current.status == .trial else {
356
+ return RollbackPreparation(
357
+ didRollback: false, activationPath: nil, eventPayload: nil, cleanupBundleIds: []
358
+ )
359
+ }
327
360
  var cleanupBundleIds = Set<String>()
328
- let rollback = rollbackLocked(
361
+ let rollback = try rollbackLocked(
329
362
  reason: reason,
330
363
  isBundleUsable: isBundleUsable,
331
364
  cleanupBundleIds: &cleanupBundleIds
@@ -339,17 +372,22 @@ final class UpdaterCoordinator {
339
372
  }
340
373
  }
341
374
 
342
- func isCurrentTrialBundle(_ bundleId: String) -> Bool {
343
- withStateLock {
344
- let current = store.getCurrentBundle()
345
- return current.id == bundleId && current.status == .trial
346
- }
375
+ private func beginTrialLocked(bundleId: String) -> Trial {
376
+ let trial = Trial(bundleId: bundleId)
377
+ activeTrial = trial
378
+ return trial
347
379
  }
348
380
 
349
381
  func cleanupBundles(_ bundleIds: [String]) {
350
- let uniqueIds = Set(bundleIds).filter { !$0.isEmpty && $0 != "builtin" }
351
- for bundleId in uniqueIds {
352
- try? FileManager.default.removeItem(at: store.bundleDirectory(for: bundleId))
382
+ withStateLock {
383
+ // An unreadable state is not evidence that these files are unreferenced.
384
+ guard let protectedIds = try? store.protectedBundleIds() else { return }
385
+ let uniqueIds = Set(bundleIds).filter { !$0.isEmpty && $0 != "builtin" }
386
+ for bundleId in uniqueIds {
387
+ // A later transition may have made an earlier cleanup candidate live again.
388
+ guard !protectedIds.contains(bundleId) else { continue }
389
+ try? FileManager.default.removeItem(at: store.bundleDirectory(for: bundleId))
390
+ }
353
391
  }
354
392
  }
355
393
 
@@ -365,27 +403,27 @@ final class UpdaterCoordinator {
365
403
  return try work()
366
404
  }
367
405
 
368
- private func clearStaleBundlePointersLocked() {
406
+ private func clearStaleBundlePointersLocked() throws {
369
407
  if let currentId = store.getCurrentBundleId(),
370
408
  store.getBundle(id: currentId) == nil {
371
- store.setCurrentBundleId(nil)
409
+ try store.setCurrentBundleId(nil)
372
410
  }
373
411
 
374
412
  if let fallbackId = store.getFallbackBundleId(),
375
413
  store.getBundle(id: fallbackId) == nil {
376
- store.setFallbackBundleId(nil)
414
+ try store.setFallbackBundleId(nil)
377
415
  }
378
416
 
379
417
  if let stagedId = store.getStagedBundleId(),
380
418
  store.getBundle(id: stagedId) == nil {
381
- store.setStagedBundleId(nil)
419
+ try store.setStagedBundleId(nil)
382
420
  }
383
421
  }
384
422
 
385
423
  private func normalizeFallbackPointerLocked(
386
424
  isBundleUsable: (BundleInfo) -> Bool,
387
425
  cleanupBundleIds: inout Set<String>
388
- ) {
426
+ ) throws {
389
427
  guard let fallbackId = store.getFallbackBundleId(),
390
428
  let fallback = store.getBundle(id: fallbackId) else {
391
429
  return
@@ -395,8 +433,8 @@ final class UpdaterCoordinator {
395
433
  return
396
434
  }
397
435
 
398
- if !isBundleUsable(fallback) {
399
- store.setFallbackBundleId(nil)
436
+ if fallback.status != .success || !isBundleUsable(fallback) {
437
+ try store.setFallbackBundleId(nil)
400
438
  cleanupBundleIds.insert(fallback.id)
401
439
  }
402
440
  }
@@ -404,18 +442,18 @@ final class UpdaterCoordinator {
404
442
  private func normalizeStagedPointerLocked(
405
443
  isBundleUsable: (BundleInfo) -> Bool,
406
444
  cleanupBundleIds: inout Set<String>
407
- ) {
445
+ ) throws {
408
446
  guard let stagedId = store.getStagedBundleId() else {
409
447
  return
410
448
  }
411
449
 
412
450
  guard let staged = store.getBundle(id: stagedId) else {
413
- store.setStagedBundleId(nil)
451
+ try store.setStagedBundleId(nil)
414
452
  return
415
453
  }
416
454
 
417
455
  if !isBundleUsable(staged) {
418
- store.setStagedBundleId(nil)
456
+ try store.setStagedBundleId(nil)
419
457
  cleanupBundleIds.insert(staged.id)
420
458
  }
421
459
  }
@@ -425,7 +463,7 @@ final class UpdaterCoordinator {
425
463
  isUsable: (BundleInfo) -> Bool
426
464
  ) -> BundleInfo? {
427
465
  var ignored = Set<String>()
428
- return stagedBundleLocked(
466
+ return try? stagedBundleLocked(
429
467
  cleanInvalid: false,
430
468
  isCompatibleRuntime: isCompatibleRuntime,
431
469
  isUsable: isUsable,
@@ -438,14 +476,14 @@ final class UpdaterCoordinator {
438
476
  isCompatibleRuntime: ((BundleInfo) -> Bool)?,
439
477
  isUsable: (BundleInfo) -> Bool,
440
478
  cleanupBundleIds: inout Set<String>
441
- ) -> BundleInfo? {
479
+ ) throws -> BundleInfo? {
442
480
  guard let stagedId = store.getStagedBundleId() else {
443
481
  return nil
444
482
  }
445
483
 
446
484
  guard let staged = store.getBundle(id: stagedId) else {
447
485
  if cleanInvalid {
448
- store.setStagedBundleId(nil)
486
+ try store.setStagedBundleId(nil)
449
487
  }
450
488
  return nil
451
489
  }
@@ -453,7 +491,7 @@ final class UpdaterCoordinator {
453
491
  if let isCompatibleRuntime,
454
492
  !isCompatibleRuntime(staged) {
455
493
  if cleanInvalid {
456
- store.setStagedBundleId(nil)
494
+ try store.setStagedBundleId(nil)
457
495
  cleanupBundleIds.insert(staged.id)
458
496
  }
459
497
  return nil
@@ -461,7 +499,7 @@ final class UpdaterCoordinator {
461
499
 
462
500
  if !isUsable(staged) {
463
501
  if cleanInvalid {
464
- store.setStagedBundleId(nil)
502
+ try store.setStagedBundleId(nil)
465
503
  cleanupBundleIds.insert(staged.id)
466
504
  }
467
505
  return nil
@@ -470,15 +508,15 @@ final class UpdaterCoordinator {
470
508
  return staged
471
509
  }
472
510
 
473
- private func detachBundleReferencesLocked(bundleId: String) {
511
+ private func detachBundleReferencesLocked(bundleId: String) throws {
474
512
  if store.getCurrentBundleId() == bundleId {
475
- store.setCurrentBundleId(nil)
513
+ try store.setCurrentBundleId(nil)
476
514
  }
477
515
  if store.getFallbackBundleId() == bundleId {
478
- store.setFallbackBundleId(nil)
516
+ try store.setFallbackBundleId(nil)
479
517
  }
480
518
  if store.getStagedBundleId() == bundleId {
481
- store.setStagedBundleId(nil)
519
+ try store.setStagedBundleId(nil)
482
520
  }
483
521
  }
484
522
 
@@ -486,7 +524,7 @@ final class UpdaterCoordinator {
486
524
  reason: String,
487
525
  isBundleUsable: (BundleInfo) -> Bool,
488
526
  cleanupBundleIds: inout Set<String>
489
- ) -> LockedRollbackResult {
527
+ ) throws -> LockedRollbackResult {
490
528
  let current = store.getCurrentBundle()
491
529
  guard !current.isBuiltin else {
492
530
  return LockedRollbackResult(
@@ -496,14 +534,14 @@ final class UpdaterCoordinator {
496
534
  )
497
535
  }
498
536
 
499
- let failed = updateStatusLocked(current, status: .error)
500
- store.setLastFailedBundle(failed)
501
- store.setStagedBundleId(nil)
502
-
503
- let fallback = restoreFallbackOrBuiltinLocked(
537
+ let failed = current.withStatus(.error)
538
+ let fallback = try restoreFallbackOrBuiltinLocked(
504
539
  isBundleUsable: isBundleUsable,
505
- cleanupBundleIds: &cleanupBundleIds
540
+ cleanupBundleIds: &cleanupBundleIds,
541
+ excluding: current.id,
542
+ failedBundle: failed
506
543
  )
544
+ activeTrial = nil
507
545
  cleanupBundleIds.insert(current.id)
508
546
 
509
547
  return LockedRollbackResult(
@@ -515,50 +553,48 @@ final class UpdaterCoordinator {
515
553
  runtimeVersion: current.runtimeVersion,
516
554
  channel: current.channel,
517
555
  releaseId: current.releaseId,
518
- detail: reason
556
+ detail: reason,
557
+ attemptId: current.attemptId,
558
+ phase: "rollback"
519
559
  )
520
560
  )
521
561
  }
522
562
 
523
563
  private func restoreFallbackOrBuiltinLocked(
524
564
  isBundleUsable: (BundleInfo) -> Bool,
525
- cleanupBundleIds: inout Set<String>
526
- ) -> BundleInfo {
565
+ cleanupBundleIds: inout Set<String>,
566
+ excluding excludedBundleId: String,
567
+ failedBundle: BundleInfo? = nil
568
+ ) throws -> BundleInfo {
569
+ var restored = store.builtinBundle()
527
570
  if let fallbackId = store.getFallbackBundleId(),
528
571
  let fallback = store.getBundle(id: fallbackId),
529
572
  !fallback.isBuiltin,
573
+ fallback.id != excludedBundleId,
574
+ fallback.status == .success,
530
575
  isBundleUsable(fallback) {
531
- store.setCurrentBundleId(fallback.id)
532
- return fallback
533
- }
534
-
535
- if let fallbackId = store.getFallbackBundleId() {
536
- store.setFallbackBundleId(nil)
537
- if fallbackId != "builtin" {
538
- cleanupBundleIds.insert(fallbackId)
539
- }
576
+ restored = fallback
540
577
  }
541
578
 
542
- store.setCurrentBundleId(nil)
543
- return store.builtinBundle()
579
+ if let fallbackId = store.getFallbackBundleId(),
580
+ fallbackId != "builtin", fallbackId != restored.id {
581
+ cleanupBundleIds.insert(fallbackId)
582
+ }
583
+ let restoredId = restored.isBuiltin ? nil : restored.id
584
+ try store.setCoreState(
585
+ currentId: restoredId, fallbackId: restoredId,
586
+ stagedId: failedBundle == nil ? store.getStagedBundleId() : nil,
587
+ lastFailed: failedBundle ?? store.getLastFailedBundle()
588
+ )
589
+ return restored
544
590
  }
545
591
 
546
592
  private func updateStatusLocked(
547
593
  _ bundle: BundleInfo,
548
594
  status: BundleStatus
549
- ) -> BundleInfo {
550
- let updated = BundleInfo(
551
- id: bundle.id,
552
- version: bundle.version,
553
- runtimeVersion: bundle.runtimeVersion,
554
- status: status,
555
- downloadedAt: bundle.downloadedAt,
556
- sha256: bundle.sha256,
557
- path: bundle.path,
558
- channel: bundle.channel,
559
- releaseId: bundle.releaseId
560
- )
561
- try? store.saveBundle(updated)
595
+ ) throws -> BundleInfo {
596
+ let updated = bundle.withStatus(status)
597
+ try store.saveBundle(updated)
562
598
  return updated
563
599
  }
564
600