@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.
Files changed (37) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +199 -162
  3. package/android/com/sigx/updates/UpdateDownloader.kt +154 -154
  4. package/android/com/sigx/updates/UpdateStore.kt +367 -367
  5. package/android/com/sigx/updates/UpdatesActivityHook.kt +25 -25
  6. package/android/com/sigx/updates/UpdatesBundleResolver.kt +18 -18
  7. package/android/com/sigx/updates/UpdatesEventBus.kt +54 -54
  8. package/android/com/sigx/updates/UpdatesLifecyclePublisher.kt +42 -42
  9. package/android/com/sigx/updates/UpdatesModule.kt +235 -235
  10. package/dist/controller.js +1 -1
  11. package/dist/controller.js.map +1 -1
  12. package/dist/index.d.ts +2 -2
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +1 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/native.d.ts.map +1 -1
  17. package/dist/native.js +4 -2
  18. package/dist/native.js.map +1 -1
  19. package/dist/provider/static-manifest.d.ts +41 -10
  20. package/dist/provider/static-manifest.d.ts.map +1 -1
  21. package/dist/provider/static-manifest.js +39 -5
  22. package/dist/provider/static-manifest.js.map +1 -1
  23. package/dist/types.d.ts +6 -6
  24. package/dist/types.d.ts.map +1 -1
  25. package/dist/types.js.map +1 -1
  26. package/dist/updates.d.ts +11 -20
  27. package/dist/updates.d.ts.map +1 -1
  28. package/dist/updates.js +13 -22
  29. package/dist/updates.js.map +1 -1
  30. package/ios/UpdateDownloader.swift +152 -152
  31. package/ios/UpdateStore.swift +286 -286
  32. package/ios/UpdatesBundleResolver.swift +15 -15
  33. package/ios/UpdatesEventBus.swift +59 -59
  34. package/ios/UpdatesLifecyclePublisher.swift +48 -48
  35. package/ios/UpdatesModule.swift +178 -178
  36. package/package.json +3 -3
  37. 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
+ }