@rovq/react-native 0.0.0-stage → 0.1.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 (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +76 -2
  3. package/Rovq.podspec +19 -0
  4. package/android/build.gradle +86 -0
  5. package/android/src/main/AndroidManifest.xml +4 -0
  6. package/android/src/main/java/com/rovq/engine/EngineState.kt +176 -0
  7. package/android/src/main/java/com/rovq/engine/ManifestVerifier.kt +173 -0
  8. package/android/src/main/java/com/rovq/engine/PackageDownloader.kt +188 -0
  9. package/android/src/main/java/com/rovq/engine/PackageRules.kt +39 -0
  10. package/android/src/main/java/com/rovq/engine/ProgressThrottle.kt +17 -0
  11. package/android/src/main/java/com/rovq/engine/RovqEngine.kt +543 -0
  12. package/android/src/main/java/com/rovq/engine/RovqException.kt +31 -0
  13. package/android/src/main/java/com/rovq/engine/SafePackageExtractor.kt +121 -0
  14. package/android/src/main/java/com/rovq/engine/StrictBase64.kt +37 -0
  15. package/android/src/main/java/com/rovq/engine/UpdateInstaller.kt +94 -0
  16. package/android/src/main/java/com/rovq/engine/UpdateService.kt +139 -0
  17. package/android/src/main/java/com/rovq/engine/ZipStructureCheck.kt +194 -0
  18. package/android/src/react/java/com/rovq/engine/react/Rovq.kt +101 -0
  19. package/android/src/react/java/com/rovq/engine/react/RovqModule.kt +164 -0
  20. package/android/src/react/java/com/rovq/engine/react/RovqPackage.kt +26 -0
  21. package/android/src/react/java/com/rovq/engine/react/RovqReactHost.kt +74 -0
  22. package/ios/Engine/EngineState.swift +119 -0
  23. package/ios/Engine/ManifestVerifier.swift +197 -0
  24. package/ios/Engine/PackageDownloader.swift +231 -0
  25. package/ios/Engine/PackageRules.swift +38 -0
  26. package/ios/Engine/ProgressThrottle.swift +20 -0
  27. package/ios/Engine/RovqEngine.swift +633 -0
  28. package/ios/Engine/RovqError.swift +35 -0
  29. package/ios/Engine/SafePackageExtractor.swift +107 -0
  30. package/ios/Engine/UpdateInstaller.swift +126 -0
  31. package/ios/Engine/UpdateService.swift +158 -0
  32. package/ios/Engine/ZipReader.swift +378 -0
  33. package/ios/React/RovqHost.swift +253 -0
  34. package/ios/React/RovqModule.mm +89 -0
  35. package/package.json +74 -3
  36. package/react-native.config.js +11 -0
  37. package/src/NativeRovq.ts +72 -0
  38. package/src/errors.ts +44 -0
  39. package/src/index.ts +228 -0
@@ -0,0 +1,633 @@
1
+ import Foundation
2
+
3
+ private let maxQueuedEvents = 200
4
+ private let eventBatchSize = 50
5
+ private let bundleFileName = PackageRules.bundleFileName
6
+
7
+ // A release closed before confirming gets this many more starts. A crash gets none.
8
+ private let maxTryingRetries = 2
9
+
10
+ private let firstPostpone: TimeInterval = 60 * 60
11
+ private let maxPostpone: TimeInterval = 24 * 60 * 60
12
+
13
+ // The same file failed its checks, so downloading it again would likely waste data for nothing.
14
+ // A network failure is not here, because trying again can work.
15
+ private let postponedCodes: Set<RovqErrorCode> = [.sizeMismatch, .hashMismatch, .invalidPackage]
16
+
17
+ /// What the engine needs to know about the app.
18
+ public struct EngineConfig {
19
+ public let apiURL: String
20
+ public let deploymentKey: String
21
+ public let runtimeVersion: String
22
+ public let binaryVersion: String
23
+ /// Changes whenever the app itself is installed or updated from a store.
24
+ public let binaryId: String
25
+ /// Key IDs and their Base64 public keys, built into the app.
26
+ public let trustedKeys: [String: String]
27
+ public let allowInsecureURLs: Bool
28
+
29
+ public init(
30
+ apiURL: String,
31
+ deploymentKey: String,
32
+ runtimeVersion: String,
33
+ binaryVersion: String,
34
+ binaryId: String,
35
+ trustedKeys: [String: String],
36
+ allowInsecureURLs: Bool = false
37
+ ) {
38
+ self.apiURL = apiURL
39
+ self.deploymentKey = deploymentKey
40
+ self.runtimeVersion = runtimeVersion
41
+ self.binaryVersion = binaryVersion
42
+ self.binaryId = binaryId
43
+ self.trustedKeys = trustedKeys
44
+ self.allowInsecureURLs = allowInsecureURLs
45
+ }
46
+ }
47
+
48
+ /// Facts about an update, all from a manifest whose signature has been verified.
49
+ public struct UpdateInfo: Equatable {
50
+ public let releaseId: String
51
+ public let sequence: Int64
52
+ public let mandatory: Bool
53
+ public let packageSize: Int64
54
+ }
55
+
56
+ public enum CheckResult: Equatable {
57
+ case upToDate
58
+ /// `alreadyDownloaded` says whether `downloadUpdate` has nothing left to fetch.
59
+ case updateAvailable(UpdateInfo, alreadyDownloaded: Bool)
60
+ /// The server asked to leave the current release. Restart to apply.
61
+ case rolledBack
62
+ case failed(code: RovqErrorCode, message: String)
63
+ }
64
+
65
+ public enum DownloadResult: Equatable {
66
+ /// Verified and on disk. It loads only after `installUpdate`.
67
+ case downloaded(UpdateInfo)
68
+ case failed(code: RovqErrorCode, message: String)
69
+ }
70
+
71
+ public enum InstallResult: Equatable {
72
+ /// Loads on the next JavaScript start.
73
+ case installed(releaseId: String)
74
+ case failed(code: RovqErrorCode, message: String)
75
+ }
76
+
77
+ public enum SyncResult: Equatable {
78
+ case upToDate
79
+ /// Installed, and loads on the next JavaScript start.
80
+ case updateReady(releaseId: String, mandatory: Bool)
81
+ /// The server asked to leave the current release. Restart to apply.
82
+ case rolledBack
83
+ case failed(code: RovqErrorCode, message: String)
84
+ }
85
+
86
+ public struct EngineStatus: Equatable {
87
+ public let deviceId: String
88
+ public let currentReleaseId: String?
89
+ public let downloadedReleaseId: String?
90
+ public let pendingReleaseId: String?
91
+ public let tryingReleaseId: String?
92
+ public let highestSequence: Int64
93
+ }
94
+
95
+ /// The state machine that decides which bundle runs. A wrong decision here can stop an app
96
+ /// from starting, so every change is saved before it takes effect, and each method is
97
+ /// written so that the process can die at any line.
98
+ public final class RovqEngine {
99
+ private let rootFolder: URL
100
+ private let updatesFolder: URL
101
+ private let config: EngineConfig
102
+ private let service: UpdateService
103
+ private let clock: () -> TimeInterval
104
+ private let store: EngineStateStore
105
+ private let verifier: ManifestVerifier
106
+ private let installer: UpdateInstaller
107
+ private let lock = NSRecursiveLock()
108
+ // Held for a whole check, download or install, because each depends on the one before.
109
+ private let syncLock = NSLock()
110
+ // Kept in memory only, because its download URL expires. Guarded by syncLock.
111
+ private var offer: OfferedUpdate?
112
+ private var offerManifest: VerifiedManifest?
113
+ private var state: EngineState
114
+
115
+ /// - Throws: if a built-in public key is malformed, which is a mistake in the app itself.
116
+ public init(
117
+ rootFolder: URL,
118
+ config: EngineConfig,
119
+ service: UpdateService? = nil,
120
+ clock: @escaping () -> TimeInterval = { Date().timeIntervalSince1970 }
121
+ ) throws {
122
+ self.clock = clock
123
+ self.rootFolder = rootFolder
124
+ self.config = config
125
+ self.service = service ?? HTTPUpdateService(apiURL: config.apiURL, allowInsecureURLs: config.allowInsecureURLs)
126
+ updatesFolder = rootFolder.appendingPathComponent("updates", isDirectory: true)
127
+ store = EngineStateStore(rootFolder: rootFolder)
128
+ verifier = try ManifestVerifier(
129
+ trustedKeys: config.trustedKeys,
130
+ deploymentKey: config.deploymentKey,
131
+ runtimeVersion: config.runtimeVersion
132
+ )
133
+ installer = UpdateInstaller(
134
+ rootFolder: rootFolder,
135
+ verifier: verifier,
136
+ downloader: PackageDownloader(allowInsecureURLs: config.allowInsecureURLs)
137
+ )
138
+ try? FileManager.default.createDirectory(at: rootFolder, withIntermediateDirectories: true)
139
+ RovqEngine.excludeFromBackup(rootFolder)
140
+ state = RovqEngine.loadState(store: store, updatesFolder: updatesFolder, config: config)
141
+ verifier.revokedPublicKeys = state.revokedPublicKeys
142
+ }
143
+
144
+ /// Called each time JavaScript starts, before it loads. Returns the bundle to run, or nil
145
+ /// for the one inside the app. It never throws, or the app could not start.
146
+ public func resolveBundle() -> URL? {
147
+ lock.lock()
148
+ defer { lock.unlock() }
149
+
150
+ // Still marked as trying means the last start never confirmed that it works.
151
+ if let failed = state.trying {
152
+ // A crash does not pass through the background, but closing the app does, so this
153
+ // start was probably cut short by a person, and the release deserves another one.
154
+ if state.backgroundedWhileTrying && state.tryingRetries < maxTryingRetries && bundleExists(failed) {
155
+ var retry = state
156
+ retry.backgroundedWhileTrying = false
157
+ retry.tryingRetries += 1
158
+ if save(retry) { return bundleFile(failed) }
159
+ }
160
+ var next = state
161
+ next.trying = nil
162
+ next.backgroundedWhileTrying = false
163
+ next.tryingRetries = 0
164
+ save(queue(next, type: "ROLLED_BACK", releaseId: failed.id, errorCode: "NOT_CONFIRMED"))
165
+ }
166
+
167
+ if let next = state.pending {
168
+ if !bundleExists(next) {
169
+ var updated = state
170
+ updated.pending = nil
171
+ save(updated)
172
+ } else {
173
+ var updated = state
174
+ updated.pending = nil
175
+ updated.trying = next
176
+ if save(updated) {
177
+ // Saved first, so that if this bundle kills the process the next start knows.
178
+ removeUnusedReleases()
179
+ return bundleFile(next)
180
+ }
181
+ // Trying it unrecorded could crash on every start forever, so run what is
182
+ // known to work. The update stays pending for when saving works again.
183
+ state.pending = next
184
+ state.trying = nil
185
+ return state.current.flatMap { bundleExists($0) ? bundleFile($0) : nil }
186
+ }
187
+ }
188
+
189
+ // A confirmed release can only disappear through outside interference.
190
+ if let current = state.current, !bundleExists(current) {
191
+ var updated = state
192
+ updated.current = state.previous
193
+ updated.previous = nil
194
+ save(updated)
195
+ }
196
+ if let current = state.current, !bundleExists(current) {
197
+ var updated = state
198
+ updated.current = nil
199
+ updated.previous = nil
200
+ save(updated)
201
+ }
202
+ removeUnusedReleases()
203
+ return state.current.map(bundleFile)
204
+ }
205
+
206
+ /// Called when the app goes to the background. See `resolveBundle`.
207
+ public func noteBackgrounded() {
208
+ lock.lock()
209
+ defer { lock.unlock() }
210
+ guard state.trying != nil, !state.backgroundedWhileTrying else { return }
211
+ var next = state
212
+ next.backgroundedWhileTrying = true
213
+ save(next)
214
+ }
215
+
216
+ /// Called when the app is crashing, so nothing that follows can look like closing the app.
217
+ public func noteCrashed() {
218
+ lock.lock()
219
+ defer { lock.unlock() }
220
+ guard state.trying != nil else { return }
221
+ var next = state
222
+ next.backgroundedWhileTrying = false
223
+ next.tryingRetries = maxTryingRetries
224
+ save(next)
225
+ }
226
+
227
+ /// Called by the app once it has started properly. It keeps the running release.
228
+ public func notifyReady() {
229
+ lock.lock()
230
+ defer { lock.unlock() }
231
+ guard let confirmed = state.trying else { return }
232
+ var next = state
233
+ next.trying = nil
234
+ next.backgroundedWhileTrying = false
235
+ next.tryingRetries = 0
236
+ next.previous = state.current
237
+ next.current = confirmed
238
+ save(queue(next, type: "CONFIRMED", releaseId: confirmed.id, errorCode: nil))
239
+ }
240
+
241
+ /// Goes back to the previous confirmed release, or to the one inside the app. It takes
242
+ /// effect on the next JavaScript start. The sequence stays, so the release that was left
243
+ /// is not offered again.
244
+ ///
245
+ /// - Returns: whether there was anything to leave.
246
+ @discardableResult
247
+ public func rollback(reason: String = "REQUESTED") -> Bool {
248
+ lock.lock()
249
+ defer { lock.unlock() }
250
+ guard let leaving = state.trying ?? state.current else { return false }
251
+ var next = state
252
+ next.backgroundedWhileTrying = false
253
+ next.tryingRetries = 0
254
+ if state.trying != nil {
255
+ next.trying = nil
256
+ } else {
257
+ next.current = state.previous
258
+ next.previous = nil
259
+ }
260
+ save(queue(next, type: "ROLLED_BACK", releaseId: leaving.id, errorCode: reason))
261
+ return true
262
+ }
263
+
264
+ /// Asks the server for updates, and verifies any offer before reporting it, so an app never
265
+ /// shows the user an update that it would refuse to download.
266
+ ///
267
+ /// A rollback from the server applies here, because it is how a bad release gets stopped,
268
+ /// and that must not depend on what the app does with the answer. Blocks until finished,
269
+ /// so call it away from the main thread.
270
+ public func checkForUpdate() -> CheckResult {
271
+ syncLock.lock()
272
+ defer { syncLock.unlock() }
273
+ let result = checkOnce()
274
+ flushEvents()
275
+ return result
276
+ }
277
+
278
+ /// Fetches the update found by the last `checkForUpdate`, without activating it. Blocks
279
+ /// until finished, so call it away from the main thread.
280
+ public func downloadUpdate(onProgress: @escaping (Int64, Int64) -> Void = { _, _ in }) -> DownloadResult {
281
+ syncLock.lock()
282
+ defer { syncLock.unlock() }
283
+ let result = downloadOnce(onProgress: onProgress)
284
+ flushEvents()
285
+ return result
286
+ }
287
+
288
+ /// Makes the downloaded update load on the next JavaScript start. It uses no network, so
289
+ /// an app can restart straight after it. Its event goes with the next check.
290
+ public func installUpdate() -> InstallResult {
291
+ syncLock.lock()
292
+ defer { syncLock.unlock() }
293
+ return installOnce()
294
+ }
295
+
296
+ /// The three steps together, for an app that does not need to ask the user in between.
297
+ /// Blocks until finished, so call it away from the main thread.
298
+ public func sync(onProgress: @escaping (Int64, Int64) -> Void = { _, _ in }) -> SyncResult {
299
+ syncLock.lock()
300
+ defer { syncLock.unlock() }
301
+ let result = syncOnce(onProgress: onProgress)
302
+ flushEvents()
303
+ return result
304
+ }
305
+
306
+ public func status() -> EngineStatus {
307
+ lock.lock()
308
+ defer { lock.unlock() }
309
+ return EngineStatus(
310
+ deviceId: state.deviceId,
311
+ currentReleaseId: (state.trying ?? state.current)?.id,
312
+ downloadedReleaseId: state.downloaded?.id,
313
+ pendingReleaseId: state.pending?.id,
314
+ tryingReleaseId: state.trying?.id,
315
+ highestSequence: state.highestSequence
316
+ )
317
+ }
318
+
319
+ private func syncOnce(onProgress: @escaping (Int64, Int64) -> Void) -> SyncResult {
320
+ let update: UpdateInfo
321
+ switch checkOnce() {
322
+ case .upToDate:
323
+ return .upToDate
324
+ case .rolledBack:
325
+ return .rolledBack
326
+ case .failed(let code, let message):
327
+ return .failed(code: code, message: message)
328
+ case .updateAvailable(let info, _):
329
+ update = info
330
+ }
331
+ if case .failed(let code, let message) = downloadOnce(onProgress: onProgress) {
332
+ return .failed(code: code, message: message)
333
+ }
334
+ switch installOnce() {
335
+ case .installed:
336
+ return .updateReady(releaseId: update.releaseId, mandatory: update.mandatory)
337
+ case .failed(let code, let message):
338
+ return .failed(code: code, message: message)
339
+ }
340
+ }
341
+
342
+ private func checkOnce() -> CheckResult {
343
+ offer = nil
344
+ offerManifest = nil
345
+ let request: CheckRequest = {
346
+ lock.lock()
347
+ defer { lock.unlock() }
348
+ return CheckRequest(
349
+ deploymentKey: config.deploymentKey,
350
+ deviceId: state.deviceId,
351
+ runtimeVersion: config.runtimeVersion,
352
+ binaryVersion: config.binaryVersion,
353
+ currentReleaseId: (state.trying ?? state.current)?.id,
354
+ highestSequence: state.highestSequence
355
+ )
356
+ }()
357
+
358
+ let answer: CheckAnswer
359
+ do {
360
+ answer = try service.check(request)
361
+ } catch {
362
+ return .failed(code: .downloadFailed, message: error.localizedDescription)
363
+ }
364
+
365
+ // First, so that an offer signed by a key revoked in this same answer is refused.
366
+ applyRevocations(answer.revocations)
367
+ switch answer.response {
368
+ case .none:
369
+ keepDownloadedOnlyIf(offered: nil)
370
+ return .upToDate
371
+ case .rollback:
372
+ keepDownloadedOnlyIf(offered: nil)
373
+ return rollback(reason: "DISABLED_ON_SERVER") ? .rolledBack : .upToDate
374
+ case .update(let offered):
375
+ return verifyOffer(offered, highestSequence: request.highestSequence)
376
+ }
377
+ }
378
+
379
+ /// Permanent, so a server compromised later cannot make this device trust the key again.
380
+ private func applyRevocations(_ revocations: [KeyRevocation]) {
381
+ let valid = revocations.compactMap {
382
+ verifier.verifyRevocation(keyId: $0.keyId, statement: $0.statement, signature: $0.signature)
383
+ }
384
+ if valid.isEmpty { return }
385
+ lock.lock()
386
+ defer { lock.unlock() }
387
+ let all = state.revokedPublicKeys.union(valid)
388
+ if all == state.revokedPublicKeys { return }
389
+ var next = state
390
+ next.revokedPublicKeys = all
391
+ // A downloaded release may have been signed with that key, and nothing checks it again.
392
+ next.downloaded = nil
393
+ save(next)
394
+ verifier.revokedPublicKeys = all
395
+ }
396
+
397
+ private func verifyOffer(_ offered: OfferedUpdate, highestSequence: Int64) -> CheckResult {
398
+ do {
399
+ let manifest = try verifier.verify(
400
+ manifest: offered.manifest,
401
+ signature: offered.signature,
402
+ keyId: offered.keyId,
403
+ highestSequence: highestSequence
404
+ )
405
+ offer = offered
406
+ offerManifest = manifest
407
+ keepDownloadedOnlyIf(offered: manifest.releaseId)
408
+ lock.lock()
409
+ defer { lock.unlock() }
410
+ return .updateAvailable(updateInfo(manifest), alreadyDownloaded: state.downloaded != nil)
411
+ } catch {
412
+ let rovqError = recordFailure(error)
413
+ return .failed(code: rovqError.code, message: rovqError.message)
414
+ }
415
+ }
416
+
417
+ /// The server offers a release until the device installs it, so an answer that does not
418
+ /// include the downloaded release means it was disabled or replaced, and must not be
419
+ /// installed. A failed check says nothing about it, so that does not get here.
420
+ private func keepDownloadedOnlyIf(offered releaseId: String?) {
421
+ lock.lock()
422
+ defer { lock.unlock() }
423
+ guard let downloaded = state.downloaded, downloaded.id != releaseId else { return }
424
+ var next = state
425
+ next.downloaded = nil
426
+ save(next)
427
+ }
428
+
429
+ private func downloadOnce(onProgress: @escaping (Int64, Int64) -> Void) -> DownloadResult {
430
+ guard let offered = offer else {
431
+ return .failed(code: .noUpdateAvailable, message: "No update to download. Check for updates first")
432
+ }
433
+ let releaseId = offerManifest?.releaseId
434
+ lock.lock()
435
+ let highestSequence = state.highestSequence
436
+ let postponed = state.postponed
437
+ lock.unlock()
438
+ // A clock moved back must not make the wait longer than the longest one allowed.
439
+ if let postponed, postponed.releaseId == releaseId {
440
+ let remaining = postponed.until - clock()
441
+ if remaining > 0 && remaining <= maxPostpone {
442
+ return .failed(
443
+ code: .downloadPostponed,
444
+ message: "This update failed its checks, so it will not be downloaded again yet")
445
+ }
446
+ }
447
+ do {
448
+ let installed = try installer.install(
449
+ offer: offered, highestSequence: highestSequence, onProgress: onProgress)
450
+ let manifest = installed.manifest
451
+ lock.lock()
452
+ defer { lock.unlock() }
453
+ var next = state
454
+ next.downloaded = ReleaseRef(id: manifest.releaseId, sequence: manifest.sequence)
455
+ next.postponed = nil
456
+ save(next)
457
+ return .downloaded(updateInfo(manifest))
458
+ } catch {
459
+ let rovqError = recordFailure(error)
460
+ if postponedCodes.contains(rovqError.code), let releaseId { postpone(releaseId) }
461
+ return .failed(code: rovqError.code, message: rovqError.message)
462
+ }
463
+ }
464
+
465
+ private func postpone(_ releaseId: String) {
466
+ lock.lock()
467
+ defer { lock.unlock() }
468
+ let attempts = state.postponed?.releaseId == releaseId ? (state.postponed?.attempts ?? 0) + 1 : 1
469
+ let wait = min(firstPostpone * pow(2, Double(attempts - 1)), maxPostpone)
470
+ var next = state
471
+ next.postponed = PostponedDownload(releaseId: releaseId, attempts: attempts, until: clock() + wait)
472
+ save(next)
473
+ }
474
+
475
+ private func installOnce() -> InstallResult {
476
+ lock.lock()
477
+ defer { lock.unlock() }
478
+ guard let downloaded = state.downloaded, bundleExists(downloaded) else {
479
+ if state.downloaded != nil {
480
+ var next = state
481
+ next.downloaded = nil
482
+ save(next)
483
+ }
484
+ return .failed(code: .noUpdateDownloaded, message: "No update has been downloaded")
485
+ }
486
+ offer = nil
487
+ offerManifest = nil
488
+ var next = state
489
+ next.downloaded = nil
490
+ next.pending = downloaded
491
+ // Recorded now, so a release that later fails is never accepted again.
492
+ next.highestSequence = max(state.highestSequence, downloaded.sequence)
493
+ save(queue(next, type: "INSTALLED", releaseId: downloaded.id, errorCode: nil))
494
+ return .installed(releaseId: downloaded.id)
495
+ }
496
+
497
+ @discardableResult
498
+ private func recordFailure(_ error: Error) -> RovqError {
499
+ let rovqError = error as? RovqError ?? RovqError(.storageFailed, error.localizedDescription)
500
+ let type = rovqError.code == .downloadFailed ? "DOWNLOAD_FAILED" : "VERIFICATION_FAILED"
501
+ lock.lock()
502
+ defer { lock.unlock() }
503
+ save(queue(state, type: type, releaseId: nil, errorCode: rovqError.code.rawValue))
504
+ return rovqError
505
+ }
506
+
507
+ private func updateInfo(_ manifest: VerifiedManifest) -> UpdateInfo {
508
+ UpdateInfo(
509
+ releaseId: manifest.releaseId,
510
+ sequence: manifest.sequence,
511
+ mandatory: manifest.mandatory,
512
+ packageSize: manifest.packageSize
513
+ )
514
+ }
515
+
516
+ /// Best effort. Events stay queued and go with a later sync if this fails.
517
+ private func flushEvents() {
518
+ lock.lock()
519
+ let batch = Array(state.events.prefix(eventBatchSize))
520
+ let deviceId = state.deviceId
521
+ lock.unlock()
522
+ if batch.isEmpty { return }
523
+
524
+ guard (try? service.report(deploymentKey: config.deploymentKey, deviceId: deviceId, events: batch)) != nil
525
+ else { return }
526
+
527
+ let sent = Set(batch.map(\.clientEventId))
528
+ lock.lock()
529
+ defer { lock.unlock() }
530
+ var next = state
531
+ next.events.removeAll { sent.contains($0.clientEventId) }
532
+ save(next)
533
+ }
534
+
535
+ private static func loadState(
536
+ store: EngineStateStore,
537
+ updatesFolder: URL,
538
+ config: EngineConfig
539
+ ) -> EngineState {
540
+ let saved = store.read()
541
+ var fresh = EngineState(
542
+ deviceId: saved?.deviceId ?? UUID().uuidString.lowercased(),
543
+ binaryId: config.binaryId,
544
+ runtimeVersion: config.runtimeVersion
545
+ )
546
+ guard let saved else {
547
+ // Without a readable state nothing on disk can be trusted, so start clean.
548
+ try? FileManager.default.removeItem(at: updatesFolder)
549
+ try? store.write(fresh)
550
+ return fresh
551
+ }
552
+ if saved.binaryId == config.binaryId && saved.runtimeVersion == config.runtimeVersion {
553
+ return saved
554
+ }
555
+ // A new app from the store is newer than any update for the old one, and the old
556
+ // bundles may not work with its native code. Sequences belong to a runtime version,
557
+ // so the count only carries over if that did not change.
558
+ try? FileManager.default.removeItem(at: updatesFolder)
559
+ fresh.highestSequence = saved.runtimeVersion == config.runtimeVersion ? saved.highestSequence : 0
560
+ fresh.events = saved.events
561
+ // Revocation is permanent, whatever the version of the app.
562
+ fresh.revokedPublicKeys = saved.revokedPublicKeys
563
+ try? store.write(fresh)
564
+ return fresh
565
+ }
566
+
567
+ /// Updates can be downloaded again, so they should not use the user's iCloud backup.
568
+ private static func excludeFromBackup(_ folder: URL) {
569
+ var url = folder
570
+ var values = URLResourceValues()
571
+ values.isExcludedFromBackup = true
572
+ try? url.setResourceValues(values)
573
+ }
574
+
575
+ private func bundleFile(_ release: ReleaseRef) -> URL {
576
+ updatesFolder
577
+ .appendingPathComponent(release.id, isDirectory: true)
578
+ .appendingPathComponent(bundleFileName)
579
+ }
580
+
581
+ private func bundleExists(_ release: ReleaseRef) -> Bool {
582
+ FileManager.default.fileExists(atPath: bundleFile(release).path)
583
+ }
584
+
585
+ private func removeUnusedReleases() {
586
+ let inUse = Set([state.current, state.previous, state.downloaded, state.pending, state.trying].compactMap { $0?.id })
587
+ let folders = (try? FileManager.default.contentsOfDirectory(at: updatesFolder, includingPropertiesForKeys: nil)) ?? []
588
+ for folder in folders where !inUse.contains(folder.lastPathComponent) {
589
+ try? FileManager.default.removeItem(at: folder)
590
+ }
591
+ }
592
+
593
+ private func queue(
594
+ _ base: EngineState,
595
+ type: String,
596
+ releaseId: String?,
597
+ errorCode: String?
598
+ ) -> EngineState {
599
+ var next = base
600
+ next.events.append(
601
+ QueuedEvent(
602
+ clientEventId: UUID().uuidString.lowercased(),
603
+ type: type,
604
+ releaseId: releaseId,
605
+ errorCode: errorCode,
606
+ occurredAt: RovqEngine.now()
607
+ ))
608
+ next.events = Array(next.events.suffix(maxQueuedEvents))
609
+ return next
610
+ }
611
+
612
+ /// A failed save must not stop the app, for example when the disk is full. In general the
613
+ /// state from before the change applies on the next start. Callers that depend on the
614
+ /// change surviving must check the result.
615
+ ///
616
+ /// - Returns: whether the change will survive a process death.
617
+ @discardableResult
618
+ private func save(_ next: EngineState) -> Bool {
619
+ state = next
620
+ do {
621
+ try store.write(next)
622
+ return true
623
+ } catch {
624
+ return false
625
+ }
626
+ }
627
+
628
+ private static func now() -> String {
629
+ let formatter = ISO8601DateFormatter()
630
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
631
+ return formatter.string(from: Date())
632
+ }
633
+ }
@@ -0,0 +1,35 @@
1
+ import Foundation
2
+
3
+ /// Why an update failed. The names match Android and are sent to the server as device
4
+ /// event error codes, except for the last four, which describe the app's use of the engine.
5
+ /// The React Native SDK has the same list, and a test keeps them equal.
6
+ public enum RovqErrorCode: String {
7
+ case unknownSigningKey = "UNKNOWN_SIGNING_KEY"
8
+ case revokedSigningKey = "REVOKED_SIGNING_KEY"
9
+ case invalidSignature = "INVALID_SIGNATURE"
10
+ case invalidManifest = "INVALID_MANIFEST"
11
+ case manifestMismatch = "MANIFEST_MISMATCH"
12
+ case staleSequence = "STALE_SEQUENCE"
13
+ case insecureURL = "INSECURE_URL"
14
+ case downloadFailed = "DOWNLOAD_FAILED"
15
+ case sizeMismatch = "SIZE_MISMATCH"
16
+ case hashMismatch = "HASH_MISMATCH"
17
+ case invalidPackage = "INVALID_PACKAGE"
18
+ case storageFailed = "STORAGE_FAILED"
19
+ case noUpdateAvailable = "NO_UPDATE_AVAILABLE"
20
+ case noUpdateDownloaded = "NO_UPDATE_DOWNLOADED"
21
+ case engineUnavailable = "ENGINE_UNAVAILABLE"
22
+ case downloadPostponed = "DOWNLOAD_POSTPONED"
23
+ }
24
+
25
+ public struct RovqError: Error, LocalizedError {
26
+ public let code: RovqErrorCode
27
+ public let message: String
28
+
29
+ public init(_ code: RovqErrorCode, _ message: String) {
30
+ self.code = code
31
+ self.message = message
32
+ }
33
+
34
+ public var errorDescription: String? { message }
35
+ }