@sigx/lynx-updates 0.8.0 → 0.8.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.
- package/LICENSE +21 -21
- package/README.md +199 -199
- 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/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,59 +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
|
-
}
|
|
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
|
+
}
|
|
@@ -1,48 +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
|
-
}
|
|
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
|
+
}
|
package/ios/UpdatesModule.swift
CHANGED
|
@@ -1,178 +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
|
-
}
|
|
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sigx/lynx-updates",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "OTA bundle updates for sigx-lynx — pluggable backends, update modes, crash rollback",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
"signalx-module.json"
|
|
20
20
|
],
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@sigx/lynx
|
|
23
|
-
"@sigx/lynx": "^0.8.
|
|
22
|
+
"@sigx/lynx": "^0.8.1",
|
|
23
|
+
"@sigx/lynx-core": "^0.8.1"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@typescript/native-preview": "7.0.0-dev.20260521.1",
|