@otakit/capacitor-updater 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +183 -0
  3. package/UpdatekitUpdater.podspec +18 -0
  4. package/android/build.gradle +49 -0
  5. package/android/src/main/AndroidManifest.xml +3 -0
  6. package/android/src/main/java/com/updatekit/updater/BundleInfo.java +100 -0
  7. package/android/src/main/java/com/updatekit/updater/BundleStatus.java +28 -0
  8. package/android/src/main/java/com/updatekit/updater/BundleStore.java +264 -0
  9. package/android/src/main/java/com/updatekit/updater/DateUtils.java +17 -0
  10. package/android/src/main/java/com/updatekit/updater/HashUtils.java +32 -0
  11. package/android/src/main/java/com/updatekit/updater/HostedManifestKeys.java +37 -0
  12. package/android/src/main/java/com/updatekit/updater/ManifestClient.java +209 -0
  13. package/android/src/main/java/com/updatekit/updater/ManifestVerifier.java +146 -0
  14. package/android/src/main/java/com/updatekit/updater/StatsClient.java +80 -0
  15. package/android/src/main/java/com/updatekit/updater/UpdaterPlugin.java +963 -0
  16. package/android/src/main/java/com/updatekit/updater/ZipUtils.java +72 -0
  17. package/dist/esm/definitions.d.ts +229 -0
  18. package/dist/esm/definitions.d.ts.map +1 -0
  19. package/dist/esm/definitions.js +17 -0
  20. package/dist/esm/definitions.js.map +1 -0
  21. package/dist/esm/index.d.ts +5 -0
  22. package/dist/esm/index.d.ts.map +1 -0
  23. package/dist/esm/index.js +85 -0
  24. package/dist/esm/index.js.map +1 -0
  25. package/dist/esm/web.d.ts +25 -0
  26. package/dist/esm/web.d.ts.map +1 -0
  27. package/dist/esm/web.js +56 -0
  28. package/dist/esm/web.js.map +1 -0
  29. package/dist/plugin.cjs.js +165 -0
  30. package/dist/plugin.cjs.js.map +1 -0
  31. package/dist/plugin.js +168 -0
  32. package/dist/plugin.js.map +1 -0
  33. package/ios/Sources/UpdaterPlugin/BundleInfo.swift +39 -0
  34. package/ios/Sources/UpdaterPlugin/BundleStatus.swift +9 -0
  35. package/ios/Sources/UpdaterPlugin/BundleStore.swift +233 -0
  36. package/ios/Sources/UpdaterPlugin/Downloader.swift +120 -0
  37. package/ios/Sources/UpdaterPlugin/HashUtils.swift +33 -0
  38. package/ios/Sources/UpdaterPlugin/HostedManifestKeys.swift +23 -0
  39. package/ios/Sources/UpdaterPlugin/ManifestClient.swift +161 -0
  40. package/ios/Sources/UpdaterPlugin/ManifestVerifier.swift +115 -0
  41. package/ios/Sources/UpdaterPlugin/StatsClient.swift +66 -0
  42. package/ios/Sources/UpdaterPlugin/UpdaterPlugin.m +15 -0
  43. package/ios/Sources/UpdaterPlugin/UpdaterPlugin.swift +913 -0
  44. package/ios/Sources/UpdaterPlugin/ZipUtils.swift +90 -0
  45. package/package.json +85 -0
