diff --git a/crates/sandbox-cli/src/main.rs b/crates/sandbox-cli/src/main.rs index 9588fe8..b2f33ff 100644 --- a/crates/sandbox-cli/src/main.rs +++ b/crates/sandbox-cli/src/main.rs @@ -90,12 +90,19 @@ fn cmd_start(command: &str, args: &[String]) -> anyhow::Result<()> { tauri_args.extend(args.iter().cloned()); } + tracing::info!("[start] bundle_path: {}", bundle_path.display()); + tracing::info!("[start] app_binary: {}", app_binary.display()); + tracing::info!("[start] tauri_args: {:?}", tauri_args); + tracing::info!("[start] app_binary exists: {}", app_binary.exists()); + // Run the binary directly (not via open -a) so arguments are passed correctly - Command::new(&app_binary) + let child = Command::new(&app_binary) .args(&tauri_args) .spawn() .context("Failed to launch Tauri sandbox app")?; + tracing::info!("[start] child pid: {:?}", child.id()); + let full_cmd = if args.is_empty() { command.to_string() } else { diff --git a/crates/sandbox-core/src/automation/ax_ui.rs b/crates/sandbox-core/src/automation/ax_ui.rs index d97a49e..75b665e 100644 --- a/crates/sandbox-core/src/automation/ax_ui.rs +++ b/crates/sandbox-core/src/automation/ax_ui.rs @@ -44,6 +44,10 @@ mod macos_impl { const K_AX_ERROR_SUCCESS: AXError = 0; + /// Minimum pointer address to be considered a valid AXUIElementRef. + /// Anything below this is almost certainly a null offset or dangling pointer. + const MIN_VALID_PTR: usize = 0x1000; + #[link(name = "ApplicationServices", kind = "framework")] extern "C" { fn AXUIElementCreateApplication(pid: i32) -> AXUIElementRef; @@ -73,15 +77,38 @@ mod macos_impl { CFString::new(s) } - /// Validate that an AXUIElementRef is usable by calling AXUIElementGetPid. - /// Returns true if the element appears valid. - unsafe fn ax_element_is_valid(element: AXUIElementRef) -> bool { + /// Safe wrapper around AXUIElementCopyAttributeValue. + /// Validates the element pointer before calling to prevent SIGSEGV. + /// Returns None if the element is invalid or the call fails. + unsafe fn ax_try_copy_value(element: AXUIElementRef, attr_name: &str) -> Option { if element.is_null() { - return false; + eprintln!("[ax_ui] ax_try_copy_value: null element for attr '{attr_name}'"); + return None; + } + if (element as usize) < MIN_VALID_PTR { + eprintln!( + "[ax_ui] ax_try_copy_value: suspicious pointer {:#x} for attr '{attr_name}'", + element as usize + ); + return None; } + // Secondary validation via AXUIElementGetPid let mut pid: i32 = 0; - let result = AXUIElementGetPid(element, &mut pid as *mut i32); - result == K_AX_ERROR_SUCCESS + let pid_result = AXUIElementGetPid(element, &mut pid as *mut i32); + if pid_result != K_AX_ERROR_SUCCESS { + eprintln!( + "[ax_ui] ax_try_copy_value: AXUIElementGetPid failed (err={}) for attr '{attr_name}'", + pid_result + ); + return None; + } + let attr = ax_attr(attr_name); + let raw = AXUIElementCopyAttributeValue(element, attr.as_concrete_TypeRef()); + if raw.is_null() { + eprintln!("[ax_ui] ax_try_copy_value: AXUIElementCopyAttributeValue returned null for attr '{attr_name}'"); + return None; + } + Some(raw) } /// Check Accessibility permission and return an error if not granted. @@ -117,29 +144,21 @@ mod macos_impl { } unsafe fn ax_get_string(element: AXUIElementRef, attr_name: &str) -> Option { - if !ax_element_is_valid(element) { - return None; - } - let attr = ax_attr(attr_name); - let raw = AXUIElementCopyAttributeValue(element, attr.as_concrete_TypeRef()); + let raw = ax_try_copy_value(element, attr_name)?; cf_to_string(raw) } unsafe fn ax_get_children(element: AXUIElementRef) -> Vec { - if !ax_element_is_valid(element) { - return vec![]; - } - let attr = ax_attr("AXChildren"); - let raw = AXUIElementCopyAttributeValue(element, attr.as_concrete_TypeRef()); - if raw.is_null() { - return vec![]; - } + let raw = match ax_try_copy_value(element, "AXChildren") { + Some(r) => r, + None => return vec![], + }; let arr = CFArray::<*const c_void>::wrap_under_get_rule(raw as CFArrayRef); let mut children = Vec::new(); for i in 0..arr.len() { if let Some(ptr_val) = arr.get(i) { let val = *ptr_val; - if !val.is_null() { + if !val.is_null() && (val as usize) >= MIN_VALID_PTR { CFRetain(val as CFTypeRef); children.push(val); } @@ -149,20 +168,16 @@ mod macos_impl { } unsafe fn ax_get_attr_array(element: AXUIElementRef, attr_name: &str) -> Vec { - if !ax_element_is_valid(element) { - return vec![]; - } - let attr = ax_attr(attr_name); - let raw = AXUIElementCopyAttributeValue(element, attr.as_concrete_TypeRef()); - if raw.is_null() { - return vec![]; - } + let raw = match ax_try_copy_value(element, attr_name) { + Some(r) => r, + None => return vec![], + }; let arr = CFArray::<*const c_void>::wrap_under_get_rule(raw as CFArrayRef); let mut items = Vec::new(); for i in 0..arr.len() { if let Some(ptr_val) = arr.get(i) { let val = *ptr_val; - if !val.is_null() { + if !val.is_null() && (val as usize) >= MIN_VALID_PTR { CFRetain(val as CFTypeRef); items.push(val); } @@ -187,11 +202,17 @@ mod macos_impl { const MAX_UI_DEPTH: usize = 30; - unsafe fn ax_to_ui_element(element: AXUIElementRef) -> UiElement { - ax_to_ui_element_inner(element, 0) - } + /// Convert an AXUIElementRef to a UiElement. + /// Returns None if the element is invalid or an error occurs. + unsafe fn ax_to_ui_element_safe(element: AXUIElementRef, depth: usize) -> Option { + if element.is_null() || (element as usize) < MIN_VALID_PTR { + eprintln!( + "[ax_ui] ax_to_ui_element_safe: invalid element {:#x} at depth {depth}", + element as usize + ); + return None; + } - unsafe fn ax_to_ui_element_inner(element: AXUIElementRef, depth: usize) -> UiElement { let role = ax_get_string(element, "AXRole").unwrap_or_else(|| "unknown".to_string()); let title = ax_get_string(element, "AXTitle"); let value = ax_get_string(element, "AXValue"); @@ -201,22 +222,24 @@ mod macos_impl { vec![] } else { let children_elements = ax_get_children(element); - let result: Vec = children_elements - .iter() - .map(|&child| ax_to_ui_element_inner(child, depth + 1)) - .collect(); + let mut result = Vec::new(); + for &child in &children_elements { + if let Some(ui_child) = ax_to_ui_element_safe(child, depth + 1) { + result.push(ui_child); + } + } ax_release_all(&children_elements); result }; - UiElement { + Some(UiElement { role, title, value, description, bounds: None, children, - } + }) } unsafe fn ax_find_in_tree( @@ -224,8 +247,13 @@ mod macos_impl { role: Option<&str>, title: Option<&str>, ) -> Vec { - let ui = ax_to_ui_element(element); - find_ui_matches(&ui, role, title) + match ax_to_ui_element_safe(element, 0) { + Some(ui) => find_ui_matches(&ui, role, title), + None => { + eprintln!("[ax_ui] ax_find_in_tree: failed to convert root element"); + vec![] + } + } } fn find_ui_matches( @@ -311,9 +339,12 @@ mod macos_impl { let pid = get_pid_for_window(window_id) .ok_or_else(|| AppError::WindowNotFound(format!("Window {window_id} not found")))?; + eprintln!("[ax_ui] inspect_window: window_id={window_id}, pid={pid}"); + unsafe { let app = AXUIElementCreateApplication(pid); if app.is_null() { + eprintln!("[ax_ui] inspect_window: AXUIElementCreateApplication returned null for pid {pid}"); return Err(AppError::Accessibility( "Failed to create AXUIElement for application".to_string(), )); @@ -321,16 +352,34 @@ mod macos_impl { let windows = ax_get_attr_array(app, "AXWindows"); if windows.is_empty() { + eprintln!("[ax_ui] inspect_window: no AXWindows for pid {pid}"); ax_release_one(app); return Err(AppError::WindowNotFound(format!( "No AXWindows for PID {pid}" ))); } - let ui = ax_to_ui_element(windows[0]); - ax_release_all(&windows); - ax_release_one(app); - Ok(ui) + let first_window = windows[0]; + eprintln!( + "[ax_ui] inspect_window: first window ptr = {:#x}", + first_window as usize + ); + + match ax_to_ui_element_safe(first_window, 0) { + Some(ui) => { + ax_release_all(&windows); + ax_release_one(app); + Ok(ui) + } + None => { + eprintln!("[ax_ui] inspect_window: failed to convert window element"); + ax_release_all(&windows); + ax_release_one(app); + Err(AppError::Accessibility( + "Failed to read UI tree from window".to_string(), + )) + } + } } } diff --git a/release/README.md b/release/README.md index 629f060..867255c 100644 --- a/release/README.md +++ b/release/README.md @@ -135,4 +135,4 @@ A: 等待几秒让 Claude 启动,终端会自动连接 PTY 输出。 --- -**版本**: v${VERSION} | **构建时间**: __BUILD_DATE__ +**版本**: v${VERSION} | **构建时间**: 2026-05-20 22:19 diff --git a/sandbox-web/index.html b/sandbox-web/index.html index 7fcf91e..8520f58 100644 --- a/sandbox-web/index.html +++ b/sandbox-web/index.html @@ -5,14 +5,14 @@ System Test Sandbox diff --git a/sandbox-web/src/api.ts b/sandbox-web/src/api.ts index 9bb5658..46918d9 100644 --- a/sandbox-web/src/api.ts +++ b/sandbox-web/src/api.ts @@ -226,4 +226,3 @@ export async function listWindows(): Promise<[number, string][]> { const res = await fetch(`${BASE()}/windows`); return res.json(); } - diff --git a/sandbox-web/src/components/Dashboard.tsx b/sandbox-web/src/components/Dashboard.tsx new file mode 100644 index 0000000..6815e8f --- /dev/null +++ b/sandbox-web/src/components/Dashboard.tsx @@ -0,0 +1,143 @@ +import { type ReactNode } from "react"; +import SandboxTerminal from "./Terminal"; + +interface DashboardProps { + command: string; + connected: boolean; + activePid: number | null; + onTerminalInput: (data: string) => void; + onScreenshot: () => void; + children?: ReactNode; +} + +export default function Dashboard({ + command, + connected, + activePid, + onTerminalInput, + onScreenshot, + children, +}: DashboardProps) { + return ( +
+ {/* Header */} +
+

