@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,231 @@
|
|
|
1
|
+
import CryptoKit
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
private let requestTimeout: TimeInterval = 30
|
|
5
|
+
|
|
6
|
+
/// Downloads a package and proves it is the signed one. HTTPS protects the transfer, but the
|
|
7
|
+
/// hash is what makes the file trustworthy.
|
|
8
|
+
public final class PackageDownloader {
|
|
9
|
+
private let allowInsecureURLs: Bool
|
|
10
|
+
private let freeSpace: (URL) -> Int64
|
|
11
|
+
|
|
12
|
+
/// - Parameter allowInsecureURLs: allows http:// for local development only.
|
|
13
|
+
public init(
|
|
14
|
+
allowInsecureURLs: Bool = false,
|
|
15
|
+
freeSpace: @escaping (URL) -> Int64 = PackageDownloader.availableSpace
|
|
16
|
+
) {
|
|
17
|
+
self.allowInsecureURLs = allowInsecureURLs
|
|
18
|
+
self.freeSpace = freeSpace
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
public static func availableSpace(in folder: URL) -> Int64 {
|
|
22
|
+
let values = try? folder.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey])
|
|
23
|
+
return values?.volumeAvailableCapacityForImportantUsage ?? Int64.max
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/// Blocks until finished, so call it away from the main thread. Continues from what `target`
|
|
27
|
+
/// already holds, because on a poor connection a large file might otherwise never finish. A
|
|
28
|
+
/// network failure keeps the file for that reason. Any other failure deletes it. It counts as
|
|
29
|
+
/// good only once the whole file matches the signed hash, so a partial or wrong file can
|
|
30
|
+
/// never be mistaken for a good one.
|
|
31
|
+
///
|
|
32
|
+
/// - Parameter onProgress: receives bytes received so far and the total.
|
|
33
|
+
func download(
|
|
34
|
+
url: String,
|
|
35
|
+
manifest: VerifiedManifest,
|
|
36
|
+
target: URL,
|
|
37
|
+
onProgress: @escaping (Int64, Int64) -> Void = { _, _ in }
|
|
38
|
+
) throws {
|
|
39
|
+
do {
|
|
40
|
+
try downloadToFile(url: url, manifest: manifest, target: target, onProgress: onProgress)
|
|
41
|
+
} catch {
|
|
42
|
+
if (error as? RovqError)?.code != .downloadFailed {
|
|
43
|
+
try? FileManager.default.removeItem(at: target)
|
|
44
|
+
}
|
|
45
|
+
throw error
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private func downloadToFile(
|
|
50
|
+
url: String,
|
|
51
|
+
manifest: VerifiedManifest,
|
|
52
|
+
target: URL,
|
|
53
|
+
onProgress: @escaping (Int64, Int64) -> Void
|
|
54
|
+
) throws {
|
|
55
|
+
guard let parsed = URL(string: url), let scheme = parsed.scheme?.lowercased() else {
|
|
56
|
+
throw RovqError(.downloadFailed, "Malformed download URL")
|
|
57
|
+
}
|
|
58
|
+
if scheme != "https" && !allowInsecureURLs {
|
|
59
|
+
throw RovqError(.insecureURL, "Download URL must use HTTPS")
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
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
|
|
67
|
+
if freeSpace(target.deletingLastPathComponent()) < needed {
|
|
68
|
+
throw RovqError(.storageFailed, "Not enough free space for this update")
|
|
69
|
+
}
|
|
70
|
+
if existing == manifest.packageSize {
|
|
71
|
+
try verifyHash(digest, manifest: manifest)
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
if existing == 0, !FileManager.default.createFile(atPath: target.path, contents: nil) {
|
|
75
|
+
throw RovqError(.storageFailed, "Could not create the file")
|
|
76
|
+
}
|
|
77
|
+
guard let output = try? FileHandle(forWritingTo: target) else {
|
|
78
|
+
throw RovqError(.storageFailed, "Could not create the file")
|
|
79
|
+
}
|
|
80
|
+
defer { try? output.close() }
|
|
81
|
+
|
|
82
|
+
let delegate = DownloadDelegate(
|
|
83
|
+
expectedSize: manifest.packageSize,
|
|
84
|
+
existing: existing,
|
|
85
|
+
digest: digest,
|
|
86
|
+
output: output,
|
|
87
|
+
onProgress: onProgress
|
|
88
|
+
)
|
|
89
|
+
var request = URLRequest(url: parsed)
|
|
90
|
+
if existing > 0 { request.setValue("bytes=\(existing)-", forHTTPHeaderField: "Range") }
|
|
91
|
+
let configuration = URLSessionConfiguration.ephemeral
|
|
92
|
+
configuration.timeoutIntervalForRequest = requestTimeout
|
|
93
|
+
let session = URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil)
|
|
94
|
+
session.dataTask(with: request).resume()
|
|
95
|
+
delegate.finished.wait()
|
|
96
|
+
session.finishTasksAndInvalidate()
|
|
97
|
+
|
|
98
|
+
if delegate.discardFile { try? FileManager.default.removeItem(at: target) }
|
|
99
|
+
if let failure = delegate.failure { throw failure }
|
|
100
|
+
|
|
101
|
+
do {
|
|
102
|
+
// Forces the bytes to disk, so power loss cannot leave a file that looks complete.
|
|
103
|
+
try output.synchronize()
|
|
104
|
+
} catch {
|
|
105
|
+
throw RovqError(.storageFailed, "Could not write the file")
|
|
106
|
+
}
|
|
107
|
+
// A lost connection can look like a normal end of file, so a short download counts as
|
|
108
|
+
// a network failure, which is worth retrying.
|
|
109
|
+
if delegate.received < manifest.packageSize {
|
|
110
|
+
throw RovqError(.downloadFailed, "The download ended early")
|
|
111
|
+
}
|
|
112
|
+
try verifyHash(delegate.digest, manifest: manifest)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/// 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 {
|
|
117
|
+
let files = FileManager.default
|
|
118
|
+
guard files.fileExists(atPath: target.path),
|
|
119
|
+
let size = (try? files.attributesOfItem(atPath: target.path))?[.size] as? Int64
|
|
120
|
+
else { return 0 }
|
|
121
|
+
if size > manifest.packageSize {
|
|
122
|
+
try? files.removeItem(at: target)
|
|
123
|
+
return 0
|
|
124
|
+
}
|
|
125
|
+
guard let input = try? FileHandle(forReadingFrom: target) else { return 0 }
|
|
126
|
+
defer { try? input.close() }
|
|
127
|
+
while let chunk = try? input.read(upToCount: 64 * 1024), !chunk.isEmpty {
|
|
128
|
+
digest.update(data: chunk)
|
|
129
|
+
}
|
|
130
|
+
return size
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private func verifyHash(_ digest: SHA256, manifest: VerifiedManifest) throws {
|
|
134
|
+
let hash = digest.finalize().map { String(format: "%02x", $0) }.joined()
|
|
135
|
+
if hash != manifest.packageHash {
|
|
136
|
+
throw RovqError(.hashMismatch, "Download does not match the signed hash")
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/// Handles the bytes as they arrive, which allows stopping as soon as something is wrong.
|
|
142
|
+
private final class DownloadDelegate: NSObject, URLSessionDataDelegate {
|
|
143
|
+
let finished = DispatchSemaphore(value: 0)
|
|
144
|
+
private(set) var failure: RovqError?
|
|
145
|
+
private(set) var received: Int64
|
|
146
|
+
private(set) var digest: SHA256
|
|
147
|
+
/// True when appending to the existing file could only produce a wrong file.
|
|
148
|
+
private(set) var discardFile = false
|
|
149
|
+
private let existing: Int64
|
|
150
|
+
private let expectedSize: Int64
|
|
151
|
+
private let output: FileHandle
|
|
152
|
+
private let onProgress: (Int64, Int64) -> Void
|
|
153
|
+
|
|
154
|
+
init(
|
|
155
|
+
expectedSize: Int64,
|
|
156
|
+
existing: Int64,
|
|
157
|
+
digest: SHA256,
|
|
158
|
+
output: FileHandle,
|
|
159
|
+
onProgress: @escaping (Int64, Int64) -> Void
|
|
160
|
+
) {
|
|
161
|
+
self.expectedSize = expectedSize
|
|
162
|
+
self.existing = existing
|
|
163
|
+
self.received = existing
|
|
164
|
+
self.digest = digest
|
|
165
|
+
self.output = output
|
|
166
|
+
self.onProgress = onProgress
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
func urlSession(
|
|
170
|
+
_ session: URLSession,
|
|
171
|
+
dataTask: URLSessionDataTask,
|
|
172
|
+
didReceive response: URLResponse,
|
|
173
|
+
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
|
|
174
|
+
) {
|
|
175
|
+
let http = response as? HTTPURLResponse
|
|
176
|
+
let status = http?.statusCode ?? 0
|
|
177
|
+
do {
|
|
178
|
+
if existing > 0 && status == 206 {
|
|
179
|
+
let range = http?.value(forHTTPHeaderField: "Content-Range") ?? ""
|
|
180
|
+
guard range.hasPrefix("bytes \(existing)-") else {
|
|
181
|
+
discardFile = true
|
|
182
|
+
failure = RovqError(.downloadFailed, "The server continued from the wrong place")
|
|
183
|
+
completionHandler(.cancel)
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
try output.seekToEnd()
|
|
187
|
+
completionHandler(.allow)
|
|
188
|
+
} else if status == 200 {
|
|
189
|
+
// The server sent the whole file, whether or not a part was asked for.
|
|
190
|
+
digest = SHA256()
|
|
191
|
+
received = 0
|
|
192
|
+
try output.truncate(atOffset: 0)
|
|
193
|
+
completionHandler(.allow)
|
|
194
|
+
} else {
|
|
195
|
+
if status == 416 { discardFile = true }
|
|
196
|
+
failure = RovqError(.downloadFailed, "Server answered \(status)")
|
|
197
|
+
completionHandler(.cancel)
|
|
198
|
+
}
|
|
199
|
+
} catch {
|
|
200
|
+
failure = RovqError(.storageFailed, "Could not write the file")
|
|
201
|
+
completionHandler(.cancel)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
|
|
206
|
+
guard failure == nil else { return }
|
|
207
|
+
received += Int64(data.count)
|
|
208
|
+
// Stops at once, so a wrong server cannot fill the disk.
|
|
209
|
+
if received > expectedSize {
|
|
210
|
+
failure = RovqError(.sizeMismatch, "Download is larger than the signed size")
|
|
211
|
+
dataTask.cancel()
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
digest.update(data: data)
|
|
215
|
+
do {
|
|
216
|
+
try output.write(contentsOf: data)
|
|
217
|
+
} catch {
|
|
218
|
+
failure = RovqError(.storageFailed, "Could not write the file")
|
|
219
|
+
dataTask.cancel()
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
onProgress(received, expectedSize)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
|
226
|
+
if failure == nil, let error {
|
|
227
|
+
failure = RovqError(.downloadFailed, "Connection lost: \(error.localizedDescription)")
|
|
228
|
+
}
|
|
229
|
+
finished.signal()
|
|
230
|
+
}
|
|
231
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// The same rules as packages/package-format/src/format.ts. The file
|
|
4
|
+
/// packages/package-format/test/path-rules.json is checked against both, so a change on one
|
|
5
|
+
/// side fails the tests until the other side matches.
|
|
6
|
+
enum PackageRules {
|
|
7
|
+
static let bundleFileName = "main.jsbundle"
|
|
8
|
+
static let maxEntries = 10_000
|
|
9
|
+
static let maxTotalSize: Int64 = 2 * 1024 * 1024 * 1024
|
|
10
|
+
private static let maxPathLength = 512
|
|
11
|
+
|
|
12
|
+
/// Returns why a path is not allowed, or nil if it is fine.
|
|
13
|
+
static func findPathProblem(_ path: String) -> String? {
|
|
14
|
+
// Counted in UTF-16 units, as JavaScript does.
|
|
15
|
+
if path.isEmpty || path.utf16.count > maxPathLength { return "has an invalid length" }
|
|
16
|
+
if path.unicodeScalars.contains(where: { $0.value <= 0x1f || $0.value == 0x7f }) {
|
|
17
|
+
return "contains a control character"
|
|
18
|
+
}
|
|
19
|
+
if path.contains("\\") { return "contains a backslash" }
|
|
20
|
+
if path.hasPrefix("/") || path.range(of: "^[A-Za-z]:", options: .regularExpression) != nil {
|
|
21
|
+
return "is an absolute path"
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let segments = path.components(separatedBy: "/")
|
|
25
|
+
if segments.contains(where: { $0.isEmpty || $0 == "." || $0 == ".." }) {
|
|
26
|
+
return "contains an empty, \".\" or \"..\" segment"
|
|
27
|
+
}
|
|
28
|
+
if path == bundleFileName { return nil }
|
|
29
|
+
if segments.count < 2 { return "is not the bundle file" }
|
|
30
|
+
return segments[0] == "assets" ? nil : "is outside the assets folder"
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/// The same name in another letter case or Unicode form is one file on some file systems.
|
|
34
|
+
/// The default iOS file system is one of them.
|
|
35
|
+
static func pathIdentity(_ path: String) -> String {
|
|
36
|
+
path.precomposedStringWithCanonicalMapping.lowercased()
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Passes on progress only when the whole percentage changes. A download reports every chunk,
|
|
4
|
+
/// which is thousands of times for a large file, and each one sent to JavaScript has a cost.
|
|
5
|
+
public final class ProgressThrottle {
|
|
6
|
+
private let onChange: (Int64, Int64) -> Void
|
|
7
|
+
private var lastPercent: Int64 = -1
|
|
8
|
+
|
|
9
|
+
public init(onChange: @escaping (Int64, Int64) -> Void) {
|
|
10
|
+
self.onChange = onChange
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
public func report(received: Int64, total: Int64) {
|
|
14
|
+
let percent = received * 100 / total
|
|
15
|
+
if percent != lastPercent {
|
|
16
|
+
lastPercent = percent
|
|
17
|
+
onChange(received, total)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|