@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,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
|
+
}
|