@otakit/capacitor-updater 2.3.3 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +46 -5
  2. package/android/build.gradle +16 -0
  3. package/android/src/main/java/com/otakit/updater/BundleCrypto.java +38 -17
  4. package/android/src/main/java/com/otakit/updater/BundleInfo.java +10 -0
  5. package/android/src/main/java/com/otakit/updater/BundleStore.java +134 -86
  6. package/android/src/main/java/com/otakit/updater/CheckFailure.java +53 -0
  7. package/android/src/main/java/com/otakit/updater/DeltaAssembler.java +46 -41
  8. package/android/src/main/java/com/otakit/updater/DeviceEventClient.java +125 -26
  9. package/android/src/main/java/com/otakit/updater/DocumentReadyBridge.java +71 -0
  10. package/android/src/main/java/com/otakit/updater/DownloadRetry.java +144 -0
  11. package/android/src/main/java/com/otakit/updater/EventOutbox.java +174 -0
  12. package/android/src/main/java/com/otakit/updater/FileDownloader.java +80 -0
  13. package/android/src/main/java/com/otakit/updater/ForegroundDeadline.java +84 -0
  14. package/android/src/main/java/com/otakit/updater/HashUtils.java +26 -0
  15. package/android/src/main/java/com/otakit/updater/ManifestClient.java +12 -9
  16. package/android/src/main/java/com/otakit/updater/ManifestKeyConfig.java +47 -0
  17. package/android/src/main/java/com/otakit/updater/ManifestVerifier.java +22 -4
  18. package/android/src/main/java/com/otakit/updater/SDKVersion.java +9 -0
  19. package/android/src/main/java/com/otakit/updater/UpdateOwner.java +27 -0
  20. package/android/src/main/java/com/otakit/updater/UpdaterCoordinator.java +154 -59
  21. package/android/src/main/java/com/otakit/updater/UpdaterPlugin.java +262 -173
  22. package/dist/esm/definitions.d.ts +3 -3
  23. package/dist/esm/definitions.d.ts.map +1 -1
  24. package/ios/Sources/UpdaterPlugin/BundleCrypto.swift +35 -3
  25. package/ios/Sources/UpdaterPlugin/BundleInfo.swift +13 -0
  26. package/ios/Sources/UpdaterPlugin/BundleStore.swift +103 -77
  27. package/ios/Sources/UpdaterPlugin/CheckFailure.swift +41 -0
  28. package/ios/Sources/UpdaterPlugin/DeltaAssembler.swift +29 -17
  29. package/ios/Sources/UpdaterPlugin/DeviceEventClient.swift +12 -10
  30. package/ios/Sources/UpdaterPlugin/DocumentReadyBridge.swift +43 -0
  31. package/ios/Sources/UpdaterPlugin/DownloadRetry.swift +51 -0
  32. package/ios/Sources/UpdaterPlugin/Downloader.swift +97 -39
  33. package/ios/Sources/UpdaterPlugin/EventOutbox.swift +183 -0
  34. package/ios/Sources/UpdaterPlugin/ForegroundDeadline.swift +94 -0
  35. package/ios/Sources/UpdaterPlugin/HashUtils.swift +16 -0
  36. package/ios/Sources/UpdaterPlugin/ManifestClient.swift +2 -4
  37. package/ios/Sources/UpdaterPlugin/ManifestKeyConfig.swift +20 -0
  38. package/ios/Sources/UpdaterPlugin/ManifestVerifier.swift +7 -1
  39. package/ios/Sources/UpdaterPlugin/SDKVersion.swift +4 -0
  40. package/ios/Sources/UpdaterPlugin/UpdateOwner.swift +12 -0
  41. package/ios/Sources/UpdaterPlugin/UpdaterCoordinator.swift +146 -110
  42. package/ios/Sources/UpdaterPlugin/UpdaterPlugin.swift +206 -202
  43. package/ios/Tests/UpdaterPluginTests/BundleCryptoTests.swift +80 -0
  44. package/ios/Tests/UpdaterPluginTests/BundlePersistenceTests.swift +102 -0
  45. package/ios/Tests/UpdaterPluginTests/CheckFailureTests.swift +55 -0
  46. package/ios/Tests/UpdaterPluginTests/DeltaCacheIntegrityTests.swift +69 -0
  47. package/ios/Tests/UpdaterPluginTests/DocumentReadyBridgeTests.swift +102 -0
  48. package/ios/Tests/UpdaterPluginTests/DownloadIntegrityTests.swift +40 -0
  49. package/ios/Tests/UpdaterPluginTests/DownloadRetryTests.swift +189 -0
  50. package/ios/Tests/UpdaterPluginTests/EventDeliveryTests.swift +56 -0
  51. package/ios/Tests/UpdaterPluginTests/EventOutboxTests.swift +86 -0
  52. package/ios/Tests/UpdaterPluginTests/ForegroundDeadlineTests.swift +144 -0
  53. package/ios/Tests/UpdaterPluginTests/ManifestKeyConfigTests.swift +48 -0
  54. package/ios/Tests/UpdaterPluginTests/UpdateOwnerTests.swift +34 -0
  55. package/ios/Tests/UpdaterPluginTests/UpdaterCoordinatorTests.swift +394 -0
  56. package/package.json +7 -3
