@sigx/lynx-updates 0.6.1 → 0.7.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/dist/updates.js CHANGED
@@ -1,21 +1,30 @@
1
1
  /**
2
- * Public `Updates` API — a thin facade over the controller, the store and
3
- * the native module. See the package README for usage.
2
+ * Public API — `defineUpdates()` (the boot-time declaration, in the
3
+ * `defineApp`/`defineRoutes` family) and the `Updates` runtime object (a
4
+ * thin facade over the controller, the store and the native module, in the
5
+ * `Haptics`/`Storage` native-module family). See the package README.
4
6
  */
5
7
  import * as controller from './controller.js';
6
8
  import { getCurrentUpdate, nativeAvailable } from './native.js';
7
9
  import { addListener, getStateSnapshot } from './state.js';
10
+ /**
11
+ * Declare the OTA update behavior for this app. Call once in `main.tsx`
12
+ * before `defineApp` — idempotent and synchronous; re-declaring updates the
13
+ * config but never re-runs the boot work (markReady / launch check).
14
+ * Kicks off the configured mode's automatic behavior on a deferred task
15
+ * (never blocks first paint). No-ops gracefully (with one warning) when the
16
+ * native module is absent (web preview, tests).
17
+ *
18
+ * @example
19
+ * ```tsx
20
+ * defineUpdates({ provider: { url: 'https://cdn.example.com/updates.json' } });
21
+ * defineApp(<App />).mount(null);
22
+ * ```
23
+ */
24
+ export function defineUpdates(config) {
25
+ controller.configure(config);
26
+ }
8
27
  export const Updates = {
9
- /**
10
- * Configure the updates client. Idempotent and synchronous; must run
11
- * before any other call — typically in `main.tsx` before `defineApp`.
12
- * Kicks off the configured mode's automatic behavior on a deferred task
13
- * (never blocks first paint). No-ops gracefully (with one warning) when
14
- * the native module is absent (web preview, tests).
15
- */
16
- configure(config) {
17
- controller.configure(config);
18
- },
19
28
  /** Ask the provider for the best available update. Works in every mode. */
20
29
  checkForUpdate() {
21
30
  return controller.checkForUpdate();
@@ -37,7 +46,7 @@ export const Updates = {
37
46
  },
38
47
  /**
39
48
  * Health signal: commits the pending update so native stops counting
40
- * launch attempts against it. Called automatically after configure()
49
+ * launch attempts against it. Called automatically after defineUpdates()
41
50
  * unless `autoMarkReady: false`. Safe to call repeatedly.
42
51
  */
43
52
  markReady() {
@@ -1 +1 @@
1
- {"version":3,"file":"updates.js","sourceRoot":"","sources":["../src/updates.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,UAAU,MAAM,iBAAiB,CAAC;AAC9C,OAAO,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAChE,OAAO,EAAE,WAAW,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAU3D,MAAM,CAAC,MAAM,OAAO,GAAG;IACnB;;;;;;OAMG;IACH,SAAS,CAAC,MAAqB;QAC3B,UAAU,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC;IACjC,CAAC;IAED,2EAA2E;IAC3E,cAAc;QACV,OAAO,UAAU,CAAC,cAAc,EAAE,CAAC;IACvC,CAAC;IAED;;;OAGG;IACH,QAAQ,CAAC,QAAyB;QAC9B,OAAO,UAAU,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;IACzC,CAAC;IAED;;;;OAIG;IACH,KAAK;QACD,OAAO,UAAU,CAAC,KAAK,EAAE,CAAC;IAC9B,CAAC;IAED;;;;OAIG;IACH,SAAS;QACL,OAAO,UAAU,CAAC,SAAS,EAAE,CAAC;IAClC,CAAC;IAED,uEAAuE;IACvE,mBAAmB;QACf,OAAO,gBAAgB,EAAE,CAAC;IAC9B,CAAC;IAED,0EAA0E;IAC1E,YAAY;QACR,OAAO,UAAU,CAAC,YAAY,EAAE,CAAC;IACrC,CAAC;IAED,6EAA6E;IAC7E,QAAQ;QACJ,OAAO,gBAAgB,EAAE,CAAC;IAC9B,CAAC;IAED,uEAAuE;IACvE,WAAW,CAAC,EAAiC;QACzC,OAAO,WAAW,CAAC,EAAE,CAAC,CAAC;IAC3B,CAAC;IAED,sEAAsE;IACtE,WAAW;QACP,OAAO,eAAe,EAAE,CAAC;IAC7B,CAAC;CACK,CAAC"}
1
+ {"version":3,"file":"updates.js","sourceRoot":"","sources":["../src/updates.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,UAAU,MAAM,iBAAiB,CAAC;AAC9C,OAAO,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAChE,OAAO,EAAE,WAAW,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAU3D;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,aAAa,CAAC,MAAqB;IAC/C,UAAU,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC;AACjC,CAAC;AAED,MAAM,CAAC,MAAM,OAAO,GAAG;IACnB,2EAA2E;IAC3E,cAAc;QACV,OAAO,UAAU,CAAC,cAAc,EAAE,CAAC;IACvC,CAAC;IAED;;;OAGG;IACH,QAAQ,CAAC,QAAyB;QAC9B,OAAO,UAAU,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;IACzC,CAAC;IAED;;;;OAIG;IACH,KAAK;QACD,OAAO,UAAU,CAAC,KAAK,EAAE,CAAC;IAC9B,CAAC;IAED;;;;OAIG;IACH,SAAS;QACL,OAAO,UAAU,CAAC,SAAS,EAAE,CAAC;IAClC,CAAC;IAED,uEAAuE;IACvE,mBAAmB;QACf,OAAO,gBAAgB,EAAE,CAAC;IAC9B,CAAC;IAED,0EAA0E;IAC1E,YAAY;QACR,OAAO,UAAU,CAAC,YAAY,EAAE,CAAC;IACrC,CAAC;IAED,6EAA6E;IAC7E,QAAQ;QACJ,OAAO,gBAAgB,EAAE,CAAC;IAC9B,CAAC;IAED,uEAAuE;IACvE,WAAW,CAAC,EAAiC;QACzC,OAAO,WAAW,CAAC,EAAE,CAAC,CAAC;IAC3B,CAAC;IAED,sEAAsE;IACtE,WAAW;QACP,OAAO,eAAe,EAAE,CAAC;IAC7B,CAAC;CACK,CAAC"}
@@ -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
+ }