@lattices/cli 0.4.8 → 0.4.10

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.10</string>
30
30
  <key>CFBundleShortVersionString</key>
31
- <string>0.4.8</string>
31
+ <string>0.4.10</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))
@@ -43,6 +43,7 @@ final class DesktopModel: ObservableObject {
43
43
  "storeuid",
44
44
  // Third-party helpers
45
45
  "CursorUIViewService",
46
+ "Codex Computer Use",
46
47
  "Electron Helper",
47
48
  "Google Chrome Helper",
48
49
  ]
@@ -144,7 +144,7 @@ enum CanvasDragMode {
144
144
 
145
145
  final class ScreenMapEditorState: ObservableObject {
146
146
  @Published var windows: [ScreenMapWindowEntry]
147
- @Published var selectedLayers: Set<Int> = [0] // empty = show all
147
+ @Published var selectedLayers: Set<Int> = [] // empty = show all
148
148
  @Published var draggingWindowId: UInt32? = nil
149
149
  var canvasDragMode: CanvasDragMode = .move
150
150
  var currentCursorMode: CanvasDragMode = .move
@@ -153,7 +153,7 @@ final class ScreenMapEditorState: ObservableObject {
153
153
  @Published var zoomLevel: CGFloat = 1.0 // 1.0 = fit-all
154
154
  @Published var panOffset: CGPoint = .zero // canvas-local pixels
155
155
  @Published var focusedDisplayIndex: Int? = nil // nil = all-displays view
156
- @Published var activeViewportPreset: ScreenMapViewportPreset? = .main
156
+ @Published var activeViewportPreset: ScreenMapViewportPreset? = .overview
157
157
  @Published var windowSearchQuery: String = ""
158
158
  @Published var isTilingMode: Bool = false
159
159
  var isSearching: Bool { !windowSearchQuery.isEmpty }
@@ -380,10 +380,11 @@ final class ScreenMapEditorState: ObservableObject {
380
380
  return "L\(layer)"
381
381
  }
382
382
 
383
- /// Windows visible for the active layer filter
383
+ /// Windows visible on the current desktop for the active layer filter.
384
384
  var visibleWindows: [ScreenMapWindowEntry] {
385
- guard !selectedLayers.isEmpty else { return windows }
386
- return windows.filter { selectedLayers.contains($0.layer) }
385
+ let onscreen = windows.filter(\.isOnScreen)
386
+ guard !selectedLayers.isEmpty else { return onscreen }
387
+ return onscreen.filter { selectedLayers.contains($0.layer) }
387
388
  }
388
389
 
389
390
  private var worldScopedDisplays: [DisplayGeometry] {
@@ -398,7 +399,7 @@ final class ScreenMapEditorState: ObservableObject {
398
399
 
399
400
  var canvasWorldBounds: CGRect {
400
401
  var rects = worldScopedDisplays.map(\.cgRect)
401
- rects.append(contentsOf: worldScopedWindows.map(\.virtualFrame))
402
+ rects.append(contentsOf: worldScopedWindows.filter(\.hasEdits).map(\.virtualFrame))
402
403
 
403
404
  if rects.isEmpty {
404
405
  return CGRect(origin: bboxOrigin, size: screenSize)
@@ -409,7 +410,7 @@ final class ScreenMapEditorState: ObservableObject {
409
410
  union = union.union(rect)
410
411
  }
411
412
 
412
- let pad: CGFloat = focusedDisplayIndex == nil ? 120 : 80
413
+ let pad: CGFloat = focusedDisplayIndex == nil ? 180 : 120
413
414
  return union.insetBy(dx: -pad, dy: -pad)
414
415
  }
415
416
 
@@ -1424,7 +1425,7 @@ final class ScreenMapController: ObservableObject {
1424
1425
 
1425
1426
  private func finalizeDisplayFocusChange(flashLabel: Bool) {
1426
1427
  guard let ed = editor else { return }
1427
- focusViewportPreset(ed.activeViewportPreset ?? .main, flashView: false)
1428
+ focusViewportPreset(ed.activeViewportPreset ?? .overview, flashView: false)
1428
1429
  if flashLabel {
1429
1430
  flash(ed.focusedDisplay?.label ?? "All displays")
1430
1431
  }
@@ -1454,6 +1455,7 @@ final class ScreenMapController: ObservableObject {
1454
1455
  guard newZoom != ed.zoomLevel else { return }
1455
1456
  ed.activeViewportPreset = nil
1456
1457
  ed.zoomLevel = newZoom
1458
+ ed.scale = ed.fitScale * newZoom
1457
1459
  objectWillChange.send()
1458
1460
  }
1459
1461
 
@@ -1677,7 +1679,7 @@ final class ScreenMapController: ObservableObject {
1677
1679
  func enter() {
1678
1680
  let existingSets = windowSets
1679
1681
  guard let windowList = CGWindowListCopyWindowInfo(
1680
- [.optionAll, .excludeDesktopElements], kCGNullWindowID
1682
+ [.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID
1681
1683
  ) as? [[String: Any]] else { return }
1682
1684
 
1683
1685
  struct CGWin {
@@ -1724,7 +1726,7 @@ final class ScreenMapController: ObservableObject {
1724
1726
  guard CGRectMakeWithDictionaryRepresentation(boundsDict, &rect) else { continue }
1725
1727
  guard rect.width >= 100 && rect.height >= 50 else { continue }
1726
1728
  let app = info[kCGWindowOwnerName as String] as? String ?? ""
1727
- if app == "Lattices" || app == "lattices" || app == "AutoFill" { continue }
1729
+ if app == "Lattices" || app == "lattices" || app == "AutoFill" || app == "Codex Computer Use" { continue }
1728
1730
  let pid = info[kCGWindowOwnerPID as String] as? Int32 ?? 0
1729
1731
  let title = info[kCGWindowName as String] as? String ?? ""
1730
1732
  let dIdx = displayIndex(for: rect)
@@ -1844,15 +1846,14 @@ final class ScreenMapController: ObservableObject {
1844
1846
  }
1845
1847
  }
1846
1848
 
1847
- // Auto-focus the display where the mouse cursor is
1848
- if screens.count > 1 {
1849
+ // Start monitor-first: focus the display under the cursor, or the first display.
1850
+ if !displayGeometries.isEmpty {
1849
1851
  let mouseLocation = NSEvent.mouseLocation
1850
1852
  let mouseCG = CGPoint(x: mouseLocation.x, y: primaryHeight - mouseLocation.y)
1851
- for disp in displayGeometries {
1852
- if disp.cgRect.contains(mouseCG) {
1853
- newEditor.focusedDisplayIndex = disp.index
1854
- break
1855
- }
1853
+ if let display = displayGeometries.first(where: { $0.cgRect.contains(mouseCG) }) {
1854
+ newEditor.focusedDisplayIndex = display.index
1855
+ } else {
1856
+ newEditor.focusedDisplayIndex = displayGeometries[0].index
1856
1857
  }
1857
1858
  }
1858
1859
 
@@ -1867,7 +1868,12 @@ final class ScreenMapController: ObservableObject {
1867
1868
  self.activeWindowSetID = nil
1868
1869
  }
1869
1870
  selectedWindowIds = []
1870
- focusViewportPreset(.main, flashView: false)
1871
+ focusViewportPreset(.overview, flashView: false)
1872
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { [weak self] in
1873
+ guard let self,
1874
+ self.editor?.activeViewportPreset == .overview else { return }
1875
+ self.focusViewportPreset(.overview, flashView: false)
1876
+ }
1871
1877
  }
1872
1878
 
1873
1879
  /// Re-snapshot, preserving display/layer context
@@ -1914,10 +1920,15 @@ final class ScreenMapController: ObservableObject {
1914
1920
  ed.activeViewportPreset = preset
1915
1921
  let rect = ed.viewportRect(for: preset)
1916
1922
  DiagnosticLog.shared.info("[Canvas] preset → \(preset.title)")
1923
+ if preset == .overview {
1924
+ ed.zoomLevel = 1
1925
+ ed.scale = ed.fitScale
1926
+ ed.panOffset = .zero
1927
+ }
1917
1928
  queueCanvasNavigation(
1918
1929
  centeredOn: CGPoint(x: rect.midX, y: rect.midY),
1919
1930
  rect: rect,
1920
- zoomToFit: true
1931
+ zoomToFit: preset != .overview
1921
1932
  )
1922
1933
  if flashView {
1923
1934
  flash(preset.title)
@@ -1960,6 +1971,14 @@ final class ScreenMapController: ObservableObject {
1960
1971
  }
1961
1972
 
1962
1973
  setViewport(centeredOn: target.center, zoomLevel: targetZoom)
1974
+ if target.zoomToFit {
1975
+ ed.pendingCanvasNavigation = ScreenMapCanvasNavigationTarget(
1976
+ center: target.center,
1977
+ rect: nil,
1978
+ zoomToFit: false
1979
+ )
1980
+ ed.canvasNavigationRevision &+= 1
1981
+ }
1963
1982
  if shouldLogView {
1964
1983
  let viewport = ed.viewportWorldRect
1965
1984
  DiagnosticLog.shared.info(