@@ -0,0 +1,394 @@
1
+ import Foundation
2
+ import XCTest
3
+ @testable import UpdaterPlugin
4
+
5
+ final class UpdaterCoordinatorTests: XCTestCase {
6
+ private var fixture: CoordinatorFixture!
7
+
8
+ override func setUpWithError() throws {
9
+ fixture = try CoordinatorFixture()
10
+ }
11
+
12
+ override func tearDownWithError() throws {
13
+ try fixture.cleanup()
14
+ }
15
+
16
+ func testReadinessAcknowledgesOnlyOnce() throws {
17
+ try fixture.apply("A")
18
+ let ready = try fixture.coordinator.prepareNotifyAppReady(activationId: fixture.trial?.activationId)
19
+ XCTAssertEqual(ready.eventPayload?.action, .applied)
20
+ XCTAssertEqual(fixture.store.getCurrentBundle().status, .success)
21
+ XCTAssertNil(try fixture.coordinator.prepareNotifyAppReady(activationId: fixture.trial?.activationId).eventPayload)
22
+ }
23
+
24
+ func testTelemetryKeepsInstallationIdentityAcrossReadinessAndRestartRollback() throws {
25
+ let installed = BundleStore.newInstallationId()
26
+ try fixture.apply(installed)
27
+ let applied = try fixture.coordinator.prepareNotifyAppReady(activationId: fixture.trial?.activationId).eventPayload
28
+ XCTAssertEqual(applied?.attemptId, installed)
29
+ XCTAssertEqual(applied?.phase, "readiness")
30
+ XCTAssertEqual(fixture.reopenStore().getCurrentBundle().attemptId, installed)
31
+ let failed = BundleStore.newInstallationId()
32
+ try fixture.apply(failed)
33
+ let restarted = UpdaterCoordinator(store: fixture.reopenStore())
34
+ let startup = try restarted.normalizeStartupState(isBundleUsable: fixture.isUsable)
35
+ XCTAssertEqual(startup.eventPayload?.attemptId, failed)
36
+ XCTAssertEqual(startup.eventPayload?.phase, "rollback")
37
+ }
38
+
39
+ func testLegacyBundleDoesNotInventAnAttemptIdentity() throws {
40
+ try fixture.apply("legacy-release")
41
+ let applied = try fixture.coordinator.prepareNotifyAppReady(activationId: fixture.trial?.activationId)
42
+ XCTAssertNil(applied.eventPayload?.attemptId)
43
+ }
44
+
45
+ func testOldOrUntaggedDocumentCannotConfirmNewTrial() throws {
46
+ try fixture.installHealthy("A")
47
+ let oldDocument = try XCTUnwrap(fixture.trial).activationId
48
+ try fixture.apply("B")
49
+ for identity in [oldDocument, nil] {
50
+ let stale = try fixture.coordinator.prepareNotifyAppReady(activationId: identity)
51
+ XCTAssertNil(stale.eventPayload)
52
+ XCTAssertTrue(stale.cleanupBundleIds.isEmpty)
53
+ XCTAssertEqual(fixture.store.getCurrentBundle().status, .trial)
54
+ XCTAssertEqual(fixture.store.getFallbackBundleId(), "A")
55
+ }
56
+ XCTAssertNotNil(try fixture.coordinator.prepareNotifyAppReady(
57
+ activationId: fixture.trial?.activationId
58
+ ).eventPayload)
59
+ }
60
+
61
+ func testTimeoutAfterReadinessDoesNotRollBackSuccess() throws {
62
+ try fixture.installHealthy("A")
63
+ try fixture.apply("B")
64
+ let ready = try fixture.coordinator.prepareNotifyAppReady(activationId: fixture.trial?.activationId)
65
+ fixture.coordinator.cleanupBundles(ready.cleanupBundleIds)
66
+ let rollback = try fixture.coordinator.prepareRollback(
67
+ expectedTrial: try XCTUnwrap(fixture.trial),
68
+ reason: "notify_timeout", isBundleUsable: fixture.isUsable
69
+ )
70
+ fixture.coordinator.cleanupBundles(rollback.cleanupBundleIds)
71
+ XCTAssertFalse(rollback.didRollback)
72
+ XCTAssertNil(rollback.eventPayload)
73
+ XCTAssertEqual(fixture.store.getCurrentBundle().id, "B")
74
+ XCTAssertEqual(fixture.store.getCurrentBundle().status, .success)
75
+ XCTAssertTrue(fixture.indexExists("B"))
76
+ }
77
+
78
+ func testFailedReadinessWritePreservesHealthyFallback() throws {
79
+ try fixture.installHealthy("A")
80
+ try fixture.apply("B")
81
+ try fixture.withReadOnlyMetadata("B") {
82
+ XCTAssertThrowsError(try fixture.coordinator.prepareNotifyAppReady(activationId: fixture.trial?.activationId))
83
+ XCTAssertEqual(fixture.store.getCurrentBundle().status, .trial)
84
+ XCTAssertEqual(fixture.store.getFallbackBundle().id, "A")
85
+ XCTAssertTrue(fixture.indexExists("A"))
86
+ }
87
+ }
88
+
89
+ func testFailedTrialWriteDoesNotSwitchCurrentBundle() throws {
90
+ try fixture.installHealthy("A")
91
+ try fixture.stage("B")
92
+ try fixture.withReadOnlyMetadata("B") {
93
+ XCTAssertThrowsError(try fixture.coordinator.prepareApplyStaged(
94
+ isCompatibleRuntime: { _ in true }, isBundleUsable: fixture.isUsable
95
+ ))
96
+ XCTAssertEqual(fixture.store.getCurrentBundle().id, "A")
97
+ XCTAssertEqual(fixture.store.getStagedBundleId(), "B")
98
+ XCTAssertTrue(fixture.indexExists("A"))
99
+ }
100
+ }
101
+
102
+ func testOldTimeoutDoesNotAffectNewTrial() throws {
103
+ try fixture.installHealthy("A")
104
+ let oldTrial = try XCTUnwrap(fixture.apply("B").trial)
105
+ let ready = try fixture.coordinator.prepareNotifyAppReady(activationId: fixture.trial?.activationId)
106
+ fixture.coordinator.cleanupBundles(ready.cleanupBundleIds)
107
+ try fixture.apply("C")
108
+ let stale = try fixture.coordinator.prepareRollback(
109
+ expectedTrial: oldTrial, reason: "notify_timeout", isBundleUsable: fixture.isUsable
110
+ )
111
+ XCTAssertFalse(stale.didRollback)
112
+ XCTAssertTrue(stale.cleanupBundleIds.isEmpty)
113
+ XCTAssertEqual(fixture.store.getCurrentBundle().id, "C")
114
+ XCTAssertEqual(fixture.store.getCurrentBundle().status, .trial)
115
+ }
116
+
117
+ func testOldTimeoutDoesNotAffectRetryOfSameBundle() throws {
118
+ try fixture.installHealthy("A")
119
+ let oldTrial = try XCTUnwrap(fixture.apply("B").trial)
120
+ let failed = try fixture.coordinator.prepareRollback(
121
+ expectedTrial: oldTrial, reason: "notify_timeout", isBundleUsable: fixture.isUsable
122
+ )
123
+ fixture.coordinator.cleanupBundles(failed.cleanupBundleIds)
124
+ let retry = try XCTUnwrap(fixture.apply("B").trial)
125
+ XCTAssertNotEqual(oldTrial.activationId, retry.activationId)
126
+ let stale = try fixture.coordinator.prepareRollback(
127
+ expectedTrial: oldTrial, reason: "notify_timeout", isBundleUsable: fixture.isUsable
128
+ )
129
+ XCTAssertFalse(stale.didRollback)
130
+ XCTAssertEqual(fixture.store.getCurrentBundle().id, "B")
131
+ let actual = try fixture.coordinator.prepareRollback(
132
+ expectedTrial: retry, reason: "notify_timeout", isBundleUsable: fixture.isUsable
133
+ )
134
+ XCTAssertTrue(actual.didRollback)
135
+ XCTAssertEqual(fixture.store.getCurrentBundle().id, "A")
136
+ let duplicate = try fixture.coordinator.prepareRollback(
137
+ expectedTrial: retry, reason: "notify_timeout", isBundleUsable: fixture.isUsable
138
+ )
139
+ XCTAssertFalse(duplicate.didRollback)
140
+ }
141
+
142
+ func testRollbackRestoresHealthyBundleAndRemovesFailedTrial() throws {
143
+ try fixture.installHealthy("A")
144
+ try fixture.apply("B")
145
+ let rollback = try fixture.coordinator.prepareRollback(
146
+ expectedTrial: try XCTUnwrap(fixture.trial),
147
+ reason: "notify_timeout", isBundleUsable: fixture.isUsable
148
+ )
149
+ fixture.coordinator.cleanupBundles(rollback.cleanupBundleIds)
150
+ XCTAssertTrue(rollback.didRollback)
151
+ XCTAssertEqual(fixture.store.getCurrentBundle().id, "A")
152
+ XCTAssertEqual(fixture.store.getCurrentBundle().status, .success)
153
+ XCTAssertTrue(fixture.indexExists("A"))
154
+ XCTAssertFalse(fixture.indexExists("B"))
155
+ XCTAssertEqual(rollback.activationPath, fixture.store.bundleDirectory(for: "A").path)
156
+ }
157
+
158
+ func testStartupRejectsIncompleteStagedBundle() throws {
159
+ try fixture.installHealthy("A")
160
+ try fixture.stage("B")
161
+ try FileManager.default.removeItem(at: fixture.indexURL("B"))
162
+ let startup = try fixture.coordinator.normalizeStartupState(isBundleUsable: fixture.isUsable)
163
+ fixture.coordinator.cleanupBundles(startup.cleanupBundleIds)
164
+ XCTAssertNil(fixture.store.getStagedBundleId())
165
+ XCTAssertEqual(fixture.store.getCurrentBundle().id, "A")
166
+ XCTAssertTrue(fixture.indexExists("A"))
167
+ }
168
+
169
+ func testSecondActivationWaitsForCurrentTrialAndPreservesHealthyFallback() throws {
170
+ try fixture.installHealthy("A")
171
+ try fixture.apply("B")
172
+ let second = try fixture.apply("C")
173
+ XCTAssertFalse(second.didApply)
174
+ XCTAssertEqual(fixture.store.getCurrentBundle().id, "B")
175
+ XCTAssertEqual(fixture.store.getFallbackBundle().id, "A")
176
+ XCTAssertEqual(fixture.store.getStagedBundleId(), "C")
177
+
178
+ let rollback = try fixture.coordinator.prepareRollback(
179
+ expectedTrial: try XCTUnwrap(fixture.trial),
180
+ reason: "notify_timeout", isBundleUsable: fixture.isUsable
181
+ )
182
+ fixture.coordinator.cleanupBundles(rollback.cleanupBundleIds)
183
+ let startup = try fixture.coordinator.normalizeStartupState(isBundleUsable: fixture.isUsable)
184
+ fixture.coordinator.cleanupBundles(startup.cleanupBundleIds)
185
+ XCTAssertEqual(fixture.store.getCurrentBundle().id, "A")
186
+ XCTAssertTrue(fixture.indexExists("A"))
187
+ }
188
+
189
+ func testLegacySelfFallbackRestoresBuiltinInsteadOfDeletingActivationTarget() throws {
190
+ try fixture.apply("B")
191
+ try fixture.store.setFallbackBundleId("B")
192
+ let startup = try fixture.coordinator.normalizeStartupState(isBundleUsable: fixture.isUsable)
193
+ fixture.coordinator.cleanupBundles(startup.cleanupBundleIds)
194
+ XCTAssertNil(startup.activationPath)
195
+ XCTAssertTrue(fixture.store.getCurrentBundle().isBuiltin)
196
+ XCTAssertNil(fixture.store.getFallbackBundleId())
197
+ }
198
+
199
+ func testDeferredUpdateCanApplyAfterReadiness() throws {
200
+ try fixture.installHealthy("A")
201
+ try fixture.apply("B")
202
+ XCTAssertFalse(try fixture.apply("C").didApply)
203
+ let ready = try fixture.coordinator.prepareNotifyAppReady(activationId: fixture.trial?.activationId)
204
+ fixture.coordinator.cleanupBundles(ready.cleanupBundleIds)
205
+ let applied = try fixture.coordinator.prepareApplyStaged(
206
+ isCompatibleRuntime: { _ in true }, isBundleUsable: fixture.isUsable
207
+ )
208
+ XCTAssertTrue(applied.didApply)
209
+ XCTAssertEqual(fixture.store.getCurrentBundle().id, "C")
210
+ XCTAssertEqual(fixture.store.getFallbackBundle().id, "B")
211
+ XCTAssertEqual(fixture.store.getFallbackBundle().status, .success)
212
+ XCTAssertTrue(fixture.indexExists("B"))
213
+ }
214
+
215
+ func testRollbackNeverRestoresAnUnconfirmedFallback() throws {
216
+ try fixture.apply("B")
217
+ try fixture.stage("C")
218
+ try fixture.store.setFallbackBundleId("C")
219
+ let rollback = try fixture.coordinator.prepareRollback(
220
+ expectedTrial: try XCTUnwrap(fixture.trial),
221
+ reason: "notify_timeout", isBundleUsable: fixture.isUsable
222
+ )
223
+ fixture.coordinator.cleanupBundles(rollback.cleanupBundleIds)
224
+ XCTAssertNil(rollback.activationPath)
225
+ XCTAssertTrue(fixture.store.getCurrentBundle().isBuiltin)
226
+ }
227
+
228
+ func testCleanupRechecksReferencesBeforeDeleting() throws {
229
+ try fixture.installHealthy("A")
230
+ try fixture.apply("B")
231
+ try fixture.stage("C")
232
+ fixture.coordinator.cleanupBundles(["A", "B", "C", "builtin"])
233
+ XCTAssertTrue(fixture.indexExists("A"))
234
+ XCTAssertTrue(fixture.indexExists("B"))
235
+ XCTAssertTrue(fixture.indexExists("C"))
236
+ }
237
+
238
+ func testStagingCannotOverwriteReferencedBundleMetadata() throws {
239
+ try fixture.installHealthy("A")
240
+ try fixture.apply("B")
241
+ try fixture.stage("C")
242
+ for id in ["A", "B", "C"] {
243
+ let existing = try XCTUnwrap(fixture.store.getBundle(id: id))
244
+ XCTAssertThrowsError(try fixture.coordinator.stageDownloadedBundle(existing.withStatus(.error)))
245
+ XCTAssertEqual(fixture.store.getBundle(id: id)?.status, existing.status)
246
+ XCTAssertTrue(fixture.indexExists(id))
247
+ }
248
+ XCTAssertEqual(fixture.store.getCurrentBundleId(), "B")
249
+ XCTAssertEqual(fixture.store.getFallbackBundleId(), "A")
250
+ XCTAssertEqual(fixture.store.getStagedBundleId(), "C")
251
+ let rollback = try fixture.coordinator.prepareRollback(
252
+ expectedTrial: try XCTUnwrap(fixture.trial), reason: "notify_timeout",
253
+ isBundleUsable: fixture.isUsable
254
+ )
255
+ XCTAssertEqual(rollback.activationPath, fixture.store.bundleDirectory(for: "A").path)
256
+ }
257
+
258
+ func testFreshInstallationIdPreservesReleaseMatching() throws {
259
+ let first = BundleStore.newInstallationId()
260
+ let second = BundleStore.newInstallationId()
261
+ XCTAssertNotEqual(first, second)
262
+ try fixture.installHealthy(first)
263
+ let latest = LatestManifest(
264
+ version: first, url: nil, sha256: "hash-\(first)", size: 0,
265
+ runtimeVersion: nil, releaseId: "release-\(first)", strategy: "zip",
266
+ forceImmediate: false, encryption: nil, files: nil
267
+ )
268
+ switch fixture.coordinator.classifyLatestManifest(
269
+ latest, targetChannel: nil, isStagedBundleUsable: fixture.isUsable
270
+ ) {
271
+ case .noUpdate: break
272
+ default: XCTFail("Release matching must not depend on installation ID")
273
+ }
274
+ try fixture.apply("B")
275
+ try fixture.stage(second)
276
+ let incoming = BundleInfo(
277
+ id: second, version: first, runtimeVersion: nil, status: .pending,
278
+ downloadedAt: Date(), sha256: latest.sha256,
279
+ path: fixture.store.bundleDirectory(for: second).path,
280
+ channel: nil, releaseId: latest.releaseId
281
+ )
282
+ try fixture.store.saveBundle(incoming)
283
+ switch fixture.coordinator.classifyLatestManifest(
284
+ latest, targetChannel: nil, isStagedBundleUsable: fixture.isUsable
285
+ ) {
286
+ case .alreadyStaged(let bundle): XCTAssertEqual(bundle.id, second)
287
+ default: XCTFail("The fresh installation must be reusable as a staged release")
288
+ }
289
+ XCTAssertEqual(fixture.store.getBundle(id: first)?.status, .success)
290
+ XCTAssertTrue(fixture.indexExists(first))
291
+ }
292
+
293
+ func testFailedStagingCleanupPreservesCurrentAndSuccessfulStaging() throws {
294
+ try fixture.installHealthy("A")
295
+ let orphan = BundleStore.newInstallationId()
296
+ try fixture.withReadOnlyState { XCTAssertThrowsError(try fixture.stage(orphan)) }
297
+ fixture.coordinator.cleanupBundles([orphan])
298
+ XCTAssertFalse(fixture.indexExists(orphan))
299
+ XCTAssertTrue(fixture.indexExists("A"))
300
+ let staged = BundleStore.newInstallationId()
301
+ try fixture.stage(staged)
302
+ fixture.coordinator.cleanupBundles([staged])
303
+ XCTAssertTrue(fixture.indexExists(staged))
304
+ XCTAssertEqual(fixture.store.getStagedBundleId(), staged)
305
+ }
306
+ }
307
+
308
+ final class CoordinatorFixture {
309
+ let root: URL
310
+ let defaults: UserDefaults
311
+ let suiteName = "otakit-coordinator-tests-\(UUID().uuidString)"
312
+ let store: BundleStore
313
+ let coordinator: UpdaterCoordinator
314
+ var trial: UpdaterCoordinator.Trial?
315
+
316
+ init() throws {
317
+ root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
318
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
319
+ defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
320
+ store = BundleStore(defaults: defaults, rootDirectory: root)
321
+ coordinator = UpdaterCoordinator(store: store)
322
+ }
323
+
324
+ func cleanup() throws {
325
+ defaults.removePersistentDomain(forName: suiteName)
326
+ try FileManager.default.removeItem(at: root)
327
+ }
328
+
329
+ func withReadOnlyMetadata(_ id: String, work: () throws -> Void) throws {
330
+ let directory = store.bundleDirectory(for: id)
331
+ let metadata = directory.appendingPathComponent("bundle.json")
332
+ let manager = FileManager.default
333
+ try manager.setAttributes([.posixPermissions: 0o400], ofItemAtPath: metadata.path)
334
+ try manager.setAttributes([.posixPermissions: 0o500], ofItemAtPath: directory.path)
335
+ defer {
336
+ try? manager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directory.path)
337
+ try? manager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: metadata.path)
338
+ }
339
+ try work()
340
+ }
341
+
342
+ func reopenStore() -> BundleStore {
343
+ BundleStore(defaults: defaults, rootDirectory: root)
344
+ }
345
+
346
+ func withReadOnlyState(work: () throws -> Void) throws {
347
+ let manager = FileManager.default
348
+ try manager.setAttributes([.posixPermissions: 0o500], ofItemAtPath: root.path)
349
+ defer { try? manager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: root.path) }
350
+ try work()
351
+ }
352
+
353
+ func indexURL(_ id: String) -> URL {
354
+ store.bundleDirectory(for: id).appendingPathComponent("index.html")
355
+ }
356
+
357
+ func indexExists(_ id: String) -> Bool {
358
+ FileManager.default.fileExists(atPath: indexURL(id).path)
359
+ }
360
+
361
+ func isUsable(_ bundle: BundleInfo) -> Bool {
362
+ bundle.isBuiltin || indexExists(bundle.id)
363
+ }
364
+
365
+ func stage(_ id: String) throws {
366
+ let directory = store.bundleDirectory(for: id)
367
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
368
+ try Data(id.utf8).write(to: indexURL(id))
369
+ let bundle = BundleInfo(
370
+ id: id, version: id, runtimeVersion: nil, status: .pending, downloadedAt: Date(),
371
+ sha256: "hash-\(id)", path: directory.path, channel: nil, releaseId: "release-\(id)"
372
+ )
373
+ coordinator.cleanupBundles(try coordinator.stageDownloadedBundle(bundle))
374
+ }
375
+
376
+ @discardableResult
377
+ func apply(_ id: String) throws -> UpdaterCoordinator.ApplyPreparation {
378
+ try stage(id)
379
+ let result = try coordinator.prepareApplyStaged(
380
+ isCompatibleRuntime: { _ in true }, isBundleUsable: isUsable
381
+ )
382
+ if let active = result.trial { trial = active }
383
+ coordinator.cleanupBundles(result.cleanupBundleIds)
384
+ return result
385
+ }
386
+
387
+ func installHealthy(_ id: String) throws {
388
+ let applied = try apply(id)
389
+ XCTAssertTrue(applied.didApply)
390
+ let ready = try coordinator.prepareNotifyAppReady(activationId: trial?.activationId)
391
+ XCTAssertNotNil(ready.eventPayload)
392
+ coordinator.cleanupBundles(ready.cleanupBundleIds)
393
+ }
394
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@otakit/capacitor-updater",
3
- "version": "2.3.3",
3
+ "version": "3.0.0",
4
4
  "description": "Capacitor plugin for OTA updates",
