diff --git a/.gitignore b/.gitignore index 2fa1b0b..f78b094 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ app/dist/ # Generated daemon sidecar for DMG bundling (make dmg) app/src-tauri/bin/ + +# Local notarization secrets (Apple ID / app-specific password) +.signing.env diff --git a/Cargo.lock b/Cargo.lock index 6789cee..e230fc7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -869,7 +869,7 @@ dependencies = [ [[package]] name = "spacesh-cli" -version = "0.1.10" +version = "0.1.27" dependencies = [ "anyhow", "clap", @@ -881,7 +881,7 @@ dependencies = [ [[package]] name = "spacesh-core" -version = "0.1.10" +version = "0.1.27" dependencies = [ "alacritty_terminal", "serde", @@ -891,7 +891,7 @@ dependencies = [ [[package]] name = "spacesh-proto" -version = "0.1.10" +version = "0.1.27" dependencies = [ "bytes", "serde", @@ -903,7 +903,7 @@ dependencies = [ [[package]] name = "spacesh-pty" -version = "0.1.10" +version = "0.1.27" dependencies = [ "anyhow", "bytes", @@ -913,7 +913,7 @@ dependencies = [ [[package]] name = "spaceshd" -version = "0.1.10" +version = "0.1.27" dependencies = [ "anyhow", "base64", diff --git a/Cargo.toml b/Cargo.toml index 25fc59e..3ea4308 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ members = [ [workspace.package] edition = "2021" -version = "0.1.10" +version = "0.1.27" [workspace.dependencies] tokio = { version = "1", features = ["full"] } diff --git a/Makefile b/Makefile index 6a83618..73b0668 100644 --- a/Makefile +++ b/Makefile @@ -7,6 +7,7 @@ DMG_DIR := $(APP_DIR)/src-tauri/target/$(TAURI_TARGET)/release/bundle/dm NATIVE_DMG_DIR := $(APP_DIR)/src-tauri/target/release/bundle/dmg NATIVE_TRIPLE := $(shell rustc -vV 2>/dev/null | awk '/^host:/{print $$2}') SIDECAR_DIR := $(APP_DIR)/src-tauri/bin +ENTITLEMENTS := $(APP_DIR)/src-tauri/Entitlements.plist BUNDLE_CONFIG := src-tauri/tauri.bundle.conf.json APP_BUNDLE := $(APP_DIR)/src-tauri/target/$(TAURI_TARGET)/release/bundle/macos/spaceshell.app NATIVE_APP_BUNDLE := $(APP_DIR)/src-tauri/target/release/bundle/macos/spaceshell.app @@ -17,6 +18,34 @@ LANDING_VERSION := $(shell cat landing/VERSION 2>/dev/null || echo 0.0.0) REGISTRY ?= git.realmanual.ru REPO ?= spacesh +# Stable code-signing identity. Without a STABLE signature the app is ad-hoc +# signed and its code identity changes every build, so macOS attributes child +# processes (the daemon → Claude Code) to a different "responsible app" each time: +# TCC permissions reset and agents lose their Keychain login on every rebuild. +# Defaults to the Developer ID (Team 3PNKDC6L42) — a stable designated requirement +# (anchor apple generic + TeamID) that Keychain/TCC trust survives across rebuilds. +# Override with `SIGN_IDENTITY="" make reinstall`, or `SIGN_IDENTITY=` +# to fall back to ad-hoc. Tauri reads APPLE_SIGNING_IDENTITY for the bundle + sidecar. +SIGN_IDENTITY ?= Developer ID Application: Vassiliy Yegorov (3PNKDC6L42) +ifneq ($(strip $(SIGN_IDENTITY)),) +export APPLE_SIGNING_IDENTITY := $(SIGN_IDENTITY) +endif + +# Notarization (required to distribute the DMG — Gatekeeper blocks un-notarized apps +# on other Macs). Secrets: put them in a gitignored `.signing.env` (make syntax, +# e.g. `APPLE_ID := you@example.com`) or pass on the CLI. NEVER commit them. +# APPLE_ID — your Apple ID email +# APPLE_PASSWORD — an app-specific password (appleid.apple.com → App-Specific Passwords) +# APPLE_TEAM_ID — 3PNKDC6L42 (defaulted below) +# When all three are present, `tauri build` auto-notarizes + staples the bundle. +-include .signing.env +APPLE_ID ?= +APPLE_PASSWORD ?= +APPLE_TEAM_ID ?= 3PNKDC6L42 +ifneq ($(strip $(APPLE_ID)),) +export APPLE_ID APPLE_PASSWORD APPLE_TEAM_ID +endif + # ---- Gitea generic package registry (versioned .dmg downloads) ---- GITEA_URL ?= https://git.realmanual.ru GITEA_OWNER ?= pub @@ -53,7 +82,7 @@ bump: ## increment the patch version for BOTH the GUI (tauri.conf.json) and the @node scripts/bump_version.mjs .PHONY: dmg -dmg: bump targets ## bump version + build the universal (Intel + Apple Silicon) .dmg — UNSIGNED +dmg: bump targets ## bump version + build universal .dmg (signed; notarized if .signing.env set) # Tauri's universal build needs BOTH the per-arch sidecars (resolved during each # arch sub-build) AND a fat spaceshd-universal-apple-darwin (copied into the final # bundle — Tauri does not lipo sidecars itself). spaceshd ships inside @@ -102,7 +131,16 @@ install: kill-daemon ## install the native .app to /Applications, restart daemon rm -rf /Applications/spacesh.app /Applications/spaceshell.app # drop the pre-rename app too cp -R "$(NATIVE_APP_BUNDLE)" /Applications/ xattr -dr com.apple.quarantine /Applications/spaceshell.app +ifneq ($(strip $(SIGN_IDENTITY)),) + # Belt-and-suspenders: re-sign inside-out with the stable identity so neither the + # embedded daemon nor the app is left ad-hoc if Tauri skipped the sidecar. + codesign --force --options runtime --timestamp --entitlements "$(ENTITLEMENTS)" --sign "$(SIGN_IDENTITY)" /Applications/spaceshell.app/Contents/MacOS/spaceshd + codesign --force --options runtime --timestamp --entitlements "$(ENTITLEMENTS)" --sign "$(SIGN_IDENTITY)" /Applications/spaceshell.app + @codesign -dvv /Applications/spaceshell.app 2>&1 | grep -E "TeamIdentifier|Signature" || true +endif @echo "Installed (native). Quit & relaunch spaceshell; the bundled daemon restarts." + @echo "Tip: on first launch grant Full Disk Access (System Settings → Privacy & Security)" + @echo " so terminals inside the app can run tmutil / reach protected folders." .PHONY: install-universal install-universal: kill-daemon ## install the universal .app to /Applications diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 70ada4f..5559f74 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -3440,7 +3440,7 @@ dependencies = [ [[package]] name = "spacesh-proto" -version = "0.1.10" +version = "0.1.26" dependencies = [ "bytes", "serde", diff --git a/app/src-tauri/Entitlements.plist b/app/src-tauri/Entitlements.plist new file mode 100644 index 0000000..a176d18 --- /dev/null +++ b/app/src-tauri/Entitlements.plist @@ -0,0 +1,16 @@ + + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + + com.apple.security.cs.disable-library-validation + + + diff --git a/app/src-tauri/src/bridge.rs b/app/src-tauri/src/bridge.rs index d4bfe65..293f9ed 100644 --- a/app/src-tauri/src/bridge.rs +++ b/app/src-tauri/src/bridge.rs @@ -534,6 +534,32 @@ pub fn open_external(url: String) -> Result<(), String> { Ok(()) } +/// Whether spaceshell.app has Full Disk Access. Terminals spawned inside the app +/// inherit its TCC grants (the app is their "responsible process"), so without FDA +/// commands like `tmutil` fail. Probe: reading the user's TCC database requires FDA — +/// a permission error means we lack it; success (or the file being absent) means we +/// have it. +#[tauri::command] +pub fn has_full_disk_access() -> bool { + let Some(home) = std::env::var_os("HOME") else { return false }; + let probe = std::path::Path::new(&home).join("Library/Application Support/com.apple.TCC/TCC.db"); + match std::fs::File::open(&probe) { + Ok(_) => true, + Err(e) => e.kind() == std::io::ErrorKind::NotFound, + } +} + +/// Open System Settings → Privacy & Security → Full Disk Access so the user can add +/// spaceshell. macOS cannot grant this programmatically; we only deep-link the pane. +#[tauri::command] +pub fn open_full_disk_access_settings() -> Result<(), String> { + std::process::Command::new("open") + .arg("x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles") + .spawn() + .map_err(|e| e.to_string())?; + Ok(()) +} + /// List the user's installed font families (CoreText) so Settings can offer any of /// them for the terminal. Hidden system families (".SF NS" etc.) are dropped; the /// result is de-duplicated and sorted case-insensitively. @@ -568,8 +594,29 @@ pub async fn set_config( font_size: Option, theme: Option, accent: Option, + background: Option, + background_image: Option, + log_shell_commands: Option, ) -> Result { - data_of(state.request(Cmd::SetConfig { default_shell, font_family, font_size, theme, accent }).await.map_err(|e| e.to_string())?) + data_of(state.request(Cmd::SetConfig { default_shell, font_family, font_size, theme, accent, background, background_image, log_shell_commands }).await.map_err(|e| e.to_string())?) +} + +/// Read a local image file and return it as a `data:` URL for use as a CSS +/// background. Kept in the GUI bridge (not the daemon) so the bytes load straight +/// into the webview without crossing the socket. +#[tauri::command] +pub async fn read_image_data_url(path: String) -> Result { + use base64::Engine as _; + let bytes = tokio::fs::read(&path).await.map_err(|e| e.to_string())?; + let mime = match std::path::Path::new(&path).extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref() { + Some("png") => "image/png", + Some("jpg") | Some("jpeg") => "image/jpeg", + Some("webp") => "image/webp", + Some("gif") => "image/gif", + _ => "application/octet-stream", + }; + let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes); + Ok(format!("data:{mime};base64,{b64}")) } #[tauri::command] diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index b9df91c..7e6a9cd 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -57,9 +57,12 @@ pub fn run() { bridge::which_agents, bridge::check_update, bridge::open_external, + bridge::has_full_disk_access, + bridge::open_full_disk_access_settings, bridge::list_fonts, bridge::get_config, bridge::set_config, + bridge::read_image_data_url, bridge::shutdown_daemon, ]) .run(tauri::generate_context!()) diff --git a/app/src-tauri/tauri.bundle.conf.json b/app/src-tauri/tauri.bundle.conf.json index 6b9b9b0..bba30a8 100644 --- a/app/src-tauri/tauri.bundle.conf.json +++ b/app/src-tauri/tauri.bundle.conf.json @@ -1,6 +1,9 @@ { "$schema": "https://schema.tauri.app/config/2", "bundle": { - "externalBin": ["bin/spaceshd"] + "externalBin": ["bin/spaceshd"], + "macOS": { + "entitlements": "Entitlements.plist" + } } } diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index 8a60a5a..245ee48 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "spaceshell", - "version": "0.1.10", + "version": "0.1.27", "identifier": "xyz.spacesh.app", "build": { "frontendDist": "../dist", @@ -14,7 +14,9 @@ { "title": "spaceshell", "width": 1100, - "height": 720 + "height": 720, + "titleBarStyle": "Overlay", + "hiddenTitle": true } ], "security": { diff --git a/app/src/App.tsx b/app/src/App.tsx index e0d19e1..c6cbb06 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -10,11 +10,13 @@ import { ConfirmDelete } from "./ConfirmDelete"; import { Settings } from "./Settings"; import { EventCenter } from "./EventCenter"; import { maybeNotify } from "./notify"; -import { COLORS, applyTheme, resolvePalette } from "./theme"; -import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent, setWorkspaceMeta, focusSurface, getEventLog, markEventsRead, clearEvents, getHealth, closeWorkspaceCmd, getConfig, checkUpdate } from "./socketBridge"; +import { COLORS, FONT, applyTheme, resolvePalette } from "./theme"; +import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent, setWorkspaceMeta, focusSurface, getEventLog, markEventsRead, clearEvents, getHealth, closeWorkspaceCmd, getConfig, checkUpdate, splitSurface, closeSurfaceCmd, setZoom, readImageDataUrl, hasFullDiskAccess, openFullDiskAccessSettings } from "./socketBridge"; import type { EventRecord, DaemonHealth, ConfigView, UpdateInfo } from "./socketBridge"; import { leafIds } from "./layoutTypes"; import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes"; +import { HOTKEYS, loadBindings, saveBindings, matches, hasModifier } from "./hotkeys"; +import type { Bindings, HotkeyId } from "./hotkeys"; /** Read a boolean UI flag from localStorage, falling back to `def`. */ function loadFlag(key: string, def: boolean): boolean { @@ -50,11 +52,20 @@ export function App() { const [focusedId, setFocusedId] = useState(null); const [searchSurfaceId, setSearchSurfaceId] = useState(null); const [searchNonce, setSearchNonce] = useState(0); + const [bindings, setBindings] = useState(loadBindings); + // Full Disk Access: terminals inherit spaceshell's TCC grants, so without FDA + // tools like tmutil fail inside panels. Default true to avoid a flash before the probe. + const [fdaOk, setFdaOk] = useState(true); + const [fdaDismissed, setFdaDismissed] = useState(() => loadFlag("spacesh.fdaDismissed", false)); const activeRef = useRef(null); const effectiveFocusRef = useRef(null); const wsRef = useRef([]); + const leavesRef = useRef([]); + const modalOpenRef = useRef(false); + const bindingsRef = useRef(bindings); activeRef.current = activeId; wsRef.current = workspaces; + bindingsRef.current = bindings; const seedEvents = useCallback(async () => { const log = await getEventLog(); @@ -97,7 +108,7 @@ export function App() { void refresh(); void seedEvents(); void loadHealth(); - void getConfig().then((c) => { setConfigState(c); applyTheme(c.theme, c.accent); }).catch(() => {}); + void getConfig().then((c) => { setConfigState(c); }).catch(() => {}); const unlisten = onDaemonEvent((evt) => { if (evt.evt === "event") { const rec = evt.data.record; @@ -116,9 +127,7 @@ export function App() { } else if (evt.evt === "exit") { void refresh(); } else if (evt.evt === "config_changed") { - const c = evt.data.config; - setConfigState(c); - applyTheme(c.theme, c.accent); + setConfigState(evt.data.config); } else { void refresh(); } @@ -128,7 +137,7 @@ export function App() { void refresh(); void seedEvents(); void loadHealth(); - void getConfig().then((c) => { setConfigState(c); applyTheme(c.theme, c.accent); }).catch(() => {}); + void getConfig().then((c) => { setConfigState(c); }).catch(() => {}); }); const reconnected = onDaemonRawEvent("spacesh:reconnected", () => { setConnected(true); @@ -136,23 +145,85 @@ export function App() { void refresh(); void seedEvents(); void loadHealth(); - void getConfig().then((c) => { setConfigState(c); applyTheme(c.theme, c.accent); }).catch(() => {}); + void getConfig().then((c) => { setConfigState(c); }).catch(() => {}); }); return () => { void unlisten.then((f) => f()); void reconnect.then((f) => f()); void reconnected.then((f) => f()); }; }, [refresh, seedEvents, loadHealth]); + // Cycle keyboard focus through the active workspace's panels. + const cycleFocus = useCallback((dir: 1 | -1) => { + const ls = leavesRef.current; + if (ls.length < 2) return; + const cur = effectiveFocusRef.current; + const i = Math.max(0, ls.indexOf(cur ?? ls[0])); + const next = ls[(i + dir + ls.length) % ls.length]; + setFocusedId(next); + void focusSurface(next); + }, []); + + // Scrollback search is meaningless on an agent surface: claude/codex/… run a + // full-screen TUI in the alternate buffer that repaints every frame, so match + // decorations are drawn and immediately clobbered (the counter updates but no + // highlight shows). Gate the search affordance off for agent panels. + const isAgentSurface = useCallback((id: string | null): boolean => { + if (!id) return false; + const w = wsRef.current.find((ws) => id in ws.surfaces); + return !!w?.surfaces[id]?.spec?.agent_label; + }, []); + + // Probe Full Disk Access on launch and again whenever the window regains focus + // (so granting it in System Settings and returning clears the banner immediately). + useEffect(() => { + const recheck = () => { void hasFullDiskAccess().then(setFdaOk); }; + recheck(); + window.addEventListener("focus", recheck); + return () => window.removeEventListener("focus", recheck); + }, []); + + // Action dispatch table for hotkeys. Reads live state through refs so the + // window listener can mount once. + const actions = useMemo void>>(() => ({ + newWorkspace: () => setWizard(true), + openSettings: () => { if (config) setSettingsOpen(true); }, + toggleSidebar: () => setSidebarOpen((v) => !v), + toggleEvents: () => setEventsOpen((v) => !v), + splitRight: () => { const f = effectiveFocusRef.current; if (f) void splitSurface(f, "right"); }, + splitDown: () => { const f = effectiveFocusRef.current; if (f) void splitSurface(f, "down"); }, + closePanel: () => { const f = effectiveFocusRef.current; if (f) void closeSurfaceCmd(f); }, + focusNext: () => cycleFocus(1), + focusPrev: () => cycleFocus(-1), + zoomToggle: () => { + const a = wsRef.current.find((w) => w.id === activeRef.current); + if (!a) return; + void setZoom(a.id, a.zoomed ? null : effectiveFocusRef.current); + }, + search: () => { + const f = effectiveFocusRef.current; + if (f && !isAgentSurface(f)) { setSearchSurfaceId(f); setSearchNonce((n) => n + 1); } + }, + }), [cycleFocus, config, isAgentSurface]); + + const actionsRef = useRef(actions); + actionsRef.current = actions; + + // Central hotkey listener (mounted once). Skips while a modal is open, and only + // ever swallows keys that carry a modifier so terminal input is never stolen. useEffect(() => { const onKey = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "f") { - if (activeRef.current && effectiveFocusRef.current) { + if (modalOpenRef.current) return; + for (const h of HOTKEYS) { + const b = bindingsRef.current[h.id]; + if (hasModifier(b) && matches(b, e)) { e.preventDefault(); - setSearchSurfaceId(effectiveFocusRef.current); // anchor to the focused panel - setSearchNonce((n) => n + 1); + e.stopPropagation(); // capture phase: keep the chord out of the focused terminal + actionsRef.current[h.id](); + return; } } }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); + // Capture phase so the chord is seen even when xterm has keyboard focus. + window.addEventListener("keydown", onKey, true); + return () => window.removeEventListener("keydown", onKey, true); }, []); // Update check: once on launch, then every 6h. @@ -170,8 +241,29 @@ export function App() { const leaves = active ? leafIds(active.layout) : []; const effectiveFocus = focusedId && leaves.includes(focusedId) ? focusedId : leaves[0] ?? null; effectiveFocusRef.current = effectiveFocus; + leavesRef.current = leaves; + modalOpenRef.current = wizard || settingsOpen || !!pendingPreset || !!deleteTarget; - const termPalette = useMemo(() => (config ? resolvePalette(config.theme, config.accent) : null), [config?.theme, config?.accent]); + // Apply theme + background fill whenever appearance changes. A custom image is + // loaded (file → data URL) before re-applying so the panel glass and root fill + // paint together. Centralized here so every config source (initial load, + // reconnect, config_changed) flows through one place. + const [bgImage, setBgImage] = useState(null); + useEffect(() => { + if (!config) return; + let cancelled = false; + if (config.background === "custom" && config.background_image) { + void readImageDataUrl(config.background_image) + .then((url) => { if (!cancelled) { setBgImage(url); applyTheme(config.theme, config.accent, config.background, url); } }) + .catch(() => { if (!cancelled) { setBgImage(null); applyTheme(config.theme, config.accent, "none", null); } }); + } else { + setBgImage(null); + applyTheme(config.theme, config.accent, config.background, null); + } + return () => { cancelled = true; }; + }, [config?.theme, config?.accent, config?.background, config?.background_image]); // eslint-disable-line react-hooks/exhaustive-deps + + const termPalette = useMemo(() => (config ? resolvePalette(config.theme, config.accent, config.background) : null), [config?.theme, config?.accent, config?.background, bgImage]); const termFont = useMemo(() => (config ? { family: config.font_family, size: config.font_size } : null), [config?.font_family, config?.font_size]); function selectWorkspace(id: string) { @@ -181,8 +273,21 @@ export function App() { } return ( -
+
setEventsOpen((v) => !v)} onShowEvents={() => setEventsOpen(true)} sidebarOpen={sidebarOpen} onToggleSidebar={() => setSidebarOpen((v) => !v)} unread={unread} onOpenSettings={() => { if (config) setSettingsOpen(true); }} update={update} updateChecking={updateChecking} onCheckUpdate={() => { void runUpdateCheck(); }} /> + {!fdaOk && !fdaDismissed && ( +
+ + Нет Full Disk Access. Терминалы внутри spaceshell наследуют права приложения — без него команды вроде tmutil и доступ к защищённым папкам падают. Добавьте spaceshell в System Settings → Privacy & Security → Full Disk Access. + + + + +
+ )}
setWizard(true)} onDelete={setDeleteTarget} health={health} connected={connected} />
@@ -193,7 +298,7 @@ export function App() { const delta = target - leaves.length; if (delta <= 0) { void applyPreset(active.id, p, []); return; } // reshape only — no new panels setPendingPreset({ id: p, delta, base: leaves.length }); - }} onOpenSearch={() => { if (effectiveFocus) { setSearchSurfaceId(effectiveFocus); setSearchNonce((n) => n + 1); } }} /> + }} onOpenSearch={() => { if (effectiveFocus && !isAgentSurface(effectiveFocus)) { setSearchSurfaceId(effectiveFocus); setSearchNonce((n) => n + 1); } }} searchDisabled={isAgentSurface(effectiveFocus)} /> )}
{active @@ -210,7 +315,7 @@ export function App() { /> )}
- {settingsOpen && config && setSettingsOpen(false)} onReload={() => { void loadHealth(); void refresh(); }} />} + {settingsOpen && config && { setBindings(b); saveBindings(b); }} onClose={() => setSettingsOpen(false)} onReload={() => { void loadHealth(); void refresh(); }} />} {wizard && { setWizard(false); setActiveId(id); void refresh(); }} onCancel={() => setWizard(false)} />} {pendingPreset && active && ( void; onOpenSearch: () => void; paneCount: number }) { +export function CenterToolbar({ selected, onSelect, onOpenSearch, paneCount, searchDisabled = false }: { selected: string; onSelect: (id: string) => void; onOpenSearch: () => void; paneCount: number; searchDisabled?: boolean }) { return ( -
+
Search scrollback diff --git a/app/src/EventCenter.tsx b/app/src/EventCenter.tsx index c6052cf..075e898 100644 --- a/app/src/EventCenter.tsx +++ b/app/src/EventCenter.tsx @@ -40,7 +40,7 @@ export function EventCenter({ : events; return ( -
+
Event Center function xtermThemeLE(p: Record) { return { - background: p["bg-panel"], + background: p["term-bg"] ?? p["bg-panel"], foreground: p["text-primary"], cursor: p["text-primary"], selectionBackground: p["search-match"], @@ -140,6 +140,7 @@ function StoppedSnapshot({ surfaceId, font, palette }: { surfaceId: string; font fontFamily: fontStackLE(font?.family ?? null), fontSize: font?.size ?? 13, theme: palette ? xtermThemeLE(palette) : undefined, + allowTransparency: true, // term-bg may be transparent under a background theme cursorBlink: false, disableStdin: true, scrollback: 0, @@ -164,7 +165,8 @@ function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus, onMouseDown={() => onFocus(id)} style={{ position: "relative", display: "flex", flexDirection: "column", width: "100%", height: "100%", - background: COLORS.bgPanel, borderRadius: 8, overflow: "hidden", + background: "transparent", + borderRadius: 8, overflow: "hidden", // Constant 2px border, color-only on focus. A width change (1px<->2px) // would resize the inner content box, fire ResizeObserver -> fit -> PTY // SIGWINCH, making zsh/powerlevel10k reprint its prompt on every focus @@ -173,7 +175,15 @@ function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus, boxSizing: "border-box", }} > - {inner} + {/* Glass fill + blur as a layer BEHIND the content. The terminal's transparent + cells show this through. Crucially the terminal canvas is NOT a descendant + of a backdrop-filter element — under WKWebView that clips/smears the WebGL + canvas (first-glyph clip at column 0, smearing on scroll). With "none" the + glass is the solid bg-panel so the classic look is unchanged. */} +
+
+ {inner} +
{dropEdge && }
); @@ -217,7 +227,7 @@ function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus,
{ onFocus(id); onStartPanelDrag(id, e); }} title="Drag to move this panel" - style={{ display: "flex", alignItems: "center", gap: 8, height: 30, flex: "0 0 30px", padding: "0 10px", background: COLORS.bgElevated, borderBottom: `1px solid ${COLORS.borderSubtle}`, cursor: "grab" }} + style={{ display: "flex", alignItems: "center", gap: 8, height: 30, flex: "0 0 30px", padding: "0 10px", background: COLORS.elevatedGlass, borderBottom: `1px solid ${COLORS.borderSubtle}`, cursor: "grab" }} > @@ -238,7 +248,7 @@ function Leaf({ id, workspaceId, running, states, surfaces, focusedId, onFocus, onMouseLeave={(e) => { e.currentTarget.style.color = COLORS.textMuted; }} />
- +
{searchSurfaceId === id && ( diff --git a/app/src/Settings.tsx b/app/src/Settings.tsx index 963cdfd..6b22722 100644 --- a/app/src/Settings.tsx +++ b/app/src/Settings.tsx @@ -1,8 +1,10 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { X, Search, Check } from "lucide-react"; -import { COLORS, FONT, ACCENTS } from "./theme"; +import { COLORS, FONT, ACCENTS, BACKGROUNDS, CUSTOM_BACKGROUND } from "./theme"; import { setConfig, restartDaemon, listFonts } from "./socketBridge"; import type { ConfigView, DaemonHealth } from "./socketBridge"; +import { HOTKEYS, defaultBindings, eventBinding, formatBinding, hasModifier } from "./hotkeys"; +import type { Bindings, HotkeyId } from "./hotkeys"; // Pinned defaults shown first; the rest are the user's installed families (list_fonts). const DEFAULT_FONTS = ["JetBrains Mono", "Menlo", "Monaco", "SF Mono", "Fira Code", "Cascadia Code"]; @@ -70,7 +72,7 @@ function FontPicker({ value, onPick }: { value: string; onPick: (family: string) ); } -export function Settings({ config, health, onClose, onReload }: { config: ConfigView; health: DaemonHealth | null; onClose: () => void; onReload: () => void }) { +export function Settings({ config, health, bindings, onBindingsChange, onClose, onReload }: { config: ConfigView; health: DaemonHealth | null; bindings: Bindings; onBindingsChange: (b: Bindings) => void; onClose: () => void; onReload: () => void }) { const ref = useRef(null); useEffect(() => { ref.current?.focus(); }, []); @@ -81,6 +83,10 @@ export function Settings({ config, health, onClose, onReload }: { config: Config // Fix 3: controlled shell input — synced from config, committed on blur. const [shellLocal, setShellLocal] = useState(config.default_shell); useEffect(() => { setShellLocal(config.default_shell); }, [config.default_shell]); + + // Custom background image path — committed on blur (switches background to "custom"). + const [bgPathLocal, setBgPathLocal] = useState(config.background_image); + useEffect(() => { setBgPathLocal(config.background_image); }, [config.background_image]); return (
e.stopPropagation()} onKeyDown={(e) => { e.stopPropagation(); if (e.key === "Escape") onClose(); }} @@ -121,16 +127,108 @@ export function Settings({ config, health, onClose, onReload }: { config: Config ))}
+
Background
+
+ {Object.entries(BACKGROUNDS).map(([id, bg]) => { + const selected = config.background === id; + return ( + + ); + })} + +
+ setBgPathLocal(e.target.value)} + onBlur={() => { if (bgPathLocal) void setConfig({ background: CUSTOM_BACKGROUND, background_image: bgPathLocal }); }} + placeholder="/path/to/wallpaper.png — image for the Custom theme" + style={{ width: "100%", padding: 8, marginBottom: 18, background: COLORS.bgPanel, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 8, fontSize: 12 }} /> +
Default shell (empty = auto)
setShellLocal(e.target.value)} onBlur={() => void setConfig({ default_shell: shellLocal })} style={{ width: "100%", padding: 8, marginBottom: 18, background: COLORS.bgPanel, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 8 }} /> +
Events
+ + + +
); } +/** Hotkeys list with click-to-record rebinding. */ +function HotkeysSection({ bindings, onChange }: { bindings: Bindings; onChange: (b: Bindings) => void }) { + const [recordingId, setRecordingId] = useState(null); + + useEffect(() => { + if (!recordingId) return; + const onKey = (e: KeyboardEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (e.key === "Escape") { setRecordingId(null); return; } + const b = eventBinding(e); + if (!b || !hasModifier(b)) return; // wait for a real chord with a modifier + onChange({ ...bindings, [recordingId]: b }); + setRecordingId(null); + }; + // Capture phase so we intercept before the modal's own key handling. + window.addEventListener("keydown", onKey, true); + return () => window.removeEventListener("keydown", onKey, true); + }, [recordingId, bindings, onChange]); + + const groups = ["Workspace", "Panel"] as const; + return ( +
+
+ Hotkeys + +
+ {groups.map((g) => ( +
+
{g}
+ {HOTKEYS.filter((h) => h.group === g).map((h) => ( +
+ {h.label} + +
+ ))} +
+ ))} +
+ ); +} + function fmtUptime(ms: number): string { const s = Math.max(0, Math.floor((Date.now() - ms) / 1000)); if (s < 60) return `${s}s`; diff --git a/app/src/Sidebar.tsx b/app/src/Sidebar.tsx index ab13724..bd06762 100644 --- a/app/src/Sidebar.tsx +++ b/app/src/Sidebar.tsx @@ -192,7 +192,7 @@ export function Sidebar({ ...ungrouped, ]; return ( -
+