@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.
@@ -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
@@ -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 raw = await infer(prompt, options);
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
@@ -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 { resolve } from "node:path";
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 ASSET_NAME = "Lattices-macos-arm64";
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
- // Copy Info.plist into bundle
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 binary...");
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 asset = release.assets?.find((a: { name: string }) => a.name === ASSET_NAME);
177
- if (!asset) throw new Error("Binary not found in release assets");
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 dlRes = await httpsGet(asset.browser_download_url);
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
- const ws = createWriteStream(binaryPath);
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 build or download the lattices app.\n" +
296
+ "Could not find a bundled lattices app or download one.\n" +
216
297
  "Options:\n" +
217
- " \u2022 Install Xcode CLI tools: xcode-select --install\n" +
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
- # Copy into app bundle so it runs with the proper bundle ID
57
- mkdir -p "$(dirname "$BUNDLE_BIN")" "$BUNDLE/Contents/Resources"
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
- cp "$APP_DIR/Info.plist" "$BUNDLE/Contents/Info.plist" 2>/dev/null || true
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."