@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
package/ios/UpdateStore.swift
CHANGED
|
@@ -1,286 +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
|
-
}
|
|
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
|
+
}
|
|
@@ -1,15 +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
|
-
}
|
|
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
|
+
}
|