@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.8</string>
29
+ <string>0.4.9</string>
30
30
  <key>CFBundleShortVersionString</key>
31
- <string>0.4.8</string>
31
+ <string>0.4.9</string>
32
32
  <key>LSMinimumSystemVersion</key>
33
33
  <string>13.0</string>
34
34
  <key>LSUIElement</key>
@@ -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
- alert.messageText = "Install the latest Lattices app update?"
42
- alert.informativeText = "Lattices will download the latest released app bundle, close, and relaunch when the update is ready."
43
- alert.addButton(withTitle: "Update")
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
- statusMessage = "Updating to the latest release. Lattices will relaunch when it's ready."
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("Install the latest published app build without leaving the menu bar. The app relaunches when the update finishes.")
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 ? "Updating…" : "Update Lattices")
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lattices/cli",
3
- "version": "0.4.8",
3
+ "version": "0.4.9",
4
4
  "description": "Agentic window manager for macOS — programmable workspace, smart layouts, managed tmux sessions, and a 35+-method agent API",
5
5
  "bin": {
6
6
  "lattices": "./bin/lattices.ts",