5
5
  "main": "dist/plugin.cjs.js",
6
6
  "module": "dist/esm/index.js",
@@ -38,7 +38,10 @@
38
38
  ],
39
39
  "scripts": {
40
40
  "verify": "npm run verify:minimal && npm run verify:pack",
41
- "verify:minimal": "node -e \"console.log('Native verification is not wired yet')\"",
41
+ "verify:minimal": "npm run verify:android && npm run verify:ios",
42
+ "verify:android": "../../examples/demo-app/android/gradlew -p android/test-project :otakit-capacitor-updater:testDebugUnitTest --console=plain",
43
+ "verify:android:device": "../../examples/demo-app/android/gradlew -p android/test-project :otakit-capacitor-updater:connectedDebugAndroidTest --console=plain",
44
+ "verify:ios": "node ../../scripts/verify-ios-plugin.mjs",
42
45
  "verify:pack": "npm pack --dry-run --json --cache /tmp/otakit-npm-pack-cache | node -e \"const data=JSON.parse(require('fs').readFileSync(0,'utf8')); const files=(data[0]?.files??[]).map((f)=>f.path); if(!files.includes('Package.swift')){console.error('Missing Package.swift in published tarball'); process.exit(1)} console.log('Verified tarball includes Package.swift')\"",
43
46
  "verify:web": "npm run build",
44
47
  "lint": "npm run eslint && npm run prettier -- --check && npm run swiftlint -- lint",
@@ -47,10 +50,11 @@
47
50
  "prettier": "prettier \"src/**/*.{ts,js}\" \"android/src/main/java/**/*.java\" --plugin=prettier-plugin-java",
48
51
  "swiftlint": "node-swiftlint",
49
52
  "docgen": "docgen --api UpdaterPlugin --output-readme README.md --output-json dist/docs.json",
50
- "build": "npm run clean && tsc && rollup -c rollup.config.mjs",
53
+ "build": "node ../../scripts/generate-native-sdk-version.mjs --write && npm run clean && tsc && rollup -c rollup.config.mjs",
51
54
  "clean": "rimraf ./dist",
52
55
  "watch": "tsc --watch",
53
56
  "prepublishOnly": "npm run build",
57
+ "prepack": "node ../../scripts/generate-native-sdk-version.mjs --write",
54
58
  "typecheck": "tsc --noEmit"
55
59
  },
56
60
  "devDependencies": {