@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,378 @@
1
+ import Compression
2
+ import Foundation
3
+
4
+ private let endOfDirectorySignature: UInt32 = 0x0605_4b50
5
+ private let centralHeaderSignature: UInt32 = 0x0201_4b50
6
+ private let localHeaderSignature: UInt32 = 0x0403_4b50
7
+ private let endOfDirectorySize = 22
8
+ private let centralHeaderSize = 46
9
+ private let localHeaderSize = 30
10
+ private let maxCommentSize = 65_535
11
+ private let flagEncrypted: UInt16 = 0x1
12
+ private let flagUTF8Name: UInt16 = 0x800
13
+ private let methodStore: UInt16 = 0
14
+ private let methodDeflate: UInt16 = 8
15
+ private let unicodePathExtraField: UInt16 = 0x7075
16
+ private let fileTypeMask: UInt32 = 0o170000
17
+ private let regularFile: UInt32 = 0o100000
18
+ private let bufferSize = 64 * 1024
19
+
20
+ /// Thrown for any malformed or disallowed ZIP file.
21
+ struct ZipError: Error {
22
+ let message: String
23
+ }
24
+
25
+ struct ZipEntry {
26
+ let name: String
27
+ let method: UInt16
28
+ let crc32: UInt32
29
+ let compressedSize: Int64
30
+ let uncompressedSize: Int64
31
+ let localHeaderOffset: Int64
32
+ let isFolder: Bool
33
+ }
34
+
35
+ /// iOS has no public ZIP API. This reader is small and strict: it reads names from the
36
+ /// central directory, the same source the server checked, and refuses anything ambiguous.
37
+ /// ZIP64 is not needed, because the size limit of a package is below 4 GB.
38
+ final class ZipReader {
39
+ private let handle: FileHandle
40
+ private let fileSize: Int64
41
+ let entries: [ZipEntry]
42
+
43
+ init(url: URL) throws {
44
+ do {
45
+ handle = try FileHandle(forReadingFrom: url)
46
+ fileSize = Int64(try handle.seekToEnd())
47
+ } catch {
48
+ throw ZipError(message: "Could not open the file")
49
+ }
50
+ entries = try ZipReader.readDirectory(handle: handle, fileSize: fileSize)
51
+ try ZipReader.checkNoOverlap(handle: handle, fileSize: fileSize, entries: entries)
52
+ }
53
+
54
+ deinit {
55
+ try? handle.close()
56
+ }
57
+
58
+ /// Writes one entry to `output`, counting real bytes.
59
+ ///
60
+ /// - Parameter allowance: the most bytes this entry may produce.
61
+ /// - Returns: the number of bytes written.
62
+ /// - Throws: `ZipError` for a problem in the file, or the underlying error from `write`.
63
+ func extract(_ entry: ZipEntry, allowance: Int64, write: (Data) throws -> Void) throws -> Int64 {
64
+ let start = try ZipReader.dataOffset(handle: handle, fileSize: fileSize, entry: entry)
65
+ try handle.seek(toOffset: UInt64(start))
66
+
67
+ var crc = CRC32()
68
+ var written: Int64 = 0
69
+ let emit: (Data) throws -> Void = { chunk in
70
+ written += Int64(chunk.count)
71
+ if written > allowance { throw ZipError(message: "The package is too large when decompressed") }
72
+ if written > entry.uncompressedSize {
73
+ throw ZipError(message: "The file \(entry.name) lies about its size")
74
+ }
75
+ crc.update(chunk)
76
+ try write(chunk)
77
+ }
78
+
79
+ switch entry.method {
80
+ case methodStore:
81
+ try copyStored(entry: entry, emit: emit)
82
+ case methodDeflate:
83
+ try inflate(entry: entry, emit: emit)
84
+ default:
85
+ throw ZipError(message: "The file \(entry.name) uses an unsupported compression method")
86
+ }
87
+
88
+ if written != entry.uncompressedSize {
89
+ throw ZipError(message: "The file \(entry.name) lies about its size")
90
+ }
91
+ if crc.value != entry.crc32 {
92
+ throw ZipError(message: "The file \(entry.name) is corrupted")
93
+ }
94
+ return written
95
+ }
96
+
97
+ private func readChunk(remaining: Int64) throws -> Data {
98
+ let wanted = min(remaining, Int64(bufferSize))
99
+ guard let chunk = try handle.read(upToCount: Int(wanted)), !chunk.isEmpty else {
100
+ throw ZipError(message: "The file ended early")
101
+ }
102
+ return chunk
103
+ }
104
+
105
+ private func copyStored(entry: ZipEntry, emit: (Data) throws -> Void) throws {
106
+ if entry.compressedSize != entry.uncompressedSize {
107
+ throw ZipError(message: "The file \(entry.name) lies about its size")
108
+ }
109
+ var remaining = entry.compressedSize
110
+ while remaining > 0 {
111
+ let chunk = try readChunk(remaining: remaining)
112
+ remaining -= Int64(chunk.count)
113
+ try emit(chunk)
114
+ }
115
+ }
116
+
117
+ private func inflate(entry: ZipEntry, emit: (Data) throws -> Void) throws {
118
+ let stream = UnsafeMutablePointer<compression_stream>.allocate(capacity: 1)
119
+ defer { stream.deallocate() }
120
+ // COMPRESSION_ZLIB is raw DEFLATE, which is what ZIP stores.
121
+ guard compression_stream_init(stream, COMPRESSION_STREAM_DECODE, COMPRESSION_ZLIB)
122
+ == COMPRESSION_STATUS_OK
123
+ else { throw ZipError(message: "Could not start decompressing") }
124
+ defer { compression_stream_destroy(stream) }
125
+
126
+ let input = UnsafeMutablePointer<UInt8>.allocate(capacity: bufferSize)
127
+ defer { input.deallocate() }
128
+ let output = UnsafeMutablePointer<UInt8>.allocate(capacity: bufferSize)
129
+ defer { output.deallocate() }
130
+
131
+ var remaining = entry.compressedSize
132
+ stream.pointee.src_ptr = UnsafePointer(input)
133
+ stream.pointee.src_size = 0
134
+ stream.pointee.dst_ptr = output
135
+ stream.pointee.dst_size = bufferSize
136
+
137
+ while true {
138
+ if stream.pointee.src_size == 0 && remaining > 0 {
139
+ let chunk = try readChunk(remaining: remaining)
140
+ chunk.copyBytes(to: input, count: chunk.count)
141
+ remaining -= Int64(chunk.count)
142
+ stream.pointee.src_ptr = UnsafePointer(input)
143
+ stream.pointee.src_size = chunk.count
144
+ }
145
+
146
+ let flags = remaining == 0 ? Int32(COMPRESSION_STREAM_FINALIZE.rawValue) : 0
147
+ let status = compression_stream_process(stream, flags)
148
+
149
+ let produced = bufferSize - stream.pointee.dst_size
150
+ if produced > 0 {
151
+ try emit(Data(bytes: output, count: produced))
152
+ stream.pointee.dst_ptr = output
153
+ stream.pointee.dst_size = bufferSize
154
+ }
155
+
156
+ switch status {
157
+ case COMPRESSION_STATUS_END:
158
+ if remaining != 0 || stream.pointee.src_size != 0 {
159
+ throw ZipError(message: "The file \(entry.name) has extra data")
160
+ }
161
+ return
162
+ case COMPRESSION_STATUS_OK:
163
+ if remaining == 0 && stream.pointee.src_size == 0 && produced == 0 {
164
+ // All input was consumed and nothing came out, so it can never finish.
165
+ throw ZipError(message: "The file \(entry.name) is truncated")
166
+ }
167
+ default:
168
+ throw ZipError(message: "The file \(entry.name) could not be decompressed")
169
+ }
170
+ }
171
+ }
172
+
173
+ // MARK: Structure
174
+
175
+ private static func read(_ handle: FileHandle, at offset: Int64, count: Int) throws -> Data {
176
+ try handle.seek(toOffset: UInt64(offset))
177
+ guard let data = try handle.read(upToCount: count), data.count == count else {
178
+ throw ZipError(message: "The file ended early")
179
+ }
180
+ return data
181
+ }
182
+
183
+ private static func readDirectory(handle: FileHandle, fileSize: Int64) throws -> [ZipEntry] {
184
+ let end = try findEndOfDirectory(handle: handle, fileSize: fileSize)
185
+ guard end.uint16(4) == 0, end.uint16(6) == 0, end.uint16(8) == end.uint16(10) else {
186
+ throw ZipError(message: "Split ZIP files are not supported")
187
+ }
188
+ let count = Int(end.uint16(10))
189
+ let directorySize = Int64(end.uint32(12))
190
+ let directoryOffset = Int64(end.uint32(16))
191
+ if end.uint16(10) == 0xffff || end.uint32(12) == 0xffff_ffff || end.uint32(16) == 0xffff_ffff {
192
+ throw ZipError(message: "ZIP64 files are not supported")
193
+ }
194
+ if directoryOffset + directorySize > fileSize {
195
+ throw ZipError(message: "The directory is outside the file")
196
+ }
197
+ if directorySize > 64 * 1024 * 1024 {
198
+ throw ZipError(message: "The directory is too large")
199
+ }
200
+
201
+ let directory = ByteReader(
202
+ try read(handle, at: directoryOffset, count: Int(directorySize))
203
+ )
204
+ var entries: [ZipEntry] = []
205
+ entries.reserveCapacity(min(count, PackageRules.maxEntries))
206
+ var position = 0
207
+ for _ in 0..<count {
208
+ guard position + centralHeaderSize <= directory.count,
209
+ directory.uint32(position) == centralHeaderSignature
210
+ else { throw ZipError(message: "The directory is malformed") }
211
+
212
+ let flags = directory.uint16(position + 8)
213
+ let method = directory.uint16(position + 10)
214
+ let nameLength = Int(directory.uint16(position + 28))
215
+ let extraLength = Int(directory.uint16(position + 30))
216
+ let commentLength = Int(directory.uint16(position + 32))
217
+ let attributes = directory.uint32(position + 38)
218
+ let total = centralHeaderSize + nameLength + extraLength + commentLength
219
+ guard position + total <= directory.count else {
220
+ throw ZipError(message: "The directory is malformed")
221
+ }
222
+
223
+ if flags & flagEncrypted != 0 { throw ZipError(message: "A file is encrypted") }
224
+
225
+ let nameBytes = directory.data(position + centralHeaderSize, nameLength)
226
+ let name = try decodeName(nameBytes, flags: flags)
227
+ try checkExtraFields(directory.data(position + centralHeaderSize + nameLength, extraLength))
228
+
229
+ // Symbolic links could point outside the package folder after extraction.
230
+ let fileType = (attributes >> 16) & fileTypeMask
231
+ let isFolderType = fileType == 0o040000
232
+ if fileType != 0 && fileType != regularFile && !isFolderType {
233
+ throw ZipError(message: "The file \(name) is not a regular file")
234
+ }
235
+
236
+ entries.append(
237
+ ZipEntry(
238
+ name: name,
239
+ method: method,
240
+ crc32: directory.uint32(position + 16),
241
+ compressedSize: Int64(directory.uint32(position + 20)),
242
+ uncompressedSize: Int64(directory.uint32(position + 24)),
243
+ localHeaderOffset: Int64(directory.uint32(position + 42)),
244
+ isFolder: name.hasSuffix("/") || isFolderType
245
+ )
246
+ )
247
+ position += total
248
+ }
249
+ guard position == directory.count else {
250
+ throw ZipError(message: "The directory has extra data")
251
+ }
252
+ return entries
253
+ }
254
+
255
+ /// Rejects names that different ZIP readers would decode differently. Otherwise the
256
+ /// server could approve one path while this device writes to another.
257
+ private static func decodeName(_ bytes: Data, flags: UInt16) throws -> String {
258
+ if flags & flagUTF8Name == 0, bytes.contains(where: { $0 >= 0x7f || $0 < 0x20 }) {
259
+ throw ZipError(message: "A file name is not ASCII and is not marked as UTF-8")
260
+ }
261
+ guard let name = String(data: bytes, encoding: .utf8) else {
262
+ throw ZipError(message: "A file name is not valid UTF-8")
263
+ }
264
+ return name
265
+ }
266
+
267
+ private static func checkExtraFields(_ extra: Data) throws {
268
+ let reader = ByteReader(extra)
269
+ var position = 0
270
+ while position + 4 <= reader.count {
271
+ if reader.uint16(position) == unicodePathExtraField {
272
+ throw ZipError(message: "A file has an alternative name, which is not allowed")
273
+ }
274
+ position += 4 + Int(reader.uint16(position + 2))
275
+ }
276
+ }
277
+
278
+ private static func findEndOfDirectory(handle: FileHandle, fileSize: Int64) throws -> ByteReader {
279
+ if fileSize < Int64(endOfDirectorySize) { throw ZipError(message: "Not a ZIP file") }
280
+ let tailSize = Int(min(fileSize, Int64(endOfDirectorySize + maxCommentSize)))
281
+ let tail = ByteReader(try read(handle, at: fileSize - Int64(tailSize), count: tailSize))
282
+
283
+ var position = tail.count - endOfDirectorySize
284
+ while position >= 0 {
285
+ // The comment length must reach exactly to the end, so comment bytes that merely
286
+ // look like this record are not mistaken for it.
287
+ if tail.uint32(position) == endOfDirectorySignature,
288
+ position + endOfDirectorySize + Int(tail.uint16(position + 20)) == tail.count
289
+ {
290
+ return ByteReader(tail.data(position, endOfDirectorySize))
291
+ }
292
+ position -= 1
293
+ }
294
+ throw ZipError(message: "Not a ZIP file")
295
+ }
296
+
297
+ /// Returns where the entry's bytes start. The local header must name the same file as
298
+ /// the directory, because other readers trust the local header instead.
299
+ private static func dataOffset(handle: FileHandle, fileSize: Int64, entry: ZipEntry) throws -> Int64 {
300
+ if entry.localHeaderOffset + Int64(localHeaderSize) > fileSize {
301
+ throw ZipError(message: "A file is outside the ZIP file")
302
+ }
303
+ let header = ByteReader(try read(handle, at: entry.localHeaderOffset, count: localHeaderSize))
304
+ guard header.uint32(0) == localHeaderSignature else {
305
+ throw ZipError(message: "A file header is malformed")
306
+ }
307
+ let nameLength = Int(header.uint16(26))
308
+ let extraLength = Int(header.uint16(28))
309
+ let nameBytes = try read(
310
+ handle, at: entry.localHeaderOffset + Int64(localHeaderSize), count: nameLength)
311
+ guard String(data: nameBytes, encoding: .utf8) == entry.name else {
312
+ throw ZipError(message: "The file \(entry.name) has a different name in its local header")
313
+ }
314
+ let start = entry.localHeaderOffset + Int64(localHeaderSize + nameLength + extraLength)
315
+ if start + entry.compressedSize > fileSize {
316
+ throw ZipError(message: "The file \(entry.name) is outside the ZIP file")
317
+ }
318
+ return start
319
+ }
320
+
321
+ /// Entries that share bytes let a tiny file expand enormously, so each must have its own.
322
+ private static func checkNoOverlap(handle: FileHandle, fileSize: Int64, entries: [ZipEntry]) throws {
323
+ let ranges = try entries.map { entry -> (start: Int64, end: Int64) in
324
+ let start = try dataOffset(handle: handle, fileSize: fileSize, entry: entry)
325
+ return (entry.localHeaderOffset, start + entry.compressedSize)
326
+ }.sorted { $0.start < $1.start }
327
+ for (previous, next) in zip(ranges, ranges.dropFirst()) where next.start < previous.end {
328
+ throw ZipError(message: "Files in the ZIP overlap")
329
+ }
330
+ }
331
+ }
332
+
333
+ /// Reads little-endian numbers without assuming alignment.
334
+ private struct ByteReader {
335
+ private let bytes: [UInt8]
336
+ var count: Int { bytes.count }
337
+
338
+ init(_ data: Data) {
339
+ bytes = [UInt8](data)
340
+ }
341
+
342
+ func uint16(_ offset: Int) -> UInt16 {
343
+ UInt16(bytes[offset]) | UInt16(bytes[offset + 1]) << 8
344
+ }
345
+
346
+ func uint32(_ offset: Int) -> UInt32 {
347
+ UInt32(uint16(offset)) | UInt32(uint16(offset + 2)) << 16
348
+ }
349
+
350
+ func data(_ offset: Int, _ length: Int) -> Data {
351
+ Data(bytes[offset..<(offset + length)])
352
+ }
353
+ }
354
+
355
+ /// ZIP's checksum. It also catches mistakes in the decompression code above.
356
+ private struct CRC32 {
357
+ private static let table: [UInt32] = (0..<256).map { index in
358
+ var value = UInt32(index)
359
+ for _ in 0..<8 {
360
+ value = value & 1 == 1 ? 0xedb8_8320 ^ (value >> 1) : value >> 1
361
+ }
362
+ return value
363
+ }
364
+
365
+ private var state: UInt32 = 0xffff_ffff
366
+
367
+ var value: UInt32 { ~state }
368
+
369
+ mutating func update(_ data: Data) {
370
+ var current = state
371
+ data.withUnsafeBytes { raw in
372
+ for byte in raw {
373
+ current = CRC32.table[Int((current ^ UInt32(byte)) & 0xff)] ^ (current >> 8)
374
+ }
375
+ }
376
+ state = current
377
+ }
378
+ }
@@ -0,0 +1,253 @@
1
+ import Foundation
2
+ import React
3
+ import UIKit
4
+
5
+ /// What an app supplies. Everything here ships inside the app, so nothing here is secret.
6
+ public struct RovqConfig {
7
+ public let apiURL: String
8
+ public let deploymentKey: String
9
+ /// Change this whenever native code changes.
10
+ public let runtimeVersion: String
11
+ /// Key IDs and their Base64 Ed25519 public keys.
12
+ public let trustedKeys: [String: String]
13
+ /// Allows http:// for local development only.
14
+ public let allowInsecureURLs: Bool
15
+
16
+ public init(
17
+ apiURL: String,
18
+ deploymentKey: String,
19
+ runtimeVersion: String,
20
+ trustedKeys: [String: String],
21
+ allowInsecureURLs: Bool = false
22
+ ) {
23
+ self.apiURL = apiURL
24
+ self.deploymentKey = deploymentKey
25
+ self.runtimeVersion = runtimeVersion
26
+ self.trustedKeys = trustedKeys
27
+ self.allowInsecureURLs = allowInsecureURLs
28
+ }
29
+ }
30
+
31
+ /// Connects the engine to React Native. Call `initialize` when the app starts, and return
32
+ /// `bundleURL()` from the React Native delegate.
33
+ @objc(RovqHost)
34
+ public final class RovqHost: NSObject {
35
+ private static var engine: RovqEngine?
36
+ private static var backgroundObservers: [NSObjectProtocol] = []
37
+ private static var isGuarding = false
38
+ private static var previousFatalHandler: RCTFatalHandler?
39
+ private static var previousExceptionHandler: RCTFatalExceptionHandler?
40
+
41
+ /// Must run before React Native starts. If the settings are unusable, the app still runs
42
+ /// the bundle inside it, and no update is ever accepted.
43
+ public static func initialize(config: RovqConfig) {
44
+ guard engine == nil else { return }
45
+ let info = Bundle.main.infoDictionary ?? [:]
46
+ let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
47
+ do {
48
+ engine = try RovqEngine(
49
+ rootFolder: support.appendingPathComponent("rovq", isDirectory: true),
50
+ config: EngineConfig(
51
+ apiURL: config.apiURL,
52
+ deploymentKey: config.deploymentKey,
53
+ runtimeVersion: config.runtimeVersion,
54
+ binaryVersion: info["CFBundleShortVersionString"] as? String ?? "0",
55
+ binaryId: binaryId(info),
56
+ trustedKeys: config.trustedKeys,
57
+ allowInsecureURLs: config.allowInsecureURLs
58
+ )
59
+ )
60
+ watchForBackground()
61
+ } catch {
62
+ NSLog("Rovq: could not start, so updates are off: %@", error.localizedDescription)
63
+ }
64
+ }
65
+
66
+ /// An app with scenes gets the scene notification, and one without gets the app one. With
67
+ /// several windows, only the last one leaving counts as leaving the app.
68
+ private static func watchForBackground() {
69
+ let center = NotificationCenter.default
70
+ backgroundObservers = [
71
+ center.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main) { _ in
72
+ engine?.noteBackgrounded()
73
+ },
74
+ center.addObserver(forName: UIScene.didEnterBackgroundNotification, object: nil, queue: .main) { _ in
75
+ let scenes = UIApplication.shared.connectedScenes
76
+ if scenes.allSatisfy({ $0.activationState == .background }) {
77
+ engine?.noteBackgrounded()
78
+ }
79
+ },
80
+ ]
81
+ }
82
+
83
+ /// React Native calls this each time JavaScript starts, including on reload. Returns nil
84
+ /// for the bundle inside the app.
85
+ public static func bundleURL() -> URL? {
86
+ guard let engine else { return nil }
87
+ let bundle = engine.resolveBundle()
88
+ if engine.status().tryingReleaseId != nil {
89
+ startGuarding()
90
+ } else {
91
+ stopGuarding()
92
+ }
93
+ NSLog("Rovq: loading %@", bundle?.path ?? "the bundle inside the app")
94
+ return bundle
95
+ }
96
+
97
+ /// Without this, code that throws before registerComponent leaves a blank screen with the
98
+ /// process alive, so rollback would not run until the user closed the app. It applies only
99
+ /// while a release is being tried. After that, the app's own fatal handling is restored.
100
+ /// See docs/ios-bundle-loading.md.
101
+ private static func startGuarding() {
102
+ guard !isGuarding else { return }
103
+ isGuarding = true
104
+ previousFatalHandler = RCTGetFatalHandler()
105
+ previousExceptionHandler = RCTGetFatalExceptionHandler()
106
+ RCTSetFatalHandler { error in
107
+ NSLog("Rovq: this update failed to start: %@", error?.localizedDescription ?? "unknown")
108
+ engine?.noteCrashed()
109
+ abort()
110
+ }
111
+ RCTSetFatalExceptionHandler { exception in
112
+ NSLog("Rovq: this update failed to start: %@", exception?.reason ?? "unknown")
113
+ engine?.noteCrashed()
114
+ abort()
115
+ }
116
+ }
117
+
118
+ private static func stopGuarding() {
119
+ guard isGuarding else { return }
120
+ isGuarding = false
121
+ RCTSetFatalHandler(previousFatalHandler)
122
+ RCTSetFatalExceptionHandler(previousExceptionHandler)
123
+ }
124
+
125
+ /// The modification date changes when the app itself is updated, even at the same version.
126
+ private static func binaryId(_ info: [String: Any]) -> String {
127
+ let version = info["CFBundleShortVersionString"] as? String ?? "0"
128
+ let build = info["CFBundleVersion"] as? String ?? "0"
129
+ let modified = Bundle.main.executableURL.flatMap {
130
+ try? $0.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate
131
+ }
132
+ return "\(version):\(build):\(modified?.timeIntervalSince1970 ?? 0)"
133
+ }
134
+
135
+ // MARK: Used by the native module
136
+
137
+ @objc public static func notifyReady() {
138
+ engine?.notifyReady()
139
+ stopGuarding()
140
+ }
141
+
142
+ // The methods below run on a background queue, because they use the network and the disk.
143
+
144
+ @objc public static func checkForUpdate(completion: @escaping ([String: Any]) -> Void) {
145
+ run(completion) { engine in
146
+ switch engine.checkForUpdate() {
147
+ case .upToDate:
148
+ return ["result": "UP_TO_DATE"]
149
+ case .rolledBack:
150
+ return ["result": "ROLLED_BACK"]
151
+ case .updateAvailable(let update, let alreadyDownloaded):
152
+ return [
153
+ "result": "UPDATE_AVAILABLE",
154
+ "update": dictionary(update),
155
+ "alreadyDownloaded": alreadyDownloaded,
156
+ ]
157
+ case .failed(let code, let message):
158
+ return failure(code, message)
159
+ }
160
+ }
161
+ }
162
+
163
+ @objc public static func downloadUpdate(
164
+ onProgress: @escaping (Int64, Int64) -> Void,
165
+ completion: @escaping ([String: Any]) -> Void
166
+ ) {
167
+ let throttle = ProgressThrottle(onChange: onProgress)
168
+ run(completion) { engine in
169
+ switch engine.downloadUpdate(onProgress: throttle.report) {
170
+ case .downloaded(let update):
171
+ return ["result": "DOWNLOADED", "update": dictionary(update)]
172
+ case .failed(let code, let message):
173
+ return failure(code, message)
174
+ }
175
+ }
176
+ }
177
+
178
+ @objc public static func installUpdate(completion: @escaping ([String: Any]) -> Void) {
179
+ run(completion) { engine in
180
+ switch engine.installUpdate() {
181
+ case .installed(let releaseId):
182
+ return ["result": "INSTALLED", "releaseId": releaseId]
183
+ case .failed(let code, let message):
184
+ return failure(code, message)
185
+ }
186
+ }
187
+ }
188
+
189
+ @objc public static func sync(
190
+ onProgress: @escaping (Int64, Int64) -> Void,
191
+ completion: @escaping ([String: Any]) -> Void
192
+ ) {
193
+ let throttle = ProgressThrottle(onChange: onProgress)
194
+ run(completion) { engine in
195
+ switch engine.sync(onProgress: throttle.report) {
196
+ case .upToDate:
197
+ return ["result": "UP_TO_DATE"]
198
+ case .rolledBack:
199
+ return ["result": "ROLLED_BACK"]
200
+ case .updateReady(let releaseId, let mandatory):
201
+ return ["result": "UPDATE_READY", "releaseId": releaseId, "mandatory": mandatory]
202
+ case .failed(let code, let message):
203
+ return failure(code, message)
204
+ }
205
+ }
206
+ }
207
+
208
+ @objc public static func rollback(completion: @escaping (Bool) -> Void) {
209
+ DispatchQueue.global(qos: .utility).async {
210
+ let result = engine?.rollback() ?? false
211
+ completion(result)
212
+ }
213
+ }
214
+
215
+ @objc public static func status() -> [String: Any] {
216
+ guard let status = engine?.status() else { return [:] }
217
+ return [
218
+ "deviceId": status.deviceId,
219
+ "currentReleaseId": status.currentReleaseId ?? NSNull(),
220
+ "downloadedReleaseId": status.downloadedReleaseId ?? NSNull(),
221
+ "pendingReleaseId": status.pendingReleaseId ?? NSNull(),
222
+ "tryingReleaseId": status.tryingReleaseId ?? NSNull(),
223
+ "highestSequence": status.highestSequence,
224
+ ]
225
+ }
226
+
227
+ private static func run(
228
+ _ completion: @escaping ([String: Any]) -> Void,
229
+ work: @escaping (RovqEngine) -> [String: Any]
230
+ ) {
231
+ DispatchQueue.global(qos: .utility).async {
232
+ guard let engine else {
233
+ completion(failure(.engineUnavailable, "RovqHost.initialize was not called, or could not start"))
234
+ return
235
+ }
236
+ completion(work(engine))
237
+ }
238
+ }
239
+
240
+ private static func failure(_ code: RovqErrorCode, _ message: String) -> [String: Any] {
241
+ NSLog("Rovq: failed with %@: %@", code.rawValue, message)
242
+ return ["result": "FAILED", "code": code.rawValue, "message": message]
243
+ }
244
+
245
+ private static func dictionary(_ update: UpdateInfo) -> [String: Any] {
246
+ [
247
+ "releaseId": update.releaseId,
248
+ "sequence": update.sequence,
249
+ "mandatory": update.mandatory,
250
+ "packageSize": update.packageSize,
251
+ ]
252
+ }
253
+ }