@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.
Files changed (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +76 -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 +173 -0
  8. package/android/src/main/java/com/rovq/engine/PackageDownloader.kt +188 -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 +543 -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 +94 -0
  16. package/android/src/main/java/com/rovq/engine/UpdateService.kt +139 -0
  17. package/android/src/main/java/com/rovq/engine/ZipStructureCheck.kt +194 -0
  18. package/android/src/react/java/com/rovq/engine/react/Rovq.kt +101 -0
  19. package/android/src/react/java/com/rovq/engine/react/RovqModule.kt +164 -0
  20. package/android/src/react/java/com/rovq/engine/react/RovqPackage.kt +26 -0
  21. package/android/src/react/java/com/rovq/engine/react/RovqReactHost.kt +74 -0
  22. package/ios/Engine/EngineState.swift +119 -0
  23. package/ios/Engine/ManifestVerifier.swift +197 -0
  24. package/ios/Engine/PackageDownloader.swift +231 -0
  25. package/ios/Engine/PackageRules.swift +38 -0
  26. package/ios/Engine/ProgressThrottle.swift +20 -0
  27. package/ios/Engine/RovqEngine.swift +633 -0
  28. package/ios/Engine/RovqError.swift +35 -0
  29. package/ios/Engine/SafePackageExtractor.swift +107 -0
  30. package/ios/Engine/UpdateInstaller.swift +126 -0
  31. package/ios/Engine/UpdateService.swift +158 -0
  32. package/ios/Engine/ZipReader.swift +378 -0
  33. package/ios/React/RovqHost.swift +253 -0
  34. package/ios/React/RovqModule.mm +89 -0
  35. package/package.json +74 -3
  36. package/react-native.config.js +11 -0
  37. package/src/NativeRovq.ts +72 -0
  38. package/src/errors.ts +44 -0
  39. 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
+ }