@rovq/react-native 0.1.0 → 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.
@@ -7,7 +7,8 @@ private let requestTimeout: TimeInterval = 30
7
7
  /// hash is what makes the file trustworthy.
8
8
  public final class PackageDownloader {
9
9
  private let allowInsecureURLs: Bool
10
- private let freeSpace: (URL) -> Int64
10
+ /// Room left in a folder. Replaced in tests.
11
+ let freeSpace: (URL) -> Int64
11
12
 
12
13
  /// - Parameter allowInsecureURLs: allows http:// for local development only.
13
14
  public init(
@@ -29,15 +30,23 @@ public final class PackageDownloader {
29
30
  /// good only once the whole file matches the signed hash, so a partial or wrong file can
30
31
  /// never be mistaken for a good one.
31
32
  ///
32
- /// - Parameter onProgress: receives bytes received so far and the total.
33
+ /// - Parameters:
34
+ /// - hash: the signed SHA-256 of the whole file.
35
+ /// - size: the signed size of the whole file.
36
+ /// - extraSpace: room needed beyond the file itself, such as for extracting it.
37
+ /// - onProgress: receives bytes received so far and the total.
33
38
  func download(
34
39
  url: String,
35
- manifest: VerifiedManifest,
40
+ hash: String,
41
+ size: Int64,
36
42
  target: URL,
43
+ extraSpace: Int64 = 0,
37
44
  onProgress: @escaping (Int64, Int64) -> Void = { _, _ in }
38
45
  ) throws {
46
+ let expected = Expected(hash: hash, size: size)
39
47
  do {
40
- try downloadToFile(url: url, manifest: manifest, target: target, onProgress: onProgress)
48
+ try downloadToFile(
49
+ url: url, expected: expected, target: target, extraSpace: extraSpace, onProgress: onProgress)
41
50
  } catch {
42
51
  if (error as? RovqError)?.code != .downloadFailed {
43
52
  try? FileManager.default.removeItem(at: target)
@@ -46,10 +55,17 @@ public final class PackageDownloader {
46
55
  }
47
56
  }
48
57
 
58
+ /// The signed facts a download must end up matching.
59
+ private struct Expected {
60
+ let hash: String
61
+ let size: Int64
62
+ }
63
+
49
64
  private func downloadToFile(
50
65
  url: String,
51
- manifest: VerifiedManifest,
66
+ expected: Expected,
52
67
  target: URL,
68
+ extraSpace: Int64,
53
69
  onProgress: @escaping (Int64, Int64) -> Void
54
70
  ) throws {
55
71
  guard let parsed = URL(string: url), let scheme = parsed.scheme?.lowercased() else {
@@ -60,15 +76,17 @@ public final class PackageDownloader {
60
76
  }
61
77
 
62
78
  var digest = SHA256()
63
- let existing = try readExisting(target: target, manifest: manifest, digest: &digest)
64
- // Extracting needs room too, and its size is unknown until then, so this asks for the
65
- // package twice. A package can still expand further, and extraction reports that.
66
- let needed = manifest.packageSize - existing + manifest.packageSize
79
+ let existing = try readExisting(target: target, expected: expected, digest: &digest)
80
+ let needed = expected.size - existing + extraSpace
67
81
  if freeSpace(target.deletingLastPathComponent()) < needed {
68
82
  throw RovqError(.storageFailed, "Not enough free space for this update")
69
83
  }
70
- if existing == manifest.packageSize {
71
- try verifyHash(digest, manifest: manifest)
84
+ if existing == expected.size {
85
+ // An empty file has nothing to download, but must still exist.
86
+ if existing == 0, !FileManager.default.createFile(atPath: target.path, contents: nil) {
87
+ throw RovqError(.storageFailed, "Could not create the file")
88
+ }
89
+ try verifyHash(digest, expected: expected)
72
90
  return
73
91
  }
74
92
  if existing == 0, !FileManager.default.createFile(atPath: target.path, contents: nil) {
@@ -80,7 +98,7 @@ public final class PackageDownloader {
80
98
  defer { try? output.close() }
81
99
 
82
100
  let delegate = DownloadDelegate(
83
- expectedSize: manifest.packageSize,
101
+ expectedSize: expected.size,
84
102
  existing: existing,
85
103
  digest: digest,
86
104
  output: output,
@@ -106,19 +124,19 @@ public final class PackageDownloader {
106
124
  }
107
125
  // A lost connection can look like a normal end of file, so a short download counts as
108
126
  // a network failure, which is worth retrying.
109
- if delegate.received < manifest.packageSize {
127
+ if delegate.received < expected.size {
110
128
  throw RovqError(.downloadFailed, "The download ended early")
111
129
  }
112
- try verifyHash(delegate.digest, manifest: manifest)
130
+ try verifyHash(delegate.digest, expected: expected)
113
131
  }
114
132
 
115
133
  /// Returns how many bytes are already there, after adding them to `digest`.
116
- private func readExisting(target: URL, manifest: VerifiedManifest, digest: inout SHA256) throws -> Int64 {
134
+ private func readExisting(target: URL, expected: Expected, digest: inout SHA256) throws -> Int64 {
117
135
  let files = FileManager.default
118
136
  guard files.fileExists(atPath: target.path),
119
137
  let size = (try? files.attributesOfItem(atPath: target.path))?[.size] as? Int64
120
138
  else { return 0 }
121
- if size > manifest.packageSize {
139
+ if size > expected.size {
122
140
  try? files.removeItem(at: target)
123
141
  return 0
124
142
  }
@@ -130,9 +148,9 @@ public final class PackageDownloader {
130
148
  return size
131
149
  }
132
150
 
133
- private func verifyHash(_ digest: SHA256, manifest: VerifiedManifest) throws {
151
+ private func verifyHash(_ digest: SHA256, expected: Expected) throws {
134
152
  let hash = digest.finalize().map { String(format: "%02x", $0) }.joined()
135
- if hash != manifest.packageHash {
153
+ if hash != expected.hash {
136
154
  throw RovqError(.hashMismatch, "Download does not match the signed hash")
137
155
  }
138
156
  }
@@ -25,6 +25,9 @@ public struct EngineConfig {
25
25
  /// Key IDs and their Base64 public keys, built into the app.
26
26
  public let trustedKeys: [String: String]
27
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?
28
31
 
29
32
  public init(
30
33
  apiURL: String,
@@ -33,7 +36,8 @@ public struct EngineConfig {
33
36
  binaryVersion: String,
34
37
  binaryId: String,
35
38
  trustedKeys: [String: String],
36
- allowInsecureURLs: Bool = false
39
+ allowInsecureURLs: Bool = false,
40
+ builtInFolder: URL? = nil
37
41
  ) {
38
42
  self.apiURL = apiURL
39
43
  self.deploymentKey = deploymentKey
@@ -42,6 +46,7 @@ public struct EngineConfig {
42
46
  self.binaryId = binaryId
43
47
  self.trustedKeys = trustedKeys
44
48
  self.allowInsecureURLs = allowInsecureURLs
49
+ self.builtInFolder = builtInFolder
45
50
  }
46
51
  }
47
52
 
@@ -133,7 +138,8 @@ public final class RovqEngine {
133
138
  installer = UpdateInstaller(
134
139
  rootFolder: rootFolder,
135
140
  verifier: verifier,
136
- downloader: PackageDownloader(allowInsecureURLs: config.allowInsecureURLs)
141
+ downloader: PackageDownloader(allowInsecureURLs: config.allowInsecureURLs),
142
+ builtInFolder: config.builtInFolder
137
143
  )
138
144
  try? FileManager.default.createDirectory(at: rootFolder, withIntermediateDirectories: true)
139
145
  RovqEngine.excludeFromBackup(rootFolder)
@@ -445,8 +451,15 @@ public final class RovqEngine {
445
451
  }
446
452
  }
447
453
  do {
454
+ let deviceId = status().deviceId
448
455
  let installed = try installer.install(
449
- offer: offered, highestSequence: highestSequence, onProgress: onProgress)
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)
450
463
  let manifest = installed.manifest
451
464
  lock.lock()
452
465
  defer { lock.unlock() }
@@ -1,13 +1,19 @@
1
+ import CryptoKit
1
2
  import Foundation
2
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
+
3
8
  /// What the server offered. None of this is trusted until the manifest has been verified.
4
9
  public struct OfferedUpdate: Equatable {
5
10
  public let manifest: String
6
11
  public let signature: String
7
12
  public let keyId: String
8
- public let downloadURL: String
13
+ /// Only for a ZIP package. A release made of files is fetched file by file.
14
+ public let downloadURL: String?
9
15
 
10
- public init(manifest: String, signature: String, keyId: String, downloadURL: String) {
16
+ public init(manifest: String, signature: String, keyId: String, downloadURL: String?) {
11
17
  self.manifest = manifest
12
18
  self.signature = signature
13
19
  self.keyId = keyId
@@ -22,6 +28,9 @@ public struct InstalledRelease: Equatable {
22
28
  public let bundleFile: URL
23
29
  }
24
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
+
25
34
  /// Takes an offered update to a verified folder on disk, without activating it.
26
35
  ///
27
36
  /// Each release gets its own folder, which keeps the design the same as Android, and makes
@@ -31,25 +40,38 @@ public final class UpdateInstaller {
31
40
  private let workFolder: URL
32
41
  // Outside the work folder, because a partial download must survive a failure or a restart.
33
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?
34
45
  private let verifier: ManifestVerifier
35
46
  private let downloader: PackageDownloader
36
47
  private let extractor = SafePackageExtractor()
37
48
  private let lock = NSLock()
38
49
 
39
- /// - Parameter rootFolder: a folder only the engine uses, inside the app's private storage.
40
- public init(rootFolder: URL, verifier: ManifestVerifier, downloader: PackageDownloader) {
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
+ ) {
41
59
  updatesFolder = rootFolder.appendingPathComponent("updates")
42
60
  workFolder = rootFolder.appendingPathComponent("work")
43
61
  partialFolder = rootFolder.appendingPathComponent("partial")
62
+ self.builtInFolder = builtInFolder
44
63
  self.verifier = verifier
45
64
  self.downloader = downloader
46
65
  }
47
66
 
48
- /// - Parameter highestSequence: the highest sequence this device has accepted in this stream.
67
+ /// - Parameters:
68
+ /// - highestSequence: the highest sequence this device has accepted in this stream.
69
+ /// - fileLinks: download links for a release made of files.
49
70
  /// - Throws: `RovqError` if any step fails. Nothing is left behind in that case.
50
71
  public func install(
51
72
  offer: OfferedUpdate,
52
73
  highestSequence: Int64,
74
+ fileLinks: FileLinks = { _, _ in throw RovqError(.downloadFailed, "No way to fetch files") },
53
75
  onProgress: @escaping (Int64, Int64) -> Void = { _, _ in }
54
76
  ) throws -> InstalledRelease {
55
77
  lock.lock()
@@ -67,7 +89,13 @@ public final class UpdateInstaller {
67
89
  // Folders appear only through the atomic rename below, so one that exists is complete.
68
90
  var isFolder: ObjCBool = false
69
91
  if !FileManager.default.fileExists(atPath: folder.path, isDirectory: &isFolder) || !isFolder.boolValue {
70
- try installFiles(offer: offer, manifest: manifest, folder: folder, onProgress: onProgress)
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
+ }
71
99
  }
72
100
  return InstalledRelease(
73
101
  manifest: manifest,
@@ -76,51 +104,245 @@ public final class UpdateInstaller {
76
104
  )
77
105
  }
78
106
 
79
- private func installFiles(
80
- offer: OfferedUpdate,
81
- manifest: VerifiedManifest,
82
- folder: URL,
83
- onProgress: @escaping (Int64, Int64) -> Void
84
- ) throws {
107
+ /// Creates an empty work folder for assembling a release, and the folders around it.
108
+ private func startWork() throws -> URL {
85
109
  let files = FileManager.default
86
110
  // Removes anything an earlier run left when the app was killed or the phone lost power.
87
111
  try? files.removeItem(at: workFolder)
88
- let extracted = workFolder.appendingPathComponent("extracted", isDirectory: true)
112
+ let assembled = workFolder.appendingPathComponent("extracted", isDirectory: true)
89
113
  do {
90
- try files.createDirectory(at: extracted, withIntermediateDirectories: true)
114
+ try files.createDirectory(at: assembled, withIntermediateDirectories: true)
91
115
  try files.createDirectory(at: updatesFolder, withIntermediateDirectories: true)
92
116
  try files.createDirectory(at: partialFolder, withIntermediateDirectories: true)
93
117
  } catch {
94
118
  throw RovqError(.storageFailed, "Could not create the engine folders")
95
119
  }
96
- defer { try? files.removeItem(at: workFolder) }
120
+ return assembled
121
+ }
97
122
 
98
- let packageFile = partialFolder.appendingPathComponent("\(manifest.packageHash).zip")
99
- // Only the current offer is worth finishing.
123
+ /// Only the current offer is worth finishing.
124
+ private func removePartialFiles(except keep: Set<String>) {
125
+ let files = FileManager.default
100
126
  for file in (try? files.contentsOfDirectory(at: partialFolder, includingPropertiesForKeys: nil)) ?? []
101
- where file.lastPathComponent != packageFile.lastPathComponent {
127
+ where !keep.contains(file.lastPathComponent) {
102
128
  try? files.removeItem(at: file)
103
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])
104
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.
105
157
  try downloader.download(
106
- url: offer.downloadURL,
107
- manifest: manifest,
158
+ url: downloadURL,
159
+ hash: manifest.packageHash,
160
+ size: manifest.packageSize,
108
161
  target: packageFile,
162
+ extraSpace: manifest.packageSize,
109
163
  onProgress: onProgress
110
164
  )
111
165
  try extractor.extract(packageFile: packageFile, destination: extracted)
112
- do {
113
- try files.moveItem(at: extracted, to: folder)
114
- } catch {
115
- throw RovqError(.storageFailed, "Could not move the release into place")
116
- }
117
- try? files.removeItem(at: packageFile)
166
+ try moveIntoPlace(extracted, folder)
167
+ try? FileManager.default.removeItem(at: packageFile)
118
168
  } catch {
119
169
  // A file that failed anything but the network is not worth continuing from.
120
170
  if (error as? RovqError)?.code != .downloadFailed {
121
- try? files.removeItem(at: packageFile)
171
+ try? FileManager.default.removeItem(at: packageFile)
122
172
  }
123
173
  throw error
124
174
  }
125
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
+ }
126
348
  }
@@ -43,6 +43,13 @@ public protocol UpdateService {
43
43
 
44
44
  /// - Throws: if the events could not be delivered.
45
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]
46
53
  }
47
54
 
48
55
  public final class HTTPUpdateService: UpdateService {
@@ -62,6 +69,8 @@ public final class HTTPUpdateService: UpdateService {
62
69
  "runtimeVersion": request.runtimeVersion,
63
70
  "binaryVersion": request.binaryVersion,
64
71
  "highestSequence": request.highestSequence,
72
+ // Both a ZIP package and a list of files. See docs/file-packages.md.
73
+ "packageFormats": [1, 2],
65
74
  ]
66
75
  if let current = request.currentReleaseId { body["currentReleaseId"] = current }
67
76
 
@@ -73,12 +82,13 @@ public final class HTTPUpdateService: UpdateService {
73
82
  case "ROLLBACK":
74
83
  return CheckAnswer(response: .rollback, revocations: revocations)
75
84
  case "UPDATE":
85
+ // A release made of files has no single download URL.
76
86
  guard let update = response["update"] as? [String: Any],
77
87
  let manifest = update["manifest"] as? String,
78
88
  let signature = update["signature"] as? String,
79
- let keyId = update["keyId"] as? String,
80
- let downloadURL = update["downloadUrl"] as? String
89
+ let keyId = update["keyId"] as? String
81
90
  else { throw RovqError(.downloadFailed, "The server sent an unreadable response") }
91
+ let downloadURL = update["downloadUrl"] as? String
82
92
  return CheckAnswer(
83
93
  response: .update(
84
94
  OfferedUpdate(manifest: manifest, signature: signature, keyId: keyId, downloadURL: downloadURL)),
@@ -117,6 +127,23 @@ public final class HTTPUpdateService: UpdateService {
117
127
  )
118
128
  }
119
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
+
120
147
  /// Blocks until finished, so call it away from the main thread.
121
148
  private func post(_ path: String, body: [String: Any]) throws -> [String: Any] {
122
149
  let base = apiURL.hasSuffix("/") ? String(apiURL.dropLast()) : apiURL
@@ -54,7 +54,9 @@ public final class RovqHost: NSObject {
54
54
  binaryVersion: info["CFBundleShortVersionString"] as? String ?? "0",
55
55
  binaryId: binaryId(info),
56
56
  trustedKeys: config.trustedKeys,
57
- allowInsecureURLs: config.allowInsecureURLs
57
+ allowInsecureURLs: config.allowInsecureURLs,
58
+ // react-native bundle puts assets at the same paths a package uses.
59
+ builtInFolder: Bundle.main.bundleURL
58
60
  )
59
61
  )
60
62
  watchForBackground()
@@ -69,7 +69,9 @@ RCT_EXPORT_MODULE(Rovq)
69
69
 
70
70
  - (void)restartApp
71
71
  {
72
- RCTExecuteOnMainQueue(^{
72
+ // Always later, never at once: RCTExecuteOnMainQueue runs immediately when already on the main
73
+ // thread, which reloads JavaScript while it is still inside this call.
74
+ dispatch_async(dispatch_get_main_queue(), ^{
73
75
  RCTTriggerReloadCommandListeners(@"ROVQ restart");
74
76
  });
75
77
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rovq/react-native",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Over-the-air updates for React Native, with signed releases and automatic rollback",
5
5
  "keywords": [
6
6
  "react-native",
@@ -34,11 +34,15 @@
34
34
  "LICENSE"
35
35
  ],
36
36
  "peerDependencies": {
37
+ "react": "*",
37
38
  "react-native": ">=0.82"
38
39
  },
39
40
  "peerDependenciesMeta": {
40
41
  "react-native": {
41
42
  "optional": true
43
+ },
44
+ "react": {
45
+ "optional": true
42
46
  }
43
47
  },
44
48
  "codegenConfig": {