@rovq/react-native 0.1.0 → 0.2.1
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/README.md +47 -1
- package/android/src/main/java/com/rovq/engine/ManifestVerifier.kt +67 -10
- package/android/src/main/java/com/rovq/engine/PackageDownloader.kt +32 -21
- package/android/src/main/java/com/rovq/engine/RovqEngine.kt +8 -1
- package/android/src/main/java/com/rovq/engine/UpdateInstaller.kt +267 -14
- package/android/src/main/java/com/rovq/engine/UpdateService.kt +37 -1
- package/android/src/react/java/com/rovq/engine/react/ResourceFiles.kt +52 -0
- package/android/src/react/java/com/rovq/engine/react/Rovq.kt +1 -0
- package/ios/Engine/ManifestVerifier.swift +94 -12
- package/ios/Engine/PackageDownloader.swift +36 -18
- package/ios/Engine/RovqEngine.swift +16 -3
- package/ios/Engine/UpdateInstaller.swift +249 -27
- package/ios/Engine/UpdateService.swift +29 -2
- package/ios/React/RovqHost.swift +3 -1
- package/ios/React/RovqModule.mm +3 -1
- package/package.json +6 -2
- package/src/api.ts +228 -0
- package/src/index.ts +10 -228
- package/src/react.tsx +158 -0
- package/src/update-flow.ts +91 -0
|
@@ -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
|
-
|
|
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
|
-
/// -
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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,
|
|
64
|
-
|
|
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 ==
|
|
71
|
-
|
|
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:
|
|
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 <
|
|
127
|
+
if delegate.received < expected.size {
|
|
110
128
|
throw RovqError(.downloadFailed, "The download ended early")
|
|
111
129
|
}
|
|
112
|
-
try verifyHash(delegate.digest,
|
|
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,
|
|
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 >
|
|
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,
|
|
151
|
+
private func verifyHash(_ digest: SHA256, expected: Expected) throws {
|
|
134
152
|
let hash = digest.finalize().map { String(format: "%02x", $0) }.joined()
|
|
135
|
-
if hash !=
|
|
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,
|
|
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
|
-
|
|
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
|
-
/// -
|
|
40
|
-
|
|
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
|
-
/// -
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
|
112
|
+
let assembled = workFolder.appendingPathComponent("extracted", isDirectory: true)
|
|
89
113
|
do {
|
|
90
|
-
try files.createDirectory(at:
|
|
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
|
-
|
|
120
|
+
return assembled
|
|
121
|
+
}
|
|
97
122
|
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
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:
|
|
107
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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?
|
|
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
|
package/ios/React/RovqHost.swift
CHANGED
|
@@ -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()
|
package/ios/React/RovqModule.mm
CHANGED
|
@@ -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
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Over-the-air updates for React Native, with signed releases and automatic rollback",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react-native",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
],
|
|
14
14
|
"license": "MIT",
|
|
15
15
|
"author": "moatazMahdi",
|
|
16
|
-
"homepage": "https://
|
|
16
|
+
"homepage": "https://rovq-dashboard.onrender.com",
|
|
17
17
|
"bugs": "https://github.com/moatazMahdi/rovq/issues",
|
|
18
18
|
"repository": {
|
|
19
19
|
"type": "git",
|
|
@@ -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": {
|