@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.
- package/README.md +46 -5
- package/android/build.gradle +16 -0
- package/android/src/main/java/com/otakit/updater/BundleCrypto.java +38 -17
- package/android/src/main/java/com/otakit/updater/BundleInfo.java +10 -0
- package/android/src/main/java/com/otakit/updater/BundleStore.java +134 -86
- package/android/src/main/java/com/otakit/updater/CheckFailure.java +53 -0
- package/android/src/main/java/com/otakit/updater/DeltaAssembler.java +46 -41
- package/android/src/main/java/com/otakit/updater/DeviceEventClient.java +125 -26
- package/android/src/main/java/com/otakit/updater/DocumentReadyBridge.java +71 -0
- package/android/src/main/java/com/otakit/updater/DownloadRetry.java +144 -0
- package/android/src/main/java/com/otakit/updater/EventOutbox.java +174 -0
- package/android/src/main/java/com/otakit/updater/FileDownloader.java +80 -0
- package/android/src/main/java/com/otakit/updater/ForegroundDeadline.java +84 -0
- package/android/src/main/java/com/otakit/updater/HashUtils.java +26 -0
- package/android/src/main/java/com/otakit/updater/ManifestClient.java +12 -9
- package/android/src/main/java/com/otakit/updater/ManifestKeyConfig.java +47 -0
- package/android/src/main/java/com/otakit/updater/ManifestVerifier.java +22 -4
- package/android/src/main/java/com/otakit/updater/SDKVersion.java +9 -0
- package/android/src/main/java/com/otakit/updater/UpdateOwner.java +27 -0
- package/android/src/main/java/com/otakit/updater/UpdaterCoordinator.java +154 -59
- package/android/src/main/java/com/otakit/updater/UpdaterPlugin.java +262 -173
- package/dist/esm/definitions.d.ts +3 -3
- package/dist/esm/definitions.d.ts.map +1 -1
- package/ios/Sources/UpdaterPlugin/BundleCrypto.swift +35 -3
- package/ios/Sources/UpdaterPlugin/BundleInfo.swift +13 -0
- package/ios/Sources/UpdaterPlugin/BundleStore.swift +103 -77
- package/ios/Sources/UpdaterPlugin/CheckFailure.swift +41 -0
- package/ios/Sources/UpdaterPlugin/DeltaAssembler.swift +29 -17
- package/ios/Sources/UpdaterPlugin/DeviceEventClient.swift +12 -10
- package/ios/Sources/UpdaterPlugin/DocumentReadyBridge.swift +43 -0
- package/ios/Sources/UpdaterPlugin/DownloadRetry.swift +51 -0
- package/ios/Sources/UpdaterPlugin/Downloader.swift +97 -39
- package/ios/Sources/UpdaterPlugin/EventOutbox.swift +183 -0
- package/ios/Sources/UpdaterPlugin/ForegroundDeadline.swift +94 -0
- package/ios/Sources/UpdaterPlugin/HashUtils.swift +16 -0
- package/ios/Sources/UpdaterPlugin/ManifestClient.swift +2 -4
- package/ios/Sources/UpdaterPlugin/ManifestKeyConfig.swift +20 -0
- package/ios/Sources/UpdaterPlugin/ManifestVerifier.swift +7 -1
- package/ios/Sources/UpdaterPlugin/SDKVersion.swift +4 -0
- package/ios/Sources/UpdaterPlugin/UpdateOwner.swift +12 -0
- package/ios/Sources/UpdaterPlugin/UpdaterCoordinator.swift +146 -110
- package/ios/Sources/UpdaterPlugin/UpdaterPlugin.swift +206 -202
- package/ios/Tests/UpdaterPluginTests/BundleCryptoTests.swift +80 -0
- package/ios/Tests/UpdaterPluginTests/BundlePersistenceTests.swift +102 -0
- package/ios/Tests/UpdaterPluginTests/CheckFailureTests.swift +55 -0
- package/ios/Tests/UpdaterPluginTests/DeltaCacheIntegrityTests.swift +69 -0
- package/ios/Tests/UpdaterPluginTests/DocumentReadyBridgeTests.swift +102 -0
- package/ios/Tests/UpdaterPluginTests/DownloadIntegrityTests.swift +40 -0
- package/ios/Tests/UpdaterPluginTests/DownloadRetryTests.swift +189 -0
- package/ios/Tests/UpdaterPluginTests/EventDeliveryTests.swift +56 -0
- package/ios/Tests/UpdaterPluginTests/EventOutboxTests.swift +86 -0
- package/ios/Tests/UpdaterPluginTests/ForegroundDeadlineTests.swift +144 -0
- package/ios/Tests/UpdaterPluginTests/ManifestKeyConfigTests.swift +48 -0
- package/ios/Tests/UpdaterPluginTests/UpdateOwnerTests.swift +34 -0
- package/ios/Tests/UpdaterPluginTests/UpdaterCoordinatorTests.swift +394 -0
- 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": "
|
|
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": "
|
|
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": {
|