@lattices/cli 0.4.0 → 0.4.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/app/Info.plist +30 -0
- package/app/Lattices.app/Contents/Info.plist +8 -2
- package/app/Lattices.app/Contents/MacOS/Lattices +0 -0
- package/app/Lattices.app/Contents/Resources/AppIcon.icns +0 -0
- package/app/Lattices.app/Contents/Resources/tap.wav +0 -0
- package/app/Lattices.app/Contents/_CodeSignature/CodeResources +139 -0
- package/app/Lattices.entitlements +15 -0
- package/app/Resources/tap.wav +0 -0
- package/app/Sources/AppDelegate.swift +1 -0
- package/app/Sources/DesktopModel.swift +26 -2
- package/app/Sources/HandsOffSession.swift +83 -14
- package/app/Sources/HotkeyStore.swift +5 -1
- package/app/Sources/IntentEngine.swift +37 -0
- package/app/Sources/LatticesApi.swift +40 -0
- package/app/Sources/MouseFinder.swift +222 -0
- package/app/Sources/ProjectScanner.swift +57 -44
- package/app/Tests/StageDragTests.swift +333 -0
- package/app/Tests/StageJoinTests.swift +313 -0
- package/app/Tests/StageManagerTests.swift +280 -0
- package/app/Tests/StageTileTests.swift +353 -0
- package/assets/AppIcon.icns +0 -0
- package/bin/handsoff-worker.ts +10 -1
- package/bin/lattices-app.ts +120 -38
- package/bin/lattices-dev +51 -3
- package/bin/lattices.ts +181 -7
- package/docs/agent-layer-guide.md +207 -0
- package/package.json +10 -3
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
import CoreGraphics
|
|
3
|
+
import AppKit
|
|
4
|
+
|
|
5
|
+
// Private APIs (same as WindowTiler uses)
|
|
6
|
+
@_silgen_name("_AXUIElementGetWindow")
|
|
7
|
+
func _AXUIElementGetWindow(_ element: AXUIElement, _ windowID: UnsafeMutablePointer<CGWindowID>) -> AXError
|
|
8
|
+
|
|
9
|
+
private let skyLight: UnsafeMutableRawPointer? = dlopen(
|
|
10
|
+
"/System/Library/PrivateFrameworks/SkyLight.framework/SkyLight", RTLD_NOW)
|
|
11
|
+
|
|
12
|
+
private typealias SLSMainConnectionIDFunc = @convention(c) () -> Int32
|
|
13
|
+
private typealias SLSDisableUpdateFunc = @convention(c) (Int32) -> Int32
|
|
14
|
+
private typealias SLSReenableUpdateFunc = @convention(c) (Int32) -> Int32
|
|
15
|
+
|
|
16
|
+
private let _SLSMainConnectionID: SLSMainConnectionIDFunc? = {
|
|
17
|
+
guard let sl = skyLight, let sym = dlsym(sl, "SLSMainConnectionID") else { return nil }
|
|
18
|
+
return unsafeBitCast(sym, to: SLSMainConnectionIDFunc.self)
|
|
19
|
+
}()
|
|
20
|
+
private let _SLSDisableUpdate: SLSDisableUpdateFunc? = {
|
|
21
|
+
guard let sl = skyLight, let sym = dlsym(sl, "SLSDisableUpdate") else { return nil }
|
|
22
|
+
return unsafeBitCast(sym, to: SLSDisableUpdateFunc.self)
|
|
23
|
+
}()
|
|
24
|
+
private let _SLSReenableUpdate: SLSReenableUpdateFunc? = {
|
|
25
|
+
guard let sl = skyLight, let sym = dlsym(sl, "SLSReenableUpdate") else { return nil }
|
|
26
|
+
return unsafeBitCast(sym, to: SLSReenableUpdateFunc.self)
|
|
27
|
+
}()
|
|
28
|
+
|
|
29
|
+
/// Tile windows within the current Stage Manager stage.
|
|
30
|
+
/// Run ONE test at a time: swift test --filter StageTileTests/testMosaic
|
|
31
|
+
final class StageTileTests: XCTestCase {
|
|
32
|
+
|
|
33
|
+
struct LiveWindow {
|
|
34
|
+
let wid: UInt32
|
|
35
|
+
let app: String
|
|
36
|
+
let pid: Int32
|
|
37
|
+
let title: String
|
|
38
|
+
let bounds: CGRect
|
|
39
|
+
let isOnScreen: Bool
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func getRealWindows() -> [LiveWindow] {
|
|
43
|
+
guard let list = CGWindowListCopyWindowInfo(
|
|
44
|
+
[.optionAll, .excludeDesktopElements],
|
|
45
|
+
kCGNullWindowID
|
|
46
|
+
) as? [[String: Any]] else { return [] }
|
|
47
|
+
|
|
48
|
+
let skip: Set<String> = [
|
|
49
|
+
"Window Server", "Dock", "Control Center", "SystemUIServer",
|
|
50
|
+
"Notification Center", "Spotlight", "WindowManager", "Lattices",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
return list.compactMap { info in
|
|
54
|
+
guard let wid = info[kCGWindowNumber as String] as? UInt32,
|
|
55
|
+
let owner = info[kCGWindowOwnerName as String] as? String,
|
|
56
|
+
let pid = info[kCGWindowOwnerPID as String] as? Int32,
|
|
57
|
+
let boundsDict = info[kCGWindowBounds as String] as? NSDictionary
|
|
58
|
+
else { return nil }
|
|
59
|
+
|
|
60
|
+
var rect = CGRect.zero
|
|
61
|
+
guard CGRectMakeWithDictionaryRepresentation(boundsDict, &rect) else { return nil }
|
|
62
|
+
let title = info[kCGWindowName as String] as? String ?? ""
|
|
63
|
+
let layer = info[kCGWindowLayer as String] as? Int ?? 0
|
|
64
|
+
let isOnScreen = info[kCGWindowIsOnscreen as String] as? Bool ?? false
|
|
65
|
+
|
|
66
|
+
guard layer == 0, rect.width >= 50, rect.height >= 50 else { return nil }
|
|
67
|
+
guard !skip.contains(owner) else { return nil }
|
|
68
|
+
|
|
69
|
+
return LiveWindow(wid: wid, app: owner, pid: pid, title: title,
|
|
70
|
+
bounds: rect, isOnScreen: isOnScreen)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
func getActiveStage() -> [LiveWindow] {
|
|
75
|
+
getRealWindows().filter { $0.isOnScreen && $0.bounds.width > 250 }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
func detectStripWidth() -> CGFloat {
|
|
79
|
+
let thumbnails = getRealWindows().filter {
|
|
80
|
+
$0.isOnScreen && $0.bounds.width < 250 && $0.bounds.height < 250
|
|
81
|
+
&& $0.bounds.origin.x >= 0 && $0.bounds.origin.x < 300
|
|
82
|
+
}
|
|
83
|
+
if thumbnails.isEmpty { return 0 }
|
|
84
|
+
let maxRight = thumbnails.map { $0.bounds.maxX }.max() ?? 0
|
|
85
|
+
return maxRight + 12
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
func stageArea() -> CGRect {
|
|
89
|
+
guard let screen = NSScreen.main else { return .zero }
|
|
90
|
+
let visible = screen.visibleFrame
|
|
91
|
+
let screenHeight = screen.frame.height
|
|
92
|
+
let cgY = screenHeight - visible.origin.y - visible.height
|
|
93
|
+
let strip = detectStripWidth()
|
|
94
|
+
return CGRect(
|
|
95
|
+
x: visible.origin.x + strip,
|
|
96
|
+
y: cgY,
|
|
97
|
+
width: visible.width - strip,
|
|
98
|
+
height: visible.height
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
func printStageState(label: String) {
|
|
103
|
+
let active = getActiveStage()
|
|
104
|
+
print("\n[\(label)] — \(active.count) windows")
|
|
105
|
+
for w in active {
|
|
106
|
+
print(" \(w.app) [\(w.wid)] \"\(w.title.prefix(40))\" — \(Int(w.bounds.origin.x)),\(Int(w.bounds.origin.y)) \(Int(w.bounds.width))x\(Int(w.bounds.height))")
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// MARK: - Batch tile (no app activation — avoids SM stage switches)
|
|
111
|
+
|
|
112
|
+
func batchTile(_ moves: [(wid: UInt32, pid: Int32, frame: CGRect)]) {
|
|
113
|
+
guard !moves.isEmpty else { return }
|
|
114
|
+
|
|
115
|
+
var byPid: [Int32: [(wid: UInt32, target: CGRect)]] = [:]
|
|
116
|
+
for move in moves {
|
|
117
|
+
byPid[move.pid, default: []].append((wid: move.wid, target: move.frame))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Freeze screen
|
|
121
|
+
let cid = _SLSMainConnectionID?()
|
|
122
|
+
if let cid { _ = _SLSDisableUpdate?(cid) }
|
|
123
|
+
|
|
124
|
+
for (pid, windowMoves) in byPid {
|
|
125
|
+
let appRef = AXUIElementCreateApplication(pid)
|
|
126
|
+
AXUIElementSetAttributeValue(appRef, "AXEnhancedUserInterface" as CFString, false as CFTypeRef)
|
|
127
|
+
|
|
128
|
+
var windowsRef: CFTypeRef?
|
|
129
|
+
guard AXUIElementCopyAttributeValue(appRef, kAXWindowsAttribute as CFString, &windowsRef) == .success,
|
|
130
|
+
let axWindows = windowsRef as? [AXUIElement] else { continue }
|
|
131
|
+
|
|
132
|
+
var axByWid: [UInt32: AXUIElement] = [:]
|
|
133
|
+
for axWin in axWindows {
|
|
134
|
+
var windowId: CGWindowID = 0
|
|
135
|
+
if _AXUIElementGetWindow(axWin, &windowId) == .success {
|
|
136
|
+
axByWid[windowId] = axWin
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for wm in windowMoves {
|
|
141
|
+
guard let axWin = axByWid[wm.wid] else { continue }
|
|
142
|
+
|
|
143
|
+
var newSize = CGSize(width: wm.target.width, height: wm.target.height)
|
|
144
|
+
var newPos = CGPoint(x: wm.target.origin.x, y: wm.target.origin.y)
|
|
145
|
+
|
|
146
|
+
if let sv = AXValueCreate(.cgSize, &newSize) {
|
|
147
|
+
AXUIElementSetAttributeValue(axWin, kAXSizeAttribute as CFString, sv)
|
|
148
|
+
}
|
|
149
|
+
if let pv = AXValueCreate(.cgPoint, &newPos) {
|
|
150
|
+
AXUIElementSetAttributeValue(axWin, kAXPositionAttribute as CFString, pv)
|
|
151
|
+
}
|
|
152
|
+
if let sv = AXValueCreate(.cgSize, &newSize) {
|
|
153
|
+
AXUIElementSetAttributeValue(axWin, kAXSizeAttribute as CFString, sv)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
AXUIElementPerformAction(axWin, kAXRaiseAction as CFString)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
AXUIElementSetAttributeValue(appRef, "AXEnhancedUserInterface" as CFString, true as CFTypeRef)
|
|
160
|
+
// NO app.activate() — just move windows in place without triggering SM
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if let cid { _ = _SLSReenableUpdate?(cid) }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
func gridShape(for count: Int) -> [Int] {
|
|
167
|
+
switch count {
|
|
168
|
+
case 1: return [1]
|
|
169
|
+
case 2: return [2]
|
|
170
|
+
case 3: return [1, 2]
|
|
171
|
+
case 4: return [2, 2]
|
|
172
|
+
case 5: return [3, 2]
|
|
173
|
+
case 6: return [3, 3]
|
|
174
|
+
default:
|
|
175
|
+
let cols = Int(ceil(sqrt(Double(count) * 1.5)))
|
|
176
|
+
var rows: [Int] = []
|
|
177
|
+
var remaining = count
|
|
178
|
+
while remaining > 0 {
|
|
179
|
+
rows.append(min(cols, remaining))
|
|
180
|
+
remaining -= cols
|
|
181
|
+
}
|
|
182
|
+
return rows
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// MARK: - Layouts (run one at a time)
|
|
187
|
+
|
|
188
|
+
/// swift test --filter StageTileTests/testMosaic
|
|
189
|
+
func testMosaic() throws {
|
|
190
|
+
let smEnabled = UserDefaults(suiteName: "com.apple.WindowManager")?.bool(forKey: "GloballyEnabled") ?? false
|
|
191
|
+
try XCTSkipUnless(smEnabled, "Stage Manager is OFF")
|
|
192
|
+
|
|
193
|
+
let windows = getActiveStage()
|
|
194
|
+
guard windows.count >= 2 else {
|
|
195
|
+
print("Need >= 2 windows in active stage, got \(windows.count)")
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let area = stageArea()
|
|
200
|
+
let gap: CGFloat = 6
|
|
201
|
+
let shape = gridShape(for: windows.count)
|
|
202
|
+
|
|
203
|
+
print("MOSAIC: \(windows.count) windows → \(shape)")
|
|
204
|
+
printStageState(label: "BEFORE")
|
|
205
|
+
|
|
206
|
+
var moves: [(wid: UInt32, pid: Int32, frame: CGRect)] = []
|
|
207
|
+
var idx = 0
|
|
208
|
+
let rows = shape.count
|
|
209
|
+
let rowH = (area.height - gap * CGFloat(rows + 1)) / CGFloat(rows)
|
|
210
|
+
|
|
211
|
+
for (row, cols) in shape.enumerated() {
|
|
212
|
+
let colW = (area.width - gap * CGFloat(cols + 1)) / CGFloat(cols)
|
|
213
|
+
for col in 0..<cols {
|
|
214
|
+
guard idx < windows.count else { break }
|
|
215
|
+
let win = windows[idx]
|
|
216
|
+
moves.append((wid: win.wid, pid: win.pid, frame: CGRect(
|
|
217
|
+
x: area.origin.x + gap + CGFloat(col) * (colW + gap),
|
|
218
|
+
y: area.origin.y + gap + CGFloat(row) * (rowH + gap),
|
|
219
|
+
width: colW,
|
|
220
|
+
height: rowH
|
|
221
|
+
)))
|
|
222
|
+
idx += 1
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
batchTile(moves)
|
|
227
|
+
Thread.sleep(forTimeInterval: 0.3)
|
|
228
|
+
printStageState(label: "AFTER")
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/// swift test --filter StageTileTests/testMainSidebar
|
|
232
|
+
func testMainSidebar() throws {
|
|
233
|
+
let smEnabled = UserDefaults(suiteName: "com.apple.WindowManager")?.bool(forKey: "GloballyEnabled") ?? false
|
|
234
|
+
try XCTSkipUnless(smEnabled, "Stage Manager is OFF")
|
|
235
|
+
|
|
236
|
+
let windows = getActiveStage()
|
|
237
|
+
guard windows.count >= 2 else { return }
|
|
238
|
+
|
|
239
|
+
let area = stageArea()
|
|
240
|
+
let gap: CGFloat = 6
|
|
241
|
+
let mainW = (area.width - gap * 3) * 0.65
|
|
242
|
+
let sideW = (area.width - gap * 3) * 0.35
|
|
243
|
+
let sideCount = windows.count - 1
|
|
244
|
+
let sideH = (area.height - gap * CGFloat(sideCount + 1)) / CGFloat(sideCount)
|
|
245
|
+
|
|
246
|
+
print("MAIN + SIDEBAR: 1 main (65%) + \(sideCount) stacked")
|
|
247
|
+
printStageState(label: "BEFORE")
|
|
248
|
+
|
|
249
|
+
var moves: [(wid: UInt32, pid: Int32, frame: CGRect)] = []
|
|
250
|
+
|
|
251
|
+
moves.append((wid: windows[0].wid, pid: windows[0].pid, frame: CGRect(
|
|
252
|
+
x: area.origin.x + gap,
|
|
253
|
+
y: area.origin.y + gap,
|
|
254
|
+
width: mainW,
|
|
255
|
+
height: area.height - gap * 2
|
|
256
|
+
)))
|
|
257
|
+
|
|
258
|
+
for i in 0..<sideCount {
|
|
259
|
+
let win = windows[i + 1]
|
|
260
|
+
moves.append((wid: win.wid, pid: win.pid, frame: CGRect(
|
|
261
|
+
x: area.origin.x + gap * 2 + mainW,
|
|
262
|
+
y: area.origin.y + gap + CGFloat(i) * (sideH + gap),
|
|
263
|
+
width: sideW,
|
|
264
|
+
height: sideH
|
|
265
|
+
)))
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
batchTile(moves)
|
|
269
|
+
Thread.sleep(forTimeInterval: 0.3)
|
|
270
|
+
printStageState(label: "AFTER")
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/// swift test --filter StageTileTests/testColumns
|
|
274
|
+
func testColumns() throws {
|
|
275
|
+
let smEnabled = UserDefaults(suiteName: "com.apple.WindowManager")?.bool(forKey: "GloballyEnabled") ?? false
|
|
276
|
+
try XCTSkipUnless(smEnabled, "Stage Manager is OFF")
|
|
277
|
+
|
|
278
|
+
let windows = getActiveStage()
|
|
279
|
+
guard windows.count >= 2 else { return }
|
|
280
|
+
|
|
281
|
+
let area = stageArea()
|
|
282
|
+
let gap: CGFloat = 6
|
|
283
|
+
let colW = (area.width - gap * CGFloat(windows.count + 1)) / CGFloat(windows.count)
|
|
284
|
+
|
|
285
|
+
print("COLUMNS: \(windows.count) equal")
|
|
286
|
+
printStageState(label: "BEFORE")
|
|
287
|
+
|
|
288
|
+
let moves = windows.enumerated().map { (i, win) in
|
|
289
|
+
(wid: win.wid, pid: win.pid, frame: CGRect(
|
|
290
|
+
x: area.origin.x + gap + CGFloat(i) * (colW + gap),
|
|
291
|
+
y: area.origin.y + gap,
|
|
292
|
+
width: colW,
|
|
293
|
+
height: area.height - gap * 2
|
|
294
|
+
))
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
batchTile(moves)
|
|
298
|
+
Thread.sleep(forTimeInterval: 0.3)
|
|
299
|
+
printStageState(label: "AFTER")
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/// swift test --filter StageTileTests/testTallWide
|
|
303
|
+
func testTallWide() throws {
|
|
304
|
+
let smEnabled = UserDefaults(suiteName: "com.apple.WindowManager")?.bool(forKey: "GloballyEnabled") ?? false
|
|
305
|
+
try XCTSkipUnless(smEnabled, "Stage Manager is OFF")
|
|
306
|
+
|
|
307
|
+
let windows = getActiveStage()
|
|
308
|
+
guard windows.count >= 2 else { return }
|
|
309
|
+
|
|
310
|
+
let area = stageArea()
|
|
311
|
+
let gap: CGFloat = 6
|
|
312
|
+
|
|
313
|
+
// Terminal-like apps go tall on the left
|
|
314
|
+
let terminalApps = Set(["iTerm2", "Terminal", "Alacritty", "kitty", "Warp"])
|
|
315
|
+
let sorted = windows.sorted { a, b in
|
|
316
|
+
let aT = terminalApps.contains(a.app)
|
|
317
|
+
let bT = terminalApps.contains(b.app)
|
|
318
|
+
if aT != bT { return aT }
|
|
319
|
+
return a.wid < b.wid
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
let tallW = (area.width - gap * 3) * 0.45
|
|
323
|
+
let wideW = (area.width - gap * 3) * 0.55
|
|
324
|
+
let wideCount = sorted.count - 1
|
|
325
|
+
let wideH = (area.height - gap * CGFloat(wideCount + 1)) / CGFloat(wideCount)
|
|
326
|
+
|
|
327
|
+
print("TALL + WIDE: terminal left (45%), \(wideCount) stacked right (55%)")
|
|
328
|
+
printStageState(label: "BEFORE")
|
|
329
|
+
|
|
330
|
+
var moves: [(wid: UInt32, pid: Int32, frame: CGRect)] = []
|
|
331
|
+
|
|
332
|
+
moves.append((wid: sorted[0].wid, pid: sorted[0].pid, frame: CGRect(
|
|
333
|
+
x: area.origin.x + gap,
|
|
334
|
+
y: area.origin.y + gap,
|
|
335
|
+
width: tallW,
|
|
336
|
+
height: area.height - gap * 2
|
|
337
|
+
)))
|
|
338
|
+
|
|
339
|
+
for i in 0..<wideCount {
|
|
340
|
+
let win = sorted[i + 1]
|
|
341
|
+
moves.append((wid: win.wid, pid: win.pid, frame: CGRect(
|
|
342
|
+
x: area.origin.x + gap * 2 + tallW,
|
|
343
|
+
y: area.origin.y + gap + CGFloat(i) * (wideH + gap),
|
|
344
|
+
width: wideW,
|
|
345
|
+
height: wideH
|
|
346
|
+
)))
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
batchTile(moves)
|
|
350
|
+
Thread.sleep(forTimeInterval: 0.3)
|
|
351
|
+
printStageState(label: "AFTER")
|
|
352
|
+
}
|
|
353
|
+
}
|
|
Binary file
|
package/bin/handsoff-worker.ts
CHANGED
|
@@ -18,9 +18,18 @@
|
|
|
18
18
|
|
|
19
19
|
import { infer, inferJSON } from "../lib/infer.ts";
|
|
20
20
|
|
|
21
|
+
const INFER_TIMEOUT_MS = 15_000;
|
|
22
|
+
|
|
21
23
|
/** Call infer and parse JSON if possible, otherwise treat as spoken-only response */
|
|
22
24
|
async function inferSmart(prompt: string, options: any): Promise<{ data: any; raw: any }> {
|
|
23
|
-
const
|
|
25
|
+
const controller = new AbortController();
|
|
26
|
+
const timer = setTimeout(() => controller.abort(), INFER_TIMEOUT_MS);
|
|
27
|
+
let raw: any;
|
|
28
|
+
try {
|
|
29
|
+
raw = await infer(prompt, { ...options, abortSignal: controller.signal });
|
|
30
|
+
} finally {
|
|
31
|
+
clearTimeout(timer);
|
|
32
|
+
}
|
|
24
33
|
|
|
25
34
|
// Try to parse as JSON
|
|
26
35
|
let cleaned = raw.text
|
package/bin/lattices-app.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
3
|
import { execSync, spawn } from "node:child_process";
|
|
4
|
-
import { existsSync, mkdirSync, chmodSync, createWriteStream } from "node:fs";
|
|
5
|
-
import {
|
|
4
|
+
import { existsSync, mkdirSync, chmodSync, createWriteStream, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join, resolve } from "node:path";
|
|
6
7
|
import { get } from "node:https";
|
|
7
8
|
import type { IncomingMessage } from "node:http";
|
|
8
9
|
|
|
@@ -12,9 +13,14 @@ const bundlePath = resolve(appDir, "Lattices.app");
|
|
|
12
13
|
const binaryDir = resolve(bundlePath, "Contents/MacOS");
|
|
13
14
|
const binaryPath = resolve(binaryDir, "Lattices");
|
|
14
15
|
const entitlementsPath = resolve(__dirname, "../app/Lattices.entitlements");
|
|
16
|
+
const resourcesDir = resolve(bundlePath, "Contents/Resources");
|
|
17
|
+
const iconPath = resolve(__dirname, "../assets/AppIcon.icns");
|
|
18
|
+
const tapSoundPath = resolve(__dirname, "../app/Resources/tap.wav");
|
|
15
19
|
|
|
16
20
|
const REPO = "arach/lattices";
|
|
17
|
-
const
|
|
21
|
+
const RELEASE_APP_ASSET_NAMES = ["Lattices.dmg"];
|
|
22
|
+
const RELEASE_BINARY_ASSET_NAMES = ["Lattices-macos-arm64", "LatticeApp-macos-arm64"];
|
|
23
|
+
type ReleaseAsset = { name: string; browser_download_url: string };
|
|
18
24
|
|
|
19
25
|
// ── Helpers ──────────────────────────────────────────────────────────
|
|
20
26
|
|
|
@@ -51,6 +57,15 @@ function hasSwift(): boolean {
|
|
|
51
57
|
}
|
|
52
58
|
}
|
|
53
59
|
|
|
60
|
+
function packageVersion(): string {
|
|
61
|
+
try {
|
|
62
|
+
const pkg = JSON.parse(readFileSync(resolve(__dirname, "../package.json"), "utf8"));
|
|
63
|
+
return typeof pkg.version === "string" ? pkg.version : "0.1.0";
|
|
64
|
+
} catch {
|
|
65
|
+
return "0.1.0";
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
54
69
|
function launch(extraArgs: string[] = []): void {
|
|
55
70
|
if (isRunning()) {
|
|
56
71
|
console.log("lattices app is already running.");
|
|
@@ -98,6 +113,53 @@ function signBundle(): void {
|
|
|
98
113
|
);
|
|
99
114
|
}
|
|
100
115
|
|
|
116
|
+
function writeInfoPlist(): void {
|
|
117
|
+
mkdirSync(resolve(bundlePath, "Contents"), { recursive: true });
|
|
118
|
+
const version = packageVersion();
|
|
119
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
120
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
121
|
+
<plist version="1.0">
|
|
122
|
+
<dict>
|
|
123
|
+
<key>CFBundleIdentifier</key>
|
|
124
|
+
<string>com.arach.lattices</string>
|
|
125
|
+
<key>CFBundleName</key>
|
|
126
|
+
<string>Lattices</string>
|
|
127
|
+
<key>CFBundleDisplayName</key>
|
|
128
|
+
<string>Lattices</string>
|
|
129
|
+
<key>CFBundleExecutable</key>
|
|
130
|
+
<string>Lattices</string>
|
|
131
|
+
<key>CFBundleIconFile</key>
|
|
132
|
+
<string>AppIcon</string>
|
|
133
|
+
<key>CFBundlePackageType</key>
|
|
134
|
+
<string>APPL</string>
|
|
135
|
+
<key>CFBundleVersion</key>
|
|
136
|
+
<string>${version}</string>
|
|
137
|
+
<key>CFBundleShortVersionString</key>
|
|
138
|
+
<string>${version}</string>
|
|
139
|
+
<key>LSMinimumSystemVersion</key>
|
|
140
|
+
<string>13.0</string>
|
|
141
|
+
<key>LSUIElement</key>
|
|
142
|
+
<true/>
|
|
143
|
+
<key>NSHighResolutionCapable</key>
|
|
144
|
+
<true/>
|
|
145
|
+
<key>NSSupportsAutomaticTermination</key>
|
|
146
|
+
<true/>
|
|
147
|
+
</dict>
|
|
148
|
+
</plist>
|
|
149
|
+
`;
|
|
150
|
+
writeFileSync(resolve(bundlePath, "Contents/Info.plist"), plist);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function syncBundleResources(): void {
|
|
154
|
+
mkdirSync(resourcesDir, { recursive: true });
|
|
155
|
+
if (existsSync(iconPath)) {
|
|
156
|
+
execSync(`cp '${iconPath}' '${resolve(resourcesDir, "AppIcon.icns")}'`);
|
|
157
|
+
}
|
|
158
|
+
if (existsSync(tapSoundPath)) {
|
|
159
|
+
execSync(`cp '${tapSoundPath}' '${resolve(resourcesDir, "tap.wav")}'`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
101
163
|
// ── Build from source (current arch only) ────────────────────────────
|
|
102
164
|
|
|
103
165
|
function buildFromSource(): boolean {
|
|
@@ -116,20 +178,8 @@ function buildFromSource(): boolean {
|
|
|
116
178
|
|
|
117
179
|
mkdirSync(binaryDir, { recursive: true });
|
|
118
180
|
execSync(`cp '${builtPath}' '${binaryPath}'`);
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const plistSrc = resolve(__dirname, "../app/Info.plist");
|
|
122
|
-
if (existsSync(plistSrc)) {
|
|
123
|
-
execSync(`cp '${plistSrc}' '${resolve(bundlePath, "Contents/Info.plist")}'`);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// Copy app icon into bundle
|
|
127
|
-
const iconSrc = resolve(__dirname, "../assets/AppIcon.icns");
|
|
128
|
-
const resourcesDir = resolve(bundlePath, "Contents/Resources");
|
|
129
|
-
mkdirSync(resourcesDir, { recursive: true });
|
|
130
|
-
if (existsSync(iconSrc)) {
|
|
131
|
-
execSync(`cp '${iconSrc}' '${resolve(resourcesDir, "AppIcon.icns")}'`);
|
|
132
|
-
}
|
|
181
|
+
writeInfoPlist();
|
|
182
|
+
syncBundleResources();
|
|
133
183
|
|
|
134
184
|
// Re-sign the bundle so macOS TCC recognizes a stable identity across rebuilds.
|
|
135
185
|
// Prefer a real local signing identity; only fall back to ad-hoc when necessary.
|
|
@@ -163,8 +213,36 @@ function httpsGet(url: string): Promise<IncomingMessage> {
|
|
|
163
213
|
});
|
|
164
214
|
}
|
|
165
215
|
|
|
216
|
+
async function downloadToFile(url: string, destination: string): Promise<void> {
|
|
217
|
+
const res = await httpsGet(url);
|
|
218
|
+
const ws = createWriteStream(destination);
|
|
219
|
+
await new Promise<void>((resolve, reject) => {
|
|
220
|
+
res.pipe(ws);
|
|
221
|
+
ws.on("finish", resolve);
|
|
222
|
+
ws.on("error", reject);
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function installBundleFromDmg(dmgPath: string): void {
|
|
227
|
+
const mountPoint = mkdtempSync(join(tmpdir(), "lattices-mount-"));
|
|
228
|
+
try {
|
|
229
|
+
execSync(`hdiutil attach -nobrowse -readonly -mountpoint '${mountPoint}' '${dmgPath}'`, { stdio: "pipe" });
|
|
230
|
+
const mountedBundle = resolve(mountPoint, "Lattices.app");
|
|
231
|
+
if (!existsSync(mountedBundle)) {
|
|
232
|
+
throw new Error("Lattices.app not found in mounted disk image");
|
|
233
|
+
}
|
|
234
|
+
rmSync(bundlePath, { recursive: true, force: true });
|
|
235
|
+
execSync(`cp -R '${mountedBundle}' '${bundlePath}'`);
|
|
236
|
+
} finally {
|
|
237
|
+
try {
|
|
238
|
+
execSync(`hdiutil detach '${mountPoint}' -quiet`, { stdio: "pipe" });
|
|
239
|
+
} catch {}
|
|
240
|
+
rmSync(mountPoint, { recursive: true, force: true });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
166
244
|
async function download(): Promise<boolean> {
|
|
167
|
-
console.log("Downloading pre-built
|
|
245
|
+
console.log("Downloading pre-built lattices app...");
|
|
168
246
|
|
|
169
247
|
try {
|
|
170
248
|
const apiUrl = `https://api.github.com/repos/${REPO}/releases/latest`;
|
|
@@ -173,20 +251,31 @@ async function download(): Promise<boolean> {
|
|
|
173
251
|
for await (const chunk of apiRes) chunks.push(chunk as Buffer);
|
|
174
252
|
const release = JSON.parse(Buffer.concat(chunks).toString());
|
|
175
253
|
|
|
176
|
-
const
|
|
177
|
-
|
|
254
|
+
const assets: ReleaseAsset[] = Array.isArray(release.assets) ? release.assets : [];
|
|
255
|
+
const appAsset = assets.find((a) =>
|
|
256
|
+
RELEASE_APP_ASSET_NAMES.includes(a.name) || (a.name.endsWith(".dmg") && a.name.startsWith("Lattices"))
|
|
257
|
+
);
|
|
258
|
+
if (appAsset) {
|
|
259
|
+
const tempDir = mkdtempSync(join(tmpdir(), "lattices-download-"));
|
|
260
|
+
const dmgPath = resolve(tempDir, appAsset.name);
|
|
261
|
+
try {
|
|
262
|
+
await downloadToFile(appAsset.browser_download_url, dmgPath);
|
|
263
|
+
installBundleFromDmg(dmgPath);
|
|
264
|
+
} finally {
|
|
265
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
266
|
+
}
|
|
267
|
+
console.log("Download complete.");
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
178
270
|
|
|
179
|
-
const
|
|
271
|
+
const binaryAsset = assets.find((a) => RELEASE_BINARY_ASSET_NAMES.includes(a.name));
|
|
272
|
+
if (!binaryAsset) throw new Error("App bundle not found in release assets");
|
|
180
273
|
|
|
181
274
|
mkdirSync(binaryDir, { recursive: true });
|
|
182
|
-
|
|
183
|
-
await new Promise<void>((resolve, reject) => {
|
|
184
|
-
dlRes.pipe(ws);
|
|
185
|
-
ws.on("finish", resolve);
|
|
186
|
-
ws.on("error", reject);
|
|
187
|
-
});
|
|
188
|
-
|
|
275
|
+
await downloadToFile(binaryAsset.browser_download_url, binaryPath);
|
|
189
276
|
chmodSync(binaryPath, 0o755);
|
|
277
|
+
writeInfoPlist();
|
|
278
|
+
syncBundleResources();
|
|
190
279
|
console.log("Download complete.");
|
|
191
280
|
return true;
|
|
192
281
|
} catch (e) {
|
|
@@ -200,21 +289,14 @@ async function download(): Promise<boolean> {
|
|
|
200
289
|
async function ensureBinary(): Promise<void> {
|
|
201
290
|
if (existsSync(binaryPath)) return;
|
|
202
291
|
|
|
203
|
-
// 1. Try local compile (fast, matches exact system)
|
|
204
|
-
if (hasSwift()) {
|
|
205
|
-
if (buildFromSource()) return;
|
|
206
|
-
console.log("Local build failed, trying download...");
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// 2. Fall back to pre-built binary from GitHub releases
|
|
210
292
|
const downloaded = await download();
|
|
211
293
|
if (downloaded) return;
|
|
212
294
|
|
|
213
|
-
// 3. Nothing worked
|
|
214
295
|
console.error(
|
|
215
|
-
"Could not
|
|
296
|
+
"Could not find a bundled lattices app or download one.\n" +
|
|
216
297
|
"Options:\n" +
|
|
217
|
-
" \u2022
|
|
298
|
+
" \u2022 Reinstall or update @lattices/cli\n" +
|
|
299
|
+
" \u2022 Developers can build from source with: lattices-app build\n" +
|
|
218
300
|
" \u2022 Download manually from: https://github.com/" + REPO + "/releases"
|
|
219
301
|
);
|
|
220
302
|
process.exit(1);
|
package/bin/lattices-dev
CHANGED
|
@@ -5,11 +5,16 @@ set -euo pipefail
|
|
|
5
5
|
|
|
6
6
|
SCRIPT_PATH="$(readlink -f "$0" 2>/dev/null || python3 -c "import os,sys; print(os.path.realpath(sys.argv[1]))" "$0")"
|
|
7
7
|
APP_DIR="$(cd "$(dirname "$SCRIPT_PATH")/../app" && pwd)"
|
|
8
|
+
ROOT="$(cd "$(dirname "$SCRIPT_PATH")/.." && pwd)"
|
|
8
9
|
LOG_FILE="$HOME/.lattices/lattices.log"
|
|
9
10
|
BINARY="$APP_DIR/.build/release/Lattices"
|
|
10
11
|
BUNDLE="$APP_DIR/Lattices.app"
|
|
11
12
|
BUNDLE_BIN="$BUNDLE/Contents/MacOS/Lattices"
|
|
13
|
+
RESOURCES_DIR="$BUNDLE/Contents/Resources"
|
|
12
14
|
ENTITLEMENTS="$APP_DIR/Lattices.entitlements"
|
|
15
|
+
ICON="$ROOT/assets/AppIcon.icns"
|
|
16
|
+
TAP_SOUND="$APP_DIR/Resources/tap.wav"
|
|
17
|
+
VERSION="$(node -p "require('$ROOT/package.json').version" 2>/dev/null || echo '0.1.0')"
|
|
13
18
|
|
|
14
19
|
red() { printf "\033[31m%s\033[0m\n" "$*"; }
|
|
15
20
|
green() { printf "\033[32m%s\033[0m\n" "$*"; }
|
|
@@ -50,13 +55,56 @@ sign_bundle() {
|
|
|
50
55
|
fi
|
|
51
56
|
}
|
|
52
57
|
|
|
58
|
+
write_info_plist() {
|
|
59
|
+
mkdir -p "$BUNDLE/Contents"
|
|
60
|
+
cat > "$BUNDLE/Contents/Info.plist" <<PLIST
|
|
61
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
62
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
63
|
+
<plist version="1.0">
|
|
64
|
+
<dict>
|
|
65
|
+
<key>CFBundleIdentifier</key>
|
|
66
|
+
<string>com.arach.lattices</string>
|
|
67
|
+
<key>CFBundleName</key>
|
|
68
|
+
<string>Lattices</string>
|
|
69
|
+
<key>CFBundleDisplayName</key>
|
|
70
|
+
<string>Lattices</string>
|
|
71
|
+
<key>CFBundleExecutable</key>
|
|
72
|
+
<string>Lattices</string>
|
|
73
|
+
<key>CFBundleIconFile</key>
|
|
74
|
+
<string>AppIcon</string>
|
|
75
|
+
<key>CFBundlePackageType</key>
|
|
76
|
+
<string>APPL</string>
|
|
77
|
+
<key>CFBundleVersion</key>
|
|
78
|
+
<string>$VERSION</string>
|
|
79
|
+
<key>CFBundleShortVersionString</key>
|
|
80
|
+
<string>$VERSION</string>
|
|
81
|
+
<key>LSMinimumSystemVersion</key>
|
|
82
|
+
<string>13.0</string>
|
|
83
|
+
<key>LSUIElement</key>
|
|
84
|
+
<true/>
|
|
85
|
+
<key>NSHighResolutionCapable</key>
|
|
86
|
+
<true/>
|
|
87
|
+
<key>NSSupportsAutomaticTermination</key>
|
|
88
|
+
<true/>
|
|
89
|
+
</dict>
|
|
90
|
+
</plist>
|
|
91
|
+
PLIST
|
|
92
|
+
}
|
|
93
|
+
|
|
53
94
|
cmd_build() {
|
|
54
95
|
echo "Building release..."
|
|
55
96
|
cd "$APP_DIR" && swift build -c release
|
|
56
|
-
#
|
|
57
|
-
|
|
97
|
+
# Refresh the bundle so dev builds and published bundles are complete.
|
|
98
|
+
rm -rf "$BUNDLE/Contents/MacOS" "$RESOURCES_DIR"
|
|
99
|
+
mkdir -p "$(dirname "$BUNDLE_BIN")" "$RESOURCES_DIR"
|
|
58
100
|
cp "$BINARY" "$BUNDLE_BIN"
|
|
59
|
-
|
|
101
|
+
if [ -f "$ICON" ]; then
|
|
102
|
+
cp "$ICON" "$RESOURCES_DIR/AppIcon.icns"
|
|
103
|
+
fi
|
|
104
|
+
if [ -f "$TAP_SOUND" ]; then
|
|
105
|
+
cp "$TAP_SOUND" "$RESOURCES_DIR/tap.wav"
|
|
106
|
+
fi
|
|
107
|
+
write_info_plist
|
|
60
108
|
# Re-sign so TCC permissions persist across rebuilds
|
|
61
109
|
sign_bundle
|
|
62
110
|
green "Build complete."
|