@lattices/cli 0.4.8 → 0.4.9
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.
|
@@ -26,9 +26,9 @@
|
|
|
26
26
|
</dict>
|
|
27
27
|
</array>
|
|
28
28
|
<key>CFBundleVersion</key>
|
|
29
|
-
<string>0.4.
|
|
29
|
+
<string>0.4.9</string>
|
|
30
30
|
<key>CFBundleShortVersionString</key>
|
|
31
|
-
<string>0.4.
|
|
31
|
+
<string>0.4.9</string>
|
|
32
32
|
<key>LSMinimumSystemVersion</key>
|
|
33
33
|
<string>13.0</string>
|
|
34
34
|
<key>LSUIElement</key>
|
|
Binary file
|
|
@@ -194,6 +194,10 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
|
|
194
194
|
AgentPool.shared.start()
|
|
195
195
|
diag.finish(tBoot)
|
|
196
196
|
|
|
197
|
+
Task {
|
|
198
|
+
await AppUpdater.shared.checkIfNeeded()
|
|
199
|
+
}
|
|
200
|
+
|
|
197
201
|
// --diagnostics flag: auto-open diagnostics panel on launch
|
|
198
202
|
if CommandLine.arguments.contains("--diagnostics") {
|
|
199
203
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import AppKit
|
|
2
2
|
import Combine
|
|
3
3
|
import Foundation
|
|
4
|
+
import SwiftUI
|
|
5
|
+
|
|
6
|
+
struct LatticesUpdateInfo: Equatable {
|
|
7
|
+
let version: String
|
|
8
|
+
let downloadURL: URL
|
|
9
|
+
let releaseNotes: String
|
|
10
|
+
let publishedAt: Date
|
|
11
|
+
let htmlURL: URL
|
|
12
|
+
}
|
|
4
13
|
|
|
5
14
|
@MainActor
|
|
6
15
|
final class AppUpdater: ObservableObject {
|
|
@@ -8,6 +17,16 @@ final class AppUpdater: ObservableObject {
|
|
|
8
17
|
|
|
9
18
|
@Published private(set) var isUpdating = false
|
|
10
19
|
@Published private(set) var statusMessage: String?
|
|
20
|
+
@Published private(set) var availableUpdate: LatticesUpdateInfo?
|
|
21
|
+
@Published private(set) var isChecking = false
|
|
22
|
+
@Published private(set) var lastChecked: Date?
|
|
23
|
+
@Published private(set) var lastError: String?
|
|
24
|
+
|
|
25
|
+
@AppStorage("appUpdater.autoCheck") var autoCheckEnabled = true
|
|
26
|
+
@AppStorage("appUpdater.lastCheckTime") private var lastCheckTimeInterval: Double = 0
|
|
27
|
+
@AppStorage("appUpdater.skippedVersion") private var skippedVersion = ""
|
|
28
|
+
|
|
29
|
+
private let checkInterval: TimeInterval = 24 * 60 * 60
|
|
11
30
|
|
|
12
31
|
private init() {}
|
|
13
32
|
|
|
@@ -27,6 +46,62 @@ final class AppUpdater: ObservableObject {
|
|
|
27
46
|
return nil
|
|
28
47
|
}
|
|
29
48
|
|
|
49
|
+
func checkIfNeeded() async {
|
|
50
|
+
guard autoCheckEnabled else { return }
|
|
51
|
+
|
|
52
|
+
let now = Date()
|
|
53
|
+
let lastCheck = Date(timeIntervalSince1970: lastCheckTimeInterval)
|
|
54
|
+
if now.timeIntervalSince(lastCheck) < checkInterval { return }
|
|
55
|
+
|
|
56
|
+
await check()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
func check() async {
|
|
60
|
+
guard !isChecking else { return }
|
|
61
|
+
|
|
62
|
+
isChecking = true
|
|
63
|
+
lastError = nil
|
|
64
|
+
|
|
65
|
+
defer {
|
|
66
|
+
isChecking = false
|
|
67
|
+
lastChecked = Date()
|
|
68
|
+
lastCheckTimeInterval = Date().timeIntervalSince1970
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
do {
|
|
72
|
+
let release = try await fetchLatestRelease()
|
|
73
|
+
guard let update = parseRelease(release), isNewerVersion(update.version) else {
|
|
74
|
+
availableUpdate = nil
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if update.version == skippedVersion {
|
|
79
|
+
availableUpdate = nil
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
availableUpdate = update
|
|
84
|
+
} catch UpdateCheckError.noRelease {
|
|
85
|
+
availableUpdate = nil
|
|
86
|
+
} catch {
|
|
87
|
+
lastError = error.localizedDescription
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
func skipCurrentUpdate() {
|
|
92
|
+
guard let update = availableUpdate else { return }
|
|
93
|
+
skippedVersion = update.version
|
|
94
|
+
availableUpdate = nil
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
func viewCurrentRelease() {
|
|
98
|
+
if let update = availableUpdate {
|
|
99
|
+
NSWorkspace.shared.open(update.htmlURL)
|
|
100
|
+
} else if let url = URL(string: "https://github.com/arach/lattices/releases/latest") {
|
|
101
|
+
NSWorkspace.shared.open(url)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
30
105
|
func promptForUpdate() {
|
|
31
106
|
guard canUpdate else {
|
|
32
107
|
presentAlert(
|
|
@@ -36,11 +111,50 @@ final class AppUpdater: ObservableObject {
|
|
|
36
111
|
return
|
|
37
112
|
}
|
|
38
113
|
|
|
114
|
+
guard availableUpdate != nil else {
|
|
115
|
+
Task {
|
|
116
|
+
await check()
|
|
117
|
+
if availableUpdate != nil {
|
|
118
|
+
presentUpdateConfirmation()
|
|
119
|
+
} else if let error = lastError {
|
|
120
|
+
presentAlert(
|
|
121
|
+
title: "Could Not Check for Updates",
|
|
122
|
+
message: error
|
|
123
|
+
)
|
|
124
|
+
} else {
|
|
125
|
+
presentAlert(
|
|
126
|
+
title: "Lattices Is Up to Date",
|
|
127
|
+
message: "You’re running \(currentVersion), which is the latest published release."
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
presentUpdateConfirmation()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private func presentUpdateConfirmation() {
|
|
39
138
|
let alert = NSAlert()
|
|
40
139
|
alert.alertStyle = .informational
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
140
|
+
if let update = availableUpdate {
|
|
141
|
+
alert.messageText = "Update Lattices?"
|
|
142
|
+
alert.informativeText = """
|
|
143
|
+
Current version: \(currentVersion)
|
|
144
|
+
New version: \(update.version)
|
|
145
|
+
|
|
146
|
+
Lattices will download the signed release, quit briefly, replace the app, and relaunch when the update is ready.
|
|
147
|
+
"""
|
|
148
|
+
} else {
|
|
149
|
+
alert.messageText = "Check and update Lattices?"
|
|
150
|
+
alert.informativeText = """
|
|
151
|
+
Current version: \(currentVersion)
|
|
152
|
+
New version: latest published release
|
|
153
|
+
|
|
154
|
+
Lattices will download the signed release, quit briefly, replace the app, and relaunch when the update is ready.
|
|
155
|
+
"""
|
|
156
|
+
}
|
|
157
|
+
alert.addButton(withTitle: availableUpdate == nil ? "Check & Update" : "Update")
|
|
44
158
|
alert.addButton(withTitle: "Cancel")
|
|
45
159
|
|
|
46
160
|
guard alert.runModal() == .alertFirstButtonReturn else { return }
|
|
@@ -72,7 +186,11 @@ final class AppUpdater: ObservableObject {
|
|
|
72
186
|
do {
|
|
73
187
|
try proc.run()
|
|
74
188
|
isUpdating = true
|
|
75
|
-
|
|
189
|
+
if let update = availableUpdate {
|
|
190
|
+
statusMessage = "Preparing Lattices \(update.version). The app will relaunch when the update is ready."
|
|
191
|
+
} else {
|
|
192
|
+
statusMessage = "Preparing the latest Lattices release. The app will relaunch when the update is ready."
|
|
193
|
+
}
|
|
76
194
|
} catch {
|
|
77
195
|
presentAlert(
|
|
78
196
|
title: "Update Failed",
|
|
@@ -89,4 +207,98 @@ final class AppUpdater: ObservableObject {
|
|
|
89
207
|
alert.addButton(withTitle: "OK")
|
|
90
208
|
alert.runModal()
|
|
91
209
|
}
|
|
210
|
+
|
|
211
|
+
private func fetchLatestRelease() async throws -> GitHubRelease {
|
|
212
|
+
let url = URL(string: "https://api.github.com/repos/arach/lattices/releases/latest")!
|
|
213
|
+
var request = URLRequest(url: url)
|
|
214
|
+
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
|
|
215
|
+
request.setValue("Lattices/\(currentVersion)", forHTTPHeaderField: "User-Agent")
|
|
216
|
+
|
|
217
|
+
let (data, response) = try await URLSession.shared.data(for: request)
|
|
218
|
+
guard let http = response as? HTTPURLResponse else {
|
|
219
|
+
throw UpdateCheckError.invalidResponse
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
switch http.statusCode {
|
|
223
|
+
case 200:
|
|
224
|
+
let decoder = JSONDecoder()
|
|
225
|
+
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
|
226
|
+
decoder.dateDecodingStrategy = .iso8601
|
|
227
|
+
return try decoder.decode(GitHubRelease.self, from: data)
|
|
228
|
+
case 404:
|
|
229
|
+
throw UpdateCheckError.noRelease
|
|
230
|
+
case 403:
|
|
231
|
+
throw UpdateCheckError.rateLimited
|
|
232
|
+
default:
|
|
233
|
+
throw UpdateCheckError.httpError(http.statusCode)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private func parseRelease(_ release: GitHubRelease) -> LatticesUpdateInfo? {
|
|
238
|
+
guard !release.draft, !release.prerelease else { return nil }
|
|
239
|
+
|
|
240
|
+
let asset = release.assets.first { asset in
|
|
241
|
+
asset.name == "Lattices.dmg" ||
|
|
242
|
+
(asset.name.hasPrefix("Lattices") && asset.name.hasSuffix(".dmg"))
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
guard let asset,
|
|
246
|
+
let downloadURL = URL(string: asset.browserDownloadUrl),
|
|
247
|
+
let htmlURL = URL(string: release.htmlUrl) else {
|
|
248
|
+
return nil
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
let version = release.tagName.hasPrefix("v")
|
|
252
|
+
? String(release.tagName.dropFirst())
|
|
253
|
+
: release.tagName
|
|
254
|
+
|
|
255
|
+
return LatticesUpdateInfo(
|
|
256
|
+
version: version,
|
|
257
|
+
downloadURL: downloadURL,
|
|
258
|
+
releaseNotes: release.body ?? "",
|
|
259
|
+
publishedAt: release.publishedAt,
|
|
260
|
+
htmlURL: htmlURL
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private func isNewerVersion(_ remoteVersion: String) -> Bool {
|
|
265
|
+
guard currentVersion != "unknown" else { return false }
|
|
266
|
+
return remoteVersion.compare(currentVersion, options: .numeric) == .orderedDescending
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private struct GitHubRelease: Decodable {
|
|
271
|
+
let tagName: String
|
|
272
|
+
let name: String
|
|
273
|
+
let body: String?
|
|
274
|
+
let htmlUrl: String
|
|
275
|
+
let publishedAt: Date
|
|
276
|
+
let assets: [GitHubAsset]
|
|
277
|
+
let prerelease: Bool
|
|
278
|
+
let draft: Bool
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private struct GitHubAsset: Decodable {
|
|
282
|
+
let name: String
|
|
283
|
+
let browserDownloadUrl: String
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private enum UpdateCheckError: LocalizedError {
|
|
287
|
+
case invalidResponse
|
|
288
|
+
case noRelease
|
|
289
|
+
case rateLimited
|
|
290
|
+
case httpError(Int)
|
|
291
|
+
|
|
292
|
+
var errorDescription: String? {
|
|
293
|
+
switch self {
|
|
294
|
+
case .invalidResponse:
|
|
295
|
+
return "Invalid response from GitHub."
|
|
296
|
+
case .noRelease:
|
|
297
|
+
return "No published release found."
|
|
298
|
+
case .rateLimited:
|
|
299
|
+
return "GitHub rate limited the update check."
|
|
300
|
+
case .httpError(let code):
|
|
301
|
+
return "GitHub returned HTTP \(code)."
|
|
302
|
+
}
|
|
303
|
+
}
|
|
92
304
|
}
|
|
@@ -301,15 +301,53 @@ struct SettingsContentView: View {
|
|
|
301
301
|
.font(Typo.mono(12))
|
|
302
302
|
.foregroundColor(Palette.text)
|
|
303
303
|
Spacer()
|
|
304
|
-
Text("v\(appUpdater.currentVersion)")
|
|
304
|
+
Text("Current v\(appUpdater.currentVersion)")
|
|
305
305
|
.font(Typo.caption(10))
|
|
306
306
|
.foregroundColor(Palette.textMuted)
|
|
307
307
|
}
|
|
308
308
|
|
|
309
|
-
Text("
|
|
309
|
+
Text("Lattices can check for new signed releases and prepare the update here. You’ll confirm before the app quits and relaunches.")
|
|
310
310
|
.font(Typo.caption(10))
|
|
311
311
|
.foregroundColor(Palette.textMuted)
|
|
312
312
|
|
|
313
|
+
if let update = appUpdater.availableUpdate {
|
|
314
|
+
VStack(alignment: .leading, spacing: 6) {
|
|
315
|
+
HStack(spacing: 6) {
|
|
316
|
+
Image(systemName: "gift.fill")
|
|
317
|
+
.font(.system(size: 10, weight: .semibold))
|
|
318
|
+
.foregroundColor(Palette.detach)
|
|
319
|
+
Text("New version v\(update.version) is ready")
|
|
320
|
+
.font(Typo.monoBold(10))
|
|
321
|
+
.foregroundColor(Palette.detach)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if !update.releaseNotes.isEmpty {
|
|
325
|
+
Text(String(update.releaseNotes.prefix(180)) + (update.releaseNotes.count > 180 ? "..." : ""))
|
|
326
|
+
.font(Typo.caption(9))
|
|
327
|
+
.foregroundColor(Palette.textMuted)
|
|
328
|
+
.lineLimit(3)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
.padding(8)
|
|
332
|
+
.background(
|
|
333
|
+
RoundedRectangle(cornerRadius: 5)
|
|
334
|
+
.fill(Palette.surfaceHov.opacity(0.65))
|
|
335
|
+
.overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Palette.detach.opacity(0.35), lineWidth: 0.5))
|
|
336
|
+
)
|
|
337
|
+
} else if appUpdater.isChecking {
|
|
338
|
+
Text("Checking for updates...")
|
|
339
|
+
.font(Typo.caption(9))
|
|
340
|
+
.foregroundColor(Palette.textMuted)
|
|
341
|
+
} else if let error = appUpdater.lastError {
|
|
342
|
+
Text(error)
|
|
343
|
+
.font(Typo.caption(9))
|
|
344
|
+
.foregroundColor(Palette.detach.opacity(0.9))
|
|
345
|
+
} else if let checked = appUpdater.lastChecked {
|
|
346
|
+
Text("Last checked \(checked, style: .relative)")
|
|
347
|
+
.font(Typo.caption(9))
|
|
348
|
+
.foregroundColor(Palette.textMuted.opacity(0.8))
|
|
349
|
+
}
|
|
350
|
+
|
|
313
351
|
if let status = appUpdater.statusMessage {
|
|
314
352
|
Text(status)
|
|
315
353
|
.font(Typo.caption(9))
|
|
@@ -326,7 +364,7 @@ struct SettingsContentView: View {
|
|
|
326
364
|
Button {
|
|
327
365
|
appUpdater.promptForUpdate()
|
|
328
366
|
} label: {
|
|
329
|
-
Text(appUpdater.isUpdating ? "
|
|
367
|
+
Text(appUpdater.isUpdating ? "Preparing…" : (appUpdater.availableUpdate == nil ? "Check for Updates" : "Update to v\(appUpdater.availableUpdate?.version ?? "")"))
|
|
330
368
|
.font(Typo.monoBold(10))
|
|
331
369
|
.foregroundColor(Palette.text)
|
|
332
370
|
.padding(.horizontal, 12)
|
|
@@ -340,6 +378,43 @@ struct SettingsContentView: View {
|
|
|
340
378
|
.buttonStyle(.plain)
|
|
341
379
|
.disabled(appUpdater.isUpdating)
|
|
342
380
|
|
|
381
|
+
Button {
|
|
382
|
+
Task { await appUpdater.check() }
|
|
383
|
+
} label: {
|
|
384
|
+
Text(appUpdater.isChecking ? "Checking..." : "Check Now")
|
|
385
|
+
.font(Typo.caption(9))
|
|
386
|
+
.foregroundColor(Palette.textMuted.opacity(0.9))
|
|
387
|
+
}
|
|
388
|
+
.buttonStyle(.plain)
|
|
389
|
+
.disabled(appUpdater.isChecking)
|
|
390
|
+
|
|
391
|
+
Toggle("Auto", isOn: $appUpdater.autoCheckEnabled)
|
|
392
|
+
.font(Typo.caption(9))
|
|
393
|
+
.toggleStyle(.checkbox)
|
|
394
|
+
.foregroundColor(Palette.textMuted.opacity(0.9))
|
|
395
|
+
|
|
396
|
+
if appUpdater.availableUpdate != nil {
|
|
397
|
+
Button {
|
|
398
|
+
appUpdater.viewCurrentRelease()
|
|
399
|
+
} label: {
|
|
400
|
+
Text("Release Notes")
|
|
401
|
+
.font(Typo.caption(9))
|
|
402
|
+
.foregroundColor(Palette.textMuted.opacity(0.9))
|
|
403
|
+
}
|
|
404
|
+
.buttonStyle(.plain)
|
|
405
|
+
|
|
406
|
+
Button {
|
|
407
|
+
appUpdater.skipCurrentUpdate()
|
|
408
|
+
} label: {
|
|
409
|
+
Text("Skip")
|
|
410
|
+
.font(Typo.caption(9))
|
|
411
|
+
.foregroundColor(Palette.textMuted.opacity(0.75))
|
|
412
|
+
}
|
|
413
|
+
.buttonStyle(.plain)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
Spacer()
|
|
417
|
+
|
|
343
418
|
Text("CLI: `lattices app update`")
|
|
344
419
|
.font(Typo.caption(9))
|
|
345
420
|
.foregroundColor(Palette.textMuted.opacity(0.8))
|
package/package.json
CHANGED