From ee845e15b377c59529d208c50f9c65ea1ab741d7 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Mon, 15 Jun 2026 22:26:06 +0700 Subject: [PATCH] Add full disk access checks and settings Add background themes and custom images Add shell command logging toggle Add UTF-8 locale guarantee for PTY Add Claude hook settings injection Add hotkey system for GUI Add glass panel styling Add search disabled state for agent panels Add zoom toggle command Add device report filtering Add entitlements for notarization Update version to 0.1.27 --- .gitignore | 3 + Cargo.lock | 10 +- Cargo.toml | 2 +- Makefile | 40 ++++++- app/src-tauri/Cargo.lock | 2 +- app/src-tauri/Entitlements.plist | 16 +++ app/src-tauri/src/bridge.rs | 49 +++++++- app/src-tauri/src/lib.rs | 3 + app/src-tauri/tauri.bundle.conf.json | 5 +- app/src-tauri/tauri.conf.json | 6 +- app/src/App.tsx | 141 +++++++++++++++++++++--- app/src/CenterToolbar.tsx | 11 +- app/src/EventCenter.tsx | 2 +- app/src/LayoutEngine.tsx | 20 +++- app/src/Settings.tsx | 102 ++++++++++++++++- app/src/Sidebar.tsx | 4 +- app/src/TerminalView.tsx | 44 +++++++- app/src/TopBar.tsx | 20 +++- app/src/Wizard.tsx | 8 ++ app/src/hotkeys.ts | 118 ++++++++++++++++++++ app/src/socketBridge.ts | 23 +++- app/src/theme.ts | 102 +++++++++++++++-- crates/spacesh-core/src/grid.rs | 74 +++++++++++-- crates/spacesh-proto/src/config_view.rs | 15 +++ crates/spacesh-proto/src/message.rs | 6 + crates/spacesh-pty/src/lib.rs | 8 ++ crates/spaceshd/src/config.rs | 18 +++ crates/spaceshd/src/hooks.rs | 71 ++++++------ crates/spaceshd/src/server.rs | 50 ++++++--- crates/spaceshd/src/surface.rs | 9 +- 30 files changed, 859 insertions(+), 123 deletions(-) create mode 100644 app/src-tauri/Entitlements.plist create mode 100644 app/src/hotkeys.ts 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 ( -
+