@@ -0,0 +1,913 @@
1
+ import Capacitor
2
+ import CryptoKit
3
+ import Foundation
4
+
5
+ @objc(UpdaterPlugin)
6
+ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
7
+ private enum UpdateMode: String {
8
+ case manual
9
+ case nextLaunch = "next-launch"
10
+ case immediate
11
+ }
12
+
13
+ public let identifier = "UpdaterPlugin"
14
+ public let jsName = "OtaKit"
15
+ public let pluginMethods: [CAPPluginMethod] = [
16
+ CAPPluginMethod(name: "check", returnType: CAPPluginReturnPromise),
17
+ CAPPluginMethod(name: "download", returnType: CAPPluginReturnPromise),
18
+ CAPPluginMethod(name: "apply", returnType: CAPPluginReturnPromise),
19
+ CAPPluginMethod(name: "debugGetState", returnType: CAPPluginReturnPromise),
20
+ CAPPluginMethod(name: "debugCheck", returnType: CAPPluginReturnPromise),
21
+ CAPPluginMethod(name: "debugDownload", returnType: CAPPluginReturnPromise),
22
+ CAPPluginMethod(name: "notifyAppReady", returnType: CAPPluginReturnPromise),
23
+ CAPPluginMethod(name: "debugReset", returnType: CAPPluginReturnPromise),
24
+ CAPPluginMethod(name: "debugListBundles", returnType: CAPPluginReturnPromise),
25
+ CAPPluginMethod(name: "debugDeleteBundle", returnType: CAPPluginReturnPromise),
26
+ CAPPluginMethod(name: "debugGetLastFailure", returnType: CAPPluginReturnPromise),
27
+ ]
28
+
29
+ private let store = BundleStore()
30
+ private var downloader = Downloader()
31
+ private let zipUtils = ZipUtils()
32
+ private let fileManager = FileManager.default
33
+
34
+ private var appReadyTimeoutMs = 10_000
35
+ private var allowInsecureUrls = false
36
+ private var updateMode: UpdateMode = .nextLaunch
37
+ private var updateUrl = UpdaterPlugin.defaultUpdateURL
38
+ private var appId: String?
39
+ private var channel: String?
40
+ private var manifestKeys: [(kid: String, key: Data)] = []
41
+ private var trialTimeoutWorkItem: DispatchWorkItem?
42
+ private static let defaultUpdateURL = "https://www.otakit.app/api/v1"
43
+ private static let apiPathSuffix = "/api/v1"
44
+
45
+ public override func load() {
46
+ let envUpdateUrl = ProcessInfo.processInfo.environment["OTAKIT_SERVER_URL"]
47
+ updateUrl = resolveUpdateUrl(
48
+ configured: getConfig().getString("serverUrl"),
49
+ env: envUpdateUrl
50
+ )
51
+ appId = getConfig().getString("appId")
52
+ channel = trimToNil(getConfig().getString("channel"))
53
+ allowInsecureUrls = getConfig().getBoolean("allowInsecureUrls", false)
54
+ let updateModeRaw = getConfig().getString("updateMode", UpdateMode.nextLaunch.rawValue)
55
+ updateMode = resolveUpdateMode(configured: updateModeRaw)
56
+ downloader = Downloader(allowInsecureUrls: allowInsecureUrls)
57
+ appReadyTimeoutMs = max(1000, getConfig().getInt("appReadyTimeout", 10_000))
58
+
59
+ let rawKeysValue = getConfig().getArray("manifestKeys")
60
+ if let rawKeys = rawKeysValue as? [[String: String]] {
61
+ manifestKeys = rawKeys.compactMap { entry in
62
+ guard let kid = entry["kid"],
63
+ let keyBase64 = entry["key"],
64
+ let keyData = Data(base64Encoded: keyBase64) else { return nil }
65
+ return (kid: kid, key: keyData)
66
+ }
67
+ if manifestKeys.isEmpty && !rawKeys.isEmpty {
68
+ print("[UpdateKit] ERROR: manifestKeys configured but all entries are invalid. Manifest verification will reject all updates.")
69
+ manifestKeys = [(kid: "_invalid_", key: Data())]
70
+ }
71
+ } else if rawKeysValue != nil {
72
+ print("[UpdateKit] ERROR: manifestKeys has wrong format (expected array of {kid, key}). Manifest verification will reject all updates.")
73
+ manifestKeys = [(kid: "_invalid_", key: Data())]
74
+ }
75
+
76
+ if manifestKeys.isEmpty && HostedManifestKeys.matchesManagedServer(updateUrl) {
77
+ manifestKeys = HostedManifestKeys.defaults
78
+ }
79
+
80
+ var current = store.getCurrentBundle()
81
+ if current.status == .trial {
82
+ rollbackCurrentBundle(reason: "app_restarted_before_notify")
83
+ current = store.getCurrentBundle()
84
+ }
85
+
86
+ if shouldActivateStagedOnLaunch() {
87
+ current = activateStagedBundleForLaunch()
88
+ }
89
+
90
+ if !current.isBuiltin, let path = current.path {
91
+ applyServerBasePath(path)
92
+ } else {
93
+ applyServerBasePath(nil)
94
+ }
95
+
96
+ if current.status == .pending {
97
+ store.markStatus(bundleId: current.id, status: .trial)
98
+ scheduleTrialTimeout(for: current.id)
99
+ }
100
+
101
+ if isAutomaticUpdateMode() {
102
+ startAutoUpdate()
103
+ }
104
+ }
105
+
106
+ private func startAutoUpdate() {
107
+ switch updateMode {
108
+ case .manual:
109
+ return
110
+ case .nextLaunch:
111
+ Task {
112
+ _ = try? await performCheckAndDownload(channel: nil, emitEvents: true)
113
+ }
114
+ case .immediate:
115
+ Task {
116
+ do {
117
+ let downloaded = try await performCheckAndDownload(channel: nil, emitEvents: true)
118
+ if downloaded != nil {
119
+ activateStagedBundleForReload()
120
+ reloadWebView()
121
+ }
122
+ } catch {
123
+ print("[UpdateKit] immediate startup update failed: \(error.localizedDescription)")
124
+ }
125
+ }
126
+ }
127
+ }
128
+
129
+ private func shouldActivateStagedOnLaunch() -> Bool {
130
+ updateMode != .manual
131
+ }
132
+
133
+ private func isAutomaticUpdateMode() -> Bool {
134
+ updateMode != .manual
135
+ }
136
+
137
+ private func resolveUpdateMode(configured: String?) -> UpdateMode {
138
+ let raw = configured?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
139
+ switch raw {
140
+ case "", UpdateMode.nextLaunch.rawValue:
141
+ return .nextLaunch
142
+ case UpdateMode.manual.rawValue:
143
+ return .manual
144
+ case UpdateMode.immediate.rawValue:
145
+ return .immediate
146
+ default:
147
+ print("[UpdateKit] Unknown updateMode '\(raw)', defaulting to 'next-launch'")
148
+ return .nextLaunch
149
+ }
150
+ }
151
+
152
+ @objc func debugGetState(_ call: CAPPluginCall) {
153
+ let current = store.getCurrentBundle()
154
+ let staged: [String: Any]? = {
155
+ guard let stagedId = store.getStagedBundleId() else {
156
+ return nil
157
+ }
158
+ guard let staged = store.getBundle(id: stagedId) else {
159
+ store.setStagedBundleId(nil)
160
+ return nil
161
+ }
162
+ return staged.toDictionary()
163
+ }()
164
+ var payload: [String: Any] = [
165
+ "current": current.toDictionary(),
166
+ "fallback": store.getFallbackBundle().toDictionary(),
167
+ "builtinVersion": store.builtinVersion,
168
+ ]
169
+ payload["staged"] = staged ?? NSNull()
170
+ call.resolve(payload)
171
+ }
172
+
173
+ @objc func debugCheck(_ call: CAPPluginCall) {
174
+ let requestedChannel = call.getString("channel")
175
+ let targetChannel = resolveTargetChannel(requestedChannel)
176
+ Task {
177
+ do {
178
+ let latest = try await fetchLatest(channel: targetChannel)
179
+ if let latest {
180
+ let staged = findMatchingStagedBundle(latest: latest, targetChannel: targetChannel)
181
+ call.resolve(manifestToDictionary(latest, downloaded: staged != nil))
182
+ } else {
183
+ call.resolve()
184
+ }
185
+ } catch {
186
+ call.reject("debugCheck failed: \(error.localizedDescription)")
187
+ }
188
+ }
189
+ }
190
+
191
+ @objc func check(_ call: CAPPluginCall) {
192
+ let targetChannel = resolveTargetChannel(nil)
193
+ Task {
194
+ do {
195
+ let latest = try await fetchLatest(channel: targetChannel)
196
+ if let latest {
197
+ let staged = findMatchingStagedBundle(latest: latest, targetChannel: targetChannel)
198
+ call.resolve(manifestToDictionary(latest, downloaded: staged != nil))
199
+ } else {
200
+ call.resolve()
201
+ }
202
+ } catch {
203
+ call.reject("check failed: \(error.localizedDescription)")
204
+ }
205
+ }
206
+ }
207
+
208
+ @objc func debugDownload(_ call: CAPPluginCall) {
209
+ let requestedChannel = call.getString("channel")
210
+ Task {
211
+ do {
212
+ let bundle = try await performCheckAndDownload(
213
+ channel: requestedChannel,
214
+ emitEvents: true
215
+ )
216
+ if let bundle {
217
+ call.resolve(bundle.toDictionary())
218
+ } else {
219
+ call.resolve()
220
+ }
221
+ } catch {
222
+ call.reject("debugDownload failed: \(error.localizedDescription)")
223
+ }
224
+ }
225
+ }
226
+
227
+ @objc func download(_ call: CAPPluginCall) {
228
+ Task {
229
+ do {
230
+ let bundle = try await performCheckAndDownload(channel: nil, emitEvents: true)
231
+ if let bundle {
232
+ call.resolve(bundle.toDictionary())
233
+ } else {
234
+ call.resolve()
235
+ }
236
+ } catch {
237
+ call.reject("download failed: \(error.localizedDescription)")
238
+ }
239
+ }
240
+ }
241
+
242
+ @objc func apply(_ call: CAPPluginCall) {
243
+ guard let stagedId = store.getStagedBundleId() else {
244
+ call.reject("No staged update to apply")
245
+ return
246
+ }
247
+ guard store.getBundle(id: stagedId) != nil else {
248
+ store.setStagedBundleId(nil)
249
+ call.reject("Staged bundle not found")
250
+ return
251
+ }
252
+
253
+ activateStagedBundleForReload()
254
+ call.resolve()
255
+ reloadWebView()
256
+ }
257
+
258
+ @objc func notifyAppReady(_ call: CAPPluginCall) {
259
+ let current = store.getCurrentBundle()
260
+ guard !current.isBuiltin else {
261
+ call.resolve()
262
+ return
263
+ }
264
+
265
+ if current.status == .trial || current.status == .pending {
266
+ let oldFallback = store.getFallbackBundle()
267
+
268
+ store.markStatus(bundleId: current.id, status: .success)
269
+ store.setFallbackBundleId(current.id)
270
+
271
+ if let confirmed = store.getBundle(id: current.id) {
272
+ notifyListeners("appReady", data: confirmed.toDictionary())
273
+ } else {
274
+ notifyListeners("appReady", data: current.toDictionary())
275
+ }
276
+
277
+ sendStats(
278
+ action: .applied,
279
+ bundleVersion: current.version,
280
+ channel: current.channel,
281
+ releaseId: current.releaseId
282
+ )
283
+
284
+ if !oldFallback.isBuiltin,
285
+ oldFallback.id != current.id {
286
+ try? store.deleteBundle(id: oldFallback.id)
287
+ }
288
+ }
289
+
290
+ call.resolve()
291
+ }
292
+
293
+ @objc func debugReset(_ call: CAPPluginCall) {
294
+ cancelTrialTimeout()
295
+ store.setCurrentBundleId(nil)
296
+ store.setStagedBundleId(nil)
297
+ store.setFallbackBundleId(nil)
298
+ store.setFailedBundle(nil)
299
+ applyServerBasePath(nil)
300
+ call.resolve()
301
+ reloadWebView()
302
+ }
303
+
304
+ @objc func debugListBundles(_ call: CAPPluginCall) {
305
+ let bundles = store.listDownloadedBundles().map { $0.toDictionary() }
306
+ call.resolve(["bundles": bundles])
307
+ }
308
+
309
+ @objc func debugDeleteBundle(_ call: CAPPluginCall) {
310
+ guard let bundleId = call.getString("bundleId") else {
311
+ call.reject("Missing bundleId")
312
+ return
313
+ }
314
+ guard store.bundleExists(id: bundleId) else {
315
+ call.reject("Bundle not found")
316
+ return
317
+ }
318
+
319
+ let current = store.getCurrentBundle()
320
+ if current.id == bundleId {
321
+ call.reject("Cannot delete current bundle")
322
+ return
323
+ }
324
+
325
+ let fallback = store.getFallbackBundle()
326
+ if fallback.id == bundleId {
327
+ call.reject("Cannot delete fallback bundle")
328
+ return
329
+ }
330
+
331
+ if store.getStagedBundleId() == bundleId {
332
+ call.reject("Cannot delete staged bundle")
333
+ return
334
+ }
335
+
336
+ do {
337
+ try store.deleteBundle(id: bundleId)
338
+ call.resolve()
339
+ } catch {
340
+ call.reject("Failed to delete bundle: \(error.localizedDescription)")
341
+ }
342
+ }
343
+
344
+ @objc func debugGetLastFailure(_ call: CAPPluginCall) {
345
+ guard let failed = store.getFailedBundle() else {
346
+ call.resolve()
347
+ return
348
+ }
349
+ call.resolve(failed.toDictionary())
350
+ }
351
+
352
+ private func fetchLatest(channel: String?) async throws -> LatestManifest? {
353
+ guard let appId else {
354
+ throw NSError(domain: "OtaKit", code: 1, userInfo: [NSLocalizedDescriptionKey: "Missing appId in plugin config"])
355
+ }
356
+
357
+ let current = store.getCurrentBundle()
358
+ return try await ManifestClient.fetchLatest(
359
+ updateUrl: updateUrl,
360
+ appId: appId,
361
+ channel: channel,
362
+ currentVersion: current.version,
363
+ currentReleaseId: current.releaseId,
364
+ nativeBuild: store.nativeBuild,
365
+ platform: "ios",
366
+ allowInsecureUrls: allowInsecureUrls,
367
+ manifestKeys: manifestKeys.map {
368
+ ManifestKey(kid: $0.kid, derData: $0.key)
369
+ }
370
+ )
371
+ }
372
+
373
+ private func performCheckAndDownload(
374
+ channel: String?,
375
+ emitEvents: Bool
376
+ ) async throws -> BundleInfo? {
377
+ let targetChannel = resolveTargetChannel(channel)
378
+ var latest = try await fetchLatest(channel: targetChannel)
379
+
380
+ guard var manifest = latest else {
381
+ if emitEvents {
382
+ notifyListeners("noUpdateAvailable", data: [:])
383
+ }
384
+ return nil
385
+ }
386
+
387
+ let staged = findMatchingStagedBundle(latest: manifest, targetChannel: targetChannel)
388
+ if emitEvents {
389
+ notifyListeners("updateAvailable", data: manifestToDictionary(manifest, downloaded: staged != nil))
390
+ }
391
+
392
+ if let staged {
393
+ return staged
394
+ }
395
+
396
+ guard var url = URL(string: manifest.url) else {
397
+ throw NSError(
398
+ domain: "OtaKit",
399
+ code: 1,
400
+ userInfo: [NSLocalizedDescriptionKey: "Invalid download URL from manifest"]
401
+ )
402
+ }
403
+
404
+ do {
405
+ return try await downloadAndStage(
406
+ url: url,
407
+ version: manifest.version,
408
+ expectedSha256: manifest.sha256,
409
+ expectedSize: manifest.size,
410
+ channel: targetChannel,
411
+ releaseId: manifest.releaseId
412
+ )
413
+ } catch let error as NSError where isExpiredURLError(error) {
414
+ // Download URL may have expired — re-fetch manifest once and retry
415
+ latest = try await fetchLatest(channel: targetChannel)
416
+ guard let refreshed = latest else {
417
+ throw error
418
+ }
419
+ manifest = refreshed
420
+ guard let retryUrl = URL(string: manifest.url) else {
421
+ throw error
422
+ }
423
+ url = retryUrl
424
+ return try await downloadAndStage(
425
+ url: url,
426
+ version: manifest.version,
427
+ expectedSha256: manifest.sha256,
428
+ expectedSize: manifest.size,
429
+ channel: targetChannel,
430
+ releaseId: manifest.releaseId
431
+ )
432
+ }
433
+ }
434
+
435
+ private func isExpiredURLError(_ error: NSError) -> Bool {
436
+ // HTTP 403 or 410 typically indicates an expired presigned URL
437
+ if error.domain == "Downloader" && (error.code == 403 || error.code == 410) {
438
+ return true
439
+ }
440
+ let desc = error.localizedDescription.lowercased()
441
+ return desc.contains("403") || desc.contains("410")
442
+ }
443
+
444
+ private func downloadAndStage(
445
+ url: URL,
446
+ version: String,
447
+ expectedSha256: String,
448
+ expectedSize: Int? = nil,
449
+ channel: String? = nil,
450
+ releaseId: String? = nil
451
+ ) async throws -> BundleInfo {
452
+ // Check disk space before downloading
453
+ if let size = expectedSize {
454
+ let requiredSpace = Int64(Double(size) * 2.5) // zip + extracted + buffer
455
+ let availableSpace = getFreeDiskSpace()
456
+ if availableSpace < requiredSpace {
457
+ let error = NSError(
458
+ domain: "OtaKit",
459
+ code: 1,
460
+ userInfo: [NSLocalizedDescriptionKey: "Insufficient disk space"]
461
+ )
462
+ sendStats(
463
+ action: .downloadError,
464
+ bundleVersion: version,
465
+ channel: channel,
466
+ releaseId: releaseId,
467
+ errorMessage: "insufficient_disk_space"
468
+ )
469
+ throw error
470
+ }
471
+ }
472
+
473
+ notifyListeners("downloadStarted", data: ["version": version])
474
+ let zipURL = try await downloader.download(from: url)
475
+
476
+ let extractDirectory = fileManager.temporaryDirectory
477
+ .appendingPathComponent("updatekit-extract-\(UUID().uuidString)", isDirectory: true)
478
+ defer {
479
+ try? fileManager.removeItem(at: zipURL)
480
+ try? fileManager.removeItem(at: extractDirectory)
481
+ }
482
+
483
+ do {
484
+ let valid = try HashUtils.verify(
485
+ fileURL: zipURL,
486
+ expectedSha256: expectedSha256
487
+ )
488
+ guard valid else {
489
+ throw NSError(
490
+ domain: "OtaKit",
491
+ code: 1,
492
+ userInfo: [NSLocalizedDescriptionKey: "Downloaded bundle hash mismatch"]
493
+ )
494
+ }
495
+
496
+ try zipUtils.extractSecurely(zipURL: zipURL, to: extractDirectory)
497
+ let bundleRoot = try resolveBundleRoot(extractedDirectory: extractDirectory)
498
+
499
+ let bundleId = buildBundleId(from: version)
500
+ let destination = store.bundleDirectory(for: bundleId)
501
+
502
+ if fileManager.fileExists(atPath: destination.path) {
503
+ try fileManager.removeItem(at: destination)
504
+ }
505
+
506
+ if bundleRoot.path != destination.path {
507
+ try fileManager.moveItem(at: bundleRoot, to: destination)
508
+ }
509
+
510
+ let info = BundleInfo(
511
+ id: bundleId,
512
+ version: version,
513
+ status: .pending,
514
+ downloadedAt: Date(),
515
+ sha256: expectedSha256,
516
+ path: destination.path,
517
+ channel: channel,
518
+ releaseId: releaseId
519
+ )
520
+
521
+ let previousStagedId = store.getStagedBundleId()
522
+ try store.saveBundle(info)
523
+ store.setStagedBundleId(bundleId)
524
+ cleanupSupersededStagedBundle(previousStagedId: previousStagedId, replacementId: bundleId)
525
+
526
+ notifyListeners("downloadComplete", data: info.toDictionary())
527
+ sendStats(
528
+ action: .downloaded,
529
+ bundleVersion: version,
530
+ channel: channel,
531
+ releaseId: releaseId
532
+ )
533
+ return info
534
+ } catch {
535
+ notifyListeners("downloadFailed", data: [
536
+ "version": version,
537
+ "error": error.localizedDescription,
538
+ ])
539
+ sendStats(
540
+ action: .downloadError,
541
+ bundleVersion: version,
542
+ channel: channel,
543
+ releaseId: releaseId,
544
+ errorMessage: error.localizedDescription
545
+ )
546
+ throw error
547
+ }
548
+ }
549
+
550
+ private func resolveBundleRoot(extractedDirectory: URL) throws -> URL {
551
+ let indexAtRoot = extractedDirectory.appendingPathComponent("index.html")
552
+ if fileManager.fileExists(atPath: indexAtRoot.path) {
553
+ return extractedDirectory
554
+ }
555
+
556
+ let children = try fileManager.contentsOfDirectory(
557
+ at: extractedDirectory,
558
+ includingPropertiesForKeys: [.isDirectoryKey],
559
+ options: [.skipsHiddenFiles]
560
+ )
561
+ if children.count == 1 {
562
+ let child = children[0]
563
+ let childIndex = child.appendingPathComponent("index.html")
564
+ if fileManager.fileExists(atPath: childIndex.path) {
565
+ return child
566
+ }
567
+ }
568
+
569
+ throw NSError(
570
+ domain: "OtaKit",
571
+ code: 1,
572
+ userInfo: [NSLocalizedDescriptionKey: "Bundle archive does not contain index.html"]
573
+ )
574
+ }
575
+
576
+ private func activateStagedBundleForLaunch() -> BundleInfo {
577
+ guard let stagedId = store.getStagedBundleId() else {
578
+ return store.getCurrentBundle()
579
+ }
580
+ guard let staged = store.getBundle(id: stagedId) else {
581
+ store.setStagedBundleId(nil)
582
+ return store.getCurrentBundle()
583
+ }
584
+
585
+ store.setCurrentBundleId(staged.id)
586
+ store.setStagedBundleId(nil)
587
+ return staged
588
+ }
589
+
590
+ private func activateStagedBundleForReload() {
591
+ guard let stagedId = store.getStagedBundleId() else {
592
+ return
593
+ }
594
+ guard var staged = store.getBundle(id: stagedId) else {
595
+ store.setStagedBundleId(nil)
596
+ return
597
+ }
598
+
599
+ store.setCurrentBundleId(staged.id)
600
+ store.setStagedBundleId(nil)
601
+
602
+ if staged.status == .pending {
603
+ store.markStatus(bundleId: staged.id, status: .trial)
604
+ staged = store.getBundle(id: staged.id) ?? staged
605
+ scheduleTrialTimeout(for: staged.id)
606
+ } else if staged.status == .trial {
607
+ scheduleTrialTimeout(for: staged.id)
608
+ }
609
+
610
+ if let path = staged.path {
611
+ applyServerBasePath(path)
612
+ } else {
613
+ applyServerBasePath(nil)
614
+ }
615
+ }
616
+
617
+ private func scheduleTrialTimeout(for bundleId: String) {
618
+ cancelTrialTimeout()
619
+
620
+ let workItem = DispatchWorkItem { [weak self] in
621
+ guard let self else {
622
+ return
623
+ }
624
+ let current = self.store.getCurrentBundle()
625
+ guard current.id == bundleId else {
626
+ return
627
+ }
628
+ if current.status == .trial {
629
+ self.rollbackCurrentBundle(reason: "notify_timeout")
630
+ }
631
+ }
632
+ trialTimeoutWorkItem = workItem
633
+
634
+ DispatchQueue.main.asyncAfter(
635
+ deadline: .now() + .milliseconds(appReadyTimeoutMs),
636
+ execute: workItem
637
+ )
638
+ }
639
+
640
+ private func cancelTrialTimeout() {
641
+ trialTimeoutWorkItem?.cancel()
642
+ trialTimeoutWorkItem = nil
643
+ }
644
+
645
+ private func rollbackCurrentBundle(reason: String) {
646
+ cancelTrialTimeout()
647
+
648
+ let current = store.getCurrentBundle()
649
+ guard !current.isBuiltin else {
650
+ return
651
+ }
652
+
653
+ let failed = BundleInfo(
654
+ id: current.id,
655
+ version: current.version,
656
+ status: .error,
657
+ downloadedAt: current.downloadedAt,
658
+ sha256: current.sha256,
659
+ path: current.path,
660
+ channel: current.channel,
661
+ releaseId: current.releaseId
662
+ )
663
+ store.markStatus(bundleId: current.id, status: .error)
664
+ store.setFailedBundle(failed)
665
+ store.setStagedBundleId(nil)
666
+
667
+ sendStats(
668
+ action: .rollback,
669
+ bundleVersion: current.version,
670
+ channel: current.channel,
671
+ releaseId: current.releaseId,
672
+ errorMessage: reason
673
+ )
674
+
675
+ let fallback = store.getFallbackBundle()
676
+ if fallback.isBuiltin {
677
+ store.setCurrentBundleId(nil)
678
+ applyServerBasePath(nil)
679
+ notifyListeners("rollback", data: [
680
+ "from": failed.toDictionary(),
681
+ "to": store.builtinBundle().toDictionary(),
682
+ "reason": reason,
683
+ ])
684
+ } else if let fallbackPath = fallback.path {
685
+ store.setCurrentBundleId(fallback.id)
686
+ applyServerBasePath(fallbackPath)
687
+ notifyListeners("rollback", data: [
688
+ "from": failed.toDictionary(),
689
+ "to": fallback.toDictionary(),
690
+ "reason": reason,
691
+ ])
692
+ } else {
693
+ store.setCurrentBundleId(nil)
694
+ applyServerBasePath(nil)
695
+ notifyListeners("rollback", data: [
696
+ "from": failed.toDictionary(),
697
+ "to": store.builtinBundle().toDictionary(),
698
+ "reason": reason,
699
+ ])
700
+ }
701
+
702
+ try? store.deleteBundle(id: current.id)
703
+
704
+ reloadWebView()
705
+ }
706
+
707
+ private func cleanupSupersededStagedBundle(
708
+ previousStagedId: String?,
709
+ replacementId: String
710
+ ) {
711
+ guard let previousStagedId,
712
+ previousStagedId != replacementId,
713
+ previousStagedId != "builtin" else {
714
+ return
715
+ }
716
+
717
+ let current = store.getCurrentBundle()
718
+ if previousStagedId == current.id {
719
+ return
720
+ }
721
+
722
+ let fallback = store.getFallbackBundle()
723
+ if previousStagedId == fallback.id {
724
+ return
725
+ }
726
+
727
+ try? store.deleteBundle(id: previousStagedId)
728
+ }
729
+
730
+ private func applyServerBasePath(_ path: String?) {
731
+ DispatchQueue.main.async { [weak self] in
732
+ if let path, !path.isEmpty {
733
+ self?.bridge?.setServerBasePath(path)
734
+ } else {
735
+ let builtinPath = Bundle.main.resourceURL?
736
+ .appendingPathComponent("public", isDirectory: true).path ?? ""
737
+ self?.bridge?.setServerBasePath(builtinPath)
738
+ }
739
+ }
740
+ }
741
+
742
+ private func reloadWebView() {
743
+ DispatchQueue.main.async { [weak self] in
744
+ if self?.bridge?.webView == nil {
745
+ print("[OtaKit] WARNING: WebView not available for reload")
746
+ }
747
+ self?.bridge?.webView?.reload()
748
+ }
749
+ }
750
+
751
+ private func manifestToDictionary(
752
+ _ latest: LatestManifest,
753
+ downloaded: Bool = false
754
+ ) -> [String: Any] {
755
+ var payload: [String: Any] = [
756
+ "version": latest.version,
757
+ "url": latest.url,
758
+ "sha256": latest.sha256,
759
+ "size": latest.size,
760
+ "downloaded": downloaded,
761
+ ]
762
+ if let releaseId = latest.releaseId {
763
+ payload["releaseId"] = releaseId
764
+ }
765
+ if let minNativeBuild = latest.minNativeBuild {
766
+ payload["minNativeBuild"] = minNativeBuild
767
+ }
768
+ return payload
769
+ }
770
+
771
+ private func findMatchingStagedBundle(
772
+ latest: LatestManifest,
773
+ targetChannel: String?
774
+ ) -> BundleInfo? {
775
+ guard let stagedId = store.getStagedBundleId() else {
776
+ return nil
777
+ }
778
+ guard let staged = store.getBundle(id: stagedId) else {
779
+ store.setStagedBundleId(nil)
780
+ return nil
781
+ }
782
+
783
+ if trimToNil(staged.channel) != targetChannel {
784
+ return nil
785
+ }
786
+
787
+ if let latestReleaseId = latest.releaseId,
788
+ let stagedReleaseId = staged.releaseId,
789
+ latestReleaseId == stagedReleaseId {
790
+ return staged
791
+ }
792
+
793
+ if latest.version == staged.version,
794
+ latest.sha256 == staged.sha256 {
795
+ return staged
796
+ }
797
+
798
+ return nil
799
+ }
800
+
801
+ private func buildBundleId(from version: String) -> String {
802
+ let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines)
803
+ let allowed = CharacterSet(
804
+ charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-"
805
+ )
806
+
807
+ var normalizedScalars = String.UnicodeScalarView()
808
+ for scalar in trimmed.unicodeScalars {
809
+ if allowed.contains(scalar) {
810
+ normalizedScalars.append(scalar)
811
+ } else {
812
+ normalizedScalars.append("-")
813
+ }
814
+ }
815
+
816
+ var normalized = String(normalizedScalars).replacingOccurrences(
817
+ of: "-{2,}",
818
+ with: "-",
819
+ options: .regularExpression
820
+ )
821
+ normalized = normalized.trimmingCharacters(in: CharacterSet(charactersIn: "-."))
822
+
823
+ if normalized.isEmpty {
824
+ normalized = "bundle"
825
+ }
826
+ if normalized.count > 64 {
827
+ normalized = String(normalized.prefix(64))
828
+ }
829
+
830
+ let digest = SHA256.hash(data: Data(trimmed.utf8))
831
+ let suffix = digest.map { String(format: "%02x", $0) }.joined().prefix(12)
832
+ return "\(normalized)-\(suffix)"
833
+ }
834
+
835
+ private func sendStats(
836
+ action: StatsAction,
837
+ bundleVersion: String? = nil,
838
+ channel: String? = nil,
839
+ releaseId: String? = nil,
840
+ errorMessage: String? = nil
841
+ ) {
842
+ guard let appId else {
843
+ return
844
+ }
845
+ StatsClient.send(
846
+ updateUrl: updateUrl,
847
+ appId: appId,
848
+ platform: "ios",
849
+ action: action,
850
+ bundleVersion: bundleVersion,
851
+ channel: channel,
852
+ releaseId: releaseId,
853
+ nativeBuild: store.nativeBuild,
854
+ errorMessage: errorMessage
855
+ )
856
+ }
857
+
858
+ private func resolveTargetChannel(_ channel: String?) -> String? {
859
+ if let channel = trimToNil(channel) {
860
+ return channel
861
+ }
862
+ return self.channel
863
+ }
864
+
865
+ private func trimToNil(_ value: String?) -> String? {
866
+ guard let value else {
867
+ return nil
868
+ }
869
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
870
+ return trimmed.isEmpty ? nil : trimmed
871
+ }
872
+
873
+ private func resolveUpdateUrl(configured: String?, env: String?) -> String {
874
+ let configuredValue = configured?.trimmingCharacters(in: .whitespacesAndNewlines)
875
+ if let configuredValue, !configuredValue.isEmpty {
876
+ return normalizeUpdateUrl(configuredValue)
877
+ }
878
+
879
+ let envValue = env?.trimmingCharacters(in: .whitespacesAndNewlines)
880
+ if let envValue, !envValue.isEmpty {
881
+ return normalizeUpdateUrl(envValue)
882
+ }
883
+
884
+ return UpdaterPlugin.defaultUpdateURL
885
+ }
886
+
887
+ private func normalizeUpdateUrl(_ raw: String) -> String {
888
+ let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
889
+ let withoutTrailingSlash = trimmed.replacingOccurrences(
890
+ of: "/+$",
891
+ with: "",
892
+ options: .regularExpression
893
+ )
894
+
895
+ if withoutTrailingSlash.lowercased().hasSuffix(UpdaterPlugin.apiPathSuffix) {
896
+ return withoutTrailingSlash
897
+ }
898
+
899
+ return withoutTrailingSlash + UpdaterPlugin.apiPathSuffix
900
+ }
901
+
902
+ private func getFreeDiskSpace() -> Int64 {
903
+ do {
904
+ let attributes = try fileManager.attributesOfFileSystem(
905
+ forPath: NSHomeDirectory()
906
+ )
907
+ if let freeSize = attributes[.systemFreeSize] as? Int64 {
908
+ return freeSize
909
+ }
910
+ } catch {}
911
+ return Int64.max // If we can't determine, allow download
912
+ }
913
+ }