@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,107 @@
1
+ import Foundation
2
+
3
+ /// Extracts a package without trusting it, even though its signature was already verified.
4
+ /// If a signing key ever leaks, this keeps an attacker inside the package folder, so the
5
+ /// files of the app itself stay out of reach.
6
+ struct SafePackageExtractor {
7
+ /// - Parameter destination: an empty folder that only this extraction uses.
8
+ func extract(packageFile: URL, destination: URL) throws {
9
+ do {
10
+ let reader = try ZipReader(url: packageFile)
11
+ try extractEntries(reader, destination: destination)
12
+ } catch let error as ZipError {
13
+ throw invalid(error.message)
14
+ }
15
+ }
16
+
17
+ private func extractEntries(_ reader: ZipReader, destination: URL) throws {
18
+ let root = destination.resolvingSymlinksInPath().standardizedFileURL.path + "/"
19
+ var seen = Set<String>()
20
+ var hasBundle = false
21
+ var totalSize: Int64 = 0
22
+
23
+ if reader.entries.count > PackageRules.maxEntries { throw invalid("Too many files") }
24
+
25
+ for entry in reader.entries {
26
+ if entry.isFolder { throw invalid("Folder entries are not allowed") }
27
+ if let problem = PackageRules.findPathProblem(entry.name) {
28
+ throw invalid("The file \(entry.name) \(problem)")
29
+ }
30
+ if !seen.insert(PackageRules.pathIdentity(entry.name)).inserted {
31
+ throw invalid("The file \(entry.name) appears more than once")
32
+ }
33
+
34
+ let target = destination.appendingPathComponent(entry.name)
35
+ // A second, independent check: whatever the rules allow, the real path must stay
36
+ // inside the folder.
37
+ let parent = target.deletingLastPathComponent()
38
+ try createFolder(parent)
39
+ let resolved = parent.resolvingSymlinksInPath().standardizedFileURL.path + "/"
40
+ if !resolved.hasPrefix(root) { throw invalid("The file \(entry.name) leaves the folder") }
41
+
42
+ let written = try write(
43
+ entry,
44
+ from: reader,
45
+ to: target,
46
+ allowance: PackageRules.maxTotalSize - totalSize
47
+ )
48
+ totalSize += written
49
+ if entry.name == PackageRules.bundleFileName {
50
+ if written == 0 { throw invalid("The bundle file is empty") }
51
+ hasBundle = true
52
+ }
53
+ }
54
+ if !hasBundle { throw invalid("The bundle file is missing") }
55
+ }
56
+
57
+ private func write(
58
+ _ entry: ZipEntry,
59
+ from reader: ZipReader,
60
+ to target: URL,
61
+ allowance: Int64
62
+ ) throws -> Int64 {
63
+ guard FileManager.default.createFile(atPath: target.path, contents: nil) else {
64
+ throw RovqError(.storageFailed, "Could not create \(entry.name)")
65
+ }
66
+ let output: FileHandle
67
+ do {
68
+ output = try FileHandle(forWritingTo: target)
69
+ } catch {
70
+ throw RovqError(.storageFailed, "Could not create \(entry.name)")
71
+ }
72
+ defer { try? output.close() }
73
+
74
+ let written: Int64
75
+ do {
76
+ written = try reader.extract(entry, allowance: allowance) { chunk in
77
+ do {
78
+ try output.write(contentsOf: chunk)
79
+ } catch {
80
+ throw RovqError(.storageFailed, "Could not write \(entry.name)")
81
+ }
82
+ }
83
+ // Forces the bytes to disk, because the release becomes active by renaming its
84
+ // folder, and that rename must not outrun the data.
85
+ try output.synchronize()
86
+ } catch let error as RovqError {
87
+ throw error
88
+ } catch let error as ZipError {
89
+ throw error
90
+ } catch {
91
+ throw RovqError(.storageFailed, "Could not write \(entry.name)")
92
+ }
93
+ return written
94
+ }
95
+
96
+ private func createFolder(_ url: URL) throws {
97
+ do {
98
+ try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
99
+ } catch {
100
+ throw RovqError(.storageFailed, "Could not create \(url.lastPathComponent)")
101
+ }
102
+ }
103
+
104
+ private func invalid(_ message: String) -> RovqError {
105
+ RovqError(.invalidPackage, message)
106
+ }
107
+ }
@@ -0,0 +1,348 @@
1
+ import CryptoKit
2
+ import Foundation
3
+
4
+ // Download links last an hour, so they are fetched a few files at a time, just before use.
5
+ private let linkBatchSize = 20
6
+ private let emptyFileHash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
7
+
8
+ /// What the server offered. None of this is trusted until the manifest has been verified.
9
+ public struct OfferedUpdate: Equatable {
10
+ public let manifest: String
11
+ public let signature: String
12
+ public let keyId: String
13
+ /// Only for a ZIP package. A release made of files is fetched file by file.
14
+ public let downloadURL: String?
15
+
16
+ public init(manifest: String, signature: String, keyId: String, downloadURL: String?) {
17
+ self.manifest = manifest
18
+ self.signature = signature
19
+ self.keyId = keyId
20
+ self.downloadURL = downloadURL
21
+ }
22
+ }
23
+
24
+ /// A verified release, fully extracted in its own folder.
25
+ public struct InstalledRelease: Equatable {
26
+ public let manifest: VerifiedManifest
27
+ public let folder: URL
28
+ public let bundleFile: URL
29
+ }
30
+
31
+ /// Asks the server for download links, by hash, for files of one release.
32
+ public typealias FileLinks = (_ releaseId: String, _ hashes: [String]) throws -> [String: String]
33
+
34
+ /// Takes an offered update to a verified folder on disk, without activating it.
35
+ ///
36
+ /// Each release gets its own folder, which keeps the design the same as Android, and makes
37
+ /// the final rename what decides that a release is complete.
38
+ public final class UpdateInstaller {
39
+ private let updatesFolder: URL
40
+ private let workFolder: URL
41
+ // Outside the work folder, because a partial download must survive a failure or a restart.
42
+ private let partialFolder: URL
43
+ /// The app's own files, where assets sit at the same paths as in a package.
44
+ private let builtInFolder: URL?
45
+ private let verifier: ManifestVerifier
46
+ private let downloader: PackageDownloader
47
+ private let extractor = SafePackageExtractor()
48
+ private let lock = NSLock()
49
+
50
+ /// - Parameters:
51
+ /// - rootFolder: a folder only the engine uses, inside the app's private storage.
52
+ /// - builtInFolder: the app bundle, whose files can be reused instead of downloaded.
53
+ public init(
54
+ rootFolder: URL,
55
+ verifier: ManifestVerifier,
56
+ downloader: PackageDownloader,
57
+ builtInFolder: URL? = nil
58
+ ) {
59
+ updatesFolder = rootFolder.appendingPathComponent("updates")
60
+ workFolder = rootFolder.appendingPathComponent("work")
61
+ partialFolder = rootFolder.appendingPathComponent("partial")
62
+ self.builtInFolder = builtInFolder
63
+ self.verifier = verifier
64
+ self.downloader = downloader
65
+ }
66
+
67
+ /// - Parameters:
68
+ /// - highestSequence: the highest sequence this device has accepted in this stream.
69
+ /// - fileLinks: download links for a release made of files.
70
+ /// - Throws: `RovqError` if any step fails. Nothing is left behind in that case.
71
+ public func install(
72
+ offer: OfferedUpdate,
73
+ highestSequence: Int64,
74
+ fileLinks: FileLinks = { _, _ in throw RovqError(.downloadFailed, "No way to fetch files") },
75
+ onProgress: @escaping (Int64, Int64) -> Void = { _, _ in }
76
+ ) throws -> InstalledRelease {
77
+ lock.lock()
78
+ defer { lock.unlock() }
79
+
80
+ // Verified first, so an unsigned update causes no network traffic or disk use.
81
+ let manifest = try verifier.verify(
82
+ manifest: offer.manifest,
83
+ signature: offer.signature,
84
+ keyId: offer.keyId,
85
+ highestSequence: highestSequence
86
+ )
87
+
88
+ let folder = updatesFolder.appendingPathComponent(manifest.releaseId, isDirectory: true)
89
+ // Folders appear only through the atomic rename below, so one that exists is complete.
90
+ var isFolder: ObjCBool = false
91
+ if !FileManager.default.fileExists(atPath: folder.path, isDirectory: &isFolder) || !isFolder.boolValue {
92
+ if let files = manifest.files {
93
+ try installFileList(
94
+ files, releaseId: manifest.releaseId, folder: folder, fileLinks: fileLinks,
95
+ onProgress: onProgress)
96
+ } else {
97
+ try installZip(offer: offer, manifest: manifest, folder: folder, onProgress: onProgress)
98
+ }
99
+ }
100
+ return InstalledRelease(
101
+ manifest: manifest,
102
+ folder: folder,
103
+ bundleFile: folder.appendingPathComponent(PackageRules.bundleFileName)
104
+ )
105
+ }
106
+
107
+ /// Creates an empty work folder for assembling a release, and the folders around it.
108
+ private func startWork() throws -> URL {
109
+ let files = FileManager.default
110
+ // Removes anything an earlier run left when the app was killed or the phone lost power.
111
+ try? files.removeItem(at: workFolder)
112
+ let assembled = workFolder.appendingPathComponent("extracted", isDirectory: true)
113
+ do {
114
+ try files.createDirectory(at: assembled, withIntermediateDirectories: true)
115
+ try files.createDirectory(at: updatesFolder, withIntermediateDirectories: true)
116
+ try files.createDirectory(at: partialFolder, withIntermediateDirectories: true)
117
+ } catch {
118
+ throw RovqError(.storageFailed, "Could not create the engine folders")
119
+ }
120
+ return assembled
121
+ }
122
+
123
+ /// Only the current offer is worth finishing.
124
+ private func removePartialFiles(except keep: Set<String>) {
125
+ let files = FileManager.default
126
+ for file in (try? files.contentsOfDirectory(at: partialFolder, includingPropertiesForKeys: nil)) ?? []
127
+ where !keep.contains(file.lastPathComponent) {
128
+ try? files.removeItem(at: file)
129
+ }
130
+ }
131
+
132
+ private func moveIntoPlace(_ assembled: URL, _ folder: URL) throws {
133
+ do {
134
+ try FileManager.default.moveItem(at: assembled, to: folder)
135
+ } catch {
136
+ throw RovqError(.storageFailed, "Could not move the release into place")
137
+ }
138
+ }
139
+
140
+ private func installZip(
141
+ offer: OfferedUpdate,
142
+ manifest: VerifiedManifest,
143
+ folder: URL,
144
+ onProgress: @escaping (Int64, Int64) -> Void
145
+ ) throws {
146
+ guard let downloadURL = offer.downloadURL else {
147
+ throw RovqError(.downloadFailed, "The server sent no download URL")
148
+ }
149
+ let extracted = try startWork()
150
+ defer { try? FileManager.default.removeItem(at: workFolder) }
151
+
152
+ let packageFile = partialFolder.appendingPathComponent("\(manifest.packageHash).zip")
153
+ removePartialFiles(except: [packageFile.lastPathComponent])
154
+ do {
155
+ // Extracting needs room too, and its size is unknown until then, so this asks for
156
+ // the package twice. A package can still expand further, and extraction reports that.
157
+ try downloader.download(
158
+ url: downloadURL,
159
+ hash: manifest.packageHash,
160
+ size: manifest.packageSize,
161
+ target: packageFile,
162
+ extraSpace: manifest.packageSize,
163
+ onProgress: onProgress
164
+ )
165
+ try extractor.extract(packageFile: packageFile, destination: extracted)
166
+ try moveIntoPlace(extracted, folder)
167
+ try? FileManager.default.removeItem(at: packageFile)
168
+ } catch {
169
+ // A file that failed anything but the network is not worth continuing from.
170
+ if (error as? RovqError)?.code != .downloadFailed {
171
+ try? FileManager.default.removeItem(at: packageFile)
172
+ }
173
+ throw error
174
+ }
175
+ }
176
+
177
+ /// Builds a release from its files: each comes from a kept release, from the app itself,
178
+ /// or from the network, in that order, and only after its content matches the signed hash.
179
+ /// See docs/file-packages.md.
180
+ private func installFileList(
181
+ _ list: [ManifestFile],
182
+ releaseId: String,
183
+ folder: URL,
184
+ fileLinks: FileLinks,
185
+ onProgress: @escaping (Int64, Int64) -> Void
186
+ ) throws {
187
+ let files = FileManager.default
188
+ let assembled = try startWork()
189
+ defer { try? files.removeItem(at: workFolder) }
190
+ removePartialFiles(except: Set(list.map(\.hash)))
191
+ let root = assembled.resolvingSymlinksInPath().standardizedFileURL.path + "/"
192
+
193
+ // A hash already placed, so the same content at another path is linked, not fetched.
194
+ var placed: [String: URL] = [:]
195
+ // Files with no local copy at their own path. Another path may still provide them.
196
+ var unresolved: [(file: ManifestFile, target: URL)] = []
197
+
198
+ for file in list {
199
+ let target = assembled.appendingPathComponent(file.path)
200
+ let parent = target.deletingLastPathComponent()
201
+ do {
202
+ try files.createDirectory(at: parent, withIntermediateDirectories: true)
203
+ } catch {
204
+ throw RovqError(.storageFailed, "Could not create a folder")
205
+ }
206
+ // A second, independent check: whatever the rules allow, the real path must stay
207
+ // inside the folder.
208
+ let resolved = parent.resolvingSymlinksInPath().standardizedFileURL.path + "/"
209
+ if !resolved.hasPrefix(root) {
210
+ throw RovqError(.invalidPackage, "The file \(file.path) leaves the folder")
211
+ }
212
+
213
+ if let source = placed[file.hash] {
214
+ try link(source, to: target)
215
+ } else if file.size == 0 {
216
+ guard file.hash == emptyFileHash else {
217
+ throw RovqError(.hashMismatch, "An empty file does not match its signed hash")
218
+ }
219
+ guard files.createFile(atPath: target.path, contents: nil) else {
220
+ throw RovqError(.storageFailed, "Could not create the file")
221
+ }
222
+ placed[file.hash] = target
223
+ } else if let local = findLocalCopy(of: file, excluding: folder) {
224
+ if local.isOwn {
225
+ try link(local.url, to: target)
226
+ } else {
227
+ // On APFS a copy shares the blocks until either side changes, so it is free.
228
+ try copy(local.url, to: target)
229
+ }
230
+ placed[file.hash] = target
231
+ } else {
232
+ unresolved.append((file, target))
233
+ }
234
+ }
235
+
236
+ // Files to download, by hash, with every path that needs each one, in manifest order.
237
+ var targets: [String: [URL]] = [:]
238
+ var missing: [ManifestFile] = []
239
+ for (file, target) in unresolved {
240
+ if let source = placed[file.hash] {
241
+ try link(source, to: target)
242
+ } else if targets[file.hash] != nil {
243
+ targets[file.hash]!.append(target)
244
+ } else {
245
+ targets[file.hash] = [target]
246
+ missing.append(file)
247
+ }
248
+ }
249
+
250
+ try download(missing, targets: targets, releaseId: releaseId, fileLinks: fileLinks, onProgress: onProgress)
251
+
252
+ try moveIntoPlace(assembled, folder)
253
+ // Linked into the release, so the content stays where it is needed.
254
+ for file in missing {
255
+ try? files.removeItem(at: partialFolder.appendingPathComponent(file.hash))
256
+ }
257
+ }
258
+
259
+ private func download(
260
+ _ missing: [ManifestFile],
261
+ targets: [String: [URL]],
262
+ releaseId: String,
263
+ fileLinks: FileLinks,
264
+ onProgress: @escaping (Int64, Int64) -> Void
265
+ ) throws {
266
+ if missing.isEmpty { return }
267
+ let total = missing.reduce(Int64(0)) { $0 + $1.size }
268
+ // Checked for the whole release first, so a full disk fails before any download.
269
+ var remaining: Int64 = 0
270
+ for file in missing {
271
+ let partial = partialFolder.appendingPathComponent(file.hash)
272
+ remaining += file.size - min(fileSize(partial) ?? 0, file.size)
273
+ }
274
+ if downloader.freeSpace(partialFolder) < remaining {
275
+ throw RovqError(.storageFailed, "Not enough free space for this update")
276
+ }
277
+
278
+ var finished: Int64 = 0
279
+ onProgress(0, total)
280
+ for start in stride(from: 0, to: missing.count, by: linkBatchSize) {
281
+ let batch = Array(missing[start..<min(start + linkBatchSize, missing.count)])
282
+ let links = try fileLinks(releaseId, batch.map(\.hash))
283
+ for file in batch {
284
+ guard let url = links[file.hash] else {
285
+ throw RovqError(.downloadFailed, "The server sent no link for a file")
286
+ }
287
+ let partial = partialFolder.appendingPathComponent(file.hash)
288
+ let before = finished
289
+ try downloader.download(url: url, hash: file.hash, size: file.size, target: partial) {
290
+ received, _ in onProgress(before + received, total)
291
+ }
292
+ finished += file.size
293
+ for target in targets[file.hash] ?? [] { try link(partial, to: target) }
294
+ }
295
+ }
296
+ }
297
+
298
+ /// The same path in a kept release, or in the app itself, if its content is the signed one.
299
+ /// Hashed every time, because reusing a damaged file would break the new release.
300
+ private func findLocalCopy(of file: ManifestFile, excluding folder: URL) -> (url: URL, isOwn: Bool)? {
301
+ let releases =
302
+ (try? FileManager.default.contentsOfDirectory(at: updatesFolder, includingPropertiesForKeys: nil)) ?? []
303
+ var candidates = releases
304
+ .filter { $0.lastPathComponent != folder.lastPathComponent }
305
+ .map { ($0.appendingPathComponent(file.path), true) }
306
+ if let builtIn = builtInFolder {
307
+ candidates.append((builtIn.appendingPathComponent(file.path), false))
308
+ }
309
+ for (url, isOwn) in candidates where fileSize(url) == file.size && hashOf(url) == file.hash {
310
+ return (url, isOwn)
311
+ }
312
+ return nil
313
+ }
314
+
315
+ private func fileSize(_ url: URL) -> Int64? {
316
+ guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
317
+ attributes[.type] as? FileAttributeType == .typeRegular
318
+ else { return nil }
319
+ return (attributes[.size] as? NSNumber)?.int64Value
320
+ }
321
+
322
+ private func hashOf(_ url: URL) -> String? {
323
+ guard let input = try? FileHandle(forReadingFrom: url) else { return nil }
324
+ defer { try? input.close() }
325
+ var digest = SHA256()
326
+ while let chunk = try? input.read(upToCount: 1024 * 1024), !chunk.isEmpty {
327
+ digest.update(data: chunk)
328
+ }
329
+ return digest.finalize().map { String(format: "%02x", $0) }.joined()
330
+ }
331
+
332
+ /// A hard link costs no space. Files in a release never change, so sharing them is safe.
333
+ private func link(_ source: URL, to target: URL) throws {
334
+ do {
335
+ try FileManager.default.linkItem(at: source, to: target)
336
+ } catch {
337
+ try copy(source, to: target)
338
+ }
339
+ }
340
+
341
+ private func copy(_ source: URL, to target: URL) throws {
342
+ do {
343
+ try FileManager.default.copyItem(at: source, to: target)
344
+ } catch {
345
+ throw RovqError(.storageFailed, "Could not place a file")
346
+ }
347
+ }
348
+ }
@@ -0,0 +1,185 @@
1
+ import Foundation
2
+
3
+ private let requestTimeout: TimeInterval = 30
4
+
5
+ /// What the device tells the server when asking for updates.
6
+ public struct CheckRequest: Equatable {
7
+ public let deploymentKey: String
8
+ public let deviceId: String
9
+ public let runtimeVersion: String
10
+ public let binaryVersion: String
11
+ public let currentReleaseId: String?
12
+ public let highestSequence: Int64
13
+ }
14
+
15
+ /// The server's answer. It is advice only, because `OfferedUpdate` is verified before use.
16
+ public enum CheckResponse: Equatable {
17
+ case none
18
+ case rollback
19
+ case update(OfferedUpdate)
20
+ }
21
+
22
+ /// A key's signed statement that it must no longer be trusted. The engine verifies it.
23
+ public struct KeyRevocation: Equatable {
24
+ public let keyId: String
25
+ public let statement: String
26
+ public let signature: String
27
+ }
28
+
29
+ public struct CheckAnswer: Equatable {
30
+ public let response: CheckResponse
31
+ public let revocations: [KeyRevocation]
32
+
33
+ public init(response: CheckResponse, revocations: [KeyRevocation] = []) {
34
+ self.response = response
35
+ self.revocations = revocations
36
+ }
37
+ }
38
+
39
+ /// The server API the engine needs, kept separate so tests can replace it.
40
+ public protocol UpdateService {
41
+ /// - Throws: if the server cannot be reached or answers with an error.
42
+ func check(_ request: CheckRequest) throws -> CheckAnswer
43
+
44
+ /// - Throws: if the events could not be delivered.
45
+ func report(deploymentKey: String, deviceId: String, events: [QueuedEvent]) throws
46
+
47
+ /// Download links for files of a release made of files, by hash. Each file is checked
48
+ /// against the signed manifest, so the links are trusted for nothing.
49
+ ///
50
+ /// - Throws: if the server cannot be reached or answers with an error.
51
+ func fileLinks(deploymentKey: String, deviceId: String, releaseId: String, hashes: [String]) throws
52
+ -> [String: String]
53
+ }
54
+
55
+ public final class HTTPUpdateService: UpdateService {
56
+ private let apiURL: String
57
+ private let allowInsecureURLs: Bool
58
+
59
+ public init(apiURL: String, allowInsecureURLs: Bool = false) {
60
+ self.apiURL = apiURL
61
+ self.allowInsecureURLs = allowInsecureURLs
62
+ }
63
+
64
+ public func check(_ request: CheckRequest) throws -> CheckAnswer {
65
+ var body: [String: Any] = [
66
+ "deploymentKey": request.deploymentKey,
67
+ "deviceId": request.deviceId,
68
+ "platform": "IOS",
69
+ "runtimeVersion": request.runtimeVersion,
70
+ "binaryVersion": request.binaryVersion,
71
+ "highestSequence": request.highestSequence,
72
+ // Both a ZIP package and a list of files. See docs/file-packages.md.
73
+ "packageFormats": [1, 2],
74
+ ]
75
+ if let current = request.currentReleaseId { body["currentReleaseId"] = current }
76
+
77
+ let response = try post("/device/updates/check", body: body)
78
+ let revocations = readRevocations(response)
79
+ switch response["action"] as? String {
80
+ case "NONE":
81
+ return CheckAnswer(response: .none, revocations: revocations)
82
+ case "ROLLBACK":
83
+ return CheckAnswer(response: .rollback, revocations: revocations)
84
+ case "UPDATE":
85
+ // A release made of files has no single download URL.
86
+ guard let update = response["update"] as? [String: Any],
87
+ let manifest = update["manifest"] as? String,
88
+ let signature = update["signature"] as? String,
89
+ let keyId = update["keyId"] as? String
90
+ else { throw RovqError(.downloadFailed, "The server sent an unreadable response") }
91
+ let downloadURL = update["downloadUrl"] as? String
92
+ return CheckAnswer(
93
+ response: .update(
94
+ OfferedUpdate(manifest: manifest, signature: signature, keyId: keyId, downloadURL: downloadURL)),
95
+ revocations: revocations
96
+ )
97
+ default:
98
+ throw RovqError(.downloadFailed, "The server sent an unknown action")
99
+ }
100
+ }
101
+
102
+ private func readRevocations(_ response: [String: Any]) -> [KeyRevocation] {
103
+ let list = response["revokedKeys"] as? [[String: Any]] ?? []
104
+ return list.compactMap { item in
105
+ guard let keyId = item["keyId"] as? String,
106
+ let statement = item["statement"] as? String,
107
+ let signature = item["signature"] as? String
108
+ else { return nil }
109
+ return KeyRevocation(keyId: keyId, statement: statement, signature: signature)
110
+ }
111
+ }
112
+
113
+ public func report(deploymentKey: String, deviceId: String, events: [QueuedEvent]) throws {
114
+ let list: [[String: Any]] = events.map { event in
115
+ var json: [String: Any] = [
116
+ "clientEventId": event.clientEventId,
117
+ "type": event.type,
118
+ "occurredAt": event.occurredAt,
119
+ ]
120
+ if let releaseId = event.releaseId { json["releaseId"] = releaseId }
121
+ if let errorCode = event.errorCode { json["errorCode"] = errorCode }
122
+ return json
123
+ }
124
+ _ = try post(
125
+ "/device/events",
126
+ body: ["deploymentKey": deploymentKey, "deviceId": deviceId, "events": list]
127
+ )
128
+ }
129
+
130
+ public func fileLinks(deploymentKey: String, deviceId: String, releaseId: String, hashes: [String]) throws
131
+ -> [String: String]
132
+ {
133
+ let response = try post(
134
+ "/device/updates/files",
135
+ body: ["deploymentKey": deploymentKey, "deviceId": deviceId, "releaseId": releaseId, "hashes": hashes]
136
+ )
137
+ guard let files = response["files"] as? [[String: Any]] else {
138
+ throw RovqError(.downloadFailed, "The server sent an unreadable response")
139
+ }
140
+ var links: [String: String] = [:]
141
+ for file in files {
142
+ if let hash = file["hash"] as? String, let url = file["url"] as? String { links[hash] = url }
143
+ }
144
+ return links
145
+ }
146
+
147
+ /// Blocks until finished, so call it away from the main thread.
148
+ private func post(_ path: String, body: [String: Any]) throws -> [String: Any] {
149
+ let base = apiURL.hasSuffix("/") ? String(apiURL.dropLast()) : apiURL
150
+ guard let url = URL(string: base + path), let scheme = url.scheme?.lowercased() else {
151
+ throw RovqError(.downloadFailed, "The API URL is not valid")
152
+ }
153
+ if scheme != "https" && !allowInsecureURLs {
154
+ throw RovqError(.insecureURL, "The API URL must use HTTPS")
155
+ }
156
+
157
+ var request = URLRequest(url: url, timeoutInterval: requestTimeout)
158
+ request.httpMethod = "POST"
159
+ request.setValue("application/json", forHTTPHeaderField: "content-type")
160
+ request.httpBody = try JSONSerialization.data(withJSONObject: body)
161
+
162
+ let finished = DispatchSemaphore(value: 0)
163
+ var answer: Data?
164
+ var status = 0
165
+ var failure: Error?
166
+ let session = URLSession(configuration: .ephemeral)
167
+ session.dataTask(with: request) { data, response, error in
168
+ answer = data
169
+ status = (response as? HTTPURLResponse)?.statusCode ?? 0
170
+ failure = error
171
+ finished.signal()
172
+ }.resume()
173
+ finished.wait()
174
+ session.finishTasksAndInvalidate()
175
+
176
+ if let failure {
177
+ throw RovqError(.downloadFailed, failure.localizedDescription)
178
+ }
179
+ guard status == 200 else { throw RovqError(.downloadFailed, "The server answered \(status)") }
180
+ guard let answer, let json = try? JSONSerialization.jsonObject(with: answer) as? [String: Any] else {
181
+ throw RovqError(.downloadFailed, "The server sent an unreadable response")
182
+ }
183
+ return json
184
+ }
185
+ }