mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 01:09:43 +02:00
feat(multimonitor): header button to open Codeman spanned across all displays
Replace the header notification bell (now hidden by default; still reachable via Settings → Notifications and the drawer) with a multi-monitor button. Clicking it POSTs /api/system/span-displays, which spawns the bundled scripts/span-codeman.sh — 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 (documented in the script). The route pins the spanned window to localhost with a digits-only port from the Host header so nothing attacker-controllable reaches the launched browser. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Executable
+70
@@ -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."
|
||||||
@@ -105,7 +105,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-icon-header btn-response-viewer-header" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
<button class="btn-icon-header btn-response-viewer-header" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||||
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications">
|
<button class="btn-icon-header btn-multimonitor" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
|
||||||
|
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications" style="display:none;">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||||
<span class="notification-badge" id="notifBadge" style="display:none;">0</span>
|
<span class="notification-badge" id="notifBadge" style="display:none;">0</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -3129,6 +3129,23 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this.notificationManager?.toggleDrawer();
|
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
|
// Alias for showToast
|
||||||
toast(message, type = 'info') {
|
toast(message, type = 'info') {
|
||||||
return this.showToast(message, type);
|
return this.showToast(message, type);
|
||||||
|
|||||||
@@ -6,10 +6,11 @@
|
|||||||
|
|
||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
import { join, dirname } from 'node:path';
|
import { join, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
import { existsSync, mkdirSync, readdirSync } from 'node:fs';
|
import { existsSync, mkdirSync, readdirSync } from 'node:fs';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import { totalmem, freemem, loadavg, cpus } from 'node:os';
|
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 { randomBytes } from 'node:crypto';
|
||||||
import { dataPath } from '../../config/instance.js';
|
import { dataPath } from '../../config/instance.js';
|
||||||
import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js';
|
import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js';
|
||||||
@@ -240,6 +241,36 @@ export function registerSystemRoutes(
|
|||||||
return { success: true };
|
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)
|
// CLI Integrations (OpenCode)
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|||||||
Reference in New Issue
Block a user