@sigx/lynx-updates 0.7.0 → 0.8.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 -21
- package/README.md +199 -162
- package/android/com/sigx/updates/UpdateDownloader.kt +154 -154
- package/android/com/sigx/updates/UpdateStore.kt +367 -367
- package/android/com/sigx/updates/UpdatesActivityHook.kt +25 -25
- package/android/com/sigx/updates/UpdatesBundleResolver.kt +18 -18
- package/android/com/sigx/updates/UpdatesEventBus.kt +54 -54
- package/android/com/sigx/updates/UpdatesLifecyclePublisher.kt +42 -42
- package/android/com/sigx/updates/UpdatesModule.kt +235 -235
- package/dist/controller.js +1 -1
- package/dist/controller.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/native.d.ts.map +1 -1
- package/dist/native.js +4 -2
- package/dist/native.js.map +1 -1
- package/dist/provider/static-manifest.d.ts +41 -10
- package/dist/provider/static-manifest.d.ts.map +1 -1
- package/dist/provider/static-manifest.js +39 -5
- package/dist/provider/static-manifest.js.map +1 -1
- package/dist/types.d.ts +6 -6
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/updates.d.ts +11 -20
- package/dist/updates.d.ts.map +1 -1
- package/dist/updates.js +13 -22
- package/dist/updates.js.map +1 -1
- package/ios/UpdateDownloader.swift +152 -152
- package/ios/UpdateStore.swift +286 -286
- package/ios/UpdatesBundleResolver.swift +15 -15
- package/ios/UpdatesEventBus.swift +59 -59
- package/ios/UpdatesLifecyclePublisher.swift +48 -48
- package/ios/UpdatesModule.swift +178 -178
- package/package.json +3 -3
- package/signalx-module.json +35 -35
|
@@ -1,152 +1,152 @@
|
|
|
1
|
-
import Foundation
|
|
2
|
-
import CryptoKit
|
|
3
|
-
|
|
4
|
-
/// Streaming bundle downloader: bytes go straight to `tmp/<id>.partial`
|
|
5
|
-
/// with an incremental SHA-256, then atomically move into `updates/<id>/`
|
|
6
|
-
/// once the hash matches. Single-flight — concurrent calls beyond the first
|
|
7
|
-
/// fail fast.
|
|
8
|
-
final class UpdateDownloader: NSObject, URLSessionDataDelegate {
|
|
9
|
-
|
|
10
|
-
private static let inFlightLock = NSLock()
|
|
11
|
-
private static var inFlight = false
|
|
12
|
-
|
|
13
|
-
private let partialURL: URL
|
|
14
|
-
private var output: FileHandle?
|
|
15
|
-
private var hasher = SHA256()
|
|
16
|
-
private var receivedBytes: Int64 = 0
|
|
17
|
-
private var totalBytes: Int64?
|
|
18
|
-
private var lastProgressAt = Date.distantPast
|
|
19
|
-
private var result: String? = "Download did not complete"
|
|
20
|
-
private let done = DispatchSemaphore(value: 0)
|
|
21
|
-
|
|
22
|
-
private init(partialURL: URL) {
|
|
23
|
-
self.partialURL = partialURL
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/// Synchronous (call off the main thread). Returns nil on success or an
|
|
27
|
-
/// error message (prefixed with E_* codes the module maps to the bridge).
|
|
28
|
-
static func download(
|
|
29
|
-
url: String,
|
|
30
|
-
expectedSha256: String,
|
|
31
|
-
updateId: String,
|
|
32
|
-
headers: [String: String],
|
|
33
|
-
manifestJson: String,
|
|
34
|
-
) -> String? {
|
|
35
|
-
let store = UpdateStore.shared
|
|
36
|
-
|
|
37
|
-
// Already on disk and intact → success without a byte transferred.
|
|
38
|
-
if FileManager.default.fileExists(atPath: store.bundleFile(updateId).path),
|
|
39
|
-
store.verifySha256(updateId) {
|
|
40
|
-
return nil
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
inFlightLock.lock()
|
|
44
|
-
if inFlight {
|
|
45
|
-
inFlightLock.unlock()
|
|
46
|
-
return "E_DOWNLOAD_IN_PROGRESS: another download is running"
|
|
47
|
-
}
|
|
48
|
-
inFlight = true
|
|
49
|
-
inFlightLock.unlock()
|
|
50
|
-
defer {
|
|
51
|
-
inFlightLock.lock()
|
|
52
|
-
inFlight = false
|
|
53
|
-
inFlightLock.unlock()
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
guard let requestURL = URL(string: url) else {
|
|
57
|
-
return "Download failed: invalid URL \(url)"
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
let fm = FileManager.default
|
|
61
|
-
try? fm.createDirectory(at: store.tmpDir, withIntermediateDirectories: true)
|
|
62
|
-
let partial = store.tmpDir.appendingPathComponent("\(updateId).partial")
|
|
63
|
-
fm.createFile(atPath: partial.path, contents: nil)
|
|
64
|
-
|
|
65
|
-
let downloader = UpdateDownloader(partialURL: partial)
|
|
66
|
-
guard let handle = try? FileHandle(forWritingTo: partial) else {
|
|
67
|
-
return "Download failed: cannot open staging file"
|
|
68
|
-
}
|
|
69
|
-
downloader.output = handle
|
|
70
|
-
|
|
71
|
-
var request = URLRequest(url: requestURL, timeoutInterval: 30)
|
|
72
|
-
for (key, value) in headers {
|
|
73
|
-
request.setValue(value, forHTTPHeaderField: key)
|
|
74
|
-
}
|
|
75
|
-
let session = URLSession(configuration: .ephemeral, delegate: downloader, delegateQueue: nil)
|
|
76
|
-
session.dataTask(with: request).resume()
|
|
77
|
-
downloader.done.wait()
|
|
78
|
-
session.finishTasksAndInvalidate()
|
|
79
|
-
|
|
80
|
-
if let failure = downloader.result {
|
|
81
|
-
try? fm.removeItem(at: partial)
|
|
82
|
-
return failure
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
let actual = downloader.hasher.finalize().map { String(format: "%02x", $0) }.joined()
|
|
86
|
-
guard actual == expectedSha256.lowercased() else {
|
|
87
|
-
try? fm.removeItem(at: partial)
|
|
88
|
-
return "E_HASH_MISMATCH: expected \(expectedSha256), got \(actual)"
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// Promote: metadata first, bundle move last.
|
|
92
|
-
let dir = store.updateDir(updateId)
|
|
93
|
-
try? fm.removeItem(at: dir)
|
|
94
|
-
do {
|
|
95
|
-
try fm.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
96
|
-
var meta = (try? JSONSerialization.jsonObject(with: Data(manifestJson.utf8))) as? [String: Any] ?? [:]
|
|
97
|
-
meta["sha256"] = expectedSha256.lowercased()
|
|
98
|
-
meta["sizeBytes"] = downloader.receivedBytes
|
|
99
|
-
meta["sourceUrl"] = url
|
|
100
|
-
meta["downloadedAt"] = Int(Date().timeIntervalSince1970 * 1000)
|
|
101
|
-
let metaData = try JSONSerialization.data(withJSONObject: meta)
|
|
102
|
-
try metaData.write(to: store.updateJsonFile(updateId), options: .atomic)
|
|
103
|
-
try fm.moveItem(at: partial, to: store.bundleFile(updateId))
|
|
104
|
-
} catch {
|
|
105
|
-
try? fm.removeItem(at: partial)
|
|
106
|
-
return "Download failed: \(error.localizedDescription)"
|
|
107
|
-
}
|
|
108
|
-
return nil
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// MARK: - URLSessionDataDelegate
|
|
112
|
-
|
|
113
|
-
func urlSession(
|
|
114
|
-
_ session: URLSession, dataTask: URLSessionDataTask,
|
|
115
|
-
didReceive response: URLResponse,
|
|
116
|
-
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void,
|
|
117
|
-
) {
|
|
118
|
-
if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
|
|
119
|
-
result = "Download failed: HTTP \(http.statusCode)"
|
|
120
|
-
completionHandler(.cancel)
|
|
121
|
-
return
|
|
122
|
-
}
|
|
123
|
-
totalBytes = response.expectedContentLength >= 0 ? response.expectedContentLength : nil
|
|
124
|
-
completionHandler(.allow)
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
|
|
128
|
-
output?.write(data)
|
|
129
|
-
hasher.update(data: data)
|
|
130
|
-
receivedBytes += Int64(data.count)
|
|
131
|
-
let now = Date()
|
|
132
|
-
if now.timeIntervalSince(lastProgressAt) >= 0.15 {
|
|
133
|
-
lastProgressAt = now
|
|
134
|
-
UpdatesEventBus.shared.emitProgress(receivedBytes: receivedBytes, totalBytes: totalBytes)
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
|
139
|
-
try? output?.close()
|
|
140
|
-
if let error {
|
|
141
|
-
// A cancel from didReceive(response:) already set a specific message.
|
|
142
|
-
if result == "Download did not complete" || result == nil {
|
|
143
|
-
result = "Download failed: \(error.localizedDescription)"
|
|
144
|
-
}
|
|
145
|
-
} else {
|
|
146
|
-
result = nil
|
|
147
|
-
UpdatesEventBus.shared.emitProgress(
|
|
148
|
-
receivedBytes: receivedBytes, totalBytes: totalBytes ?? receivedBytes)
|
|
149
|
-
}
|
|
150
|
-
done.signal()
|
|
151
|
-
}
|
|
152
|
-
}
|
|
1
|
+
import Foundation
|
|
2
|
+
import CryptoKit
|
|
3
|
+
|
|
4
|
+
/// Streaming bundle downloader: bytes go straight to `tmp/<id>.partial`
|
|
5
|
+
/// with an incremental SHA-256, then atomically move into `updates/<id>/`
|
|
6
|
+
/// once the hash matches. Single-flight — concurrent calls beyond the first
|
|
7
|
+
/// fail fast.
|
|
8
|
+
final class UpdateDownloader: NSObject, URLSessionDataDelegate {
|
|
9
|
+
|
|
10
|
+
private static let inFlightLock = NSLock()
|
|
11
|
+
private static var inFlight = false
|
|
12
|
+
|
|
13
|
+
private let partialURL: URL
|
|
14
|
+
private var output: FileHandle?
|
|
15
|
+
private var hasher = SHA256()
|
|
16
|
+
private var receivedBytes: Int64 = 0
|
|
17
|
+
private var totalBytes: Int64?
|
|
18
|
+
private var lastProgressAt = Date.distantPast
|
|
19
|
+
private var result: String? = "Download did not complete"
|
|
20
|
+
private let done = DispatchSemaphore(value: 0)
|
|
21
|
+
|
|
22
|
+
private init(partialURL: URL) {
|
|
23
|
+
self.partialURL = partialURL
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/// Synchronous (call off the main thread). Returns nil on success or an
|
|
27
|
+
/// error message (prefixed with E_* codes the module maps to the bridge).
|
|
28
|
+
static func download(
|
|
29
|
+
url: String,
|
|
30
|
+
expectedSha256: String,
|
|
31
|
+
updateId: String,
|
|
32
|
+
headers: [String: String],
|
|
33
|
+
manifestJson: String,
|
|
34
|
+
) -> String? {
|
|
35
|
+
let store = UpdateStore.shared
|
|
36
|
+
|
|
37
|
+
// Already on disk and intact → success without a byte transferred.
|
|
38
|
+
if FileManager.default.fileExists(atPath: store.bundleFile(updateId).path),
|
|
39
|
+
store.verifySha256(updateId) {
|
|
40
|
+
return nil
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
inFlightLock.lock()
|
|
44
|
+
if inFlight {
|
|
45
|
+
inFlightLock.unlock()
|
|
46
|
+
return "E_DOWNLOAD_IN_PROGRESS: another download is running"
|
|
47
|
+
}
|
|
48
|
+
inFlight = true
|
|
49
|
+
inFlightLock.unlock()
|
|
50
|
+
defer {
|
|
51
|
+
inFlightLock.lock()
|
|
52
|
+
inFlight = false
|
|
53
|
+
inFlightLock.unlock()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
guard let requestURL = URL(string: url) else {
|
|
57
|
+
return "Download failed: invalid URL \(url)"
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let fm = FileManager.default
|
|
61
|
+
try? fm.createDirectory(at: store.tmpDir, withIntermediateDirectories: true)
|
|
62
|
+
let partial = store.tmpDir.appendingPathComponent("\(updateId).partial")
|
|
63
|
+
fm.createFile(atPath: partial.path, contents: nil)
|
|
64
|
+
|
|
65
|
+
let downloader = UpdateDownloader(partialURL: partial)
|
|
66
|
+
guard let handle = try? FileHandle(forWritingTo: partial) else {
|
|
67
|
+
return "Download failed: cannot open staging file"
|
|
68
|
+
}
|
|
69
|
+
downloader.output = handle
|
|
70
|
+
|
|
71
|
+
var request = URLRequest(url: requestURL, timeoutInterval: 30)
|
|
72
|
+
for (key, value) in headers {
|
|
73
|
+
request.setValue(value, forHTTPHeaderField: key)
|
|
74
|
+
}
|
|
75
|
+
let session = URLSession(configuration: .ephemeral, delegate: downloader, delegateQueue: nil)
|
|
76
|
+
session.dataTask(with: request).resume()
|
|
77
|
+
downloader.done.wait()
|
|
78
|
+
session.finishTasksAndInvalidate()
|
|
79
|
+
|
|
80
|
+
if let failure = downloader.result {
|
|
81
|
+
try? fm.removeItem(at: partial)
|
|
82
|
+
return failure
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let actual = downloader.hasher.finalize().map { String(format: "%02x", $0) }.joined()
|
|
86
|
+
guard actual == expectedSha256.lowercased() else {
|
|
87
|
+
try? fm.removeItem(at: partial)
|
|
88
|
+
return "E_HASH_MISMATCH: expected \(expectedSha256), got \(actual)"
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Promote: metadata first, bundle move last.
|
|
92
|
+
let dir = store.updateDir(updateId)
|
|
93
|
+
try? fm.removeItem(at: dir)
|
|
94
|
+
do {
|
|
95
|
+
try fm.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
96
|
+
var meta = (try? JSONSerialization.jsonObject(with: Data(manifestJson.utf8))) as? [String: Any] ?? [:]
|
|
97
|
+
meta["sha256"] = expectedSha256.lowercased()
|
|
98
|
+
meta["sizeBytes"] = downloader.receivedBytes
|
|
99
|
+
meta["sourceUrl"] = url
|
|
100
|
+
meta["downloadedAt"] = Int(Date().timeIntervalSince1970 * 1000)
|
|
101
|
+
let metaData = try JSONSerialization.data(withJSONObject: meta)
|
|
102
|
+
try metaData.write(to: store.updateJsonFile(updateId), options: .atomic)
|
|
103
|
+
try fm.moveItem(at: partial, to: store.bundleFile(updateId))
|
|
104
|
+
} catch {
|
|
105
|
+
try? fm.removeItem(at: partial)
|
|
106
|
+
return "Download failed: \(error.localizedDescription)"
|
|
107
|
+
}
|
|
108
|
+
return nil
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// MARK: - URLSessionDataDelegate
|
|
112
|
+
|
|
113
|
+
func urlSession(
|
|
114
|
+
_ session: URLSession, dataTask: URLSessionDataTask,
|
|
115
|
+
didReceive response: URLResponse,
|
|
116
|
+
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void,
|
|
117
|
+
) {
|
|
118
|
+
if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
|
|
119
|
+
result = "Download failed: HTTP \(http.statusCode)"
|
|
120
|
+
completionHandler(.cancel)
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
totalBytes = response.expectedContentLength >= 0 ? response.expectedContentLength : nil
|
|
124
|
+
completionHandler(.allow)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
|
|
128
|
+
output?.write(data)
|
|
129
|
+
hasher.update(data: data)
|
|
130
|
+
receivedBytes += Int64(data.count)
|
|
131
|
+
let now = Date()
|
|
132
|
+
if now.timeIntervalSince(lastProgressAt) >= 0.15 {
|
|
133
|
+
lastProgressAt = now
|
|
134
|
+
UpdatesEventBus.shared.emitProgress(receivedBytes: receivedBytes, totalBytes: totalBytes)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
|
139
|
+
try? output?.close()
|
|
140
|
+
if let error {
|
|
141
|
+
// A cancel from didReceive(response:) already set a specific message.
|
|
142
|
+
if result == "Download did not complete" || result == nil {
|
|
143
|
+
result = "Download failed: \(error.localizedDescription)"
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
result = nil
|
|
147
|
+
UpdatesEventBus.shared.emitProgress(
|
|
148
|
+
receivedBytes: receivedBytes, totalBytes: totalBytes ?? receivedBytes)
|
|
149
|
+
}
|
|
150
|
+
done.signal()
|
|
151
|
+
}
|
|
152
|
+
}
|