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