@sigx/lynx-updates 0.6.1

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 (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +162 -0
  3. package/android/com/sigx/updates/UpdateDownloader.kt +154 -0
  4. package/android/com/sigx/updates/UpdateStore.kt +367 -0
  5. package/android/com/sigx/updates/UpdatesActivityHook.kt +25 -0
  6. package/android/com/sigx/updates/UpdatesBundleResolver.kt +18 -0
  7. package/android/com/sigx/updates/UpdatesEventBus.kt +54 -0
  8. package/android/com/sigx/updates/UpdatesLifecyclePublisher.kt +42 -0
  9. package/android/com/sigx/updates/UpdatesModule.kt +235 -0
  10. package/dist/controller.d.ts +31 -0
  11. package/dist/controller.d.ts.map +1 -0
  12. package/dist/controller.js +344 -0
  13. package/dist/controller.js.map +1 -0
  14. package/dist/events.d.ts +18 -0
  15. package/dist/events.d.ts.map +1 -0
  16. package/dist/events.js +61 -0
  17. package/dist/events.js.map +1 -0
  18. package/dist/index.d.ts +5 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +5 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/native.d.ts +41 -0
  23. package/dist/native.d.ts.map +1 -0
  24. package/dist/native.js +161 -0
  25. package/dist/native.js.map +1 -0
  26. package/dist/provider/static-manifest.d.ts +66 -0
  27. package/dist/provider/static-manifest.d.ts.map +1 -0
  28. package/dist/provider/static-manifest.js +173 -0
  29. package/dist/provider/static-manifest.js.map +1 -0
  30. package/dist/state.d.ts +23 -0
  31. package/dist/state.d.ts.map +1 -0
  32. package/dist/state.js +73 -0
  33. package/dist/state.js.map +1 -0
  34. package/dist/types.d.ts +203 -0
  35. package/dist/types.d.ts.map +1 -0
  36. package/dist/types.js +12 -0
  37. package/dist/types.js.map +1 -0
  38. package/dist/updates.d.ts +45 -0
  39. package/dist/updates.d.ts.map +1 -0
  40. package/dist/updates.js +67 -0
  41. package/dist/updates.js.map +1 -0
  42. package/dist/use-updates.d.ts +16 -0
  43. package/dist/use-updates.d.ts.map +1 -0
  44. package/dist/use-updates.js +29 -0
  45. package/dist/use-updates.js.map +1 -0
  46. package/ios/UpdateDownloader.swift +152 -0
  47. package/ios/UpdateStore.swift +286 -0
  48. package/ios/UpdatesBundleResolver.swift +15 -0
  49. package/ios/UpdatesEventBus.swift +59 -0
  50. package/ios/UpdatesLifecyclePublisher.swift +48 -0
  51. package/ios/UpdatesModule.swift +178 -0
  52. package/package.json +59 -0
  53. package/signalx-module.json +35 -0
@@ -0,0 +1,286 @@
1
+ import Foundation
2
+ import Lynx
3
+
4
+ /// On-disk update store + state machine shared by `UpdatesBundleResolver`
5
+ /// (startup, before any LynxView exists) and `UpdatesModule` (JS bridge).
6
+ /// Mirror of the Android `UpdateStore` — see that file for the lifecycle
7
+ /// description; the layouts and state.json schema are identical.
8
+ final class UpdateStore {
9
+
10
+ static let shared = UpdateStore()
11
+ private init() {}
12
+
13
+ private let lock = NSRecursiveLock()
14
+ static let runtimeVersionPlistKey = "SigxRuntimeVersion"
15
+
16
+ /// What this process actually loaded (set by the resolver).
17
+ private(set) var launchedUpdateId: String?
18
+ /// True when this launch is a pending update's trial run.
19
+ private(set) var isFirstLaunchAfterUpdate = false
20
+ /// True when the resolver rolled back at this startup.
21
+ private(set) var didRollBack = false
22
+ private(set) var rolledBackUpdateId: String?
23
+
24
+ /// Last-attached LynxView — apply-now reload target.
25
+ private weak var lynxView: LynxView?
26
+
27
+ func attachView(_ view: LynxView) {
28
+ lock.lock(); defer { lock.unlock() }
29
+ lynxView = view
30
+ }
31
+
32
+ func currentView() -> LynxView? {
33
+ lock.lock(); defer { lock.unlock() }
34
+ return lynxView
35
+ }
36
+
37
+ // MARK: - Paths
38
+
39
+ var rootDir: URL {
40
+ let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
41
+ return base.appendingPathComponent("sigx-updates", isDirectory: true)
42
+ }
43
+ var updatesDir: URL { rootDir.appendingPathComponent("updates", isDirectory: true) }
44
+ var tmpDir: URL { rootDir.appendingPathComponent("tmp", isDirectory: true) }
45
+ func updateDir(_ updateId: String) -> URL { updatesDir.appendingPathComponent(updateId, isDirectory: true) }
46
+ func bundleFile(_ updateId: String) -> URL { updateDir(updateId).appendingPathComponent("main.lynx.bundle") }
47
+ func updateJsonFile(_ updateId: String) -> URL { updateDir(updateId).appendingPathComponent("update.json") }
48
+ private var stateFile: URL { rootDir.appendingPathComponent("state.json") }
49
+
50
+ /// OTA payloads are re-downloadable — exclude from iCloud/iTunes backup.
51
+ private func excludeFromBackup(_ url: URL) {
52
+ var u = url
53
+ var values = URLResourceValues()
54
+ values.isExcludedFromBackup = true
55
+ try? u.setResourceValues(values)
56
+ }
57
+
58
+ // MARK: - Binary identity
59
+
60
+ func installedRuntimeVersion() -> String {
61
+ (Bundle.main.object(forInfoDictionaryKey: Self.runtimeVersionPlistKey) as? String) ?? "unknown"
62
+ }
63
+
64
+ func installedBinaryVersion() -> String {
65
+ (Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String) ?? "unknown"
66
+ }
67
+
68
+ // MARK: - State
69
+
70
+ struct State {
71
+ var installedRuntimeVersion = ""
72
+ var installedBinaryVersion = ""
73
+ var currentUpdateId: String?
74
+ var previousUpdateId: String?
75
+ var pendingUpdateId: String?
76
+ var pendingLaunchAttempts = 0
77
+ var maxLaunchAttempts = 2
78
+ var lastRollbackUpdateId: String?
79
+ var lastRollbackReason: String?
80
+ }
81
+
82
+ func readState() -> State? {
83
+ lock.lock(); defer { lock.unlock() }
84
+ guard let data = try? Data(contentsOf: stateFile),
85
+ let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else {
86
+ return nil
87
+ }
88
+ func str(_ key: String) -> String? {
89
+ let v = json[key] as? String
90
+ return (v?.isEmpty ?? true) ? nil : v
91
+ }
92
+ var state = State()
93
+ state.installedRuntimeVersion = (json["installedRuntimeVersion"] as? String) ?? ""
94
+ state.installedBinaryVersion = (json["installedBinaryVersion"] as? String) ?? ""
95
+ state.currentUpdateId = str("currentUpdateId")
96
+ state.previousUpdateId = str("previousUpdateId")
97
+ state.pendingUpdateId = str("pendingUpdateId")
98
+ state.pendingLaunchAttempts = (json["pendingLaunchAttempts"] as? Int) ?? 0
99
+ state.maxLaunchAttempts = (json["maxLaunchAttempts"] as? Int) ?? 2
100
+ state.lastRollbackUpdateId = str("lastRollbackUpdateId")
101
+ state.lastRollbackReason = str("lastRollbackReason")
102
+ return state
103
+ }
104
+
105
+ /// Atomic write — the launch-attempt counter must survive a crash.
106
+ func writeState(_ state: State) {
107
+ lock.lock(); defer { lock.unlock() }
108
+ let json: [String: Any] = [
109
+ "schemaVersion": 1,
110
+ "installedRuntimeVersion": state.installedRuntimeVersion,
111
+ "installedBinaryVersion": state.installedBinaryVersion,
112
+ "currentUpdateId": state.currentUpdateId ?? "",
113
+ "previousUpdateId": state.previousUpdateId ?? "",
114
+ "pendingUpdateId": state.pendingUpdateId ?? "",
115
+ "pendingLaunchAttempts": state.pendingLaunchAttempts,
116
+ "maxLaunchAttempts": state.maxLaunchAttempts,
117
+ "lastRollbackUpdateId": state.lastRollbackUpdateId ?? "",
118
+ "lastRollbackReason": state.lastRollbackReason ?? "",
119
+ ]
120
+ try? FileManager.default.createDirectory(at: rootDir, withIntermediateDirectories: true)
121
+ excludeFromBackup(rootDir)
122
+ if let data = try? JSONSerialization.data(withJSONObject: json) {
123
+ try? data.write(to: stateFile, options: .atomic)
124
+ }
125
+ }
126
+
127
+ func freshState() -> State {
128
+ var state = State()
129
+ state.installedRuntimeVersion = installedRuntimeVersion()
130
+ state.installedBinaryVersion = installedBinaryVersion()
131
+ return state
132
+ }
133
+
134
+ // MARK: - Startup resolution
135
+
136
+ /// Decide which bundle this launch loads. Returns an absolute path to an
137
+ /// OTA bundle, or nil to use the baked resource. Mutates rollback state —
138
+ /// the generated host calls it exactly once per process launch.
139
+ func resolveStartupBundlePath() -> String? {
140
+ lock.lock(); defer { lock.unlock() }
141
+ let fm = FileManager.default
142
+ try? fm.removeItem(at: tmpDir)
143
+
144
+ guard var state = readState() else {
145
+ if fm.fileExists(atPath: stateFile.path) {
146
+ // Unparseable state — wipe everything, run baked.
147
+ try? fm.removeItem(at: rootDir)
148
+ }
149
+ return nil
150
+ }
151
+
152
+ // Binary-update tripwire: a store update invalidates every
153
+ // downloaded update — they were published for the old runtime.
154
+ let runtimeNow = installedRuntimeVersion()
155
+ let binaryNow = installedBinaryVersion()
156
+ if state.installedRuntimeVersion != runtimeNow || state.installedBinaryVersion != binaryNow {
157
+ try? fm.removeItem(at: updatesDir)
158
+ writeState(freshState())
159
+ return nil
160
+ }
161
+
162
+ // Pending update: crash-guarded trial launch.
163
+ if let pending = state.pendingUpdateId {
164
+ if state.pendingLaunchAttempts >= state.maxLaunchAttempts {
165
+ rollbackPending(&state, reason: "crash")
166
+ } else {
167
+ let bundle = bundleFile(pending)
168
+ let firstAttempt = state.pendingLaunchAttempts == 0
169
+ let intact = fm.fileExists(atPath: bundle.path) && (!firstAttempt || verifySha256(pending))
170
+ if intact {
171
+ state.pendingLaunchAttempts += 1
172
+ writeState(state)
173
+ launchedUpdateId = pending
174
+ isFirstLaunchAfterUpdate = firstAttempt
175
+ sweepOrphans(state)
176
+ return bundle.path
177
+ }
178
+ rollbackPending(&state, reason: "corrupt")
179
+ }
180
+ }
181
+
182
+ // Committed update: trusted (existence check only).
183
+ if let current = state.currentUpdateId {
184
+ let bundle = bundleFile(current)
185
+ if fm.fileExists(atPath: bundle.path) {
186
+ launchedUpdateId = current
187
+ sweepOrphans(state)
188
+ return bundle.path
189
+ }
190
+ state.currentUpdateId = nil
191
+ writeState(state)
192
+ }
193
+
194
+ sweepOrphans(state)
195
+ return nil
196
+ }
197
+
198
+ private func rollbackPending(_ state: inout State, reason: String) {
199
+ guard let pending = state.pendingUpdateId else { return }
200
+ didRollBack = true
201
+ rolledBackUpdateId = pending
202
+ try? FileManager.default.removeItem(at: updateDir(pending))
203
+ state.lastRollbackUpdateId = pending
204
+ state.lastRollbackReason = reason
205
+ state.pendingUpdateId = nil
206
+ state.pendingLaunchAttempts = 0
207
+ writeState(state)
208
+ }
209
+
210
+ private func sweepOrphans(_ state: State) {
211
+ let keep = Set([state.currentUpdateId, state.previousUpdateId, state.pendingUpdateId].compactMap { $0 })
212
+ let entries = (try? FileManager.default.contentsOfDirectory(at: updatesDir, includingPropertiesForKeys: nil)) ?? []
213
+ for entry in entries where !keep.contains(entry.lastPathComponent) {
214
+ try? FileManager.default.removeItem(at: entry)
215
+ }
216
+ }
217
+
218
+ // MARK: - Transitions
219
+
220
+ /// Stage a downloaded update to load on the next launch. Returns an error message or nil.
221
+ func stagePending(_ updateId: String) -> String? {
222
+ lock.lock(); defer { lock.unlock() }
223
+ guard FileManager.default.fileExists(atPath: bundleFile(updateId).path) else {
224
+ return "Update \(updateId) is not on disk"
225
+ }
226
+ var state = readState() ?? freshState()
227
+ if state.currentUpdateId == updateId { return nil }
228
+ state.pendingUpdateId = updateId
229
+ state.pendingLaunchAttempts = 0
230
+ writeState(state)
231
+ return nil
232
+ }
233
+
234
+ /// Arm the crash guard for an in-place reload (which bypasses the resolver).
235
+ func recordReloadAttempt(_ updateId: String) {
236
+ lock.lock(); defer { lock.unlock() }
237
+ var state = readState() ?? freshState()
238
+ if state.pendingUpdateId != updateId {
239
+ state.pendingUpdateId = updateId
240
+ }
241
+ state.pendingLaunchAttempts += 1
242
+ writeState(state)
243
+ launchedUpdateId = updateId
244
+ isFirstLaunchAfterUpdate = true
245
+ }
246
+
247
+ /// Commit the running pending update as healthy. Idempotent.
248
+ func markReady() {
249
+ lock.lock(); defer { lock.unlock() }
250
+ guard var state = readState(), let pending = state.pendingUpdateId else { return }
251
+ guard launchedUpdateId == pending else { return }
252
+ if let previous = state.previousUpdateId {
253
+ try? FileManager.default.removeItem(at: updateDir(previous))
254
+ }
255
+ state.previousUpdateId = state.currentUpdateId
256
+ state.currentUpdateId = pending
257
+ state.pendingUpdateId = nil
258
+ state.pendingLaunchAttempts = 0
259
+ writeState(state)
260
+ }
261
+
262
+ func setMaxLaunchAttempts(_ max: Int) {
263
+ lock.lock(); defer { lock.unlock() }
264
+ var state = readState() ?? freshState()
265
+ state.maxLaunchAttempts = Swift.min(Swift.max(max, 1), 10)
266
+ writeState(state)
267
+ }
268
+
269
+ func clearAll() {
270
+ lock.lock(); defer { lock.unlock() }
271
+ try? FileManager.default.removeItem(at: updatesDir)
272
+ try? FileManager.default.removeItem(at: tmpDir)
273
+ writeState(freshState())
274
+ }
275
+
276
+ // MARK: - Helpers
277
+
278
+ func verifySha256(_ updateId: String) -> Bool {
279
+ guard let data = try? Data(contentsOf: updateJsonFile(updateId)),
280
+ let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
281
+ let expected = (json["sha256"] as? String)?.lowercased(), !expected.isEmpty else {
282
+ return false
283
+ }
284
+ return Sha256.fileHex(bundleFile(updateId)) == expected
285
+ }
286
+ }
@@ -0,0 +1,15 @@
1
+ import Foundation
2
+
3
+ /// Startup bundle resolver — the host's `GeneratedBundleResolver` delegates
4
+ /// here (declared as `ios.bundleResolverClass` in signalx-module.json).
5
+ ///
6
+ /// Runs synchronously in `ContentView.init` BEFORE any LynxView is built,
7
+ /// and mutates rollback state (the launch-attempt counter), so it must run
8
+ /// exactly once per process launch — which the generated host guarantees by
9
+ /// resolving into a stored `let`.
10
+ enum UpdatesBundleResolver {
11
+
12
+ static func resolveStartupBundlePath() -> String? {
13
+ UpdateStore.shared.resolveStartupBundlePath()
14
+ }
15
+ }
@@ -0,0 +1,59 @@
1
+ import Foundation
2
+ import CryptoKit
3
+
4
+ /// Module → publisher event bus (the `BackgroundEventBus` pattern). The
5
+ /// module emits download progress / foreground events here; the per-LynxView
6
+ /// `UpdatesLifecyclePublisher` pumps them into JS via `sendGlobalEvent` on
7
+ /// the `__sigxUpdatesEvent` channel.
8
+ final class UpdatesEventBus {
9
+
10
+ static let shared = UpdatesEventBus()
11
+ static let channel = "__sigxUpdatesEvent"
12
+
13
+ typealias Payload = [String: Any]
14
+ typealias Listener = (Payload) -> Void
15
+
16
+ private let queue = DispatchQueue(label: "com.sigx.updates.bus")
17
+ private var listeners: [(token: UUID, fn: Listener)] = []
18
+
19
+ @discardableResult
20
+ func addListener(_ fn: @escaping Listener) -> UUID {
21
+ let token = UUID()
22
+ queue.sync { listeners.append((token, fn)) }
23
+ return token
24
+ }
25
+
26
+ func removeListener(_ token: UUID) {
27
+ queue.sync { listeners.removeAll { $0.token == token } }
28
+ }
29
+
30
+ func emit(_ payload: Payload) {
31
+ let snapshot = queue.sync { listeners }
32
+ for (_, fn) in snapshot { fn(payload) }
33
+ }
34
+
35
+ func emitProgress(receivedBytes: Int64, totalBytes: Int64?) {
36
+ var payload: Payload = ["kind": "progress", "receivedBytes": receivedBytes]
37
+ if let total = totalBytes, total >= 0 { payload["totalBytes"] = total }
38
+ emit(payload)
39
+ }
40
+
41
+ func emitForeground() {
42
+ emit(["kind": "foreground"])
43
+ }
44
+ }
45
+
46
+ /// Streaming SHA-256 helpers (CryptoKit).
47
+ enum Sha256 {
48
+ static func fileHex(_ url: URL) -> String? {
49
+ guard let handle = try? FileHandle(forReadingFrom: url) else { return nil }
50
+ defer { try? handle.close() }
51
+ var hasher = SHA256()
52
+ while true {
53
+ let chunk = handle.readData(ofLength: 64 * 1024)
54
+ if chunk.isEmpty { break }
55
+ hasher.update(data: chunk)
56
+ }
57
+ return hasher.finalize().map { String(format: "%02x", $0) }.joined()
58
+ }
59
+ }
@@ -0,0 +1,48 @@
1
+ import Foundation
2
+ import UIKit
3
+ import Lynx
4
+
5
+ /// Per-LynxView publisher: pumps `UpdatesEventBus` payloads into JS
6
+ /// (`__sigxUpdatesEvent`), registers the view with `UpdateStore` so
7
+ /// `applyNow` has a reload target, and converts background→active app
8
+ /// transitions into `foreground` events for `checkOn: ['foreground']`.
9
+ /// Instantiated by the generated `GeneratedLifecyclePublishers.attachAll(to:)`.
10
+ final class UpdatesLifecyclePublisher {
11
+
12
+ private weak var lynxView: LynxView?
13
+ private var token: UUID?
14
+ private var observers: [NSObjectProtocol] = []
15
+ private var sawBackground = false
16
+
17
+ init(lynxView: LynxView) {
18
+ self.lynxView = lynxView
19
+ UpdateStore.shared.attachView(lynxView)
20
+ token = UpdatesEventBus.shared.addListener { [weak self] payload in
21
+ guard let view = self?.lynxView else { return }
22
+ view.sendGlobalEvent(UpdatesEventBus.channel, withParams: [payload])
23
+ }
24
+
25
+ // Foreground detection: only resume-after-background counts — cold
26
+ // start is covered by the JS 'launch' trigger.
27
+ let center = NotificationCenter.default
28
+ observers.append(center.addObserver(
29
+ forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main
30
+ ) { [weak self] _ in
31
+ self?.sawBackground = true
32
+ })
33
+ observers.append(center.addObserver(
34
+ forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main
35
+ ) { [weak self] _ in
36
+ guard let self, self.sawBackground else { return }
37
+ self.sawBackground = false
38
+ UpdatesEventBus.shared.emitForeground()
39
+ })
40
+ }
41
+
42
+ deinit {
43
+ if let token { UpdatesEventBus.shared.removeListener(token) }
44
+ for observer in observers {
45
+ NotificationCenter.default.removeObserver(observer)
46
+ }
47
+ }
48
+ }
@@ -0,0 +1,178 @@
1
+ import Foundation
2
+ import Lynx
3
+
4
+ /// JS bridge for OTA updates.
5
+ /// JS usage: NativeModules.Updates.downloadUpdate({...}, callback)
6
+ class UpdatesModule: NSObject, LynxModule {
7
+
8
+ @objc static var name: String { "Updates" }
9
+
10
+ @objc static var methodLookup: [String: String] {
11
+ [
12
+ "getInstalledRuntimeVersion": NSStringFromSelector(#selector(getInstalledRuntimeVersion)),
13
+ "getPlatform": NSStringFromSelector(#selector(getPlatform)),
14
+ "getCurrentUpdate": NSStringFromSelector(#selector(getCurrentUpdate(_:))),
15
+ "getState": NSStringFromSelector(#selector(getState(_:))),
16
+ "downloadUpdate": NSStringFromSelector(#selector(downloadUpdate(_:callback:))),
17
+ "applyOnNextLaunch": NSStringFromSelector(#selector(applyOnNextLaunch(_:callback:))),
18
+ "applyNow": NSStringFromSelector(#selector(applyNow(_:callback:))),
19
+ "markReady": NSStringFromSelector(#selector(markReady(_:))),
20
+ "setRollbackOptions": NSStringFromSelector(#selector(setRollbackOptions(_:callback:))),
21
+ "clearUpdates": NSStringFromSelector(#selector(clearUpdates(_:))),
22
+ ]
23
+ }
24
+
25
+ private static let downloadQueue = DispatchQueue(label: "com.sigx.updates.download")
26
+
27
+ required override init() { super.init() }
28
+ required init(param: Any) { super.init() }
29
+
30
+ private func errorMap(_ message: String, code: String? = nil) -> [String: Any] {
31
+ var map: [String: Any] = ["error": message]
32
+ if let code { map["code"] = code }
33
+ return map
34
+ }
35
+
36
+ @objc func getInstalledRuntimeVersion() -> String {
37
+ UpdateStore.shared.installedRuntimeVersion()
38
+ }
39
+
40
+ @objc func getPlatform() -> String { "ios" }
41
+
42
+ @objc func getCurrentUpdate(_ callback: LynxCallbackBlock?) {
43
+ let store = UpdateStore.shared
44
+ var map: [String: Any] = [
45
+ "runtimeVersion": store.installedRuntimeVersion(),
46
+ // Store-shipped app version — providers receive it as
47
+ // UpdateCheckContext.embeddedVersion.
48
+ "embeddedVersion": (Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String) ?? "",
49
+ "isFirstLaunchAfterUpdate": store.isFirstLaunchAfterUpdate,
50
+ "didRollBack": store.didRollBack,
51
+ ]
52
+ if let updateId = store.launchedUpdateId {
53
+ map["updateId"] = updateId
54
+ map["isEmbedded"] = false
55
+ if let data = try? Data(contentsOf: store.updateJsonFile(updateId)),
56
+ let meta = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
57
+ let version = meta["version"] as? String {
58
+ map["version"] = version
59
+ }
60
+ } else {
61
+ map["isEmbedded"] = true
62
+ }
63
+ if let rolledBack = store.rolledBackUpdateId {
64
+ map["rolledBackUpdateId"] = rolledBack
65
+ }
66
+ callback?(map)
67
+ }
68
+
69
+ @objc func getState(_ callback: LynxCallbackBlock?) {
70
+ let store = UpdateStore.shared
71
+ var map: [String: Any] = ["runningUpdateId": store.launchedUpdateId ?? ""]
72
+ if let state = store.readState() {
73
+ map["currentUpdateId"] = state.currentUpdateId ?? ""
74
+ map["previousUpdateId"] = state.previousUpdateId ?? ""
75
+ map["pendingUpdateId"] = state.pendingUpdateId ?? ""
76
+ map["pendingLaunchAttempts"] = state.pendingLaunchAttempts
77
+ map["maxLaunchAttempts"] = state.maxLaunchAttempts
78
+ map["lastRollbackUpdateId"] = state.lastRollbackUpdateId ?? ""
79
+ map["lastRollbackReason"] = state.lastRollbackReason ?? ""
80
+ }
81
+ callback?(map)
82
+ }
83
+
84
+ @objc func downloadUpdate(_ params: [String: Any]?, callback: LynxCallbackBlock?) {
85
+ guard let url = params?["url"] as? String, !url.isEmpty,
86
+ let sha256 = params?["sha256"] as? String, !sha256.isEmpty,
87
+ let updateId = params?["updateId"] as? String, !updateId.isEmpty else {
88
+ callback?(errorMap("url, sha256 and updateId are required"))
89
+ return
90
+ }
91
+
92
+ // Refuse incompatible bundles at the door — defense in depth on top
93
+ // of the JS-side check.
94
+ let installed = UpdateStore.shared.installedRuntimeVersion()
95
+ if let runtimeVersion = params?["runtimeVersion"] as? String,
96
+ !runtimeVersion.isEmpty, runtimeVersion != installed {
97
+ callback?(errorMap(
98
+ "Update requires runtime \(runtimeVersion) but binary is \(installed)",
99
+ code: "E_RUNTIME_MISMATCH"))
100
+ return
101
+ }
102
+
103
+ let headers = (params?["headers"] as? [String: String]) ?? [:]
104
+ let manifestJson = (params?["manifestJson"] as? String) ?? "{}"
105
+
106
+ Self.downloadQueue.async {
107
+ let result = UpdateDownloader.download(
108
+ url: url, expectedSha256: sha256, updateId: updateId,
109
+ headers: headers, manifestJson: manifestJson)
110
+ if let result {
111
+ let code: String? = result.hasPrefix("E_DOWNLOAD_IN_PROGRESS") ? "E_DOWNLOAD_IN_PROGRESS"
112
+ : result.hasPrefix("E_HASH_MISMATCH") ? "hash-mismatch"
113
+ : nil
114
+ callback?(self.errorMap(result, code: code))
115
+ } else {
116
+ callback?(["ok": true])
117
+ }
118
+ }
119
+ }
120
+
121
+ @objc func applyOnNextLaunch(_ updateId: String?, callback: LynxCallbackBlock?) {
122
+ guard let updateId, !updateId.isEmpty else {
123
+ callback?(errorMap("updateId is required"))
124
+ return
125
+ }
126
+ if let failure = UpdateStore.shared.stagePending(updateId) {
127
+ callback?(errorMap(failure))
128
+ } else {
129
+ callback?(["ok": true])
130
+ }
131
+ }
132
+
133
+ @objc func applyNow(_ updateId: String?, callback: LynxCallbackBlock?) {
134
+ guard let updateId, !updateId.isEmpty else {
135
+ callback?(errorMap("updateId is required"))
136
+ return
137
+ }
138
+ let store = UpdateStore.shared
139
+ let bundle = store.bundleFile(updateId)
140
+ guard FileManager.default.fileExists(atPath: bundle.path) else {
141
+ callback?(errorMap("Update \(updateId) is not on disk"))
142
+ return
143
+ }
144
+ guard let view = store.currentView() else {
145
+ callback?(errorMap("No LynxView attached — cannot reload in place", code: "E_NO_VIEW"))
146
+ return
147
+ }
148
+ // Stage first so a crash mid-reload still gets crash-guarded rollback
149
+ // (the reload bypasses the startup resolver).
150
+ _ = store.stagePending(updateId)
151
+ store.recordReloadAttempt(updateId)
152
+ DispatchQueue.main.async {
153
+ guard let data = try? Data(contentsOf: bundle) else {
154
+ callback?(self.errorMap("Could not read \(bundle.path)", code: "apply-failed"))
155
+ return
156
+ }
157
+ view.loadTemplate(data, withURL: bundle.path)
158
+ // JS context is replaced — the callback never reaches the old
159
+ // context on success, which is the documented contract.
160
+ }
161
+ }
162
+
163
+ @objc func markReady(_ callback: LynxCallbackBlock?) {
164
+ UpdateStore.shared.markReady()
165
+ callback?(["ok": true])
166
+ }
167
+
168
+ @objc func setRollbackOptions(_ params: [String: Any]?, callback: LynxCallbackBlock?) {
169
+ let max = (params?["maxFailedLaunches"] as? Int) ?? 2
170
+ UpdateStore.shared.setMaxLaunchAttempts(max)
171
+ callback?(["ok": true])
172
+ }
173
+
174
+ @objc func clearUpdates(_ callback: LynxCallbackBlock?) {
175
+ UpdateStore.shared.clearAll()
176
+ callback?(["ok": true])
177
+ }
178
+ }
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@sigx/lynx-updates",
3
+ "version": "0.6.1",
4
+ "description": "OTA bundle updates for sigx-lynx — pluggable backends, update modes, crash rollback",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ },
13
+ "./signalx-module.json": "./signalx-module.json"
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "ios",
18
+ "android",
19
+ "signalx-module.json"
20
+ ],
21
+ "dependencies": {
22
+ "@sigx/lynx-core": "^0.6.1",
23
+ "@sigx/lynx": "^0.6.1"
24
+ },
25
+ "devDependencies": {
26
+ "@typescript/native-preview": "7.0.0-dev.20260521.1",
27
+ "typescript": "^6.0.3"
28
+ },
29
+ "author": "Andreas Ekdahl",
30
+ "license": "MIT",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/signalxjs/lynx.git",
34
+ "directory": "packages/lynx-updates"
35
+ },
36
+ "homepage": "https://sigx.dev/lynx/modules/updates/overview/",
37
+ "bugs": {
38
+ "url": "https://github.com/signalxjs/lynx/issues"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "keywords": [
44
+ "signalx",
45
+ "sigx",
46
+ "lynx",
47
+ "mobile",
48
+ "ios",
49
+ "android",
50
+ "ota",
51
+ "updates",
52
+ "codepush"
53
+ ],
54
+ "scripts": {
55
+ "build": "node ../../scripts/clean.mjs dist && tsgo",
56
+ "dev": "tsgo --watch",
57
+ "clean": "node ../../scripts/clean.mjs dist .turbo"
58
+ }
59
+ }
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "Updates",
3
+ "package": "@sigx/lynx-updates",
4
+ "description": "OTA bundle updates: download, two-phase apply, crash rollback",
5
+ "platforms": ["android", "ios"],
6
+ "android": {
7
+ "moduleClass": "com.sigx.updates.UpdatesModule",
8
+ "publisherClass": "com.sigx.updates.UpdatesLifecyclePublisher",
9
+ "bundleResolverClass": "com.sigx.updates.UpdatesBundleResolver",
10
+ "activityHook": {
11
+ "class": "com.sigx.updates.UpdatesActivityHook",
12
+ "methods": ["onResume", "onPause"]
13
+ },
14
+ "sourceDir": "android",
15
+ "permissions": ["android.permission.INTERNET"]
16
+ },
17
+ "ios": {
18
+ "moduleClass": "UpdatesModule",
19
+ "publisherClass": "UpdatesLifecyclePublisher",
20
+ "bundleResolverClass": "UpdatesBundleResolver",
21
+ "sourceDir": "ios",
22
+ "methods": [
23
+ "getInstalledRuntimeVersion",
24
+ "getPlatform",
25
+ "getState",
26
+ "getCurrentUpdate",
27
+ "downloadUpdate",
28
+ "applyOnNextLaunch",
29
+ "applyNow",
30
+ "markReady",
31
+ "setRollbackOptions",
32
+ "clearUpdates"
33
+ ]
34
+ }
35
+ }