diff --git a/ac-electron/main.js b/ac-electron/main.js index bd74d14c1..313e32268 100644 --- a/ac-electron/main.js +++ b/ac-electron/main.js @@ -208,6 +208,14 @@ if (!acDropSingleLock) { app.quit(); } else { app.on('second-instance', (_event, argv) => { + // `--preview ` from a relaunch (slab requesting a preview): open a + // new frameless preview window in this primary instance and stop — don't + // fall through to focusing an unrelated existing window. + const previewURL = parsePreviewArg(argv); + if (previewURL) { + app.whenReady().then(() => openPreviewWindow(previewURL)); + return; + } for (const arg of argv) { if (acDropIsAudio(arg)) { app.whenReady().then(() => acDropHandleFile(arg)); @@ -253,6 +261,14 @@ app.commandLine.appendSwitch('disable-backgrounding-occluded-windows'); app.commandLine.appendSwitch('js-flags', '--max-old-space-size=4096'); app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required'); +// Always expose a local CDP endpoint so external drivers (slab, Claude) can +// attach to any window/preview — eval, screenshot, instrument. Idempotent: if +// a launch flag already set the port (npm start scripts pass 9222) this is a +// no-op. Chromium binds remote debugging to 127.0.0.1 by default → local-only. +if (!process.argv.some((a) => a.startsWith('--remote-debugging-port'))) { + app.commandLine.appendSwitch('remote-debugging-port', '9222'); +} + // Preferences storage const PREFS_PATH = path.join(app.getPath('userData'), 'preferences.json'); let preferences = { @@ -580,6 +596,16 @@ const pieceArg = (pieceFlagIdx >= 0 ? args[pieceFlagIdx + 1] : undefined); const initialPiece = pieceArg?.replace('--piece=', '') || 'prompt'; +// `--preview ` opens a frameless, CDP-attached preview window for any URL +// (used by slab to render dynamic/web previews and tile them with terminals). +function parsePreviewArg(list) { + const eq = list.find((a) => a.startsWith('--preview=')); + if (eq) return eq.slice('--preview='.length); + const i = list.indexOf('--preview'); + return i >= 0 ? list[i + 1] : undefined; +} +const initialPreviewURL = parsePreviewArg(args); + // URLs - nogap removes the aesthetic gap border for desktop mode const URLS = { production: `https://aesthetic.computer/${initialPiece}?nogap=true`, @@ -1551,6 +1577,67 @@ async function openAcPaneWindow(options = {}) { return openAcPaneWindowInternal(options); } +// Open a FRAMELESS preview window for an arbitrary URL/path. This is slab's +// "dynamic preview" engine: chromeless (no title bar), a normal-level window +// so the slab tiler (AXTiler) packs it into the terminal grid, and — like +// every ac-electron window — automatically a CDP target on the local +// remote-debugging port, so slab/Claude can attach, eval, and screenshot it. +// Re-requesting the same URL focuses the existing preview instead of stacking. +const previewWindows = new Map(); // url -> BrowserWindow +function openPreviewWindow(rawUrl) { + if (!rawUrl) return null; + let url = rawUrl; + if (!/^(https?|file):\/\//.test(url)) { + url = 'file://' + (url.startsWith('/') ? url : path.resolve(url)); + } + const existing = previewWindows.get(url); + if (existing && !existing.isDestroyed()) { + if (existing.isMinimized()) existing.restore(); + existing.focus(); + return existing; + } + const win = new BrowserWindow({ + width: 480, + height: 360, + title: url, + frame: false, // chromeless + transparent: false, + backgroundColor: '#000000', + alwaysOnTop: false, // a normal window → AXTiler treats it as standard + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + sandbox: false, // so preview-preload.js can use ipcRenderer + backgroundThrottling: false, + preload: getAppPath('renderer/preview-preload.js'), // click-drag to move + }, + }); + win.loadURL(url); + const windowId = windowIdCounter++; + windows.set(windowId, { window: win, mode: 'preview' }); + previewWindows.set(url, win); + win.on('focus', () => { focusedWindowId = windowId; }); + win.on('closed', () => { windows.delete(windowId); previewWindows.delete(url); }); + return win; +} + +// Frameless preview drag-to-move: the preload streams cursor screen coords; we +// move the window by the delta from where the drag began. Keyed per webContents +// so multiple previews drag independently. +const previewDragOrigin = new Map(); // webContents.id -> { win:[x,y], sx, sy } +ipcMain.on('preview-drag-start', (event, { sx, sy }) => { + const win = BrowserWindow.fromWebContents(event.sender); + if (win) previewDragOrigin.set(event.sender.id, { win: win.getPosition(), sx, sy }); +}); +ipcMain.on('preview-drag-move', (event, { sx, sy }) => { + const o = previewDragOrigin.get(event.sender.id); + const win = BrowserWindow.fromWebContents(event.sender); + if (o && win && !win.isDestroyed()) { + win.setPosition(o.win[0] + Math.round(sx - o.sx), o.win[1] + Math.round(sy - o.sy)); + } +}); +ipcMain.on('preview-drag-end', (event) => { previewDragOrigin.delete(event.sender.id); }); + // Open a standalone Notepat window — compact window dedicated to the /notepat piece let notepatWindow = null; function openNotepatWindow() { @@ -3083,7 +3170,9 @@ app.whenReady().then(async () => { // Create initial window(s) // When launched silently at login, stay in menubar-daemon mode: no AC // window, no dock icon. The user opens things explicitly from the tray. - if (!launchedSilently || acDropColdLaunchFile) { + if (initialPreviewURL) { + openPreviewWindow(initialPreviewURL); // launched as a preview host (slab) + } else if (!launchedSilently || acDropColdLaunchFile) { openAcPaneWindow({ piece: initialPiece }); } diff --git a/ac-electron/renderer/preview-preload.js b/ac-electron/renderer/preview-preload.js new file mode 100644 index 000000000..5e669ba13 --- /dev/null +++ b/ac-electron/renderer/preview-preload.js @@ -0,0 +1,36 @@ +// preview-preload.js — makes a frameless slab preview window draggable by +// click-dragging anywhere on its body. A small movement threshold means a +// quick tap still passes through to the page (so click-to-regen etc. work); +// once you drag past the threshold the window follows the cursor instead. +// +// Wired only into `--preview` windows (see openPreviewWindow in main.js). +const { ipcRenderer } = require('electron'); + +const THRESHOLD = 4; // px of travel before it counts as a drag +let down = false, dragging = false, startX = 0, startY = 0; + +window.addEventListener('mousedown', (e) => { + if (e.button !== 0) return; // left button only + down = true; dragging = false; + startX = e.screenX; startY = e.screenY; + ipcRenderer.send('preview-drag-start', { sx: e.screenX, sy: e.screenY }); +}, true); + +window.addEventListener('mousemove', (e) => { + if (!down) return; + if (!dragging && + Math.abs(e.screenX - startX) + Math.abs(e.screenY - startY) > THRESHOLD) { + dragging = true; + } + if (dragging) { + ipcRenderer.send('preview-drag-move', { sx: e.screenX, sy: e.screenY }); + e.preventDefault(); e.stopPropagation(); + } +}, true); + +const end = () => { + if (down && dragging) ipcRenderer.send('preview-drag-end'); + down = false; dragging = false; +}; +window.addEventListener('mouseup', end, true); +window.addEventListener('blur', end, true); diff --git a/slab/bin/slab-web b/slab/bin/slab-web new file mode 100755 index 000000000..c23ae62d0 --- /dev/null +++ b/slab/bin/slab-web @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# slab-web — open a frameless, CDP-attached, tileable WEB preview in slab, +# rendered by the AC Electron app (Aesthetic.Computer.app). +# +# Mirrors the slab-pdf / slab-video contract but for dynamic/web content: the +# AC Electron app opens a chromeless (frameless) window for the URL, exposes it +# on the local CDP port (9222) so slab/Claude can drive it, and because it's a +# normal external app window the slab tiler (AXTiler) packs it into the same +# grid as your Terminal/iTerm windows. +# +# Usage: +# slab-web +# slab-web http://localhost:8123/spatial-self-3d.html +# slab-web ~/aesthetic-computer/studies/spatial-flow.html +# slab-web '$gla' # an AC piece/$code → aesthetic.computer +# +# CDP endpoint after open: http://127.0.0.1:9222/json (ws targets per window) +set -euo pipefail + +APP="Aesthetic.Computer" +url="${1:-}" +[ -n "$url" ] || { echo "usage: slab-web " >&2; exit 1; } + +case "$url" in + http://*|https://*|file://*) : ;; # full URL — as-is + /*) url="file://$url" ;; # absolute path + ./*|../*|*/*) url="file://$(cd "$(dirname "$url")" && pwd)/$(basename "$url")" ;; # relative path + *.html) url="file://$PWD/$url" ;; # bare file in cwd + *) url="https://aesthetic.computer/$url" ;; # bare token → AC piece/$code +esac + +# `open -na … --args --preview ` launches a transient instance; the AC +# app's single-instance lock hands the args to the running primary via its +# `second-instance` handler (which opens the preview window there). If the app +# isn't running yet, this cold-launches it straight into preview mode. +exec open -na "$APP" --args --preview "$url" diff --git a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift index 0bd1cfd0f..c8a980bd9 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift @@ -2036,7 +2036,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private static func axTilePass(geom: ScreenGeom, textSize: TextSize) -> AXPass? { let iterm = AXTiler.windows(bundleId: "com.googlecode.iterm2") let term = AXTiler.windows(bundleId: "com.apple.Terminal") - let all = iterm + term + // AC Electron preview windows (slab-web) are external standard windows, + // so they pack into the same grid as the terminals — frameless dynamic + // previews tiled right next to your shells. + let acpane = AXTiler.windows(bundleId: "computer.aesthetic.app") + let all = iterm + term + acpane guard !all.isEmpty, let layout = computeTileLayout(count: all.count, geom: geom, size: textSize) else { return nil }