@otakit/capacitor-updater 2.0.1 → 2.1.1

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