@otakit/capacitor-updater 2.1.0 → 2.1.2

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.
@@ -0,0 +1,635 @@
1
+ import Foundation
2
+
3
+ final class UpdaterCoordinator {
4
+ struct StateSnapshot {
5
+ let current: BundleInfo
6
+ let fallback: BundleInfo
7
+ let staged: BundleInfo?
8
+ let builtinVersion: String
9
+ }
10
+
11
+ struct DeviceEventPayload {
12
+ let action: DeviceEventAction
13
+ let bundleVersion: String?
14
+ let runtimeVersion: String?
15
+ let channel: String?
16
+ let releaseId: String?
17
+ let detail: String?
18
+ }
19
+
20
+ enum LatestManifestClassification {
21
+ case noUpdate
22
+ case alreadyStaged(BundleInfo)
23
+ case updateAvailable
24
+ }
25
+
26
+ struct StartupPreparation {
27
+ let activationPath: String?
28
+ let trialBundleId: String?
29
+ let cleanupBundleIds: [String]
30
+ let eventPayload: DeviceEventPayload?
31
+ }
32
+
33
+ struct ApplyPreparation {
34
+ let activationPath: String?
35
+ let trialBundleId: String?
36
+ let cleanupBundleIds: [String]
37
+
38
+ var didApply: Bool {
39
+ activationPath != nil
40
+ }
41
+ }
42
+
43
+ struct NotifyReadyPreparation {
44
+ let eventPayload: DeviceEventPayload?
45
+ let cleanupBundleIds: [String]
46
+ }
47
+
48
+ struct RollbackPreparation {
49
+ let didRollback: Bool
50
+ let activationPath: String?
51
+ let eventPayload: DeviceEventPayload?
52
+ let cleanupBundleIds: [String]
53
+ }
54
+
55
+ private let store: BundleStore
56
+ private let operationLock = NSLock()
57
+ private let stateLock = NSLock()
58
+ private var operationInProgress = false
59
+
60
+ init(store: BundleStore) {
61
+ self.store = store
62
+ }
63
+
64
+ var nativeBuild: String {
65
+ store.nativeBuild
66
+ }
67
+
68
+ func bundleDirectory(for id: String) -> URL {
69
+ store.bundleDirectory(for: id)
70
+ }
71
+
72
+ func tryBeginOperation() -> Bool {
73
+ operationLock.lock()
74
+ defer { operationLock.unlock() }
75
+ if operationInProgress {
76
+ return false
77
+ }
78
+ operationInProgress = true
79
+ return true
80
+ }
81
+
82
+ func endOperation() {
83
+ operationLock.lock()
84
+ defer { operationLock.unlock() }
85
+ operationInProgress = false
86
+ }
87
+
88
+ func snapshotState(
89
+ isStagedBundleUsable: @escaping (BundleInfo) -> Bool
90
+ ) -> StateSnapshot {
91
+ withStateLock {
92
+ let staged = readStagedBundleLocked(
93
+ isCompatibleRuntime: nil,
94
+ isUsable: isStagedBundleUsable
95
+ )
96
+ return StateSnapshot(
97
+ current: store.getCurrentBundle(),
98
+ fallback: store.getFallbackBundle(),
99
+ staged: staged,
100
+ builtinVersion: store.builtinVersion
101
+ )
102
+ }
103
+ }
104
+
105
+ func lastFailure() -> BundleInfo? {
106
+ withStateLock {
107
+ store.getLastFailedBundle()
108
+ }
109
+ }
110
+
111
+ func pruneIncompatibleBundles(
112
+ isCompatibleRuntime: @escaping (BundleInfo) -> Bool
113
+ ) -> [String] {
114
+ withStateLock {
115
+ var cleanupBundleIds = Set<String>()
116
+
117
+ for bundle in store.listDownloadedBundles() where !isCompatibleRuntime(bundle) {
118
+ detachBundleReferencesLocked(bundleId: bundle.id)
119
+ cleanupBundleIds.insert(bundle.id)
120
+ }
121
+
122
+ if let failed = store.getLastFailedBundle(),
123
+ !isCompatibleRuntime(failed) {
124
+ store.setLastFailedBundle(nil)
125
+ }
126
+
127
+ return Array(cleanupBundleIds)
128
+ }
129
+ }
130
+
131
+ func normalizeStartupState(
132
+ isBundleUsable: @escaping (BundleInfo) -> Bool
133
+ ) -> StartupPreparation {
134
+ withStateLock {
135
+ var cleanupBundleIds = Set<String>()
136
+ clearStaleBundlePointersLocked()
137
+ normalizeStagedPointerLocked(
138
+ isBundleUsable: isBundleUsable,
139
+ cleanupBundleIds: &cleanupBundleIds
140
+ )
141
+ normalizeFallbackPointerLocked(
142
+ isBundleUsable: isBundleUsable,
143
+ cleanupBundleIds: &cleanupBundleIds
144
+ )
145
+
146
+ var eventPayload: DeviceEventPayload?
147
+ let current = store.getCurrentBundle()
148
+ if !current.isBuiltin,
149
+ current.status == .trial {
150
+ let rollback = rollbackLocked(
151
+ reason: "app_restarted_before_notify",
152
+ isBundleUsable: isBundleUsable,
153
+ cleanupBundleIds: &cleanupBundleIds
154
+ )
155
+ eventPayload = rollback.eventPayload
156
+ }
157
+
158
+ var normalizedCurrent = store.getCurrentBundle()
159
+ if !normalizedCurrent.isBuiltin,
160
+ !isBundleUsable(normalizedCurrent) {
161
+ cleanupBundleIds.insert(normalizedCurrent.id)
162
+ normalizedCurrent = restoreFallbackOrBuiltinLocked(
163
+ isBundleUsable: isBundleUsable,
164
+ cleanupBundleIds: &cleanupBundleIds
165
+ )
166
+ }
167
+
168
+ var trialBundleId: String?
169
+ if !normalizedCurrent.isBuiltin,
170
+ normalizedCurrent.status == .pending {
171
+ normalizedCurrent = updateStatusLocked(normalizedCurrent, status: .trial)
172
+ trialBundleId = normalizedCurrent.id
173
+ }
174
+
175
+ return StartupPreparation(
176
+ activationPath: normalizedCurrent.isBuiltin ? nil : normalizedCurrent.path,
177
+ trialBundleId: trialBundleId,
178
+ cleanupBundleIds: Array(cleanupBundleIds),
179
+ eventPayload: eventPayload
180
+ )
181
+ }
182
+ }
183
+
184
+ func isRuntimeUnresolved(currentRuntimeKey: String) -> Bool {
185
+ withStateLock {
186
+ store.getLastResolvedRuntimeKey() != currentRuntimeKey
187
+ }
188
+ }
189
+
190
+ func resolveRuntimeKey(_ currentRuntimeKey: String) {
191
+ withStateLock {
192
+ store.setLastResolvedRuntimeKey(currentRuntimeKey)
193
+ }
194
+ }
195
+
196
+ func classifyLatestManifest(
197
+ _ manifest: LatestManifest,
198
+ targetChannel: String?,
199
+ isStagedBundleUsable: @escaping (BundleInfo) -> Bool
200
+ ) -> LatestManifestClassification {
201
+ withStateLock {
202
+ let current = store.getCurrentBundle()
203
+ if doesBundleMatchLatest(current, latest: manifest, targetChannel: targetChannel) {
204
+ return .noUpdate
205
+ }
206
+
207
+ if let failed = store.getLastFailedBundle(),
208
+ doesFailedBundleMatchLatest(failed, latest: manifest, targetChannel: targetChannel) {
209
+ return .noUpdate
210
+ }
211
+
212
+ if let staged = readStagedBundleLocked(
213
+ isCompatibleRuntime: { [self] bundle in
214
+ self.trimToNil(bundle.runtimeVersion) == self.trimToNil(manifest.runtimeVersion)
215
+ },
216
+ isUsable: isStagedBundleUsable
217
+ ),
218
+ doesBundleMatchLatest(staged, latest: manifest, targetChannel: targetChannel) {
219
+ return .alreadyStaged(staged)
220
+ }
221
+
222
+ return .updateAvailable
223
+ }
224
+ }
225
+
226
+ func stageDownloadedBundle(_ bundle: BundleInfo) throws -> [String] {
227
+ try withStateLock {
228
+ var cleanupBundleIds = Set<String>()
229
+ let previousStagedId = store.getStagedBundleId()
230
+ try store.saveBundle(bundle)
231
+ store.setStagedBundleId(bundle.id)
232
+
233
+ if let previousStagedId,
234
+ previousStagedId != bundle.id,
235
+ previousStagedId != "builtin",
236
+ previousStagedId != store.getCurrentBundleId(),
237
+ previousStagedId != store.getFallbackBundleId() {
238
+ cleanupBundleIds.insert(previousStagedId)
239
+ }
240
+
241
+ return Array(cleanupBundleIds)
242
+ }
243
+ }
244
+
245
+ func prepareApplyStaged(
246
+ isCompatibleRuntime: @escaping (BundleInfo) -> Bool,
247
+ isBundleUsable: @escaping (BundleInfo) -> Bool
248
+ ) -> ApplyPreparation {
249
+ withStateLock {
250
+ var cleanupBundleIds = Set<String>()
251
+ guard var staged = stagedBundleLocked(
252
+ cleanInvalid: true,
253
+ isCompatibleRuntime: isCompatibleRuntime,
254
+ isUsable: isBundleUsable,
255
+ cleanupBundleIds: &cleanupBundleIds
256
+ ) else {
257
+ return ApplyPreparation(
258
+ activationPath: nil,
259
+ trialBundleId: nil,
260
+ cleanupBundleIds: Array(cleanupBundleIds)
261
+ )
262
+ }
263
+
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?
275
+ if staged.status == .pending {
276
+ staged = updateStatusLocked(staged, status: .trial)
277
+ trialBundleId = staged.id
278
+ } else if staged.status == .trial {
279
+ trialBundleId = staged.id
280
+ }
281
+
282
+ return ApplyPreparation(
283
+ activationPath: staged.path,
284
+ trialBundleId: trialBundleId,
285
+ cleanupBundleIds: Array(cleanupBundleIds)
286
+ )
287
+ }
288
+ }
289
+
290
+ func prepareNotifyAppReady() -> NotifyReadyPreparation {
291
+ withStateLock {
292
+ let current = store.getCurrentBundle()
293
+ guard !current.isBuiltin,
294
+ current.status == .trial else {
295
+ return NotifyReadyPreparation(eventPayload: nil, cleanupBundleIds: [])
296
+ }
297
+
298
+ let oldFallbackId = store.getFallbackBundleId()
299
+ _ = updateStatusLocked(current, status: .success)
300
+ store.setFallbackBundleId(current.id)
301
+
302
+ var cleanupBundleIds = Set<String>()
303
+ if let oldFallbackId,
304
+ oldFallbackId != current.id {
305
+ cleanupBundleIds.insert(oldFallbackId)
306
+ }
307
+
308
+ return NotifyReadyPreparation(
309
+ eventPayload: DeviceEventPayload(
310
+ action: .applied,
311
+ bundleVersion: current.version,
312
+ runtimeVersion: current.runtimeVersion,
313
+ channel: current.channel,
314
+ releaseId: current.releaseId,
315
+ detail: nil
316
+ ),
317
+ cleanupBundleIds: Array(cleanupBundleIds)
318
+ )
319
+ }
320
+ }
321
+
322
+ func prepareRollback(
323
+ reason: String,
324
+ isBundleUsable: @escaping (BundleInfo) -> Bool
325
+ ) -> RollbackPreparation {
326
+ withStateLock {
327
+ var cleanupBundleIds = Set<String>()
328
+ let rollback = rollbackLocked(
329
+ reason: reason,
330
+ isBundleUsable: isBundleUsable,
331
+ cleanupBundleIds: &cleanupBundleIds
332
+ )
333
+ return RollbackPreparation(
334
+ didRollback: rollback.didRollback,
335
+ activationPath: rollback.activationPath,
336
+ eventPayload: rollback.eventPayload,
337
+ cleanupBundleIds: Array(cleanupBundleIds)
338
+ )
339
+ }
340
+ }
341
+
342
+ func isCurrentTrialBundle(_ bundleId: String) -> Bool {
343
+ withStateLock {
344
+ let current = store.getCurrentBundle()
345
+ return current.id == bundleId && current.status == .trial
346
+ }
347
+ }
348
+
349
+ 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))
353
+ }
354
+ }
355
+
356
+ private struct LockedRollbackResult {
357
+ let didRollback: Bool
358
+ let activationPath: String?
359
+ let eventPayload: DeviceEventPayload?
360
+ }
361
+
362
+ private func withStateLock<T>(_ work: () throws -> T) rethrows -> T {
363
+ stateLock.lock()
364
+ defer { stateLock.unlock() }
365
+ return try work()
366
+ }
367
+
368
+ private func clearStaleBundlePointersLocked() {
369
+ if let currentId = store.getCurrentBundleId(),
370
+ store.getBundle(id: currentId) == nil {
371
+ store.setCurrentBundleId(nil)
372
+ }
373
+
374
+ if let fallbackId = store.getFallbackBundleId(),
375
+ store.getBundle(id: fallbackId) == nil {
376
+ store.setFallbackBundleId(nil)
377
+ }
378
+
379
+ if let stagedId = store.getStagedBundleId(),
380
+ store.getBundle(id: stagedId) == nil {
381
+ store.setStagedBundleId(nil)
382
+ }
383
+ }
384
+
385
+ private func normalizeFallbackPointerLocked(
386
+ isBundleUsable: (BundleInfo) -> Bool,
387
+ cleanupBundleIds: inout Set<String>
388
+ ) {
389
+ guard let fallbackId = store.getFallbackBundleId(),
390
+ let fallback = store.getBundle(id: fallbackId) else {
391
+ return
392
+ }
393
+
394
+ guard !fallback.isBuiltin else {
395
+ return
396
+ }
397
+
398
+ if !isBundleUsable(fallback) {
399
+ store.setFallbackBundleId(nil)
400
+ cleanupBundleIds.insert(fallback.id)
401
+ }
402
+ }
403
+
404
+ private func normalizeStagedPointerLocked(
405
+ isBundleUsable: (BundleInfo) -> Bool,
406
+ cleanupBundleIds: inout Set<String>
407
+ ) {
408
+ guard let stagedId = store.getStagedBundleId() else {
409
+ return
410
+ }
411
+
412
+ guard let staged = store.getBundle(id: stagedId) else {
413
+ store.setStagedBundleId(nil)
414
+ return
415
+ }
416
+
417
+ if !isBundleUsable(staged) {
418
+ store.setStagedBundleId(nil)
419
+ cleanupBundleIds.insert(staged.id)
420
+ }
421
+ }
422
+
423
+ private func readStagedBundleLocked(
424
+ isCompatibleRuntime: ((BundleInfo) -> Bool)?,
425
+ isUsable: (BundleInfo) -> Bool
426
+ ) -> BundleInfo? {
427
+ var ignored = Set<String>()
428
+ return stagedBundleLocked(
429
+ cleanInvalid: false,
430
+ isCompatibleRuntime: isCompatibleRuntime,
431
+ isUsable: isUsable,
432
+ cleanupBundleIds: &ignored
433
+ )
434
+ }
435
+
436
+ private func stagedBundleLocked(
437
+ cleanInvalid: Bool,
438
+ isCompatibleRuntime: ((BundleInfo) -> Bool)?,
439
+ isUsable: (BundleInfo) -> Bool,
440
+ cleanupBundleIds: inout Set<String>
441
+ ) -> BundleInfo? {
442
+ guard let stagedId = store.getStagedBundleId() else {
443
+ return nil
444
+ }
445
+
446
+ guard let staged = store.getBundle(id: stagedId) else {
447
+ if cleanInvalid {
448
+ store.setStagedBundleId(nil)
449
+ }
450
+ return nil
451
+ }
452
+
453
+ if let isCompatibleRuntime,
454
+ !isCompatibleRuntime(staged) {
455
+ if cleanInvalid {
456
+ store.setStagedBundleId(nil)
457
+ cleanupBundleIds.insert(staged.id)
458
+ }
459
+ return nil
460
+ }
461
+
462
+ if !isUsable(staged) {
463
+ if cleanInvalid {
464
+ store.setStagedBundleId(nil)
465
+ cleanupBundleIds.insert(staged.id)
466
+ }
467
+ return nil
468
+ }
469
+
470
+ return staged
471
+ }
472
+
473
+ private func detachBundleReferencesLocked(bundleId: String) {
474
+ if store.getCurrentBundleId() == bundleId {
475
+ store.setCurrentBundleId(nil)
476
+ }
477
+ if store.getFallbackBundleId() == bundleId {
478
+ store.setFallbackBundleId(nil)
479
+ }
480
+ if store.getStagedBundleId() == bundleId {
481
+ store.setStagedBundleId(nil)
482
+ }
483
+ }
484
+
485
+ private func rollbackLocked(
486
+ reason: String,
487
+ isBundleUsable: (BundleInfo) -> Bool,
488
+ cleanupBundleIds: inout Set<String>
489
+ ) -> LockedRollbackResult {
490
+ let current = store.getCurrentBundle()
491
+ guard !current.isBuiltin else {
492
+ return LockedRollbackResult(
493
+ didRollback: false,
494
+ activationPath: nil,
495
+ eventPayload: nil
496
+ )
497
+ }
498
+
499
+ let failed = updateStatusLocked(current, status: .error)
500
+ store.setLastFailedBundle(failed)
501
+ store.setStagedBundleId(nil)
502
+
503
+ let fallback = restoreFallbackOrBuiltinLocked(
504
+ isBundleUsable: isBundleUsable,
505
+ cleanupBundleIds: &cleanupBundleIds
506
+ )
507
+ cleanupBundleIds.insert(current.id)
508
+
509
+ return LockedRollbackResult(
510
+ didRollback: true,
511
+ activationPath: fallback.isBuiltin ? nil : fallback.path,
512
+ eventPayload: DeviceEventPayload(
513
+ action: .rollback,
514
+ bundleVersion: current.version,
515
+ runtimeVersion: current.runtimeVersion,
516
+ channel: current.channel,
517
+ releaseId: current.releaseId,
518
+ detail: reason
519
+ )
520
+ )
521
+ }
522
+
523
+ private func restoreFallbackOrBuiltinLocked(
524
+ isBundleUsable: (BundleInfo) -> Bool,
525
+ cleanupBundleIds: inout Set<String>
526
+ ) -> BundleInfo {
527
+ if let fallbackId = store.getFallbackBundleId(),
528
+ let fallback = store.getBundle(id: fallbackId),
529
+ !fallback.isBuiltin,
530
+ 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
+ }
540
+ }
541
+
542
+ store.setCurrentBundleId(nil)
543
+ return store.builtinBundle()
544
+ }
545
+
546
+ private func updateStatusLocked(
547
+ _ bundle: BundleInfo,
548
+ 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)
562
+ return updated
563
+ }
564
+
565
+ private func doesFailedBundleMatchLatest(
566
+ _ failed: BundleInfo,
567
+ latest: LatestManifest,
568
+ targetChannel: String?
569
+ ) -> Bool {
570
+ if trimToNil(failed.channel) != targetChannel {
571
+ return false
572
+ }
573
+
574
+ if trimToNil(failed.runtimeVersion) != trimToNil(latest.runtimeVersion) {
575
+ return false
576
+ }
577
+
578
+ if let failedReleaseId = trimToNil(failed.releaseId),
579
+ let latestReleaseId = trimToNil(latest.releaseId),
580
+ latestReleaseId == failedReleaseId {
581
+ return true
582
+ }
583
+
584
+ if let failedSha = trimToNil(failed.sha256),
585
+ let latestSha = trimToNil(latest.sha256),
586
+ latestSha == failedSha {
587
+ return true
588
+ }
589
+
590
+ return false
591
+ }
592
+
593
+ private func doesBundleMatchLatest(
594
+ _ bundle: BundleInfo,
595
+ latest: LatestManifest,
596
+ targetChannel: String?
597
+ ) -> Bool {
598
+ if trimToNil(bundle.channel) != targetChannel {
599
+ return false
600
+ }
601
+
602
+ if trimToNil(bundle.runtimeVersion) != trimToNil(latest.runtimeVersion) {
603
+ return false
604
+ }
605
+
606
+ if let bundleReleaseId = trimToNil(bundle.releaseId),
607
+ let latestReleaseId = trimToNil(latest.releaseId),
608
+ latestReleaseId == bundleReleaseId {
609
+ return true
610
+ }
611
+
612
+ if let bundleSha = trimToNil(bundle.sha256),
613
+ let latestSha = trimToNil(latest.sha256),
614
+ latestSha == bundleSha {
615
+ return true
616
+ }
617
+
618
+ if trimToNil(bundle.releaseId) != nil ||
619
+ trimToNil(latest.releaseId) != nil ||
620
+ trimToNil(bundle.sha256) != nil ||
621
+ trimToNil(latest.sha256) != nil {
622
+ return false
623
+ }
624
+
625
+ return latest.version == bundle.version
626
+ }
627
+
628
+ private func trimToNil(_ value: String?) -> String? {
629
+ guard let value else {
630
+ return nil
631
+ }
632
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
633
+ return trimmed.isEmpty ? nil : trimmed
634
+ }
635
+ }
@@ -5,6 +5,7 @@ CAP_PLUGIN(UpdaterPlugin, "OtaKit",
5
5
  CAP_PLUGIN_METHOD(check, CAPPluginReturnPromise);
6
6
  CAP_PLUGIN_METHOD(download, CAPPluginReturnPromise);
7
7
  CAP_PLUGIN_METHOD(apply, CAPPluginReturnPromise);
8
+ CAP_PLUGIN_METHOD(update, CAPPluginReturnPromise);
8
9
  CAP_PLUGIN_METHOD(notifyAppReady, CAPPluginReturnPromise);
9
10
  CAP_PLUGIN_METHOD(getLastFailure, CAPPluginReturnPromise);
10
11
  )