@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.
@@ -1,19 +1,26 @@
1
1
  import Capacitor
2
2
  import CryptoKit
3
3
  import Foundation
4
+ import UIKit
4
5
 
5
6
  @objc(UpdaterPlugin)
6
7
  public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
7
- private enum UpdateMode: String {
8
- case manual
9
- case nextLaunch = "next-launch"
10
- case nextResume = "next-resume"
8
+ private enum Policy: String {
9
+ case off
10
+ case shadow
11
+ case applyStaged = "apply-staged"
11
12
  case immediate
12
13
  }
13
14
 
14
- private enum Trigger {
15
- case launch
16
- case resume
15
+ private enum CheckResolution {
16
+ case noUpdate
17
+ case alreadyStaged(latest: LatestManifest, bundle: BundleInfo)
18
+ case updateAvailable(LatestManifest)
19
+ }
20
+
21
+ private enum DownloadResolution {
22
+ case noUpdate
23
+ case staged(BundleInfo)
17
24
  }
18
25
 
19
26
  public let identifier = "UpdaterPlugin"
@@ -23,18 +30,22 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
23
30
  CAPPluginMethod(name: "check", returnType: CAPPluginReturnPromise),
24
31
  CAPPluginMethod(name: "download", returnType: CAPPluginReturnPromise),
25
32
  CAPPluginMethod(name: "apply", returnType: CAPPluginReturnPromise),
33
+ CAPPluginMethod(name: "update", returnType: CAPPluginReturnPromise),
26
34
  CAPPluginMethod(name: "notifyAppReady", returnType: CAPPluginReturnPromise),
27
35
  CAPPluginMethod(name: "getLastFailure", returnType: CAPPluginReturnPromise),
28
36
  ]
29
37
 
30
38
  private let store = BundleStore()
39
+ private lazy var coordinator = UpdaterCoordinator(store: store)
31
40
  private var downloader = Downloader()
32
41
  private let zipUtils = ZipUtils()
33
42
  private let fileManager = FileManager.default
34
43
 
35
44
  private var appReadyTimeoutMs = 10_000
36
45
  private var allowInsecureUrls = false
37
- private var updateMode: UpdateMode = .nextLaunch
46
+ private var launchPolicy: Policy = .applyStaged
47
+ private var resumePolicy: Policy = .shadow
48
+ private var runtimePolicy: Policy = .immediate
38
49
  private var ingestUrl = UpdaterPlugin.defaultIngestURL
39
50
  private var cdnUrl = UpdaterPlugin.defaultCdnURL
40
51
  private var appId: String?
@@ -43,13 +54,12 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
43
54
  private var manifestKeys: [(kid: String, key: Data)] = []
44
55
  private var trialTimeoutWorkItem: DispatchWorkItem?
45
56
  private var checkIntervalMs: Int = 600_000
46
- private let isCheckInProgress = NSLock()
47
- private var checkInProgress = false
48
57
  private var foregroundObserver: NSObjectProtocol?
49
58
  private static let defaultIngestURL = "https://ingest.otakit.app/v1"
50
59
  private static let defaultCdnURL = "https://cdn.otakit.app"
51
60
  private static let ingestPathSuffix = "/v1"
52
61
  private static let lastCheckTimestampKey = "otakit_last_check_timestamp"
62
+ private static let defaultRuntimeKey = "__default__"
53
63
 
54
64
  public override func load() {
55
65
  let envIngestUrl = ProcessInfo.processInfo.environment["OTAKIT_INGEST_URL"]
@@ -67,11 +77,12 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
67
77
  runtimeVersion = trimToNil(getConfig().getString("runtimeVersion"))
68
78
  store.appRuntimeVersion = runtimeVersion
69
79
  allowInsecureUrls = getConfig().getBoolean("allowInsecureUrls", false)
70
- let updateModeRaw = getConfig().getString("updateMode", UpdateMode.nextLaunch.rawValue)
71
- updateMode = resolveUpdateMode(configured: updateModeRaw)
80
+ launchPolicy = resolvePolicy(configured: getConfig().getString("launchPolicy"), defaultPolicy: .applyStaged)
81
+ resumePolicy = resolvePolicy(configured: getConfig().getString("resumePolicy"), defaultPolicy: .shadow)
82
+ runtimePolicy = resolvePolicy(configured: getConfig().getString("runtimePolicy"), defaultPolicy: .immediate)
72
83
  downloader = Downloader(allowInsecureUrls: allowInsecureUrls)
73
84
  appReadyTimeoutMs = max(1000, getConfig().getInt("appReadyTimeout", 10_000))
74
- checkIntervalMs = max(600_000, getConfig().getInt("checkInterval", 600_000))
85
+ checkIntervalMs = getConfig().getInt("checkInterval", 600_000)
75
86
 
76
87
  let rawKeysValue = getConfig().getArray("manifestKeys")
77
88
  if let rawKeys = rawKeysValue as? [[String: String]] {
@@ -96,32 +107,30 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
96
107
 
97
108
  pruneIncompatibleBundles()
98
109
 
99
- var current = store.getCurrentBundle()
100
- if current.status == .trial {
101
- rollbackCurrentBundle(reason: "app_restarted_before_notify")
102
- current = store.getCurrentBundle()
103
- }
110
+ let startup = coordinator.normalizeStartupState(
111
+ isBundleUsable: isBundleUsable
112
+ )
113
+ coordinator.cleanupBundles(startup.cleanupBundleIds)
104
114
 
105
- if shouldActivateStagedOnLaunch() {
106
- current = activateStagedBundleForLaunch()
115
+ do {
116
+ try applyServerBasePathSynchronously(startup.activationPath)
117
+ } catch {
118
+ print("[OtaKit] startup activation failed: \(error.localizedDescription)")
107
119
  }
108
120
 
109
- if !current.isBuiltin, let path = current.path {
110
- applyServerBasePath(path)
111
- } else {
112
- applyServerBasePath(nil)
121
+ if let eventPayload = startup.eventPayload {
122
+ sendDeviceEvent(eventPayload)
113
123
  }
114
124
 
115
- if current.status == .pending {
116
- store.markStatus(bundleId: current.id, status: .trial)
117
- scheduleTrialTimeout(for: current.id)
125
+ if let trialBundleId = startup.trialBundleId {
126
+ scheduleTrialTimeout(for: trialBundleId)
127
+ } else {
128
+ cancelTrialTimeout()
118
129
  }
119
130
 
120
- if isAutomaticUpdateMode() {
121
- runAutomaticUpdate(trigger: .launch)
122
- }
131
+ dispatchColdStart()
123
132
 
124
- if isAutomaticUpdateMode() {
133
+ if resumePolicy != .off {
125
134
  foregroundObserver = NotificationCenter.default.addObserver(
126
135
  forName: UIApplication.willEnterForegroundNotification,
127
136
  object: nil,
@@ -139,191 +148,203 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
139
148
  }
140
149
 
141
150
  private func handleAppWillEnterForeground() {
142
- runAutomaticUpdate(trigger: .resume)
151
+ handleResume()
143
152
  }
144
153
 
145
- private func runAutomaticUpdate(trigger: Trigger) {
146
- // Resume-only guards
147
- if trigger == .resume {
148
- if updateMode == .manual { return }
149
-
150
- // next-resume and immediate: activate staged bundle on resume without server check
151
- if (updateMode == .nextResume || updateMode == .immediate),
152
- let stagedId = store.getStagedBundleId(),
153
- store.getBundle(id: stagedId) != nil {
154
- activateStagedBundleForReload()
155
- reloadWebView()
156
- return
157
- }
154
+ private func shouldSkipCheckInterval() -> Bool {
155
+ guard checkIntervalMs > 0 else { return false }
156
+ let lastCheck = UserDefaults.standard.double(forKey: UpdaterPlugin.lastCheckTimestampKey)
157
+ guard lastCheck > 0 else { return false }
158
+ let elapsed = Date().timeIntervalSince1970 * 1000 - lastCheck
159
+ return elapsed < Double(checkIntervalMs)
160
+ }
158
161
 
159
- if updateMode != .immediate && shouldThrottleCheck() { return }
160
- }
162
+ private func recordCheckTimestamp() {
163
+ UserDefaults.standard.set(
164
+ Date().timeIntervalSince1970 * 1000,
165
+ forKey: UpdaterPlugin.lastCheckTimestampKey
166
+ )
167
+ }
161
168
 
162
- if trigger == .launch && updateMode != .immediate && shouldThrottleCheck() {
163
- return
169
+ private func dispatchColdStart() {
170
+ if isRuntimeUnresolved() {
171
+ handleRuntime()
172
+ } else {
173
+ handleLaunch()
164
174
  }
175
+ }
165
176
 
166
- // Acquire in-flight guard (submit-time)
167
- guard claimCheckInProgress() else { return }
168
-
169
- // Immediate mode on cold start: block until check+download+activate completes
170
- if updateMode == .immediate && trigger == .launch {
171
- Task {
172
- defer { releaseCheckInProgress() }
177
+ private func handleRuntime() {
178
+ switch runtimePolicy {
179
+ case .off:
180
+ resolveCurrentRuntimeKey()
181
+ case .applyStaged:
182
+ let hasStagedBundle = coordinator.snapshotState(
183
+ isStagedBundleUsable: { [self] bundle in
184
+ isCompatibleRuntime(bundle) && isBundleUsable(bundle)
185
+ }
186
+ ).staged != nil
187
+ if hasStagedBundle {
188
+ resolveCurrentRuntimeKey()
173
189
  do {
174
- let result = try await performCheckAndDownload(channel: nil, emitEvents: true)
175
- if result != nil {
176
- activateStagedBundleForReload()
177
- reloadWebView()
190
+ if !(try applyStaged(reloadAfterApply: false)) {
191
+ print("[OtaKit] Failed to apply a valid staged bundle during runtime handling")
178
192
  }
179
193
  } catch {
180
- print("[OtaKit] immediate startup update failed: \(error.localizedDescription)")
194
+ print("[OtaKit] runtime apply-staged failed: \(error.localizedDescription)")
181
195
  }
196
+ return
182
197
  }
183
- return
184
- }
185
-
186
- // Immediate mode on resume: background check+download, activate when done
187
- if updateMode == .immediate && trigger == .resume {
188
- Task {
189
- defer { releaseCheckInProgress() }
190
- do {
191
- let result = try await performCheckAndDownload(channel: nil, emitEvents: true)
192
- if result != nil {
193
- activateStagedBundleForReload()
194
- reloadWebView()
195
- }
196
- } catch {
197
- print("[OtaKit] immediate resume update failed: \(error.localizedDescription)")
198
+ executeAutomaticUpdate(label: "runtime apply-staged fallback") { [self] in
199
+ _ = try await downloadLatest(respectInterval: false, channel: nil)
200
+ resolveCurrentRuntimeKey()
201
+ }
202
+ case .shadow:
203
+ executeAutomaticUpdate(label: "runtime shadow") { [self] in
204
+ _ = try await downloadLatest(respectInterval: false, channel: nil)
205
+ resolveCurrentRuntimeKey()
206
+ }
207
+ case .immediate:
208
+ executeAutomaticUpdate(label: "runtime immediate") { [self] in
209
+ let result = try await downloadLatest(respectInterval: false, channel: nil)
210
+ switch result {
211
+ case .noUpdate:
212
+ resolveCurrentRuntimeKey()
213
+ case .staged:
214
+ resolveCurrentRuntimeKey()
215
+ try requireApplyStaged(reloadAfterApply: true)
198
216
  }
199
217
  }
200
- return
201
218
  }
219
+ }
202
220
 
203
- // next-launch / next-resume: background check+download (fire-and-forget)
204
- Task {
205
- defer { releaseCheckInProgress() }
221
+ private func handleLaunch() {
222
+ switch launchPolicy {
223
+ case .off:
224
+ return
225
+ case .applyStaged:
206
226
  do {
207
- _ = try await performCheckAndDownload(channel: nil, emitEvents: true)
208
- recordCheckTimestamp()
227
+ if try applyStaged(reloadAfterApply: false) {
228
+ return
229
+ }
209
230
  } catch {
210
- // Check failed — timestamp not recorded, will retry on next trigger
231
+ print("[OtaKit] launch apply-staged failed: \(error.localizedDescription)")
232
+ return
233
+ }
234
+ executeAutomaticUpdate(label: "launch apply-staged fallback") { [self] in
235
+ _ = try await downloadLatest(respectInterval: false, channel: nil)
236
+ }
237
+ case .shadow:
238
+ executeAutomaticUpdate(label: "launch shadow") { [self] in
239
+ _ = try await downloadLatest(respectInterval: false, channel: nil)
240
+ }
241
+ case .immediate:
242
+ executeAutomaticUpdate(label: "launch immediate") { [self] in
243
+ let result = try await downloadLatest(respectInterval: false, channel: nil)
244
+ if case .staged = result {
245
+ try requireApplyStaged(reloadAfterApply: true)
246
+ }
211
247
  }
212
248
  }
213
249
  }
214
250
 
215
- private func shouldThrottleCheck() -> Bool {
216
- let lastCheck = UserDefaults.standard.double(forKey: UpdaterPlugin.lastCheckTimestampKey)
217
- guard lastCheck > 0 else { return false }
218
- let elapsed = Date().timeIntervalSince1970 * 1000 - lastCheck
219
- return elapsed < Double(checkIntervalMs)
251
+ private func handleResume() {
252
+ switch resumePolicy {
253
+ case .off:
254
+ return
255
+ case .applyStaged:
256
+ executeAutomaticUpdate(label: "resume apply-staged") { [self] in
257
+ if try applyStaged(reloadAfterApply: true) {
258
+ return
259
+ }
260
+ _ = try await downloadLatest(respectInterval: true, channel: nil)
261
+ }
262
+ case .shadow:
263
+ executeAutomaticUpdate(label: "resume shadow") { [self] in
264
+ _ = try await downloadLatest(respectInterval: true, channel: nil)
265
+ }
266
+ case .immediate:
267
+ executeAutomaticUpdate(label: "resume immediate") { [self] in
268
+ let result = try await downloadLatest(respectInterval: false, channel: nil)
269
+ if case .staged = result {
270
+ try requireApplyStaged(reloadAfterApply: true)
271
+ }
272
+ }
273
+ }
220
274
  }
221
275
 
222
- private func recordCheckTimestamp() {
223
- UserDefaults.standard.set(
224
- Date().timeIntervalSince1970 * 1000,
225
- forKey: UpdaterPlugin.lastCheckTimestampKey
226
- )
227
- }
276
+ private func executeAutomaticUpdate(
277
+ label: String,
278
+ operation: @escaping () async throws -> Void
279
+ ) {
280
+ guard coordinator.tryBeginOperation() else {
281
+ print("[OtaKit] Skipping \(label): update already in progress")
282
+ return
283
+ }
228
284
 
229
- private func claimCheckInProgress() -> Bool {
230
- isCheckInProgress.lock()
231
- defer { isCheckInProgress.unlock() }
232
- if checkInProgress { return false }
233
- checkInProgress = true
234
- return true
285
+ Task {
286
+ defer { coordinator.endOperation() }
287
+ do {
288
+ try await operation()
289
+ } catch {
290
+ print("[OtaKit] \(label) failed: \(error.localizedDescription)")
291
+ }
292
+ }
235
293
  }
236
294
 
237
- private func releaseCheckInProgress() {
238
- isCheckInProgress.lock()
239
- defer { isCheckInProgress.unlock() }
240
- checkInProgress = false
295
+ private func currentRuntimeKey() -> String {
296
+ runtimeVersion ?? UpdaterPlugin.defaultRuntimeKey
241
297
  }
242
298
 
243
- private func shouldActivateStagedOnLaunch() -> Bool {
244
- updateMode != .manual
299
+ private func isRuntimeUnresolved() -> Bool {
300
+ coordinator.isRuntimeUnresolved(currentRuntimeKey: currentRuntimeKey())
245
301
  }
246
302
 
247
- private func isAutomaticUpdateMode() -> Bool {
248
- updateMode != .manual
303
+ private func resolveCurrentRuntimeKey() {
304
+ coordinator.resolveRuntimeKey(currentRuntimeKey())
249
305
  }
250
306
 
251
- private func resolveUpdateMode(configured: String?) -> UpdateMode {
307
+ private func resolvePolicy(configured: String?, defaultPolicy: Policy) -> Policy {
252
308
  let raw = configured?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
253
309
  switch raw {
254
- case "", UpdateMode.nextLaunch.rawValue:
255
- return .nextLaunch
256
- case UpdateMode.manual.rawValue:
257
- return .manual
258
- case UpdateMode.nextResume.rawValue:
259
- return .nextResume
260
- case UpdateMode.immediate.rawValue:
310
+ case "":
311
+ return defaultPolicy
312
+ case Policy.off.rawValue:
313
+ return .off
314
+ case Policy.shadow.rawValue:
315
+ return .shadow
316
+ case Policy.applyStaged.rawValue:
317
+ return .applyStaged
318
+ case Policy.immediate.rawValue:
261
319
  return .immediate
262
320
  default:
263
- print("[OtaKit] Unknown updateMode '\(raw)', defaulting to 'next-launch'")
264
- return .nextLaunch
321
+ print("[OtaKit] Unknown policy '\(raw)', defaulting to '\(defaultPolicy.rawValue)'")
322
+ return defaultPolicy
265
323
  }
266
324
  }
267
325
 
268
326
  @objc func getState(_ call: CAPPluginCall) {
269
- let current = store.getCurrentBundle()
270
- let staged: [String: Any]? = {
271
- guard let stagedId = store.getStagedBundleId() else {
272
- return nil
273
- }
274
- guard let staged = store.getBundle(id: stagedId) else {
275
- store.setStagedBundleId(nil)
276
- return nil
277
- }
278
- return staged.toDictionary()
279
- }()
327
+ let snapshot = coordinator.snapshotState(isStagedBundleUsable: isBundleUsable)
280
328
  var payload: [String: Any] = [
281
- "current": current.toDictionary(),
282
- "fallback": store.getFallbackBundle().toDictionary(),
283
- "builtinVersion": store.builtinVersion,
329
+ "current": snapshot.current.toDictionary(),
330
+ "fallback": snapshot.fallback.toDictionary(),
331
+ "builtinVersion": snapshot.builtinVersion,
284
332
  ]
285
- payload["staged"] = staged ?? NSNull()
333
+ payload["staged"] = snapshot.staged?.toDictionary() ?? NSNull()
286
334
  call.resolve(payload)
287
335
  }
288
336
 
289
337
  @objc func check(_ call: CAPPluginCall) {
290
- if !claimCheckInProgress() {
291
- if let stagedId = store.getStagedBundleId(),
292
- let staged = store.getBundle(id: stagedId) {
293
- var payload: [String: Any] = [
294
- "version": staged.version,
295
- "url": "",
296
- "sha256": staged.sha256 ?? "",
297
- "size": 0,
298
- "downloaded": true,
299
- ]
300
- if let runtimeVersion = staged.runtimeVersion {
301
- payload["runtimeVersion"] = runtimeVersion
302
- }
303
- if let releaseId = staged.releaseId {
304
- payload["releaseId"] = releaseId
305
- }
306
- call.resolve(payload)
307
- } else {
308
- call.resolve()
309
- }
338
+ if !coordinator.tryBeginOperation() {
339
+ call.reject("Another update operation is already in progress")
310
340
  return
311
341
  }
312
- let targetChannel = resolveTargetChannel(nil)
342
+
313
343
  Task {
314
- defer { releaseCheckInProgress() }
344
+ defer { coordinator.endOperation() }
315
345
  do {
316
- let latest = try await fetchLatest(channel: targetChannel)
317
- if let latest {
318
- if isCurrentBundleLatest(latest: latest, targetChannel: targetChannel) {
319
- call.resolve()
320
- return
321
- }
322
- let staged = findMatchingStagedBundle(latest: latest, targetChannel: targetChannel)
323
- call.resolve(manifestToDictionary(latest, downloaded: staged != nil))
324
- } else {
325
- call.resolve()
326
- }
346
+ let result = try await checkLatest(respectInterval: false, channel: nil)
347
+ call.resolve(checkResultDictionary(result))
327
348
  } catch {
328
349
  call.reject("check failed: \(error.localizedDescription)")
329
350
  }
@@ -331,24 +352,16 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
331
352
  }
332
353
 
333
354
  @objc func download(_ call: CAPPluginCall) {
334
- if !claimCheckInProgress() {
335
- if let stagedId = store.getStagedBundleId(),
336
- let staged = store.getBundle(id: stagedId) {
337
- call.resolve(staged.toDictionary())
338
- } else {
339
- call.resolve()
340
- }
355
+ if !coordinator.tryBeginOperation() {
356
+ call.reject("Another update operation is already in progress")
341
357
  return
342
358
  }
359
+
343
360
  Task {
344
- defer { releaseCheckInProgress() }
361
+ defer { coordinator.endOperation() }
345
362
  do {
346
- let bundle = try await performCheckAndDownload(channel: nil, emitEvents: true)
347
- if let bundle {
348
- call.resolve(bundle.toDictionary())
349
- } else {
350
- call.resolve()
351
- }
363
+ let result = try await downloadLatest(respectInterval: false, channel: nil)
364
+ call.resolve(downloadResultDictionary(result))
352
365
  } catch {
353
366
  call.reject("download failed: \(error.localizedDescription)")
354
367
  }
@@ -356,59 +369,57 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
356
369
  }
357
370
 
358
371
  @objc func apply(_ call: CAPPluginCall) {
359
- guard let stagedId = store.getStagedBundleId() else {
360
- call.reject("No staged update to apply")
372
+ guard coordinator.tryBeginOperation() else {
373
+ call.reject("Another update operation is already in progress")
361
374
  return
362
375
  }
363
- guard store.getBundle(id: stagedId) != nil else {
364
- store.setStagedBundleId(nil)
365
- call.reject("Staged bundle not found")
376
+ defer { coordinator.endOperation() }
377
+
378
+ do {
379
+ guard try applyStaged(reloadAfterApply: true) else {
380
+ call.reject("No valid staged update to apply")
381
+ return
382
+ }
383
+ } catch {
384
+ call.reject("apply failed: \(error.localizedDescription)")
366
385
  return
367
386
  }
368
-
369
- activateStagedBundleForReload()
370
- call.resolve()
371
- reloadWebView()
372
387
  }
373
388
 
374
- @objc func notifyAppReady(_ call: CAPPluginCall) {
375
- let current = store.getCurrentBundle()
376
- guard !current.isBuiltin else {
377
- call.resolve()
389
+ @objc func update(_ call: CAPPluginCall) {
390
+ guard coordinator.tryBeginOperation() else {
391
+ call.reject("Another update operation is already in progress")
378
392
  return
379
393
  }
380
394
 
381
- if current.status == .trial || current.status == .pending {
382
- let oldFallback = store.getFallbackBundle()
383
-
384
- store.markStatus(bundleId: current.id, status: .success)
385
- store.setFallbackBundleId(current.id)
386
-
387
- if let confirmed = store.getBundle(id: current.id) {
388
- notifyListeners("appReady", data: confirmed.toDictionary())
389
- } else {
390
- notifyListeners("appReady", data: current.toDictionary())
395
+ Task {
396
+ defer { coordinator.endOperation() }
397
+ do {
398
+ let result = try await downloadLatest(respectInterval: false, channel: nil)
399
+ if case .staged = result {
400
+ try requireApplyStaged(reloadAfterApply: true)
401
+ return
402
+ }
403
+ call.resolve()
404
+ } catch {
405
+ call.reject("update failed: \(error.localizedDescription)")
391
406
  }
407
+ }
408
+ }
392
409
 
393
- sendDeviceEvent(
394
- action: .applied,
395
- bundleVersion: current.version,
396
- runtimeVersion: current.runtimeVersion,
397
- channel: current.channel,
398
- releaseId: current.releaseId
399
- )
400
-
401
- if !oldFallback.isBuiltin,
402
- oldFallback.id != current.id {
403
- try? store.deleteBundle(id: oldFallback.id)
404
- }
410
+ @objc func notifyAppReady(_ call: CAPPluginCall) {
411
+ cancelTrialTimeout()
412
+ let preparation = coordinator.prepareNotifyAppReady()
413
+ coordinator.cleanupBundles(preparation.cleanupBundleIds)
414
+ if let eventPayload = preparation.eventPayload {
415
+ sendDeviceEvent(eventPayload)
405
416
  }
406
417
 
407
418
  call.resolve()
408
419
  }
409
420
 
410
421
  @objc func getLastFailure(_ call: CAPPluginCall) {
411
- guard let failed = store.getFailedBundle() else {
422
+ guard let failed = coordinator.lastFailure() else {
412
423
  call.resolve()
413
424
  return
414
425
  }
@@ -432,82 +443,123 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
432
443
  )
433
444
  }
434
445
 
435
- private func performCheckAndDownload(
436
- channel: String?,
437
- emitEvents: Bool
438
- ) async throws -> BundleInfo? {
446
+ private func checkLatest(
447
+ respectInterval: Bool,
448
+ channel: String?
449
+ ) async throws -> CheckResolution {
439
450
  let targetChannel = resolveTargetChannel(channel)
440
- var latest = try await fetchLatest(channel: targetChannel)
451
+ if respectInterval, shouldSkipCheckInterval() {
452
+ print("[OtaKit] Skipping resume check: checkInterval has not elapsed")
453
+ return .noUpdate
454
+ }
441
455
 
442
- guard var manifest = latest else {
443
- if emitEvents {
444
- notifyListeners("noUpdateAvailable", data: [:])
456
+ let latest = try await fetchLatest(channel: targetChannel)
457
+ guard let manifest = latest else {
458
+ if respectInterval {
459
+ recordCheckTimestamp()
445
460
  }
446
- return nil
461
+ return .noUpdate
447
462
  }
448
463
 
449
- guard isCompatibleRuntime(manifest.runtimeVersion) else {
450
- throw NSError(
451
- domain: "OtaKit",
452
- code: 1,
453
- userInfo: [NSLocalizedDescriptionKey: "Manifest runtimeVersion does not match the installed app runtime"]
454
- )
455
- }
464
+ let resolution = try classifyLatestManifest(manifest, targetChannel: targetChannel)
456
465
 
457
- if isCurrentBundleLatest(latest: manifest, targetChannel: targetChannel) {
458
- if emitEvents {
459
- notifyListeners("noUpdateAvailable", data: [:])
460
- }
461
- return nil
466
+ if respectInterval {
467
+ recordCheckTimestamp()
462
468
  }
469
+ return resolution
470
+ }
463
471
 
464
- let staged = findMatchingStagedBundle(latest: manifest, targetChannel: targetChannel)
465
- if emitEvents {
466
- notifyListeners("updateAvailable", data: manifestToDictionary(manifest, downloaded: staged != nil))
467
- }
472
+ private func downloadLatest(
473
+ respectInterval: Bool,
474
+ channel: String?
475
+ ) async throws -> DownloadResolution {
476
+ let targetChannel = resolveTargetChannel(channel)
477
+ let result = try await checkLatest(respectInterval: respectInterval, channel: channel)
478
+ switch result {
479
+ case .noUpdate:
480
+ return .noUpdate
481
+ case let .alreadyStaged(_, bundle):
482
+ return .staged(bundle)
483
+ case let .updateAvailable(manifest):
484
+ do {
485
+ let bundle = try await downloadLatestManifest(
486
+ manifest,
487
+ targetChannel: targetChannel
488
+ )
489
+ return .staged(bundle)
490
+ } catch let error as NSError where isExpiredURLError(error) {
491
+ guard let refreshed = try await fetchLatest(channel: targetChannel) else {
492
+ return .noUpdate
493
+ }
468
494
 
469
- if let staged {
470
- return staged
495
+ switch try classifyLatestManifest(refreshed, targetChannel: targetChannel) {
496
+ case .noUpdate:
497
+ return .noUpdate
498
+ case let .alreadyStaged(_, bundle):
499
+ return .staged(bundle)
500
+ case let .updateAvailable(retryManifest):
501
+ let bundle = try await downloadLatestManifest(
502
+ retryManifest,
503
+ targetChannel: targetChannel
504
+ )
505
+ return .staged(bundle)
506
+ }
507
+ }
471
508
  }
509
+ }
472
510
 
473
- guard var url = URL(string: manifest.url) else {
511
+ private func classifyLatestManifest(
512
+ _ manifest: LatestManifest,
513
+ targetChannel: String?
514
+ ) throws -> CheckResolution {
515
+ guard isCompatibleRuntime(manifest.runtimeVersion) else {
474
516
  throw NSError(
475
517
  domain: "OtaKit",
476
518
  code: 1,
477
- userInfo: [NSLocalizedDescriptionKey: "Invalid download URL from manifest"]
519
+ userInfo: [NSLocalizedDescriptionKey: "Manifest runtimeVersion does not match the installed app runtime"]
478
520
  )
479
521
  }
480
522
 
481
- do {
482
- return try await downloadAndStage(
483
- url: url,
484
- version: manifest.version,
485
- expectedSha256: manifest.sha256,
486
- expectedSize: manifest.size,
487
- runtimeVersion: manifest.runtimeVersion,
488
- channel: targetChannel,
489
- releaseId: manifest.releaseId
490
- )
491
- } catch let error as NSError where isExpiredURLError(error) {
492
- // Download URL may have expired — re-fetch manifest once and retry
493
- latest = try await fetchLatest(channel: targetChannel)
494
- guard let refreshed = latest else {
495
- throw error
496
- }
497
- manifest = refreshed
498
- guard let retryUrl = URL(string: manifest.url) else {
499
- throw error
500
- }
501
- url = retryUrl
502
- return try await downloadAndStage(
503
- url: url,
504
- version: manifest.version,
505
- expectedSha256: manifest.sha256,
506
- expectedSize: manifest.size,
507
- runtimeVersion: manifest.runtimeVersion,
508
- channel: targetChannel,
509
- releaseId: manifest.releaseId
510
- )
523
+ switch coordinator.classifyLatestManifest(
524
+ manifest,
525
+ targetChannel: targetChannel,
526
+ isStagedBundleUsable: isBundleUsable
527
+ ) {
528
+ case .noUpdate:
529
+ return .noUpdate
530
+ case let .alreadyStaged(bundle):
531
+ return .alreadyStaged(latest: manifest, bundle: bundle)
532
+ case .updateAvailable:
533
+ return .updateAvailable(manifest)
534
+ }
535
+ }
536
+
537
+ private func checkResultDictionary(_ result: CheckResolution) -> [String: Any] {
538
+ switch result {
539
+ case .noUpdate:
540
+ return ["kind": "no_update"]
541
+ case let .alreadyStaged(latest, _):
542
+ return [
543
+ "kind": "already_staged",
544
+ "latest": manifestToDictionary(latest),
545
+ ]
546
+ case let .updateAvailable(latest):
547
+ return [
548
+ "kind": "update_available",
549
+ "latest": manifestToDictionary(latest),
550
+ ]
551
+ }
552
+ }
553
+
554
+ private func downloadResultDictionary(_ result: DownloadResolution) -> [String: Any] {
555
+ switch result {
556
+ case .noUpdate:
557
+ return ["kind": "no_update"]
558
+ case let .staged(bundle):
559
+ return [
560
+ "kind": "staged",
561
+ "bundle": bundle.toDictionary(),
562
+ ]
511
563
  }
512
564
  }
513
565
 
@@ -551,7 +603,6 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
551
603
  }
552
604
  }
553
605
 
554
- notifyListeners("downloadStarted", data: ["version": version])
555
606
  let zipURL = try await downloader.download(from: url)
556
607
 
557
608
  let extractDirectory = fileManager.temporaryDirectory
@@ -577,8 +628,12 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
577
628
  try zipUtils.extractSecurely(zipURL: zipURL, to: extractDirectory)
578
629
  let bundleRoot = try resolveBundleRoot(extractedDirectory: extractDirectory)
579
630
 
580
- let bundleId = buildBundleId(from: version)
581
- let destination = store.bundleDirectory(for: bundleId)
631
+ let bundleId = buildBundleId(
632
+ version: version,
633
+ releaseId: releaseId,
634
+ sha256: expectedSha256
635
+ )
636
+ let destination = coordinator.bundleDirectory(for: bundleId)
582
637
 
583
638
  if fileManager.fileExists(atPath: destination.path) {
584
639
  try fileManager.removeItem(at: destination)
@@ -600,12 +655,9 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
600
655
  releaseId: releaseId
601
656
  )
602
657
 
603
- let previousStagedId = store.getStagedBundleId()
604
- try store.saveBundle(info)
605
- store.setStagedBundleId(bundleId)
606
- cleanupSupersededStagedBundle(previousStagedId: previousStagedId, replacementId: bundleId)
658
+ let cleanupBundleIds = try coordinator.stageDownloadedBundle(info)
659
+ coordinator.cleanupBundles(cleanupBundleIds)
607
660
 
608
- notifyListeners("downloadComplete", data: info.toDictionary())
609
661
  sendDeviceEvent(
610
662
  action: .downloaded,
611
663
  bundleVersion: version,
@@ -615,10 +667,6 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
615
667
  )
616
668
  return info
617
669
  } catch {
618
- notifyListeners("downloadFailed", data: [
619
- "version": version,
620
- "error": error.localizedDescription,
621
- ])
622
670
  sendDeviceEvent(
623
671
  action: .downloadError,
624
672
  bundleVersion: version,
@@ -657,52 +705,39 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
657
705
  )
658
706
  }
659
707
 
660
- private func activateStagedBundleForLaunch() -> BundleInfo {
661
- guard let stagedId = store.getStagedBundleId() else {
662
- return store.getCurrentBundle()
663
- }
664
- guard let staged = store.getBundle(id: stagedId) else {
665
- store.setStagedBundleId(nil)
666
- return store.getCurrentBundle()
667
- }
668
- guard isCompatibleRuntime(staged) else {
669
- try? store.deleteBundle(id: staged.id)
670
- return store.getCurrentBundle()
708
+ @discardableResult
709
+ private func applyStaged(reloadAfterApply: Bool) throws -> Bool {
710
+ let preparation = coordinator.prepareApplyStaged(
711
+ isCompatibleRuntime: isCompatibleRuntime,
712
+ isBundleUsable: isBundleUsable
713
+ )
714
+ coordinator.cleanupBundles(preparation.cleanupBundleIds)
715
+
716
+ guard let activationPath = preparation.activationPath else {
717
+ return false
671
718
  }
672
719
 
673
- store.setCurrentBundleId(staged.id)
674
- store.setStagedBundleId(nil)
675
- return staged
676
- }
720
+ try applyServerBasePathSynchronously(activationPath)
677
721
 
678
- private func activateStagedBundleForReload() {
679
- guard let stagedId = store.getStagedBundleId() else {
680
- return
681
- }
682
- guard var staged = store.getBundle(id: stagedId) else {
683
- store.setStagedBundleId(nil)
684
- return
685
- }
686
- guard isCompatibleRuntime(staged) else {
687
- try? store.deleteBundle(id: staged.id)
688
- return
722
+ if reloadAfterApply {
723
+ try reloadWebViewSynchronously()
689
724
  }
690
725
 
691
- store.setCurrentBundleId(staged.id)
692
- store.setStagedBundleId(nil)
693
-
694
- if staged.status == .pending {
695
- store.markStatus(bundleId: staged.id, status: .trial)
696
- staged = store.getBundle(id: staged.id) ?? staged
697
- scheduleTrialTimeout(for: staged.id)
698
- } else if staged.status == .trial {
699
- scheduleTrialTimeout(for: staged.id)
726
+ cancelTrialTimeout()
727
+ if let trialBundleId = preparation.trialBundleId {
728
+ scheduleTrialTimeout(for: trialBundleId)
700
729
  }
701
730
 
702
- if let path = staged.path {
703
- applyServerBasePath(path)
704
- } else {
705
- applyServerBasePath(nil)
731
+ return true
732
+ }
733
+
734
+ private func requireApplyStaged(reloadAfterApply: Bool) throws {
735
+ guard try applyStaged(reloadAfterApply: reloadAfterApply) else {
736
+ throw NSError(
737
+ domain: "OtaKit",
738
+ code: 1,
739
+ userInfo: [NSLocalizedDescriptionKey: "Expected a staged bundle to be ready for apply"]
740
+ )
706
741
  }
707
742
  }
708
743
 
@@ -713,12 +748,8 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
713
748
  guard let self else {
714
749
  return
715
750
  }
716
- let current = self.store.getCurrentBundle()
717
- guard current.id == bundleId else {
718
- return
719
- }
720
- if current.status == .trial {
721
- self.rollbackCurrentBundle(reason: "notify_timeout")
751
+ if self.coordinator.isCurrentTrialBundle(bundleId) {
752
+ self.rollbackCurrentBundle(reason: "notify_timeout", shouldReload: true)
722
753
  }
723
754
  }
724
755
  trialTimeoutWorkItem = workItem
@@ -734,124 +765,99 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
734
765
  trialTimeoutWorkItem = nil
735
766
  }
736
767
 
737
- private func rollbackCurrentBundle(reason: String) {
768
+ private func rollbackCurrentBundle(reason: String, shouldReload: Bool) {
738
769
  cancelTrialTimeout()
739
-
740
- let current = store.getCurrentBundle()
741
- guard !current.isBuiltin else {
770
+ let preparation = coordinator.prepareRollback(
771
+ reason: reason,
772
+ isBundleUsable: isBundleUsable
773
+ )
774
+ guard preparation.didRollback else {
742
775
  return
743
776
  }
744
777
 
745
- let failed = BundleInfo(
746
- id: current.id,
747
- version: current.version,
748
- runtimeVersion: current.runtimeVersion,
749
- status: .error,
750
- downloadedAt: current.downloadedAt,
751
- sha256: current.sha256,
752
- path: current.path,
753
- channel: current.channel,
754
- releaseId: current.releaseId
755
- )
756
- store.markStatus(bundleId: current.id, status: .error)
757
- store.setFailedBundle(failed)
758
- store.setStagedBundleId(nil)
759
-
760
- sendDeviceEvent(
761
- action: .rollback,
762
- bundleVersion: current.version,
763
- runtimeVersion: current.runtimeVersion,
764
- channel: current.channel,
765
- releaseId: current.releaseId,
766
- detail: reason
767
- )
778
+ coordinator.cleanupBundles(preparation.cleanupBundleIds)
779
+ if let eventPayload = preparation.eventPayload {
780
+ sendDeviceEvent(eventPayload)
781
+ }
768
782
 
769
- let fallback = store.getFallbackBundle()
770
- if fallback.isBuiltin {
771
- store.setCurrentBundleId(nil)
772
- applyServerBasePath(nil)
773
- notifyListeners("rollback", data: [
774
- "from": failed.toDictionary(),
775
- "to": store.builtinBundle().toDictionary(),
776
- "reason": reason,
777
- ])
778
- } else if let fallbackPath = fallback.path {
779
- store.setCurrentBundleId(fallback.id)
780
- applyServerBasePath(fallbackPath)
781
- notifyListeners("rollback", data: [
782
- "from": failed.toDictionary(),
783
- "to": fallback.toDictionary(),
784
- "reason": reason,
785
- ])
786
- } else {
787
- store.setCurrentBundleId(nil)
788
- applyServerBasePath(nil)
789
- notifyListeners("rollback", data: [
790
- "from": failed.toDictionary(),
791
- "to": store.builtinBundle().toDictionary(),
792
- "reason": reason,
793
- ])
783
+ do {
784
+ try applyServerBasePathSynchronously(preparation.activationPath)
785
+ if shouldReload {
786
+ try reloadWebViewSynchronously()
787
+ }
788
+ } catch {
789
+ print("[OtaKit] rollback activation failed: \(error.localizedDescription)")
794
790
  }
791
+ }
795
792
 
796
- try? store.deleteBundle(id: current.id)
793
+ private func applyServerBasePathSynchronously(_ path: String?) throws {
794
+ try runOnMainSynchronously { [weak self] in
795
+ guard let self, let bridge = self.bridge else {
796
+ throw NSError(
797
+ domain: "OtaKit",
798
+ code: 1,
799
+ userInfo: [NSLocalizedDescriptionKey: "Bridge not available for activation"]
800
+ )
801
+ }
797
802
 
798
- reloadWebView()
803
+ if let path, !path.isEmpty {
804
+ bridge.setServerBasePath(path)
805
+ } else {
806
+ let builtinPath = Bundle.main.resourceURL?
807
+ .appendingPathComponent("public", isDirectory: true).path ?? ""
808
+ bridge.setServerBasePath(builtinPath)
809
+ }
810
+ }
799
811
  }
800
812
 
801
- private func cleanupSupersededStagedBundle(
802
- previousStagedId: String?,
803
- replacementId: String
804
- ) {
805
- guard let previousStagedId,
806
- previousStagedId != replacementId,
807
- previousStagedId != "builtin" else {
808
- return
813
+ private func reloadWebViewSynchronously() throws {
814
+ try runOnMainSynchronously { [weak self] in
815
+ guard let webView = self?.bridge?.webView else {
816
+ throw NSError(
817
+ domain: "OtaKit",
818
+ code: 1,
819
+ userInfo: [NSLocalizedDescriptionKey: "WebView not available for reload"]
820
+ )
821
+ }
822
+ webView.reload()
809
823
  }
824
+ }
810
825
 
811
- let current = store.getCurrentBundle()
812
- if previousStagedId == current.id {
826
+ private func runOnMainSynchronously(_ work: @escaping () throws -> Void) throws {
827
+ if Thread.isMainThread {
828
+ try work()
813
829
  return
814
830
  }
815
831
 
816
- let fallback = store.getFallbackBundle()
817
- if previousStagedId == fallback.id {
818
- return
832
+ let semaphore = DispatchSemaphore(value: 0)
833
+ final class FailureBox {
834
+ var error: Error?
819
835
  }
836
+ let failure = FailureBox()
820
837
 
821
- try? store.deleteBundle(id: previousStagedId)
822
- }
823
-
824
- private func applyServerBasePath(_ path: String?) {
825
- DispatchQueue.main.async { [weak self] in
826
- if let path, !path.isEmpty {
827
- self?.bridge?.setServerBasePath(path)
828
- } else {
829
- let builtinPath = Bundle.main.resourceURL?
830
- .appendingPathComponent("public", isDirectory: true).path ?? ""
831
- self?.bridge?.setServerBasePath(builtinPath)
838
+ DispatchQueue.main.async {
839
+ defer { semaphore.signal() }
840
+ do {
841
+ try work()
842
+ } catch {
843
+ failure.error = error
832
844
  }
833
845
  }
834
- }
835
846
 
836
- private func reloadWebView() {
837
- DispatchQueue.main.async { [weak self] in
838
- if self?.bridge?.webView == nil {
839
- print("[OtaKit] WARNING: WebView not available for reload")
840
- }
841
- self?.bridge?.webView?.reload()
847
+ semaphore.wait()
848
+ if let error = failure.error {
849
+ throw error
842
850
  }
843
851
  }
844
852
 
845
853
  private func manifestToDictionary(
846
- _ latest: LatestManifest,
847
- downloaded: Bool = false
854
+ _ latest: LatestManifest
848
855
  ) -> [String: Any] {
849
856
  var payload: [String: Any] = [
850
857
  "version": latest.version,
851
858
  "url": latest.url,
852
859
  "sha256": latest.sha256,
853
860
  "size": latest.size,
854
- "downloaded": downloaded,
855
861
  ]
856
862
  if let runtimeVersion = latest.runtimeVersion {
857
863
  payload["runtimeVersion"] = runtimeVersion
@@ -860,72 +866,34 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
860
866
  return payload
861
867
  }
862
868
 
863
- private func isCurrentBundleLatest(
864
- latest: LatestManifest,
869
+ private func downloadLatestManifest(
870
+ _ manifest: LatestManifest,
865
871
  targetChannel: String?
866
- ) -> Bool {
867
- doesBundleMatchLatest(store.getCurrentBundle(), latest: latest, targetChannel: targetChannel)
868
- }
869
-
870
- private func doesBundleMatchLatest(
871
- _ bundle: BundleInfo,
872
- latest: LatestManifest,
873
- targetChannel: String?
874
- ) -> Bool {
875
- if trimToNil(bundle.channel) != targetChannel {
876
- return false
877
- }
878
-
879
- if trimToNil(bundle.runtimeVersion) != trimToNil(latest.runtimeVersion) {
880
- return false
881
- }
882
-
883
- if let bundleReleaseId = bundle.releaseId,
884
- latest.releaseId == bundleReleaseId {
885
- return true
886
- }
887
-
888
- if !latest.sha256.isEmpty,
889
- let bundleSha = bundle.sha256,
890
- !bundleSha.isEmpty,
891
- latest.sha256 == bundleSha {
892
- return true
893
- }
894
-
895
- return latest.version == bundle.version
896
- }
897
-
898
- private func findMatchingStagedBundle(
899
- latest: LatestManifest,
900
- targetChannel: String?
901
- ) -> BundleInfo? {
902
- guard let stagedId = store.getStagedBundleId() else {
903
- return nil
904
- }
905
- guard let staged = store.getBundle(id: stagedId) else {
906
- store.setStagedBundleId(nil)
907
- return nil
908
- }
909
-
910
- if trimToNil(staged.channel) != targetChannel {
911
- return nil
912
- }
913
-
914
- if trimToNil(staged.runtimeVersion) != trimToNil(latest.runtimeVersion) {
915
- return nil
872
+ ) async throws -> BundleInfo {
873
+ guard let url = URL(string: manifest.url) else {
874
+ throw NSError(
875
+ domain: "OtaKit",
876
+ code: 1,
877
+ userInfo: [NSLocalizedDescriptionKey: "Invalid download URL from manifest"]
878
+ )
916
879
  }
917
880
 
918
- return doesBundleMatchLatest(staged, latest: latest, targetChannel: targetChannel) ? staged : nil
881
+ return try await downloadAndStage(
882
+ url: url,
883
+ version: manifest.version,
884
+ expectedSha256: manifest.sha256,
885
+ expectedSize: manifest.size,
886
+ runtimeVersion: manifest.runtimeVersion,
887
+ channel: targetChannel,
888
+ releaseId: manifest.releaseId
889
+ )
919
890
  }
920
891
 
921
892
  private func pruneIncompatibleBundles() {
922
- for bundle in store.listDownloadedBundles() where !isCompatibleRuntime(bundle) {
923
- try? store.deleteBundle(id: bundle.id)
924
- }
925
-
926
- if let failed = store.getFailedBundle(), !isCompatibleRuntime(failed) {
927
- store.setFailedBundle(nil)
928
- }
893
+ let cleanupBundleIds = coordinator.pruneIncompatibleBundles(
894
+ isCompatibleRuntime: isCompatibleRuntime
895
+ )
896
+ coordinator.cleanupBundles(cleanupBundleIds)
929
897
  }
930
898
 
931
899
  private func isCompatibleRuntime(_ runtimeVersion: String?) -> Bool {
@@ -936,7 +904,11 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
936
904
  isCompatibleRuntime(bundle.runtimeVersion)
937
905
  }
938
906
 
939
- private func buildBundleId(from version: String) -> String {
907
+ private func buildBundleId(
908
+ version: String,
909
+ releaseId: String?,
910
+ sha256: String?
911
+ ) -> String {
940
912
  let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines)
941
913
  let allowed = CharacterSet(
942
914
  charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-"
@@ -965,7 +937,8 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
965
937
  normalized = String(normalized.prefix(64))
966
938
  }
967
939
 
968
- let digest = SHA256.hash(data: Data(trimmed.utf8))
940
+ let identitySource = trimToNil(releaseId) ?? trimToNil(sha256) ?? trimmed
941
+ let digest = SHA256.hash(data: Data(identitySource.utf8))
969
942
  let suffix = digest.map { String(format: "%02x", $0) }.joined().prefix(12)
970
943
  return "\(normalized)-\(suffix)"
971
944
  }
@@ -978,18 +951,31 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
978
951
  releaseId: String? = nil,
979
952
  detail: String? = nil
980
953
  ) {
954
+ sendDeviceEvent(
955
+ UpdaterCoordinator.DeviceEventPayload(
956
+ action: action,
957
+ bundleVersion: bundleVersion,
958
+ runtimeVersion: runtimeVersion,
959
+ channel: channel,
960
+ releaseId: releaseId,
961
+ detail: detail
962
+ )
963
+ )
964
+ }
965
+
966
+ private func sendDeviceEvent(_ payload: UpdaterCoordinator.DeviceEventPayload) {
981
967
  guard let appId else {
982
968
  return
983
969
  }
984
- guard let bundleVersion = trimToNil(bundleVersion) else {
970
+ guard let bundleVersion = trimToNil(payload.bundleVersion) else {
985
971
  print("[OtaKit] Skipping device event without bundleVersion")
986
972
  return
987
973
  }
988
- guard let releaseId = trimToNil(releaseId) else {
974
+ guard let releaseId = trimToNil(payload.releaseId) else {
989
975
  print("[OtaKit] Skipping device event without releaseId")
990
976
  return
991
977
  }
992
- let nativeBuild = store.nativeBuild.trimmingCharacters(in: .whitespacesAndNewlines)
978
+ let nativeBuild = coordinator.nativeBuild.trimmingCharacters(in: .whitespacesAndNewlines)
993
979
  guard !nativeBuild.isEmpty else {
994
980
  print("[OtaKit] Skipping device event without nativeBuild")
995
981
  return
@@ -998,16 +984,39 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
998
984
  ingestUrl: ingestUrl,
999
985
  appId: appId,
1000
986
  platform: "ios",
1001
- action: action,
987
+ action: payload.action,
1002
988
  bundleVersion: bundleVersion,
1003
- channel: channel,
1004
- runtimeVersion: trimToNil(runtimeVersion),
989
+ channel: payload.channel,
990
+ runtimeVersion: trimToNil(payload.runtimeVersion),
1005
991
  releaseId: releaseId,
1006
992
  nativeBuild: nativeBuild,
1007
- detail: detail
993
+ detail: payload.detail
1008
994
  )
1009
995
  }
1010
996
 
997
+ private func isBundleUsable(_ bundle: BundleInfo) -> Bool {
998
+ guard !bundle.isBuiltin else {
999
+ return true
1000
+ }
1001
+ return isBundlePathUsable(bundle.path)
1002
+ }
1003
+
1004
+ private func isBundlePathUsable(_ path: String?) -> Bool {
1005
+ guard let path = trimToNil(path) else {
1006
+ return false
1007
+ }
1008
+
1009
+ var isDirectory: ObjCBool = false
1010
+ guard fileManager.fileExists(atPath: path, isDirectory: &isDirectory),
1011
+ isDirectory.boolValue else {
1012
+ return false
1013
+ }
1014
+
1015
+ let indexPath = URL(fileURLWithPath: path, isDirectory: true)
1016
+ .appendingPathComponent("index.html", isDirectory: false).path
1017
+ return fileManager.fileExists(atPath: indexPath)
1018
+ }
1019
+
1011
1020
  private func resolveTargetChannel(_ channel: String?) -> String? {
1012
1021
  if let channel = trimToNil(channel) {
1013
1022
  return channel