@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.
- package/LICENSE +21 -0
- package/README.md +76 -2
- package/Rovq.podspec +19 -0
- package/android/build.gradle +86 -0
- package/android/src/main/AndroidManifest.xml +4 -0
- package/android/src/main/java/com/rovq/engine/EngineState.kt +176 -0
- package/android/src/main/java/com/rovq/engine/ManifestVerifier.kt +173 -0
- package/android/src/main/java/com/rovq/engine/PackageDownloader.kt +188 -0
- package/android/src/main/java/com/rovq/engine/PackageRules.kt +39 -0
- package/android/src/main/java/com/rovq/engine/ProgressThrottle.kt +17 -0
- package/android/src/main/java/com/rovq/engine/RovqEngine.kt +543 -0
- package/android/src/main/java/com/rovq/engine/RovqException.kt +31 -0
- package/android/src/main/java/com/rovq/engine/SafePackageExtractor.kt +121 -0
- package/android/src/main/java/com/rovq/engine/StrictBase64.kt +37 -0
- package/android/src/main/java/com/rovq/engine/UpdateInstaller.kt +94 -0
- package/android/src/main/java/com/rovq/engine/UpdateService.kt +139 -0
- package/android/src/main/java/com/rovq/engine/ZipStructureCheck.kt +194 -0
- package/android/src/react/java/com/rovq/engine/react/Rovq.kt +101 -0
- package/android/src/react/java/com/rovq/engine/react/RovqModule.kt +164 -0
- package/android/src/react/java/com/rovq/engine/react/RovqPackage.kt +26 -0
- package/android/src/react/java/com/rovq/engine/react/RovqReactHost.kt +74 -0
- package/ios/Engine/EngineState.swift +119 -0
- package/ios/Engine/ManifestVerifier.swift +197 -0
- package/ios/Engine/PackageDownloader.swift +231 -0
- package/ios/Engine/PackageRules.swift +38 -0
- package/ios/Engine/ProgressThrottle.swift +20 -0
- package/ios/Engine/RovqEngine.swift +633 -0
- package/ios/Engine/RovqError.swift +35 -0
- package/ios/Engine/SafePackageExtractor.swift +107 -0
- package/ios/Engine/UpdateInstaller.swift +126 -0
- package/ios/Engine/UpdateService.swift +158 -0
- package/ios/Engine/ZipReader.swift +378 -0
- package/ios/React/RovqHost.swift +253 -0
- package/ios/React/RovqModule.mm +89 -0
- package/package.json +74 -3
- package/react-native.config.js +11 -0
- package/src/NativeRovq.ts +72 -0
- package/src/errors.ts +44 -0
- 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
|
+
}
|