+ Dashboard +

+
+ + +
+
+ + {/* Content */} +
+ {/* Sandbox card */} +
+ {/* Card header */} +
+
+ + {">_"} + + + {command} (Sandboxed) + +
+ +
+ + {/* Terminal — fills remaining space */} +
+ +
+
+
+ + {/* Screenshot preview floating panel */} + {children} +
+ ); +} + +function ResourceStats({ connected }: { connected: boolean }) { + return ( +
+ + + +
+ ); +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+
+ {value} +
+
{label}
+
+ ); +} diff --git a/sandbox-web/src/components/Sidebar.tsx b/sandbox-web/src/components/Sidebar.tsx new file mode 100644 index 0000000..de91aa9 --- /dev/null +++ b/sandbox-web/src/components/Sidebar.tsx @@ -0,0 +1,126 @@ +import { useTheme } from "../themes/ThemeContext"; + +interface SidebarProps { + command: string; +} + +export default function Sidebar({ command }: SidebarProps) { + const { toggleTheme, theme } = useTheme(); + const isDark = theme.kind === "dark"; + + return ( + + ); +} diff --git a/sandbox-web/src/components/Terminal.tsx b/sandbox-web/src/components/Terminal.tsx index e8c6053..1897609 100644 --- a/sandbox-web/src/components/Terminal.tsx +++ b/sandbox-web/src/components/Terminal.tsx @@ -2,6 +2,8 @@ import { useEffect, useRef, useCallback } from "react"; import { Terminal } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import * as api from "../api"; +import { useTheme } from "../themes/ThemeContext"; +import type { TerminalTheme } from "../themes/types"; import "@xterm/xterm/css/xterm.css"; interface TerminalProps { @@ -9,31 +11,32 @@ interface TerminalProps { activePid?: number | null; } -// Tokyo Night inspired color scheme — clean, readable, macOS-like -const TERM_THEME = { - background: "#1a1b26", - foreground: "#a9b1d6", - cursor: "#c0caf5", - cursorAccent: "#1a1b26", - selectionBackground: "rgba(122, 162, 247, 0.3)", - selectionForeground: "#c0caf5", - black: "#15161e", - red: "#f7768e", - green: "#9ece6a", - yellow: "#e0af68", - blue: "#7aa2f7", - magenta: "#bb9af7", - cyan: "#7dcfff", - white: "#a9b1d6", - brightBlack: "#414868", - brightRed: "#f7768e", - brightGreen: "#9ece6a", - brightYellow: "#e0af68", - brightBlue: "#7aa2f7", - brightMagenta: "#bb9af7", - brightCyan: "#7dcfff", - brightWhite: "#c0caf5", -}; +function buildTerminalTheme(t: TerminalTheme): Record { + return { + background: t.background, + foreground: t.foreground, + cursor: t.cursor, + cursorAccent: t.cursorAccent, + selectionBackground: t.selectionBackground, + selectionForeground: t.selectionForeground, + black: t.black, + red: t.red, + green: t.green, + yellow: t.yellow, + blue: t.blue, + magenta: t.magenta, + cyan: t.cyan, + white: t.white, + brightBlack: t.brightBlack, + brightRed: t.brightRed, + brightGreen: t.brightGreen, + brightYellow: t.brightYellow, + brightBlue: t.brightBlue, + brightMagenta: t.brightMagenta, + brightCyan: t.brightCyan, + brightWhite: t.brightWhite, + }; +} export default function SandboxTerminal({ onInput, @@ -43,10 +46,16 @@ export default function SandboxTerminal({ const xtermRef = useRef(null); const fitAddonRef = useRef(null); const pollRef = useRef | null>(null); + const { theme } = useTheme(); + const onInputRef = useRef(onInput); + onInputRef.current = onInput; - // Initialize xterm.js once + // Initialize xterm.js once — theme updates in-place useEffect(() => { - if (!terminalRef.current || xtermRef.current) return; + if (!terminalRef.current) return; + if (xtermRef.current) return; // already initialized + + console.log("[Terminal] initializing xterm.js"); const term = new Terminal({ cursorBlink: true, @@ -59,7 +68,7 @@ export default function SandboxTerminal({ fontWeightBold: "600", letterSpacing: 0, scrollback: 10000, - theme: TERM_THEME, + theme: buildTerminalTheme(theme.terminal), allowProposedApi: true, drawBoldTextInBrightColors: true, }); @@ -70,7 +79,7 @@ export default function SandboxTerminal({ fitAddon.fit(); term.onData((data) => { - onInput?.(data); + onInputRef.current?.(data); }); const handleResize = () => fitAddon.fit(); @@ -82,9 +91,18 @@ export default function SandboxTerminal({ return () => { window.removeEventListener("resize", handleResize); term.dispose(); + xtermRef.current = null; }; }, []); // eslint-disable-line react-hooks/exhaustive-deps + // Update terminal theme in-place without disposing + useEffect(() => { + if (!xtermRef.current) return; + const newTheme = buildTerminalTheme(theme.terminal); + xtermRef.current.options.theme = newTheme; + console.log(`[Terminal] theme updated to: ${theme.id} (${theme.kind})`); + }, [theme.id]); + // PTY output polling useEffect(() => { if (pollRef.current) { @@ -106,7 +124,7 @@ export default function SandboxTerminal({ pollRef.current = null; } } - }, 100); // 100ms polling for smooth streaming + }, 100); return () => { if (pollRef.current) { diff --git a/sandbox-web/src/index.css b/sandbox-web/src/index.css index 968503d..a304d6b 100644 --- a/sandbox-web/src/index.css +++ b/sandbox-web/src/index.css @@ -12,15 +12,26 @@ html, body, #root { width: 100%; height: 100%; overflow: hidden; - background: #1a1b26; + background: var(--sandbox-bg-primary); font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } -/* xterm.js — clean macOS terminal feel */ +/* Tauri drag region */ +[data-tauri-drag-region] { + -webkit-app-region: drag; +} + +[data-tauri-drag-region] button, +[data-tauri-drag-region] a, +[data-tauri-drag-region] input { + -webkit-app-region: no-drag; +} + +/* xterm.js */ .xterm { - padding: 48px 16px 12px 16px; + padding: 8px 16px 12px 16px; height: 100%; } @@ -29,19 +40,19 @@ html, body, #root { } .xterm-viewport::-webkit-scrollbar-track { - background: transparent; + background: var(--sandbox-scrollbar-bg); } .xterm-viewport::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.12); + background: var(--sandbox-scrollbar-fg); border-radius: 3px; } .xterm-viewport::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.2); + background: var(--sandbox-scrollbar-fg); + opacity: 0.8; } -/* Smooth cursor */ .xterm-cursor-layer { transition: opacity 0.1s; } diff --git a/sandbox-web/src/main.tsx b/sandbox-web/src/main.tsx index 545a68a..04ad903 100644 --- a/sandbox-web/src/main.tsx +++ b/sandbox-web/src/main.tsx @@ -1,15 +1,21 @@ import { useState, useCallback, useEffect, useRef } from "react"; import ReactDOM from "react-dom/client"; -import SandboxTerminal from "./components/Terminal"; +import { invoke } from "@tauri-apps/api/core"; +import Sidebar from "./components/Sidebar"; +import Dashboard from "./components/Dashboard"; +import { ThemeProvider } from "./themes/ThemeContext"; import * as api from "./api"; import "./index.css"; +const isMac = + typeof navigator !== "undefined" && navigator.platform.startsWith("Mac"); + function App() { const [activePid, setActivePid] = useState(null); const [connected, setConnected] = useState(false); const [screenshotUrl, setScreenshotUrl] = useState(null); - const [screenshotLoading, setScreenshotLoading] = useState(false); const [showPreview, setShowPreview] = useState(false); + const [command, setCommand] = useState("Sandbox"); const hasConnectedRef = useRef(false); // Auto-connect to spawned processes @@ -39,7 +45,26 @@ function App() { return () => clearInterval(interval); }, [activePid]); - // Terminal input → PTY + // Fetch command from Tauri sandbox config + useEffect(() => { + invoke<{ + command?: string; + mode?: string; + args?: string[]; + }>("get_sandbox_config") + .then((config) => { + if (config.command) { + const args = + config.args && config.args.length > 0 + ? " " + config.args.join(" ") + : ""; + setCommand(config.command + args); + } + }) + .catch(() => {}); + }, []); + + // Terminal input -> PTY const handleTerminalInput = useCallback( (data: string) => { if (activePid !== null) { @@ -51,19 +76,15 @@ function App() { // Screenshot const handleScreenshot = useCallback(async () => { - setScreenshotLoading(true); try { const url = await api.takeScreenshot(); setScreenshotUrl(url); setShowPreview(true); } catch { // silent - } finally { - setScreenshotLoading(false); } }, []); - // Close preview const closePreview = useCallback(() => { setShowPreview(false); if (screenshotUrl) { @@ -73,100 +94,78 @@ function App() { }, [screenshotUrl]); return ( -
- {/* Full-screen terminal */} - +
+ {/* macOS drag region — reserves space for traffic light buttons */} + {isMac && ( +
+ )} - {/* Floating toolbar — top right, macOS style */} -
-
- {/* Screenshot button */} - - - {/* Divider */} -
- - {/* Connection status */} -
- - {connected ? "Connected" : "Waiting..."} -
-
-
- - {/* Screenshot preview — floating panel */} - {showPreview && screenshotUrl && ( -
-
- {/* Preview header */} -
- Screenshot - + + + + +
+ Screenshot
- {/* Preview image */} - Screenshot
-
- )} + )} +
); } -ReactDOM.createRoot(document.getElementById("root")!).render(); +ReactDOM.createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/sandbox-web/src/themes/ThemeContext.tsx b/sandbox-web/src/themes/ThemeContext.tsx new file mode 100644 index 0000000..9b676ad --- /dev/null +++ b/sandbox-web/src/themes/ThemeContext.tsx @@ -0,0 +1,95 @@ +import { + createContext, + useContext, + useState, + useCallback, + useEffect, + type ReactNode, +} from "react"; +import type { SandboxTheme, TerminalTheme } from "./types"; +import { themeRegistry } from "./registry"; + +interface ThemeContextValue { + theme: SandboxTheme; + setTheme: (id: string) => void; + toggleTheme: () => void; + themes: SandboxTheme[]; +} + +const ThemeContext = createContext(null); + +const STORAGE_KEY = "sandbox-theme"; + +function applyThemeCSS(theme: SandboxTheme): void { + const root = document.documentElement; + root.setAttribute("data-theme", theme.id); + root.setAttribute("data-theme-kind", theme.kind); + + const c = theme.colors; + root.style.setProperty("--sandbox-bg-primary", c.bgPrimary); + root.style.setProperty("--sandbox-bg-secondary", c.bgSecondary); + root.style.setProperty("--sandbox-bg-tertiary", c.bgTertiary); + root.style.setProperty("--sandbox-fg-primary", c.fgPrimary); + root.style.setProperty("--sandbox-fg-secondary", c.fgSecondary); + root.style.setProperty("--sandbox-fg-tertiary", c.fgTertiary); + root.style.setProperty("--sandbox-border", c.border); + root.style.setProperty("--sandbox-accent", c.accent); + root.style.setProperty("--sandbox-scrollbar-bg", c.scrollbarBg); + root.style.setProperty("--sandbox-scrollbar-fg", c.scrollbarFg); + root.style.setProperty("--sandbox-success", c.success); + root.style.setProperty("--sandbox-error", c.error); + root.style.setProperty("--sandbox-titlebar-bg", c.titlebarBg); + root.style.setProperty("--sandbox-titlebar-fg", c.titlebarFg); + root.style.setProperty("--sandbox-sidebar-bg", c.sidebarBg); + root.style.setProperty("--sandbox-sidebar-fg", c.sidebarFg); + root.style.setProperty("--sandbox-sidebar-border", c.sidebarBorder); + root.style.setProperty("--sandbox-sidebar-active", c.sidebarActive); + root.style.setProperty("--sandbox-panel-bg", c.panelBg); +} + +export { ThemeContext, STORAGE_KEY }; +export type { ThemeContextValue, SandboxTheme, TerminalTheme }; + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [theme, setThemeState] = useState(() => { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + const t = themeRegistry.get(stored); + if (t) return t; + } + return themeRegistry.defaultTheme(); + }); + + useEffect(() => { + applyThemeCSS(theme); + }, [theme]); + + const setTheme = useCallback((id: string) => { + const t = themeRegistry.get(id); + if (t) { + setThemeState(t); + localStorage.setItem(STORAGE_KEY, id); + } + }, []); + + const toggleTheme = useCallback(() => { + const themes = themeRegistry.list(); + const idx = themes.findIndex((t) => t.id === theme.id); + const next = themes[(idx + 1) % themes.length]; + setTheme(next.id); + }, [theme.id, setTheme]); + + return ( + + {children} + + ); +} + +export function useTheme(): ThemeContextValue { + const ctx = useContext(ThemeContext); + if (!ctx) throw new Error("useTheme must be used within ThemeProvider"); + return ctx; +} diff --git a/sandbox-web/src/themes/registry.ts b/sandbox-web/src/themes/registry.ts new file mode 100644 index 0000000..d16e3cb --- /dev/null +++ b/sandbox-web/src/themes/registry.ts @@ -0,0 +1,30 @@ +import type { SandboxTheme } from "./types"; +import { tokyoNight } from "./tokyo-night"; +import { vscodeLight } from "./vscode-light"; + +class ThemeRegistry { + private themes = new Map(); + + constructor() { + this.register(tokyoNight); + this.register(vscodeLight); + } + + register(theme: SandboxTheme): void { + this.themes.set(theme.id, theme); + } + + get(id: string): SandboxTheme | undefined { + return this.themes.get(id); + } + + list(): SandboxTheme[] { + return Array.from(this.themes.values()); + } + + defaultTheme(): SandboxTheme { + return tokyoNight; + } +} + +export const themeRegistry = new ThemeRegistry(); diff --git a/sandbox-web/src/themes/tokyo-night.ts b/sandbox-web/src/themes/tokyo-night.ts new file mode 100644 index 0000000..3948f1e --- /dev/null +++ b/sandbox-web/src/themes/tokyo-night.ts @@ -0,0 +1,52 @@ +import type { SandboxTheme } from "./types"; + +export const tokyoNight: SandboxTheme = { + id: "tokyo-night", + name: "Tokyo Night", + kind: "dark", + colors: { + bgPrimary: "#1a1b26", + bgSecondary: "#24283b", + bgTertiary: "#3b4261", + fgPrimary: "#a9b1d6", + fgSecondary: "#565f89", + fgTertiary: "#414868", + border: "#3b4261", + accent: "#7aa2f7", + scrollbarBg: "transparent", + scrollbarFg: "rgba(255, 255, 255, 0.12)", + success: "#9ece6a", + error: "#f7768e", + titlebarBg: "#1f2233", + titlebarFg: "#a9b1d6", + sidebarBg: "#16161e", + sidebarFg: "#a9b1d6", + sidebarBorder: "#292e42", + sidebarActive: "rgba(122, 162, 247, 0.15)", + panelBg: "#1f2233", + }, + terminal: { + background: "#1a1b26", + foreground: "#a9b1d6", + cursor: "#c0caf5", + cursorAccent: "#1a1b26", + selectionBackground: "rgba(122, 162, 247, 0.3)", + selectionForeground: "#c0caf5", + black: "#15161e", + red: "#f7768e", + green: "#9ece6a", + yellow: "#e0af68", + blue: "#7aa2f7", + magenta: "#bb9af7", + cyan: "#7dcfff", + white: "#a9b1d6", + brightBlack: "#414868", + brightRed: "#f7768e", + brightGreen: "#9ece6a", + brightYellow: "#e0af68", + brightBlue: "#7aa2f7", + brightMagenta: "#bb9af7", + brightCyan: "#7dcfff", + brightWhite: "#c0caf5", + }, +}; diff --git a/sandbox-web/src/themes/types.ts b/sandbox-web/src/themes/types.ts new file mode 100644 index 0000000..1bc451f --- /dev/null +++ b/sandbox-web/src/themes/types.ts @@ -0,0 +1,58 @@ +/** VS Code-inspired theme system — color tokens prefixed with `--sandbox-` */ +export interface SandboxThemeColors { + bgPrimary: string; + bgSecondary: string; + bgTertiary: string; + fgPrimary: string; + fgSecondary: string; + fgTertiary: string; + border: string; + accent: string; + scrollbarBg: string; + scrollbarFg: string; + success: string; + error: string; + titlebarBg: string; + titlebarFg: string; + /** Sidebar background (typically dark even in light themes) */ + sidebarBg: string; + sidebarFg: string; + sidebarBorder: string; + sidebarActive: string; + /** Right detail panel background */ + panelBg: string; +} + +/** xterm.js terminal theme (subset of ITerminalOptions['theme']) */ +export interface TerminalTheme { + background: string; + foreground: string; + cursor: string; + cursorAccent: string; + selectionBackground: string; + selectionForeground: string; + black: string; + red: string; + green: string; + yellow: string; + blue: string; + magenta: string; + cyan: string; + white: string; + brightBlack: string; + brightRed: string; + brightGreen: string; + brightYellow: string; + brightBlue: string; + brightMagenta: string; + brightCyan: string; + brightWhite: string; +} + +export interface SandboxTheme { + id: string; + name: string; + kind: "dark" | "light"; + colors: SandboxThemeColors; + terminal: TerminalTheme; +} diff --git a/sandbox-web/src/themes/vscode-light.ts b/sandbox-web/src/themes/vscode-light.ts new file mode 100644 index 0000000..0818072 --- /dev/null +++ b/sandbox-web/src/themes/vscode-light.ts @@ -0,0 +1,53 @@ +import type { SandboxTheme } from "./types"; + +/** VS Code macOS light theme inspired palette */ +export const vscodeLight: SandboxTheme = { + id: "vscode-light", + name: "VS Code Light", + kind: "light", + colors: { + bgPrimary: "#f5f5f7", + bgSecondary: "#ffffff", + bgTertiary: "#e8e8e8", + fgPrimary: "#1d1d1f", + fgSecondary: "#6e6e73", + fgTertiary: "#aeaeb2", + border: "#d2d2d7", + accent: "#0071e3", + scrollbarBg: "transparent", + scrollbarFg: "rgba(0, 0, 0, 0.15)", + success: "#34c759", + error: "#ff3b30", + titlebarBg: "#e8e8ed", + titlebarFg: "#1d1d1f", + sidebarBg: "#1c1c2e", + sidebarFg: "#c7c7cc", + sidebarBorder: "#2c2c3e", + sidebarActive: "rgba(0, 113, 227, 0.2)", + panelBg: "#ffffff", + }, + terminal: { + background: "#1e1e2e", + foreground: "#cdd6f4", + cursor: "#f5e0dc", + cursorAccent: "#1e1e2e", + selectionBackground: "rgba(137, 180, 250, 0.3)", + selectionForeground: "#cdd6f4", + black: "#45475a", + red: "#f38ba8", + green: "#a6e3a1", + yellow: "#f9e2af", + blue: "#89b4fa", + magenta: "#f5c2e7", + cyan: "#94e2d5", + white: "#bac2de", + brightBlack: "#585b70", + brightRed: "#f38ba8", + brightGreen: "#a6e3a1", + brightYellow: "#f9e2af", + brightBlue: "#89b4fa", + brightMagenta: "#f5c2e7", + brightCyan: "#94e2d5", + brightWhite: "#a6adc8", + }, +}; diff --git a/sandbox-web/tailwind.config.js b/sandbox-web/tailwind.config.js index 45b0102..5dfcd2a 100644 --- a/sandbox-web/tailwind.config.js +++ b/sandbox-web/tailwind.config.js @@ -4,13 +4,38 @@ export default { theme: { extend: { colors: { - term: { - bg: "#1a1b26", - fg: "#a9b1d6", - surface: "#24283b", - border: "#3b4261", - accent: "#7aa2f7", - muted: "#565f89", + sandbox: { + bg: { + primary: "var(--sandbox-bg-primary)", + secondary: "var(--sandbox-bg-secondary)", + tertiary: "var(--sandbox-bg-tertiary)", + }, + fg: { + primary: "var(--sandbox-fg-primary)", + secondary: "var(--sandbox-fg-secondary)", + tertiary: "var(--sandbox-fg-tertiary)", + }, + border: "var(--sandbox-border)", + accent: "var(--sandbox-accent)", + scrollbar: { + bg: "var(--sandbox-scrollbar-bg)", + fg: "var(--sandbox-scrollbar-fg)", + }, + success: "var(--sandbox-success)", + error: "var(--sandbox-error)", + titlebar: { + bg: "var(--sandbox-titlebar-bg)", + fg: "var(--sandbox-titlebar-fg)", + }, + sidebar: { + bg: "var(--sandbox-sidebar-bg)", + fg: "var(--sandbox-sidebar-fg)", + border: "var(--sandbox-sidebar-border)", + active: "var(--sandbox-sidebar-active)", + }, + panel: { + bg: "var(--sandbox-panel-bg)", + }, }, }, fontFamily: { diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a36c5d9..cd1698b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -11,7 +11,7 @@ tauri-build = { version = "2", features = [] } [dependencies] sandbox-core = { workspace = true, features = ["screencapturekit"] } -tauri = { version = "2", features = [] } +tauri = { version = "2", features = ["macos-private-api"] } tauri-plugin-shell = "2" serde.workspace = true serde_json.workspace = true diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index b850c6b..2667b85 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -55,8 +55,7 @@ struct SandboxLaunchArgs { args: Vec, } -fn parse_sandbox_args() -> SandboxLaunchArgs { - let args: Vec = std::env::args().collect(); +fn parse_sandbox_args_from_slice(args: &[String]) -> SandboxLaunchArgs { let mut result = SandboxLaunchArgs::default(); let mut i = 1; while i < args.len() { @@ -82,7 +81,6 @@ fn parse_sandbox_args() -> SandboxLaunchArgs { i += 1; result.cmd = Some(args[i].clone()); } else if arg == "--" { - // Everything after -- is passed as trailing args to the CLI command result.args = args[(i + 1)..].to_vec(); break; } @@ -91,16 +89,29 @@ fn parse_sandbox_args() -> SandboxLaunchArgs { result } +fn parse_sandbox_args() -> SandboxLaunchArgs { + let args: Vec = std::env::args().collect(); + tracing::info!("[args] raw args: {:?}", args); + let result = parse_sandbox_args_from_slice(&args); + tracing::info!( + "[args] parsed: mode={:?}, cmd={:?}, args={:?}, sandbox_id={:?}, port={:?}", + result.mode, + result.cmd, + result.args, + result.sandbox_id, + result.sandbox_port, + ); + result +} + fn main() { let launch_args = parse_sandbox_args(); // Auto-generate sandbox_id and port if not provided - let sandbox_id = launch_args.sandbox_id.clone().or_else(|| { - Some(format!( - "{}", - uuid::Uuid::new_v4().to_string()[..8].to_string() - )) - }); + let sandbox_id = launch_args + .sandbox_id + .clone() + .or_else(|| Some(uuid::Uuid::new_v4().to_string()[..8].to_string())); let sandbox_port = launch_args.sandbox_port.or(Some(5801)); let config = SandboxConfig { @@ -153,9 +164,19 @@ fn main() { init_sandbox, ]) .setup(move |app_handle| { + tracing::info!( + "[setup] sandbox_id={:?}, port={:?}, kind={:?}", + sandbox_id, + sandbox_port, + kind + ); + // Set window title if let Some(window) = app_handle.get_webview_window("main") { + tracing::info!("[setup] setting window title: {}", title); let _ = window.set_title(&title); + } else { + tracing::warn!("[setup] main window not found!"); } // Start embedded HTTP server if in managed mode @@ -207,32 +228,55 @@ fn main() { if let Some(InstanceKind::Cli { command, args }) = &kind { let cmd = command.clone(); let cmd_args = args.clone(); + tracing::info!("[setup] auto-spawn CLI: cmd={:?}, args={:?}", cmd, cmd_args); tauri::async_runtime::spawn(async move { tokio::time::sleep(std::time::Duration::from_millis(500)).await; + tracing::info!("[setup] spawning CLI now: {} {:?}", cmd, cmd_args); match ProcessManager::spawn_cli(&cmd, &cmd_args) { Ok(info) => { - tracing::info!("Auto-spawned CLI: {} (pid={})", cmd, info.pid); + tracing::info!( + "[setup] auto-spawned CLI: {} (pid={})", + cmd, + info.pid + ); } Err(e) => { - tracing::error!("Failed to auto-spawn CLI '{cmd}': {e}"); + tracing::error!( + "[setup] failed to auto-spawn CLI '{}': {}", + cmd, + e + ); } } }); + } else { + tracing::info!("[setup] not CLI mode, skipping auto-spawn. kind={:?}", kind); } // Auto-discover the Tauri window's SCWindow ID for screenshot support. // The window needs time to render before ScreenCaptureKit can find it. tauri::async_runtime::spawn(async move { tokio::time::sleep(std::time::Duration::from_secs(2)).await; + tracing::info!("[setup] discovering window by title 'System Test Sandbox'"); match sandbox_core::capture::ScreenCapture::find_window_by_title( "System Test Sandbox", ) { Ok(id) => { - tracing::info!("Discovered sandbox window: SCWindow ID={id}"); + tracing::info!("[setup] discovered sandbox window: SCWindow ID={id}"); state_for_window.lock().await.window_id = Some(id); } Err(e) => { - tracing::warn!("Failed to discover sandbox window: {e}"); + tracing::warn!("[setup] failed to discover sandbox window: {e}"); + // List all windows for debugging + if let Ok(windows) = + sandbox_core::capture::ScreenCapture::list_windows() + { + for (wid, wtitle) in &windows { + if !wtitle.is_empty() { + tracing::info!("[setup] window {}: '{}'", wid, wtitle); + } + } + } } } }); @@ -259,3 +303,255 @@ fn main() { .run(tauri::generate_context!()) .expect("error while running tauri application"); } + +#[cfg(test)] +mod tests { + use super::*; + + fn args(s: &[&str]) -> Vec { + s.iter().map(|s| s.to_string()).collect() + } + + // ── parse_sandbox_args_from_slice ────────────────────── + + #[test] + fn parse_mode_and_cmd_eq() { + let r = parse_sandbox_args_from_slice(&args(&["bin", "--mode=cli", "--cmd=claude"])); + assert_eq!(r.mode.as_deref(), Some("cli")); + assert_eq!(r.cmd.as_deref(), Some("claude")); + assert!(r.args.is_empty()); + } + + #[test] + fn parse_mode_and_cmd_space() { + let r = parse_sandbox_args_from_slice(&args(&["bin", "--mode", "cli", "--cmd", "claude"])); + assert_eq!(r.mode.as_deref(), Some("cli")); + assert_eq!(r.cmd.as_deref(), Some("claude")); + } + + #[test] + fn parse_trailing_args_after_separator() { + let r = parse_sandbox_args_from_slice(&args(&[ + "bin", + "--mode=cli", + "--cmd=claude", + "--", + "-p", + "你是谁?", + ])); + assert_eq!(r.mode.as_deref(), Some("cli")); + assert_eq!(r.cmd.as_deref(), Some("claude")); + assert_eq!(r.args, vec!["-p", "你是谁?"]); + } + + #[test] + fn parse_no_trailing_args() { + let r = parse_sandbox_args_from_slice(&args(&["bin", "--mode=cli", "--cmd=zsh"])); + assert_eq!(r.mode.as_deref(), Some("cli")); + assert_eq!(r.cmd.as_deref(), Some("zsh")); + assert!(r.args.is_empty()); + } + + #[test] + fn parse_sandbox_id_and_port() { + let r = parse_sandbox_args_from_slice(&args(&[ + "bin", + "--sandbox-id=abc123", + "--sandbox-port=15801", + "--mode=cli", + "--cmd=echo", + ])); + assert_eq!(r.sandbox_id.as_deref(), Some("abc123")); + assert_eq!(r.sandbox_port, Some(15801)); + assert_eq!(r.mode.as_deref(), Some("cli")); + assert_eq!(r.cmd.as_deref(), Some("echo")); + } + + #[test] + fn parse_empty_args() { + let r = parse_sandbox_args_from_slice(&args(&["bin"])); + assert!(r.mode.is_none()); + assert!(r.cmd.is_none()); + assert!(r.args.is_empty()); + } + + #[test] + fn parse_only_cmd_no_mode() { + let r = parse_sandbox_args_from_slice(&args(&["bin", "--cmd=claude"])); + assert!(r.mode.is_none()); + assert_eq!(r.cmd.as_deref(), Some("claude")); + } + + #[test] + fn parse_mixed_eq_and_space() { + let r = parse_sandbox_args_from_slice(&args(&[ + "bin", + "--mode=cli", + "--cmd", + "claude", + "--", + "-p", + "hello", + ])); + assert_eq!(r.mode.as_deref(), Some("cli")); + assert_eq!(r.cmd.as_deref(), Some("claude")); + assert_eq!(r.args, vec!["-p", "hello"]); + } + + // ── SandboxConfig construction from parsed args ─────── + + #[test] + fn config_from_parsed_cli_args() { + let r = parse_sandbox_args_from_slice(&args(&[ + "bin", + "--sandbox-id=test1", + "--sandbox-port=5801", + "--mode=cli", + "--cmd=claude", + "--", + "-p", + "你是谁?", + ])); + + let config = SandboxConfig { + id: r.sandbox_id.clone(), + port: r.sandbox_port, + mode: r.mode.clone(), + command: r.cmd.clone(), + args: r.args.clone(), + ..SandboxConfig::default() + }; + + assert_eq!(config.id.as_deref(), Some("test1")); + assert_eq!(config.port, Some(5801)); + assert_eq!(config.mode.as_deref(), Some("cli")); + assert_eq!(config.command.as_deref(), Some("claude")); + assert_eq!(config.args, vec!["-p", "你是谁?"]); + } + + #[test] + fn kind_from_config_cli() { + let config = SandboxConfig { + mode: Some("cli".into()), + command: Some("claude".into()), + args: vec!["-p".into(), "你是谁?".into()], + ..SandboxConfig::default() + }; + let sandbox = Sandbox::new(config); + let kind = sandbox.kind().unwrap(); + match kind { + InstanceKind::Cli { command, args } => { + assert_eq!(command, "claude"); + assert_eq!(args, vec!["-p", "你是谁?"]); + } + _ => panic!("Expected CLI kind"), + } + } + + #[test] + fn kind_from_config_app() { + let config = SandboxConfig { + mode: Some("app".into()), + command: Some("/Applications/Safari.app".into()), + ..SandboxConfig::default() + }; + let sandbox = Sandbox::new(config); + let kind = sandbox.kind().unwrap(); + assert!(matches!(kind, InstanceKind::App { .. })); + } + + #[test] + fn kind_none_without_mode() { + let config = SandboxConfig::default(); + let sandbox = Sandbox::new(config); + assert!(sandbox.kind().is_none()); + } + + // ── CLI args construction (mirrors cmd_start logic) ─── + + #[test] + fn cli_builds_tauri_args_simple() { + let command = "claude"; + let cli_args: Vec = vec![]; + + let mut tauri_args = vec!["--mode=cli".to_string(), format!("--cmd={}", command)]; + if !cli_args.is_empty() { + tauri_args.push("--".to_string()); + tauri_args.extend(cli_args.iter().cloned()); + } + + assert_eq!(tauri_args, vec!["--mode=cli", "--cmd=claude"]); + } + + #[test] + fn cli_builds_tauri_args_with_trailing() { + let command = "claude"; + let cli_args: Vec = vec!["-p".into(), "你是谁?".into()]; + + let mut tauri_args = vec!["--mode=cli".to_string(), format!("--cmd={}", command)]; + if !cli_args.is_empty() { + tauri_args.push("--".to_string()); + tauri_args.extend(cli_args.iter().cloned()); + } + + assert_eq!( + tauri_args, + vec!["--mode=cli", "--cmd=claude", "--", "-p", "你是谁?"] + ); + } + + #[test] + fn cli_builds_tauri_args_zsh() { + let command = "zsh"; + let cli_args: Vec = vec![]; + + let mut tauri_args = vec!["--mode=cli".to_string(), format!("--cmd={}", command)]; + if !cli_args.is_empty() { + tauri_args.push("--".to_string()); + tauri_args.extend(cli_args.iter().cloned()); + } + + assert_eq!(tauri_args, vec!["--mode=cli", "--cmd=zsh"]); + } + + // ── Round-trip: CLI builds args → Tauri parses them ─── + + #[test] + fn roundtrip_claude_with_args() { + // CLI constructs these args + let command = "claude"; + let cli_args = vec!["-p".to_string(), "你是谁?".to_string()]; + let mut tauri_args = vec!["--mode=cli".to_string(), format!("--cmd={}", command)]; + tauri_args.push("--".to_string()); + tauri_args.extend(cli_args); + + // Prepend program name (as std::env::args would) + let mut full_args = vec!["system-test-sandbox".to_string()]; + full_args.extend(tauri_args); + + // Tauri parses them + let r = parse_sandbox_args_from_slice(&full_args); + assert_eq!(r.mode.as_deref(), Some("cli")); + assert_eq!(r.cmd.as_deref(), Some("claude")); + assert_eq!(r.args, vec!["-p", "你是谁?"]); + } + + #[test] + fn roundtrip_simple_command() { + let command = "zsh"; + let cli_args: Vec = vec![]; + let mut tauri_args = vec!["--mode=cli".to_string(), format!("--cmd={}", command)]; + if !cli_args.is_empty() { + tauri_args.push("--".to_string()); + tauri_args.extend(cli_args); + } + + let mut full_args = vec!["system-test-sandbox".to_string()]; + full_args.extend(tauri_args); + + let r = parse_sandbox_args_from_slice(&full_args); + assert_eq!(r.mode.as_deref(), Some("cli")); + assert_eq!(r.cmd.as_deref(), Some("zsh")); + assert!(r.args.is_empty()); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 322c8c6..10e2faa 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -13,16 +13,20 @@ "windows": [ { "label": "sandbox", - "title": "System Test Sandbox", + "title": "", "width": 1280, "height": 800, + "minWidth": 800, + "minHeight": 500, "resizable": true, - "transparent": false + "titleBarStyle": "Overlay", + "center": true } ], "security": { "csp": null - } + }, + "macOSPrivateApi": true }, "bundle": { "active": true,