paneful 0.7.2 → 0.8.0

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.
@@ -2,36 +2,42 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
4
  import { execFileSync } from 'node:child_process';
5
- const APP_PATH = '/Applications/Paneful.app';
5
+ const DEFAULT_APP_PATH = '/Applications/Paneful.app';
6
6
  const BUNDLE_ID = 'com.paneful.app';
7
- function findIconSource() {
8
- // From compiled dist/server/ ../web/icon-512.png
9
- const distIcon = path.resolve(import.meta.dirname, '..', 'web', 'icon-512.png');
10
- if (fs.existsSync(distIcon))
11
- return distIcon;
12
- // From dev server/ web/public/icon-512.png
13
- const devIcon = path.resolve(import.meta.dirname, '..', 'web', 'public', 'icon-512.png');
14
- if (fs.existsSync(devIcon))
15
- return devIcon;
7
+ function findIconDir() {
8
+ // Walk up from import.meta.dirname to find AppIcon.appiconset
9
+ // Works from both dist/server/ (built) and server/ (dev)
10
+ let dir = import.meta.dirname;
11
+ for (let i = 0; i < 5; i++) {
12
+ const candidate = path.join(dir, 'assets', 'icons');
13
+ if (fs.existsSync(path.join(candidate, 'icon-mac-512x512.png')))
14
+ return candidate;
15
+ dir = path.dirname(dir);
16
+ }
16
17
  return null;
17
18
  }
18
- function buildIcns(pngPath, contentsDir) {
19
+ function buildIcns(iconDir, contentsDir) {
19
20
  const iconsetDir = path.join(os.tmpdir(), 'Paneful.iconset');
20
21
  const icnsPath = path.join(contentsDir, 'Resources', 'Paneful.icns');
21
22
  try {
22
23
  fs.mkdirSync(iconsetDir, { recursive: true });
23
- // sips to create required icon sizes
24
- const sizes = [16, 32, 64, 128, 256, 512];
25
- for (const size of sizes) {
26
- const outFile = path.join(iconsetDir, `icon_${size}x${size}.png`);
27
- execFileSync('sips', ['-z', String(size), String(size), pngPath, '--out', outFile], { stdio: 'pipe' });
28
- // @2x variant (retina) for half-size key
29
- if (size >= 32) {
30
- const halfKey = size / 2;
31
- const retinaFile = path.join(iconsetDir, `icon_${halfKey}x${halfKey}@2x.png`);
32
- if (!fs.existsSync(retinaFile)) {
33
- fs.copyFileSync(outFile, retinaFile);
34
- }
24
+ // Map pre-sized PNGs to iconset naming convention
25
+ const mappings = [
26
+ ['icon-mac-16x16.png', 'icon_16x16.png'],
27
+ ['icon-mac-16x16@2x.png', 'icon_16x16@2x.png'],
28
+ ['icon-mac-32x32.png', 'icon_32x32.png'],
29
+ ['icon-mac-32x32@2x.png', 'icon_32x32@2x.png'],
30
+ ['icon-mac-128x128.png', 'icon_128x128.png'],
31
+ ['icon-mac-128x128@2x.png', 'icon_128x128@2x.png'],
32
+ ['icon-mac-256x256.png', 'icon_256x256.png'],
33
+ ['icon-mac-256x256@2x.png', 'icon_256x256@2x.png'],
34
+ ['icon-mac-512x512.png', 'icon_512x512.png'],
35
+ ['icon-mac-512x512@2x.png', 'icon_512x512@2x.png'],
36
+ ];
37
+ for (const [src, dest] of mappings) {
38
+ const srcPath = path.join(iconDir, src);
39
+ if (fs.existsSync(srcPath)) {
40
+ fs.copyFileSync(srcPath, path.join(iconsetDir, dest));
35
41
  }
36
42
  }
37
43
  fs.mkdirSync(path.dirname(icnsPath), { recursive: true });
@@ -72,123 +78,301 @@ ${hasIcon ? ` <key>CFBundleIconFile</key>
72
78
  </plist>
73
79
  `;
74
80
  }
75
- function getLauncherScript() {
76
- // Bake in current paths at install time
77
- const bakedNode = process.execPath;
78
- const bakedPaneful = fs.realpathSync(process.argv[1]);
79
- return `#!/bin/bash
80
- # Paneful.app launcher — generated by paneful --install-app
81
- # Baked paths (fast path), with runtime fallbacks
82
-
83
- LOG="$HOME/.paneful/app.log"
84
- mkdir -p "$HOME/.paneful"
85
- exec >> "$LOG" 2>&1
86
- echo "--- $(date) ---"
87
-
88
- BAKED_NODE="${bakedNode}"
89
- BAKED_PANEFUL="${bakedPaneful}"
90
-
91
- find_node() {
92
- # 1. Baked path
93
- if [[ -x "$BAKED_NODE" ]]; then echo "$BAKED_NODE"; return; fi
94
-
95
- # 2. Common locations
96
- for p in /usr/local/bin/node /opt/homebrew/bin/node; do
97
- if [[ -x "$p" ]]; then echo "$p"; return; fi
98
- done
99
-
100
- # 3. nvm
101
- if [[ -d "$HOME/.nvm/versions/node" ]]; then
102
- local latest
103
- latest=$(ls -1d "$HOME/.nvm/versions/node"/v* 2>/dev/null | sort -V | tail -1)
104
- if [[ -x "$latest/bin/node" ]]; then echo "$latest/bin/node"; return; fi
105
- fi
106
-
107
- # 4. fnm
108
- if [[ -d "$HOME/.fnm" ]]; then
109
- local latest
110
- latest=$(ls -1d "$HOME/.fnm/node-versions"/v* 2>/dev/null | sort -V | tail -1)
111
- if [[ -x "$latest/installation/bin/node" ]]; then echo "$latest/installation/bin/node"; return; fi
112
- fi
113
-
114
- # 5. volta
115
- if [[ -x "$HOME/.volta/bin/node" ]]; then echo "$HOME/.volta/bin/node"; return; fi
116
-
117
- # 6. Parse shell config PATH exports
118
- for rc in "$HOME/.zshrc" "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.zprofile"; do
119
- if [[ -f "$rc" ]]; then
120
- while IFS= read -r line; do
121
- if [[ "$line" =~ ^export\\ +PATH=.*|^PATH= ]]; then
122
- local extracted
123
- extracted=$(echo "$line" | sed 's/.*PATH=["]*//;s/["]*$//' | tr ':' '\\n' | sed "s|\\$HOME|$HOME|g;s|~|$HOME|g")
124
- while IFS= read -r dir; do
125
- dir=$(eval echo "$dir" 2>/dev/null)
126
- if [[ -x "$dir/node" ]]; then echo "$dir/node"; return; fi
127
- done <<< "$extracted"
128
- fi
129
- done < "$rc"
130
- fi
131
- done
132
-
133
- return 1
81
+ function escapeSwiftString(s) {
82
+ return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
83
+ }
84
+ function getSwiftSource() {
85
+ const bakedNode = escapeSwiftString(process.execPath);
86
+ const bakedPaneful = escapeSwiftString(fs.realpathSync(process.argv[1]));
87
+ return `import Cocoa
88
+ import WebKit
89
+
90
+ // Port is determined at runtime; read from lockfile after server starts
91
+ let HOME = NSHomeDirectory()
92
+ let DATA_DIR = HOME + "/.paneful"
93
+ let LOCKFILE = DATA_DIR + "/paneful.lock"
94
+ let LOG_FILE = DATA_DIR + "/app.log"
95
+ let BAKED_NODE = "${bakedNode}"
96
+ let BAKED_PANEFUL = "${bakedPaneful}"
97
+
98
+ var logHandle: FileHandle?
99
+
100
+ func log(_ msg: String) {
101
+ guard let h = logHandle else { return }
102
+ let df = DateFormatter()
103
+ df.dateFormat = "yyyy-MM-dd HH:mm:ss"
104
+ if let data = "[\\(df.string(from: Date()))] \\(msg)\\n".data(using: .utf8) {
105
+ h.write(data)
106
+ }
107
+ }
108
+
109
+ func findNode() -> String? {
110
+ let fm = FileManager.default
111
+ if fm.isExecutableFile(atPath: BAKED_NODE) { return BAKED_NODE }
112
+ for p in ["/usr/local/bin/node", "/opt/homebrew/bin/node"] {
113
+ if fm.isExecutableFile(atPath: p) { return p }
114
+ }
115
+ // nvm
116
+ let nvmDir = HOME + "/.nvm/versions/node"
117
+ if let vs = try? fm.contentsOfDirectory(atPath: nvmDir) {
118
+ if let latest = vs.filter({ $0.hasPrefix("v") }).sorted().last {
119
+ let p = "\\(nvmDir)/\\(latest)/bin/node"
120
+ if fm.isExecutableFile(atPath: p) { return p }
121
+ }
122
+ }
123
+ // fnm
124
+ let fnmDir = HOME + "/.fnm/node-versions"
125
+ if let vs = try? fm.contentsOfDirectory(atPath: fnmDir) {
126
+ if let latest = vs.filter({ $0.hasPrefix("v") }).sorted().last {
127
+ let p = "\\(fnmDir)/\\(latest)/installation/bin/node"
128
+ if fm.isExecutableFile(atPath: p) { return p }
129
+ }
130
+ }
131
+ // volta
132
+ let volta = HOME + "/.volta/bin/node"
133
+ if fm.isExecutableFile(atPath: volta) { return volta }
134
+ return nil
135
+ }
136
+
137
+ func findPaneful(nodeDir: String) -> String? {
138
+ let fm = FileManager.default
139
+ if fm.fileExists(atPath: BAKED_PANEFUL) { return BAKED_PANEFUL }
140
+ for p in ["/usr/local/bin/paneful", "/opt/homebrew/bin/paneful", nodeDir + "/paneful"] {
141
+ if fm.isExecutableFile(atPath: p) {
142
+ // Resolve symlink to get actual .js entry point
143
+ if let dest = try? fm.destinationOfSymbolicLink(atPath: p) {
144
+ let base = (p as NSString).deletingLastPathComponent
145
+ return dest.hasPrefix("/") ? dest : base + "/" + dest
146
+ }
147
+ return p
148
+ }
149
+ }
150
+ return nil
134
151
  }
135
152
 
136
- find_paneful() {
137
- # 1. Baked path
138
- if [[ -f "$BAKED_PANEFUL" ]]; then echo "$BAKED_PANEFUL"; return; fi
139
-
140
- # 2. Common global bin locations
141
- for p in /usr/local/bin/paneful /opt/homebrew/bin/paneful; do
142
- if [[ -x "$p" ]]; then
143
- # Resolve symlink to get the actual .js file
144
- local resolved
145
- resolved=$(readlink -f "$p" 2>/dev/null || readlink "$p" 2>/dev/null || echo "$p")
146
- echo "$resolved"; return
147
- fi
148
- done
149
-
150
- # 3. npm global prefix
151
- local node_bin
152
- node_bin=$(dirname "$1")
153
- if [[ -x "$node_bin/paneful" ]]; then
154
- local resolved
155
- resolved=$(readlink -f "$node_bin/paneful" 2>/dev/null || readlink "$node_bin/paneful" 2>/dev/null || echo "$node_bin/paneful")
156
- echo "$resolved"; return
157
- fi
158
-
159
- return 1
153
+ func readLockfile() -> (pid: pid_t, port: Int)? {
154
+ guard let content = try? String(contentsOfFile: LOCKFILE, encoding: .utf8) else { return nil }
155
+ let lines = content.trimmingCharacters(in: .whitespacesAndNewlines).split(separator: "\\n")
156
+ guard lines.count >= 2, let pid = pid_t(lines[0]), let port = Int(lines[1]) else { return nil }
157
+ guard kill(pid, 0) == 0 else { return nil }
158
+ return (pid, port)
160
159
  }
161
160
 
162
- show_error() {
163
- osascript -e "display dialog \\"$1\\" with title \\"Paneful\\" buttons {\\"OK\\"} default button \\"OK\\" with icon stop" 2>/dev/null
161
+ class AppDelegate: NSObject, NSApplicationDelegate {
162
+ var window: NSWindow!
163
+ var webView: WKWebView!
164
+ var serverProcess: Process?
165
+ var ownedServer = false
166
+ var port = 0
167
+
168
+ func applicationDidFinishLaunching(_ notification: Notification) {
169
+ try? FileManager.default.createDirectory(atPath: DATA_DIR, withIntermediateDirectories: true)
170
+ FileManager.default.createFile(atPath: LOG_FILE, contents: nil)
171
+ logHandle = FileHandle(forWritingAtPath: LOG_FILE)
172
+ logHandle?.seekToEndOfFile()
173
+ log("--- App launched ---")
174
+
175
+ setupMenuBar()
176
+
177
+ if let lock = readLockfile() {
178
+ log("Server already running on port \\(lock.port), connecting")
179
+ port = lock.port
180
+ createWindow()
181
+ } else {
182
+ log("Starting server")
183
+ if startServer() {
184
+ ownedServer = true
185
+ waitForLockfile(attempt: 0)
186
+ } else {
187
+ let a = NSAlert()
188
+ a.messageText = "Paneful"
189
+ a.informativeText = "Failed to start server.\\nMake sure Node.js and paneful are installed.\\nCheck ~/.paneful/app.log"
190
+ a.alertStyle = .critical
191
+ a.runModal()
192
+ NSApp.terminate(nil)
193
+ }
194
+ }
195
+ }
196
+
197
+ func startServer() -> Bool {
198
+ guard let node = findNode() else { log("ERROR: node not found"); return false }
199
+ log("Using node: \\(node)")
200
+ let nodeDir = (node as NSString).deletingLastPathComponent
201
+ guard let paneful = findPaneful(nodeDir: nodeDir) else { log("ERROR: paneful not found"); return false }
202
+ log("Using paneful: \\(paneful)")
203
+
204
+ let proc = Process()
205
+ proc.executableURL = URL(fileURLWithPath: node)
206
+ proc.arguments = [paneful]
207
+ var env = ProcessInfo.processInfo.environment
208
+ env["PANEFUL_APP"] = "1"
209
+ proc.environment = env
210
+ do {
211
+ try proc.run()
212
+ serverProcess = proc
213
+ log("Server started (PID: \\(proc.processIdentifier))")
214
+ return true
215
+ } catch {
216
+ log("Failed to start: \\(error)")
217
+ return false
218
+ }
219
+ }
220
+
221
+ func waitForLockfile(attempt: Int) {
222
+ if attempt > 50 {
223
+ log("Server did not start in time")
224
+ let a = NSAlert()
225
+ a.messageText = "Paneful"
226
+ a.informativeText = "Server failed to start in time.\\nCheck ~/.paneful/app.log"
227
+ a.alertStyle = .critical
228
+ a.runModal()
229
+ NSApp.terminate(nil)
230
+ return
231
+ }
232
+ if let lock = readLockfile() {
233
+ log("Server ready on port \\(lock.port)")
234
+ port = lock.port
235
+ DispatchQueue.main.async { self.createWindow() }
236
+ } else {
237
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
238
+ self.waitForLockfile(attempt: attempt + 1)
239
+ }
240
+ }
241
+ }
242
+
243
+ func createWindow() {
244
+ let config = WKWebViewConfiguration()
245
+ config.preferences.setValue(true, forKey: "developerExtrasEnabled")
246
+
247
+ webView = WKWebView(frame: .zero, configuration: config)
248
+ webView.load(URLRequest(url: URL(string: "http://localhost:\\(self.port)")!))
249
+
250
+ window = NSWindow(
251
+ contentRect: NSRect(x: 0, y: 0, width: 1200, height: 800),
252
+ styleMask: [.titled, .closable, .resizable, .miniaturizable],
253
+ backing: .buffered,
254
+ defer: false
255
+ )
256
+ window.title = "Paneful"
257
+ window.contentView = webView
258
+ window.center()
259
+ window.makeKeyAndOrderFront(nil)
260
+ log("Window created")
261
+ }
262
+
263
+ func setupMenuBar() {
264
+ let mainMenu = NSMenu()
265
+
266
+ // App menu
267
+ let appMenu = NSMenu()
268
+ appMenu.addItem(withTitle: "About Paneful", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "")
269
+ appMenu.addItem(.separator())
270
+ appMenu.addItem(withTitle: "Hide Paneful", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h")
271
+ let ho = appMenu.addItem(withTitle: "Hide Others", action: #selector(NSApplication.hideOtherApplications(_:)), keyEquivalent: "h")
272
+ ho.keyEquivalentModifierMask = [.command, .option]
273
+ appMenu.addItem(withTitle: "Show All", action: #selector(NSApplication.unhideAllApplications(_:)), keyEquivalent: "")
274
+ appMenu.addItem(.separator())
275
+ appMenu.addItem(withTitle: "Quit Paneful", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
276
+ let appItem = NSMenuItem(); appItem.submenu = appMenu
277
+ mainMenu.addItem(appItem)
278
+
279
+ // Edit menu (enables Cmd+C/V/X/A in WebView)
280
+ let editMenu = NSMenu(title: "Edit")
281
+ editMenu.addItem(withTitle: "Undo", action: Selector(("undo:")), keyEquivalent: "z")
282
+ editMenu.addItem(withTitle: "Redo", action: Selector(("redo:")), keyEquivalent: "Z")
283
+ editMenu.addItem(.separator())
284
+ editMenu.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x")
285
+ editMenu.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")
286
+ editMenu.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")
287
+ editMenu.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")
288
+ let editItem = NSMenuItem(); editItem.submenu = editMenu
289
+ mainMenu.addItem(editItem)
290
+
291
+ // View menu
292
+ let viewMenu = NSMenu(title: "View")
293
+ viewMenu.addItem(withTitle: "Reload", action: #selector(reloadPage(_:)), keyEquivalent: "r")
294
+ let viewItem = NSMenuItem(); viewItem.submenu = viewMenu
295
+ mainMenu.addItem(viewItem)
296
+
297
+ // Window menu
298
+ let windowMenu = NSMenu(title: "Window")
299
+ windowMenu.addItem(withTitle: "Minimize", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m")
300
+ windowMenu.addItem(withTitle: "Close", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w")
301
+ let windowItem = NSMenuItem(); windowItem.submenu = windowMenu
302
+ mainMenu.addItem(windowItem)
303
+
304
+ NSApp.mainMenu = mainMenu
305
+ NSApp.windowsMenu = windowMenu
306
+ }
307
+
308
+ @objc func reloadPage(_ sender: Any?) {
309
+ webView?.reload()
310
+ }
311
+
312
+ func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
313
+ return true
314
+ }
315
+
316
+ func applicationWillTerminate(_ notification: Notification) {
317
+ if ownedServer, let proc = serverProcess, proc.isRunning {
318
+ log("Terminating server (PID: \\(proc.processIdentifier))")
319
+ proc.terminate()
320
+ proc.waitUntilExit()
321
+ }
322
+ log("App terminated")
323
+ logHandle?.closeFile()
324
+ }
164
325
  }
165
326
 
166
- NODE=$(find_node)
167
- if [[ -z "$NODE" ]]; then
168
- echo "ERROR: node not found"
169
- show_error "Could not find Node.js.\\n\\nInstall Node.js and run: paneful --install-app"
170
- exit 1
171
- fi
172
- echo "Using node: $NODE"
173
-
174
- PANEFUL=$(find_paneful "$NODE")
175
- if [[ -z "$PANEFUL" ]]; then
176
- echo "ERROR: paneful not found"
177
- show_error "Could not find paneful.\\n\\nRun: npm install -g paneful && paneful --install-app"
178
- exit 1
179
- fi
180
- echo "Using paneful: $PANEFUL"
181
-
182
- exec "$NODE" "$PANEFUL"
327
+ let app = NSApplication.shared
328
+ app.setActivationPolicy(.regular)
329
+ let delegate = AppDelegate()
330
+ app.delegate = delegate
331
+ app.activate(ignoringOtherApps: true)
332
+ app.run()
183
333
  `;
184
334
  }
185
- export async function installApp() {
335
+ export async function installApp(appPath) {
186
336
  if (process.platform !== 'darwin') {
187
337
  console.error('paneful --install-app is only supported on macOS.');
188
338
  process.exit(1);
189
339
  }
190
- console.log('Creating Paneful.app in /Applications...');
191
- const contentsDir = path.join(APP_PATH, 'Contents');
340
+ // Check for swiftc
341
+ try {
342
+ execFileSync('swiftc', ['--version'], { stdio: 'pipe' });
343
+ }
344
+ catch {
345
+ console.error('swiftc not found. Install Xcode Command Line Tools:');
346
+ console.error(' xcode-select --install');
347
+ process.exit(1);
348
+ }
349
+ let resolvedPath = appPath;
350
+ if (!resolvedPath) {
351
+ // Show a folder picker dialog so the user can choose where to install
352
+ try {
353
+ const chosen = execFileSync('osascript', ['-e', `
354
+ set defaultDir to POSIX file "/Applications" as alias
355
+ try
356
+ set chosenFolder to choose folder with prompt "Choose where to install Paneful.app:" default location defaultDir
357
+ return POSIX path of chosenFolder
358
+ on error
359
+ return ""
360
+ end try
361
+ `], { encoding: 'utf-8', timeout: 60_000 }).trim();
362
+ if (!chosen) {
363
+ console.log('Installation cancelled.');
364
+ return;
365
+ }
366
+ resolvedPath = path.join(chosen, 'Paneful.app');
367
+ }
368
+ catch {
369
+ // Fallback if osascript dialog fails (e.g. no GUI session)
370
+ resolvedPath = DEFAULT_APP_PATH;
371
+ }
372
+ }
373
+ const installDir = path.dirname(resolvedPath);
374
+ console.log(`Creating Paneful.app in ${installDir}...`);
375
+ const contentsDir = path.join(resolvedPath, 'Contents');
192
376
  const macosDir = path.join(contentsDir, 'MacOS');
193
377
  const resourcesDir = path.join(contentsDir, 'Resources');
194
378
  try {
@@ -196,32 +380,57 @@ export async function installApp() {
196
380
  fs.mkdirSync(macosDir, { recursive: true });
197
381
  fs.mkdirSync(resourcesDir, { recursive: true });
198
382
  // Icon
199
- const iconSource = findIconSource();
383
+ const iconDir = findIconDir();
200
384
  let hasIcon = false;
201
- if (iconSource) {
202
- const icnsPath = buildIcns(iconSource, contentsDir);
385
+ if (iconDir) {
386
+ const icnsPath = buildIcns(iconDir, contentsDir);
203
387
  hasIcon = icnsPath !== null;
204
388
  if (hasIcon) {
205
- console.log(' Icon: converted to .icns');
389
+ console.log(' Icon: built .icns from assets');
206
390
  }
207
391
  else {
208
392
  console.log(' Icon: conversion failed, using generic icon');
209
393
  }
210
394
  }
211
395
  else {
212
- console.log(' Icon: source not found, using generic icon');
396
+ console.log(' Icon: assets not found, using generic icon');
213
397
  }
214
398
  // Info.plist
215
399
  fs.writeFileSync(path.join(contentsDir, 'Info.plist'), getInfoPlist(hasIcon));
216
400
  console.log(' Info.plist: written');
217
- // Launcher script
218
- const launcherPath = path.join(macosDir, 'Paneful');
219
- fs.writeFileSync(launcherPath, getLauncherScript());
220
- fs.chmodSync(launcherPath, 0o755);
221
- console.log(' Launcher: written');
401
+ // Compile Swift wrapper
402
+ const swiftSource = getSwiftSource();
403
+ const tmpSwift = path.join(os.tmpdir(), 'PanefulApp.swift');
404
+ const binaryPath = path.join(macosDir, 'Paneful');
405
+ fs.writeFileSync(tmpSwift, swiftSource);
406
+ console.log(' Compiling native wrapper...');
407
+ try {
408
+ execFileSync('swiftc', [
409
+ tmpSwift,
410
+ '-o', binaryPath,
411
+ '-framework', 'Cocoa',
412
+ '-framework', 'WebKit',
413
+ '-O',
414
+ ], { stdio: 'pipe', timeout: 60_000 });
415
+ }
416
+ finally {
417
+ try {
418
+ fs.unlinkSync(tmpSwift);
419
+ }
420
+ catch { /* ok */ }
421
+ }
422
+ console.log(' Binary: compiled');
423
+ // Ad-hoc code sign to avoid Gatekeeper warning
424
+ try {
425
+ execFileSync('codesign', ['--force', '--deep', '--sign', '-', resolvedPath], { stdio: 'pipe' });
426
+ console.log(' Signed: ad-hoc');
427
+ }
428
+ catch {
429
+ console.log(' Signing: skipped (codesign not available)');
430
+ }
222
431
  // Touch the app so Finder picks up changes
223
432
  try {
224
- execFileSync('touch', [APP_PATH], { stdio: 'pipe' });
433
+ execFileSync('touch', [resolvedPath], { stdio: 'pipe' });
225
434
  }
226
435
  catch { /* ok */ }
227
436
  console.log('\nPaneful.app installed successfully!');
@@ -41,10 +41,11 @@ export function startIpcListener(socketPath, ptyManager, projectStore, wsHandler
41
41
  function handleIpcRequest(request, ptyManager, projectStore, wsHandler) {
42
42
  switch (request.command) {
43
43
  case 'spawn': {
44
- // Always send to frontend — it deduplicates by cwd
45
- const id = uuidv4();
46
- const project = newProject(id, request.name, request.cwd);
47
- projectStore.create(project);
44
+ const existing = projectStore.findByCwd(request.cwd);
45
+ const id = existing?.id ?? uuidv4();
46
+ if (!existing) {
47
+ projectStore.create(newProject(id, request.name, request.cwd));
48
+ }
48
49
  wsHandler.send({
49
50
  type: 'project:spawned',
50
51
  projectId: id,
@@ -13,7 +13,7 @@ export class PtyManager {
13
13
  env.TERM = 'xterm-256color';
14
14
  env.LANG = 'en_US.UTF-8';
15
15
  env.LC_ALL = 'en_US.UTF-8';
16
- const proc = pty.spawn(shell, [], {
16
+ const proc = pty.spawn(shell, ['--login'], {
17
17
  name: 'xterm-256color',
18
18
  cols: 80,
19
19
  rows: 24,
@@ -0,0 +1,64 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ const DEFAULTS = {
4
+ favourites: {},
5
+ ui: {
6
+ theme: 'system',
7
+ sidebarWidth: 224,
8
+ editorSyncEnabled: true,
9
+ },
10
+ activeProjectId: null,
11
+ };
12
+ export class SettingsStore {
13
+ filePath;
14
+ data;
15
+ constructor(dataDir) {
16
+ this.filePath = path.join(dataDir, 'settings.json');
17
+ this.data = { ...DEFAULTS, ui: { ...DEFAULTS.ui } };
18
+ if (fs.existsSync(this.filePath)) {
19
+ try {
20
+ const raw = JSON.parse(fs.readFileSync(this.filePath, 'utf-8'));
21
+ this.data = {
22
+ favourites: raw.favourites ?? DEFAULTS.favourites,
23
+ ui: {
24
+ theme: raw.ui?.theme ?? DEFAULTS.ui.theme,
25
+ sidebarWidth: raw.ui?.sidebarWidth ?? DEFAULTS.ui.sidebarWidth,
26
+ editorSyncEnabled: raw.ui?.editorSyncEnabled ?? DEFAULTS.ui.editorSyncEnabled,
27
+ },
28
+ activeProjectId: raw.activeProjectId ?? DEFAULTS.activeProjectId,
29
+ };
30
+ }
31
+ catch {
32
+ // Corrupted file, use defaults
33
+ }
34
+ }
35
+ }
36
+ get() {
37
+ return this.data;
38
+ }
39
+ update(partial) {
40
+ if (partial.favourites !== undefined) {
41
+ this.data.favourites = partial.favourites;
42
+ }
43
+ if (partial.ui !== undefined) {
44
+ this.data.ui = { ...this.data.ui, ...partial.ui };
45
+ }
46
+ if (partial.activeProjectId !== undefined) {
47
+ this.data.activeProjectId = partial.activeProjectId;
48
+ }
49
+ this.persist();
50
+ return this.data;
51
+ }
52
+ persist() {
53
+ try {
54
+ const dir = path.dirname(this.filePath);
55
+ if (!fs.existsSync(dir)) {
56
+ fs.mkdirSync(dir, { recursive: true });
57
+ }
58
+ fs.writeFileSync(this.filePath, JSON.stringify(this.data, null, 2));
59
+ }
60
+ catch {
61
+ console.error('Failed to persist settings');
62
+ }
63
+ }
64
+ }