diff --git a/scripts/span-codeman.sh b/scripts/span-codeman.sh new file mode 100755 index 00000000..06c98275 --- /dev/null +++ b/scripts/span-codeman.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# +# span-codeman.sh — open a Codeman window stretched across ALL displays, so that +# in-page floating session panels can be dragged from one physical monitor to +# the other. Spawned by the header "multi-monitor" button (POST +# /api/system/span-displays), or run by hand at the desk. +# +# ── PREREQUISITE (one-time, manual) ────────────────────────────────────────── +# System Settings → Desktop & Dock → turn OFF "Displays have separate Spaces", +# then LOG OUT and back in. Until you do, macOS keeps every window on a single +# display and this script's window will clamp to one monitor instead of spanning. +# (Equivalent CLI: `defaults write com.apple.spaces spans-displays -bool true`, +# still needs a re-login. Revert with `-bool false`.) +# +# Why a maximized --app window and not fullscreen: browser fullscreen is +# per-display and will NOT span. We size a windowed app to the union of all +# displays instead. macOS only. +# +set -euo pipefail + +URL="${1:-http://localhost:5000}" + +# Union rect of all displays in top-left-origin points — exactly what Chromium's +# --window-position/--window-size expect. Finder's desktop window bounds already +# encloses every monitor (and handles a monitor placed left/above via a negative +# origin), so no per-display math or coordinate flipping is needed. +bounds=$(osascript -e 'tell application "Finder" to get bounds of window of desktop') +X=$(echo "$bounds" | awk -F', *' '{print $1}') +Y=$(echo "$bounds" | awk -F', *' '{print $2}') +R=$(echo "$bounds" | awk -F', *' '{print $3}') +B=$(echo "$bounds" | awk -F', *' '{print $4}') +W=$((R - X)) +H=$((B - Y)) +echo "Display union: position ${X},${Y} size ${W}x${H}" + +# Pick a Chromium-family browser. Brave leads the list — plain Google Chrome +# bounced when launched this way on the desk machine (created its profile then +# exited without a window). Force a specific one with, e.g., +# BROWSER="Google Chrome" ./span-codeman.sh +app="${BROWSER:-}" +if [ -z "$app" ]; then + for c in "Brave Browser" "Google Chrome" "Google Chrome Beta" "Chromium" "Microsoft Edge"; do + [ -x "/Applications/$c.app/Contents/MacOS/$c" ] && app="$c" && break + done +fi +bin="/Applications/$app.app/Contents/MacOS/$app" +[ -n "$app" ] && [ -x "$bin" ] || { echo "No Chrome-family browser found (BROWSER='$app')" >&2; exit 1; } + +# A dedicated, PER-BROWSER profile forces a FRESH instance — an already-running +# browser would hand the URL to itself and silently ignore the geometry flags. +# Per-browser so a Chrome-made profile can't confuse Brave (or vice-versa). +slug=$(echo "$app" | tr '[:upper:] ' '[:lower:]-') +profile="$HOME/.codeman-gesture-$slug" + +echo "Browser: $bin" +echo "URL: $URL" + +# Detach so the caller (terminal / web server) isn't blocked for the window's life. +nohup "$bin" \ + --app="$URL" \ + --user-data-dir="$profile" \ + --window-position="${X},${Y}" \ + --window-size="${W},${H}" \ + --no-first-run \ + --no-default-browser-check \ + >/dev/null 2>&1 & + +echo "Launched spanning window (pid $!)." +echo "If it filled only one monitor, the 'separate Spaces' prerequisite above" +echo "isn't active yet — toggle it off, log out/in, and re-run." diff --git a/src/web/public/index.html b/src/web/public/index.html index d78490eb..b5473817 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -105,7 +105,8 @@ - + diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index ac84cb8b..db0ed143 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -3129,6 +3129,23 @@ Object.assign(CodemanApp.prototype, { this.notificationManager?.toggleDrawer(); }, + // Open a Codeman window stretched across all displays (multi-monitor mode). + // The server spawns scripts/span-codeman.sh, which launches a fresh, spanning + // browser --app window so in-page floating panels can cross the monitor seam. + async launchMultiMonitor() { + try { + const res = await fetch('/api/system/span-displays', { method: 'POST' }); + const data = await res.json().catch(() => ({})); + if (res.ok && data.success) { + this.showToast('Opening Codeman across all displays…', 'success'); + } else { + this.showToast(data.error || 'Could not open spanning window', 'error'); + } + } catch (err) { + this.showToast('Could not open spanning window: ' + (err?.message || err), 'error'); + } + }, + // Alias for showToast toast(message, type = 'info') { return this.showToast(message, type); diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index 572bd030..b9552695 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -6,10 +6,11 @@ import { FastifyInstance } from 'fastify'; import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { existsSync, mkdirSync, readdirSync } from 'node:fs'; import fs from 'node:fs/promises'; import { totalmem, freemem, loadavg, cpus } from 'node:os'; -import { execSync } from 'node:child_process'; +import { execSync, spawn } from 'node:child_process'; import { randomBytes } from 'node:crypto'; import { dataPath } from '../../config/instance.js'; import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js'; @@ -240,6 +241,36 @@ export function registerSystemRoutes( return { success: true }; }); + // ═══════════════════════════════════════════════════════════════ + // Multi-monitor: span Codeman across all displays + // ═══════════════════════════════════════════════════════════════ + + // Spawn scripts/span-codeman.sh, which opens a fresh, maximized browser --app + // window sized to the union of all displays — so in-page floating session + // panels can be dragged across the physical monitor seam. macOS only; needs + // the one-time "Displays have separate Spaces" OFF prerequisite (see script). + app.post('/api/system/span-displays', async (req, reply) => { + // Resolve the bundled launcher relative to this module (works from src/ and dist/). + const scriptPath = join(dirname(fileURLToPath(import.meta.url)), '../../../scripts/span-codeman.sh'); + if (!existsSync(scriptPath)) { + return reply.code(500).send(createErrorResponse(ApiErrorCode.INTERNAL_ERROR, 'span-codeman.sh not found')); + } + // Point the spanning window at THIS server. Pin the host to localhost (same + // machine) and take only a digits-only port from the Host header so nothing + // attacker-controllable reaches the launched browser. + const hostPort = String(req.headers.host ?? '').split(':')[1] ?? ''; + const port = /^\d+$/.test(hostPort) ? hostPort : '5000'; + const url = `http://localhost:${port}`; + try { + const child = spawn('bash', [scriptPath, url], { detached: true, stdio: 'ignore' }); + child.on('error', (err) => app.log.error({ err }, 'span-displays launch failed')); + child.unref(); + return { success: true, url }; + } catch (err) { + return reply.code(500).send(createErrorResponse(ApiErrorCode.INTERNAL_ERROR, getErrorMessage(err))); + } + }); + // ═══════════════════════════════════════════════════════════════ // CLI Integrations (OpenCode) // ═══════════════════════════════════════════════════════════════