From f9174513c2ae2b56108037c40ca0b775472c73fa Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 26 Mar 2026 08:43:35 -0700 Subject: [PATCH] dashboard (#1034) * dashboard * fix: re-apply download behavior on recording context (#1019) * fix: re-apply download behavior on recording context record start creates a new browser context via Target.createBrowserContext. Browser.setDownloadBehavior called at launch only applies to the default context, so downloads in the recording context are silently dropped. Fix: 1. Store download_path on BrowserManager (from LaunchOptions) 2. After creating the recording context, call Browser.setDownloadBehavior with the new browserContextId This ensures downloads work during recording. Fixes #1018 * fix: add download_path to third BrowserManager constructor (auto_connect_cdp) * fix: reap zombie Chrome process and fast-detect crash for auto-restart (#1023) When Chrome crashes (e.g. SIGTRAP from CHECK() assertion), the daemon now: 1. Reaps the zombie immediately via a SIGCHLD handler in the event loop that calls waitpid(-1, WNOHANG) 2. Detects the crash instantly on the next command via a non-blocking try_wait() check (has_process_exited), avoiding the 3-second CDP timeout that is_connection_alive() would incur 3. Auto-relaunches Chrome transparently for the caller Fixes #1017 Co-authored-by: ctate <366502+ctate@users.noreply.github.com> * fix: route keyboard type through text input (#1014) * fix: handle --clear flag in console command (#1015) The console and errors commands parsed --clear from CLI args but the action handlers silently ignored the flag. The handlers did not accept the cmd parameter so they had no way to read the clear field. Changes: - Add clear_console() method to EventTracker in network.rs - Update handle_console to accept cmd, read the clear field, and clear the buffer when --clear is passed (returns {cleared: true}) - Update call site in execute_command to pass cmd Co-authored-by: xuyongliang * chore: patch release - ### Bug Fixes - **Re-apply download behavior on r... (#1025) * Add runtime stream enable/disable/status commands (#951) * Add runtime stream management commands * Run rustfmt and satisfy clippy * Fix stream disable cleanup semantics * Format stream disable regression tests * fix: retain radio/checkbox elements in compact snapshot tree (#1008) compact_tree() checked for "[ref=" to identify lines worth keeping, but radio and checkbox elements render as e.g. [checked=false, ref=e1] where the "[" opens before "checked=", not "ref=". Dropping the leading bracket so the check is just "ref=" fixes the match for all elements with refs. Fixes #1006 Co-authored-by: ctate <366502+ctate@users.noreply.github.com> * chore: version packages (#1027) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * fixes * dashboard * fixes * remove observe * fmt * fixes * fixes * jotai * fmt * upload dashboard --------- Co-authored-by: Stefan Smiljkovic Co-authored-by: ctate <366502+ctate@users.noreply.github.com> Co-authored-by: zhanba Co-authored-by: xuyongliang <478439790@qq.com> Co-authored-by: xuyongliang Co-authored-by: Thomas Kosiewski Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 26 +- .gitignore | 4 + AGENTS.md | 5 + README.md | 47 +- cli/src/connection.rs | 2 +- cli/src/install.rs | 121 + cli/src/main.rs | 326 + cli/src/native/actions.rs | 195 +- cli/src/native/daemon.rs | 70 +- cli/src/native/stream.rs | 1024 +- cli/src/output.rs | 54 +- docs/src/app/commands/page.mdx | 12 +- docs/src/app/configuration/page.mdx | 2 +- docs/src/app/observe/page.mdx | 130 + docs/src/app/streaming/page.mdx | 24 +- package.json | 3 +- packages/dashboard/components.json | 25 + packages/dashboard/next-env.d.ts | 6 + packages/dashboard/next.config.ts | 9 + packages/dashboard/package.json | 32 + packages/dashboard/postcss.config.mjs | 7 + packages/dashboard/public/lightpanda.svg | 1 + packages/dashboard/src/app/globals.css | 188 + packages/dashboard/src/app/layout.tsx | 29 + packages/dashboard/src/app/page.tsx | 118 + .../src/components/activity-feed.tsx | 247 + .../src/components/console-panel.tsx | 306 + .../src/components/extensions-panel.tsx | 110 + .../dashboard/src/components/json-syntax.tsx | 70 + .../src/components/network-panel.tsx | 427 + .../dashboard/src/components/session-tree.tsx | 484 + .../src/components/storage-panel.tsx | 344 + .../dashboard/src/components/ui/badge.tsx | 49 + .../dashboard/src/components/ui/button.tsx | 67 + .../src/components/ui/collapsible.tsx | 33 + .../src/components/ui/context-menu.tsx | 263 + .../dashboard/src/components/ui/dialog.tsx | 168 + .../src/components/ui/dropdown-menu.tsx | 269 + .../dashboard/src/components/ui/resizable.tsx | 50 + .../src/components/ui/scroll-area.tsx | 55 + .../dashboard/src/components/ui/separator.tsx | 28 + packages/dashboard/src/components/ui/tabs.tsx | 90 + .../dashboard/src/components/ui/tooltip.tsx | 57 + .../dashboard/src/components/viewport.tsx | 724 ++ .../dashboard/src/hooks/use-media-query.ts | 17 + packages/dashboard/src/lib/exec.ts | 30 + packages/dashboard/src/lib/utils.ts | 6 + packages/dashboard/src/store/activity.ts | 120 + packages/dashboard/src/store/provider.tsx | 7 + packages/dashboard/src/store/sessions.ts | 258 + packages/dashboard/src/store/stream.ts | 230 + packages/dashboard/src/store/tabs.ts | 38 + packages/dashboard/src/types.ts | 111 + packages/dashboard/tsconfig.json | 41 + pnpm-lock.yaml | 10795 +++++++++++++++- pnpm-workspace.yaml | 3 + skills/agent-browser/SKILL.md | 30 +- skills/agent-browser/references/commands.md | 2 +- 58 files changed, 17881 insertions(+), 108 deletions(-) create mode 100644 docs/src/app/observe/page.mdx create mode 100644 packages/dashboard/components.json create mode 100644 packages/dashboard/next-env.d.ts create mode 100644 packages/dashboard/next.config.ts create mode 100644 packages/dashboard/package.json create mode 100644 packages/dashboard/postcss.config.mjs create mode 100644 packages/dashboard/public/lightpanda.svg create mode 100644 packages/dashboard/src/app/globals.css create mode 100644 packages/dashboard/src/app/layout.tsx create mode 100644 packages/dashboard/src/app/page.tsx create mode 100644 packages/dashboard/src/components/activity-feed.tsx create mode 100644 packages/dashboard/src/components/console-panel.tsx create mode 100644 packages/dashboard/src/components/extensions-panel.tsx create mode 100644 packages/dashboard/src/components/json-syntax.tsx create mode 100644 packages/dashboard/src/components/network-panel.tsx create mode 100644 packages/dashboard/src/components/session-tree.tsx create mode 100644 packages/dashboard/src/components/storage-panel.tsx create mode 100644 packages/dashboard/src/components/ui/badge.tsx create mode 100644 packages/dashboard/src/components/ui/button.tsx create mode 100644 packages/dashboard/src/components/ui/collapsible.tsx create mode 100644 packages/dashboard/src/components/ui/context-menu.tsx create mode 100644 packages/dashboard/src/components/ui/dialog.tsx create mode 100644 packages/dashboard/src/components/ui/dropdown-menu.tsx create mode 100644 packages/dashboard/src/components/ui/resizable.tsx create mode 100644 packages/dashboard/src/components/ui/scroll-area.tsx create mode 100644 packages/dashboard/src/components/ui/separator.tsx create mode 100644 packages/dashboard/src/components/ui/tabs.tsx create mode 100644 packages/dashboard/src/components/ui/tooltip.tsx create mode 100644 packages/dashboard/src/components/viewport.tsx create mode 100644 packages/dashboard/src/hooks/use-media-query.ts create mode 100644 packages/dashboard/src/lib/exec.ts create mode 100644 packages/dashboard/src/lib/utils.ts create mode 100644 packages/dashboard/src/store/activity.ts create mode 100644 packages/dashboard/src/store/provider.tsx create mode 100644 packages/dashboard/src/store/sessions.ts create mode 100644 packages/dashboard/src/store/stream.ts create mode 100644 packages/dashboard/src/store/tabs.ts create mode 100644 packages/dashboard/src/types.ts create mode 100644 packages/dashboard/tsconfig.json create mode 100644 pnpm-workspace.yaml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b4fa99f..76ee0d0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -260,6 +260,26 @@ jobs: fi echo "Found $BINARY_COUNT binaries" + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build dashboard + run: pnpm --filter dashboard build + + - name: Create dashboard.zip + run: cd packages/dashboard/out && zip -r ../../../dashboard.zip . + - name: Create GitHub Release run: | VERSION=$(node -p "require('./package.json').version") @@ -267,14 +287,14 @@ jobs: # Check if release already exists if gh release view "$TAG" &>/dev/null; then - echo "Release $TAG already exists, uploading binaries..." - gh release upload "$TAG" bin/agent-browser-* --clobber + echo "Release $TAG already exists, uploading assets..." + gh release upload "$TAG" bin/agent-browser-* dashboard.zip --clobber else echo "Creating release $TAG..." gh release create "$TAG" \ --title "$TAG" \ --generate-notes \ - bin/agent-browser-* + bin/agent-browser-* dashboard.zip fi env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 5a923c0..3cc57d2 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,7 @@ docs/package-lock.json # pnpm .pnpm-store/ + +# next +.next/ +out/ diff --git a/AGENTS.md b/AGENTS.md index 46e7d2d..94c2aea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,11 @@ This applies to changes that either human users or AI agents would need to know In the `docs/src/app/` MDX files, always use HTML `` syntax for tables (not markdown pipe tables). This matches the existing convention across the docs site. +## Dashboard (packages/dashboard) + +- Never use native browser dialogs (`alert`, `confirm`, `prompt`). Use shadcn/ui components (`Dialog`, `AlertDialog`, etc.) instead. +- Use param-case (kebab-case) for all file and folder names (e.g., `session-tree.tsx`, not `SessionTree.tsx`). The `ui/` directory follows shadcn conventions which already uses param-case. + ## Architecture This is a Rust codebase. The browser automation daemon lives in `cli/src/native/` (daemon, actions, browser, CDP client, snapshot, state). The `--engine` flag selects Chrome vs Lightpanda. The `install` command downloads Chrome from Chrome for Testing directly. diff --git a/README.md b/README.md index 9380e2d..19b10f3 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,7 @@ agent-browser stream enable [--port ] # Start runtime WebSocket streaming agent-browser stream status # Show runtime streaming state and bound port agent-browser stream disable # Stop runtime WebSocket streaming agent-browser close # Close browser (aliases: quit, exit) +agent-browser close --all # Close all active sessions ``` ### Get Info @@ -596,6 +597,32 @@ This is useful for multimodal AI models that can reason about visual layout, unl | `--config ` | Use a custom config file (or `AGENT_BROWSER_CONFIG` env) | | `--debug` | Debug output | +## Observability Dashboard + +Monitor agent-browser sessions in real time with a local web dashboard showing a live viewport and command activity feed. + +```bash +# Install the dashboard (one time) +agent-browser dashboard install + +# Start the dashboard server (runs in background on port 4848) +agent-browser dashboard start +agent-browser dashboard start --port 8080 # Custom port + +# All sessions are automatically visible in the dashboard +agent-browser open example.com + +# Stop the dashboard +agent-browser dashboard stop +``` + +The dashboard runs as a standalone background process on port 4848, independent of browser sessions. It stays available even when no sessions are running. All sessions automatically stream to the dashboard. + +The dashboard displays: +- **Live viewport** -- real-time JPEG frames from the browser +- **Activity feed** -- chronological command/result stream with timing and expandable details +- **Console output** -- browser console messages (log, warn, error) + ## Configuration Create an `agent-browser.json` file to set persistent defaults instead of repeating flags on every command. @@ -926,28 +953,28 @@ This is useful when: Stream the browser viewport via WebSocket for live preview or "pair browsing" where a human can watch and interact alongside an AI agent. -### Enable Streaming +### Streaming -For an already-running session, enable streaming at runtime: +Every session automatically starts a WebSocket stream server on an OS-assigned port. Use `stream status` to see the bound port and connection state: ```bash -agent-browser stream enable agent-browser stream status -agent-browser stream disable ``` -`stream enable` binds an available localhost port automatically unless you pass `--port `. -Use `stream status` to inspect whether streaming is enabled, which port is active, whether a browser is attached, and whether screencasting is active. - -If you want streaming to be available immediately when the daemon starts, set `AGENT_BROWSER_STREAM_PORT` before the first command in that session: +To bind to a specific port, set `AGENT_BROWSER_STREAM_PORT`: ```bash AGENT_BROWSER_STREAM_PORT=9223 agent-browser open example.com ``` -The environment variable only affects daemon startup. For sessions that are already running, use `agent-browser stream enable` instead. +You can also manage streaming at runtime with `stream enable`, `stream disable`, and `stream status`: -Once enabled, the WebSocket server streams the browser viewport and accepts input events. +```bash +agent-browser stream enable --port 9223 # Re-enable on a specific port +agent-browser stream disable # Stop streaming for the session +``` + +The WebSocket server streams the browser viewport and accepts input events. ### WebSocket Protocol diff --git a/cli/src/connection.rs b/cli/src/connection.rs index 867fd44..41440f3 100644 --- a/cli/src/connection.rs +++ b/cli/src/connection.rs @@ -154,7 +154,7 @@ fn get_port_for_session(session: &str) -> u16 { 49152 + ((hash.unsigned_abs() as u32 % 16383) as u16) } -fn daemon_ready(session: &str) -> bool { +pub fn daemon_ready(session: &str) -> bool { #[cfg(unix)] { let socket_path = get_socket_path(session); diff --git a/cli/src/install.rs b/cli/src/install.rs index f0cd2ad..4f288d1 100644 --- a/cli/src/install.rs +++ b/cli/src/install.rs @@ -643,3 +643,124 @@ fn package_exists_apt(pkg: &str) -> bool { .map(|s| s.success()) .unwrap_or(false) } + +// --------------------------------------------------------------------------- +// Dashboard install +// --------------------------------------------------------------------------- + +pub fn get_dashboard_dir() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".agent-browser") + .join("dashboard") +} + +const DASHBOARD_VERSION: &str = env!("CARGO_PKG_VERSION"); + +fn dashboard_download_url() -> String { + format!( + "https://github.com/vercel-labs/agent-browser/releases/download/v{}/dashboard.zip", + DASHBOARD_VERSION + ) +} + +pub fn run_dashboard_install() { + println!("{}", color::cyan("Installing dashboard...")); + + let dest = get_dashboard_dir(); + + if dest.join("index.html").exists() { + println!( + "{} Dashboard is already installed at {}", + color::success_indicator(), + dest.display() + ); + return; + } + + let url = dashboard_download_url(); + println!(" Downloading dashboard v{}", DASHBOARD_VERSION); + println!(" {}", url); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap_or_else(|e| { + eprintln!( + "{} Failed to create runtime: {}", + color::error_indicator(), + e + ); + exit(1); + }); + + let bytes = match rt.block_on(download_bytes(&url)) { + Ok(b) => b, + Err(e) => { + eprintln!("{} {}", color::error_indicator(), e); + eprintln!(" The dashboard may not be available for this version yet."); + eprintln!(" You can build it locally: cd packages/dashboard && pnpm build"); + exit(1); + } + }; + + match extract_dashboard_zip(bytes, &dest) { + Ok(()) => { + println!( + "{} Dashboard v{} installed successfully", + color::success_indicator(), + DASHBOARD_VERSION + ); + println!(" Location: {}", dest.display()); + } + Err(e) => { + let _ = fs::remove_dir_all(&dest); + eprintln!("{} {}", color::error_indicator(), e); + exit(1); + } + } +} + +fn extract_dashboard_zip(bytes: Vec, dest: &Path) -> Result<(), String> { + fs::create_dir_all(dest).map_err(|e| format!("Failed to create directory: {}", e))?; + + let cursor = io::Cursor::new(bytes); + let mut archive = + zip::ZipArchive::new(cursor).map_err(|e| format!("Failed to read zip archive: {}", e))?; + + for i in 0..archive.len() { + let mut file = archive + .by_index(i) + .map_err(|e| format!("Failed to read zip entry: {}", e))?; + + let enclosed = match file.enclosed_name() { + Some(name) => name.to_owned(), + None => continue, + }; + let rel_path = enclosed.to_string_lossy().to_string(); + + if rel_path.is_empty() || file.is_dir() { + if file.is_dir() { + let out_dir = dest.join(&rel_path); + let _ = fs::create_dir_all(&out_dir); + } + continue; + } + + let out_path = dest.join(&rel_path); + if !out_path.starts_with(dest) { + continue; + } + + if let Some(parent) = out_path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create parent dir {}: {}", parent.display(), e))?; + } + let mut out_file = fs::File::create(&out_path) + .map_err(|e| format!("Failed to create file {}: {}", out_path.display(), e))?; + io::copy(&mut file, &mut out_file) + .map_err(|e| format!("Failed to write {}: {}", out_path.display(), e))?; + } + + Ok(()) +} diff --git a/cli/src/main.rs b/cli/src/main.rs index ab01c45..e046b83 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -198,6 +198,278 @@ fn run_session(args: &[String], session: &str, json_mode: bool) { } } +fn get_dashboard_pid_path() -> std::path::PathBuf { + get_socket_dir().join("dashboard.pid") +} + +fn is_pid_alive(pid: u32) -> bool { + #[cfg(unix)] + { + unsafe { libc::kill(pid as i32, 0) == 0 } + } + #[cfg(windows)] + { + unsafe { + let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); + if handle != 0 { + CloseHandle(handle); + true + } else { + false + } + } + } +} + +fn run_dashboard_start(port: u16, json_mode: bool) { + let pid_path = get_dashboard_pid_path(); + + // Check if already running + if let Ok(pid_str) = fs::read_to_string(&pid_path) { + if let Ok(pid) = pid_str.trim().parse::() { + if is_pid_alive(pid) { + if json_mode { + print_json_value(json!({ + "success": true, + "data": { "port": port, "pid": pid, "already_running": true }, + })); + } else { + println!("Dashboard already running at http://localhost:{}", port); + } + return; + } + } + let _ = fs::remove_file(&pid_path); + } + + let socket_dir = get_socket_dir(); + if !socket_dir.exists() { + let _ = fs::create_dir_all(&socket_dir); + } + + let exe_path = match env::current_exe() { + Ok(p) => p.canonicalize().unwrap_or(p), + Err(e) => { + if json_mode { + print_json_error(format!("Failed to get executable path: {}", e)); + } else { + eprintln!( + "{} Failed to get executable path: {}", + color::error_indicator(), + e + ); + } + exit(1); + } + }; + + let mut cmd = std::process::Command::new(&exe_path); + cmd.env("AGENT_BROWSER_DASHBOARD", "1") + .env("AGENT_BROWSER_DASHBOARD_PORT", port.to_string()); + + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + unsafe { + cmd.pre_exec(|| { + libc::setsid(); + Ok(()) + }); + } + } + + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; + const DETACHED_PROCESS: u32 = 0x00000008; + cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS); + } + + match cmd + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + { + Ok(child) => { + let pid = child.id(); + let _ = fs::write(&pid_path, pid.to_string()); + + if json_mode { + print_json_value(json!({ + "success": true, + "data": { "port": port, "pid": pid }, + })); + } else { + println!("Dashboard started at http://localhost:{}", port); + } + } + Err(e) => { + if json_mode { + print_json_error(format!("Failed to start dashboard: {}", e)); + } else { + eprintln!( + "{} Failed to start dashboard: {}", + color::error_indicator(), + e + ); + } + exit(1); + } + } +} + +fn run_dashboard_stop(json_mode: bool) { + let pid_path = get_dashboard_pid_path(); + + let pid_str = match fs::read_to_string(&pid_path) { + Ok(s) => s, + Err(_) => { + if json_mode { + print_json_value( + json!({ "success": true, "data": { "stopped": false, "reason": "not running" } }), + ); + } else { + println!("Dashboard is not running"); + } + return; + } + }; + + let pid: u32 = match pid_str.trim().parse() { + Ok(p) => p, + Err(_) => { + let _ = fs::remove_file(&pid_path); + if json_mode { + print_json_value( + json!({ "success": true, "data": { "stopped": false, "reason": "invalid pid" } }), + ); + } else { + println!("Dashboard is not running"); + } + return; + } + }; + + #[cfg(unix)] + { + unsafe { + libc::kill(pid as i32, libc::SIGTERM); + } + } + #[cfg(windows)] + { + unsafe { + let handle = OpenProcess(1, 0, pid); // PROCESS_TERMINATE = 1 + if handle != 0 { + windows_sys::Win32::System::Threading::TerminateProcess(handle, 0); + CloseHandle(handle); + } + } + } + + let _ = fs::remove_file(&pid_path); + + if json_mode { + print_json_value(json!({ "success": true, "data": { "stopped": true } })); + } else { + println!("{} Dashboard stopped", color::green("✓")); + } +} + +fn run_close_all(flags: &Flags) { + let socket_dir = get_socket_dir(); + let mut sessions: Vec = Vec::new(); + + if let Ok(entries) = fs::read_dir(&socket_dir) { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if let Some(session_name) = name.strip_suffix(".pid") { + if session_name.is_empty() { + continue; + } + let pid_path = socket_dir.join(&name); + if let Ok(pid_str) = fs::read_to_string(&pid_path) { + if let Ok(pid) = pid_str.trim().parse::() { + #[cfg(unix)] + let running = unsafe { + libc::kill(pid as i32, 0) == 0 + || std::io::Error::last_os_error().raw_os_error() + != Some(libc::ESRCH) + }; + #[cfg(windows)] + let running = unsafe { + let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); + if handle != 0 { + CloseHandle(handle); + true + } else { + false + } + }; + if running { + sessions.push(session_name.to_string()); + } + } + } + } + } + } + + if sessions.is_empty() { + if flags.json { + print_json_value(json!({ + "success": true, + "data": { "closed": 0, "sessions": [] }, + })); + } else { + println!("No active sessions"); + } + return; + } + + let mut closed: Vec = Vec::new(); + let mut failed: Vec<(String, String)> = Vec::new(); + + for session in &sessions { + let cmd = json!({ "id": gen_id(), "action": "close" }); + match send_command(cmd, session) { + Ok(resp) if resp.success => closed.push(session.clone()), + Ok(resp) => { + let err = resp.error.unwrap_or_else(|| "Unknown error".to_string()); + failed.push((session.clone(), err)); + } + Err(e) => failed.push((session.clone(), e.to_string())), + } + } + + if flags.json { + print_json_value(json!({ + "success": failed.is_empty(), + "data": { + "closed": closed.len(), + "sessions": closed, + "failed": failed.iter().map(|(s, e)| json!({"session": s, "error": e})).collect::>(), + }, + })); + } else { + for s in &closed { + println!("{} Closed session: {}", color::green("✓"), s); + } + for (s, e) in &failed { + eprintln!("{} Failed to close {}: {}", color::error_indicator(), s, e); + } + if closed.is_empty() && !failed.is_empty() { + exit(1); + } + } + + if !failed.is_empty() { + exit(1); + } +} + fn main() { // Rust ignores SIGPIPE by default, causing println! to panic on broken pipes. // Reset to SIG_DFL so the OS terminates the process cleanly instead. @@ -227,6 +499,17 @@ fn main() { return; } + // Standalone dashboard server mode + if env::var("AGENT_BROWSER_DASHBOARD").is_ok() { + let port: u16 = env::var("AGENT_BROWSER_DASHBOARD_PORT") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(4848); + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + rt.block_on(native::stream::run_dashboard_server(port)); + return; + } + let args: Vec = env::args().skip(1).collect(); let flags = parse_flags(&args); let clean = clean_args(&args); @@ -267,12 +550,54 @@ fn main() { return; } + // Handle dashboard subcommand + if clean.first().map(|s| s.as_str()) == Some("dashboard") { + match clean.get(1).map(|s| s.as_str()) { + Some("install") => { + install::run_dashboard_install(); + return; + } + Some("start") | None => { + let port = clean + .iter() + .position(|a| a == "--port") + .and_then(|i| clean.get(i + 1)) + .and_then(|s| s.parse::().ok()) + .unwrap_or(4848); + run_dashboard_start(port, flags.json); + return; + } + Some("stop") => { + run_dashboard_stop(flags.json); + return; + } + Some(unknown) => { + eprintln!( + "{} Unknown dashboard subcommand: {}", + color::error_indicator(), + unknown + ); + exit(1); + } + } + } + // Handle session separately (doesn't need daemon) if clean.first().map(|s| s.as_str()) == Some("session") { run_session(&clean, &flags.session, flags.json); return; } + // Handle close --all: close all active sessions + if matches!( + clean.first().map(|s| s.as_str()), + Some("close") | Some("quit") | Some("exit") + ) && clean.iter().any(|a| a == "--all") + { + run_close_all(&flags); + return; + } + let mut cmd = match parse_command(&clean, &flags) { Ok(c) => c, Err(e) => { @@ -397,6 +722,7 @@ fn main() { idle_timeout: flags.idle_timeout.as_deref(), cdp: flags.cdp.as_deref(), }; + let daemon_result = match ensure_daemon(&flags.session, &daemon_opts) { Ok(result) => result, Err(e) => { diff --git a/cli/src/native/actions.rs b/cli/src/native/actions.rs index 5b4c1de..2cf07ea 100644 --- a/cli/src/native/actions.rs +++ b/cli/src/native/actions.rs @@ -212,6 +212,8 @@ pub struct DaemonState { pub stream_client: Option>>>>, /// Stream server instance kept alive so the broadcast channel remains open. pub stream_server: Option>, + /// Browser engine name (e.g. "chrome", "lightpanda") for observability. + pub engine: String, } impl DaemonState { @@ -254,6 +256,7 @@ impl DaemonState { pending_dialog: None, stream_client: None, stream_server: None, + engine: env::var("AGENT_BROWSER_ENGINE").unwrap_or_else(|_| "chrome".to_string()), } } @@ -268,6 +271,9 @@ impl DaemonState { stream_server: Option>, ) -> Self { let mut s = Self::new(); + if stream_server.is_some() { + s.request_tracking = true; + } s.stream_client = stream_client; s.stream_server = stream_server; s @@ -410,7 +416,14 @@ impl DaemonState { let connected = self.browser.is_some(); let sc = server.is_screencasting().await; let (vw, vh) = server.viewport().await; - server.broadcast_status(connected, sc, vw, vh); + server + .broadcast_status(connected, sc, vw, vh, &self.engine) + .await; + if let Some(ref mgr) = self.browser { + server.broadcast_tabs(&mgr.tab_list()).await; + } else { + server.broadcast_tabs(&[]).await; + } // Notify the background CDP event loop that the client changed server.notify_client_changed(); } @@ -441,6 +454,10 @@ impl DaemonState { recording::stop_recording_task(&mut self.recording_state).await } + pub fn drain_cdp_events_background(&mut self) { + let _ = self.drain_cdp_events(); + } + fn drain_cdp_events(&mut self) -> DrainedEvents { let rx = match self.event_rx.as_mut() { Some(rx) => rx, @@ -557,6 +574,9 @@ impl DaemonState { .join(" "); self.event_tracker .add_console(&console_event.call_type, &text); + if let Some(ref server) = self.stream_server { + server.broadcast_console(&console_event.call_type, &text); + } } } "Runtime.exceptionThrown" => { @@ -575,6 +595,13 @@ impl DaemonState { details.line_number, details.column_number, ); + if let Some(ref server) = self.stream_server { + server.broadcast_page_error( + text, + details.line_number, + details.column_number, + ); + } } } "Network.requestWillBeSent" @@ -837,6 +864,12 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value { .unwrap_or("") .to_string(); + let cmd_start = std::time::Instant::now(); + + if let Some(ref server) = state.stream_server { + server.broadcast_command(action, &id, cmd); + } + // Drain pending CDP events (console, errors, screencast frames, target lifecycle) let DrainedEvents { pending_acks, @@ -1221,6 +1254,31 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value { } } + if let Some(ref server) = state.stream_server { + let duration_ms = cmd_start.elapsed().as_millis() as u64; + let success = resp + .get("status") + .and_then(|v| v.as_str()) + .is_some_and(|s| s == "success"); + let data = resp.get("data").cloned().unwrap_or(Value::Null); + server.broadcast_result(&id, action, success, &data, duration_ms); + + if let Some(ref mgr) = state.browser { + server.broadcast_tabs(&mgr.tab_list()).await; + + // Keep the stream server's CDP session in sync with the active tab + // so screencasting always targets the correct page. + if matches!( + action, + "tab_new" | "tab_switch" | "tab_close" | "open" | "navigate" + ) { + let session_id = mgr.active_session_id().ok().map(|s| s.to_string()); + server.set_cdp_session_id(session_id).await; + server.notify_client_changed(); + } + } + } + resp } @@ -1255,6 +1313,10 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> { )); } + state.engine = engine.as_deref().unwrap_or("chrome").to_string(); + write_engine_file(&state.session_id, &state.engine); + write_extensions_file(&state.session_id); + if let Ok(cdp) = env::var("AGENT_BROWSER_CDP") { let mgr = BrowserManager::connect_cdp(&cdp).await?; state.reset_input_state(); @@ -1569,6 +1631,9 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result Result Result Result>(s) { + if arr.len() == 2 && arr[0] > 0 && arr[1] > 0 { + server.set_viewport(arr[0], arr[1]).await; + } + } + } + } + } + + Ok(result) } async fn handle_tab_close(cmd: &Value, state: &mut DaemonState) -> Result { @@ -3264,11 +3355,26 @@ async fn handle_set_media(cmd: &Value, state: &DaemonState) -> Result>() - }); + let mut feat_list: Vec<(String, String)> = Vec::new(); + + if let Some(scheme) = cmd.get("colorScheme").and_then(|v| v.as_str()) { + feat_list.push(("prefers-color-scheme".to_string(), scheme.to_string())); + } + if let Some(motion) = cmd.get("reducedMotion").and_then(|v| v.as_str()) { + feat_list.push(("prefers-reduced-motion".to_string(), motion.to_string())); + } + + if let Some(obj) = cmd.get("features").and_then(|v| v.as_object()) { + for (k, v) in obj { + feat_list.push((k.clone(), v.as_str().unwrap_or("").to_string())); + } + } + + let features = if feat_list.is_empty() { + None + } else { + Some(feat_list) + }; mgr.set_emulated_media(media, features).await?; Ok(json!({ "set": true })) @@ -3601,12 +3707,22 @@ async fn handle_recording_start(cmd: &Value, state: &mut DaemonState) -> Result< let result = recording::recording_start(&mut state.recording_state, path)?; state.start_recording_task(client, new_session_id).await?; + if let Some(ref server) = state.stream_server { + server.set_recording(true, &state.engine).await; + } + Ok(result) } async fn handle_recording_stop(state: &mut DaemonState) -> Result { state.stop_recording_task().await?; - recording::recording_stop(&mut state.recording_state) + let result = recording::recording_stop(&mut state.recording_state); + + if let Some(ref server) = state.stream_server { + server.set_recording(false, &state.engine).await; + } + + result } async fn handle_recording_restart(cmd: &Value, state: &mut DaemonState) -> Result { @@ -4256,15 +4372,21 @@ async fn handle_device(cmd: &Value, state: &DaemonState) -> Result (393, 852, 3.0, true, "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"), + "iphone 16" | "iphone16" => (393, 852, 3.0, true, "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1"), + "iphone 16 pro" | "iphone16pro" => (402, 874, 3.0, true, "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1"), + "iphone 17" | "iphone17" => (402, 874, 3.0, true, "Mozilla/5.0 (iPhone; CPU iPhone OS 19_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/19.0 Mobile/15E148 Safari/604.1"), + "ipad" | "ipad air" => (820, 1180, 2.0, true, "Mozilla/5.0 (iPad; CPU OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/604.1"), + "ipad pro" => (1024, 1366, 2.0, true, "Mozilla/5.0 (iPad; CPU OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/604.1"), + "pixel 9" | "pixel9" => (412, 923, 2.625, true, "Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36"), + "galaxy s25" | "galaxys25" => (360, 800, 3.0, true, "Mozilla/5.0 (Linux; Android 15; SM-S931B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36"), + // Legacy aliases "iphone 12" | "iphone12" => (390, 844, 3.0, true, "Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1"), "iphone 14" | "iphone14" => (390, 844, 3.0, true, "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1"), - "iphone 15" | "iphone15" => (393, 852, 3.0, true, "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"), - "ipad" | "ipad air" => (820, 1180, 2.0, true, "Mozilla/5.0 (iPad; CPU OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Safari/604.1"), - "ipad pro" => (1024, 1366, 2.0, true, "Mozilla/5.0 (iPad; CPU OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Safari/604.1"), "pixel 5" | "pixel5" => (393, 851, 2.75, true, "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.91 Mobile Safari/537.36"), "pixel 7" | "pixel7" => (412, 915, 2.625, true, "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Mobile Safari/537.36"), "galaxy s21" | "galaxys21" => (360, 800, 3.0, true, "Mozilla/5.0 (Linux; Android 11; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.91 Mobile Safari/537.36"), - _ => return Err(format!("Unknown device: {}. Supported: iPhone 12, iPhone 14, iPhone 15, iPad, iPad Pro, Pixel 5, Pixel 7, Galaxy S21", name)), + _ => return Err(format!("Unknown device: {}. Supported: iPhone 15, iPhone 16, iPhone 16 Pro, iPhone 17, iPad, iPad Pro, Pixel 9, Galaxy S25", name)), }; mgr.set_viewport(width, height, scale, mobile).await?; @@ -4316,6 +4438,37 @@ fn remove_stream_file(session_id: &str) -> Result<(), String> { } } +fn engine_file_path(session_id: &str) -> PathBuf { + get_socket_dir().join(format!("{}.engine", session_id)) +} + +fn write_engine_file(session_id: &str, engine: &str) { + let _ = fs::write(engine_file_path(session_id), engine); +} + +fn remove_engine_file(session_id: &str) { + let _ = fs::remove_file(engine_file_path(session_id)); +} + +fn extensions_file_path(session_id: &str) -> PathBuf { + get_socket_dir().join(format!("{}.extensions", session_id)) +} + +fn write_extensions_file(session_id: &str) { + if let Ok(val) = env::var("AGENT_BROWSER_EXTENSIONS") { + let trimmed = val.trim(); + if !trimmed.is_empty() { + let _ = fs::write(extensions_file_path(session_id), trimmed); + return; + } + } + let _ = fs::remove_file(extensions_file_path(session_id)); +} + +fn remove_extensions_file(session_id: &str) { + let _ = fs::remove_file(extensions_file_path(session_id)); +} + async fn current_stream_status(state: &DaemonState) -> Value { debug_assert_eq!( state.stream_server.is_some(), @@ -4356,7 +4509,7 @@ async fn handle_stream_enable(cmd: &Value, state: &mut DaemonState) -> Result Result Result state.stream_server = None; state.stream_client = None; remove_stream_file(&state.session_id)?; + remove_engine_file(&state.session_id); Ok(json!({ "disabled": true })) } @@ -4434,7 +4589,15 @@ async fn handle_screencast_start(cmd: &Value, state: &mut DaemonState) -> Result if let Some(ref server) = state.stream_server { server.set_screencasting(true).await; - server.broadcast_status(true, true, max_width as u32, max_height as u32); + server + .broadcast_status( + true, + true, + max_width as u32, + max_height as u32, + &state.engine, + ) + .await; } Ok(json!({ "started": true })) @@ -4454,7 +4617,9 @@ async fn handle_screencast_stop(state: &mut DaemonState) -> Result() { @@ -44,23 +46,20 @@ pub async fn run_daemon(session: &str) { let mut stream_client: Option>>>> = None; let mut stream_server_instance: Option> = None; - if let Ok(port_str) = env::var("AGENT_BROWSER_STREAM_PORT") { - if let Ok(port) = port_str.parse::() { - if port > 0 { - match StreamServer::start_without_client(port, session.to_string()).await { - Ok((stream_server, client_slot)) => { - stream_client = Some(client_slot.clone()); - if let Err(e) = fs::write(&stream_path, stream_server.port().to_string()) { - let _ = - writeln!(std::io::stderr(), "Failed to write .stream file: {}", e); - } - stream_server_instance = Some(Arc::new(stream_server)); - } - Err(e) => { - let _ = writeln!(std::io::stderr(), "Stream server failed to start: {}", e); - } - } + let preferred_port = env::var("AGENT_BROWSER_STREAM_PORT") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + match StreamServer::start_without_client(preferred_port, session.to_string(), true).await { + Ok((stream_server, client_slot)) => { + stream_client = Some(client_slot.clone()); + if let Err(e) = fs::write(&stream_path, stream_server.port().to_string()) { + let _ = writeln!(std::io::stderr(), "Failed to write .stream file: {}", e); } + stream_server_instance = Some(Arc::new(stream_server)); + } + Err(e) => { + let _ = writeln!(std::io::stderr(), "Stream server failed to start: {}", e); } } @@ -83,6 +82,8 @@ pub async fn run_daemon(session: &str) { let _ = fs::remove_file(&socket_path); let _ = fs::remove_file(&pid_path); let _ = fs::remove_file(&stream_path); + let _ = fs::remove_file(socket_dir.join(format!("{}.engine", session))); + let _ = fs::remove_file(socket_dir.join(format!("{}.extensions", session))); if let Err(e) = result { let _ = writeln!(std::io::stderr(), "Daemon error: {}", e); @@ -93,7 +94,7 @@ pub async fn run_daemon(session: &str) { #[cfg(unix)] async fn run_socket_server( socket_path: &PathBuf, - _session: &str, + session: &str, stream_client: Option>>>>, stream_server: Option>, idle_timeout_ms: Option, @@ -103,6 +104,13 @@ async fn run_socket_server( let listener = UnixListener::bind(socket_path).map_err(|e| format!("Failed to bind socket: {}", e))?; + let stream_file: Option = if stream_server.is_some() { + let dir = socket_path.parent().unwrap_or(std::path::Path::new(".")); + Some(dir.join(format!("{}.stream", session))) + } else { + None + }; + let state: std::sync::Arc> = std::sync::Arc::new( tokio::sync::Mutex::new(DaemonState::new_with_stream(stream_client, stream_server)), ); @@ -116,6 +124,9 @@ async fn run_socket_server( let mut sigchld = signal::unix::signal(signal::unix::SignalKind::child()) .map_err(|e| format!("Failed to install SIGCHLD handler: {}", e))?; + let mut drain_interval = tokio::time::interval(Duration::from_millis(500)); + drain_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { let sleep_future = idle_timeout_ms.map(|ms| tokio::time::sleep(Duration::from_millis(ms))); let mut sleep_pin = sleep_future.map(Box::pin); @@ -126,8 +137,9 @@ async fn run_socket_server( Ok((stream, _)) => { let state = state.clone(); let reset_tx = reset_tx.clone(); + let sf = stream_file.clone(); tokio::spawn(async move { - handle_connection(stream, state, reset_tx).await; + handle_connection(stream, state, reset_tx, sf).await; }); } Err(e) => { @@ -136,11 +148,14 @@ async fn run_socket_server( } } _ = sigchld.recv() => { - // Reap all zombie children. The browser will be re-launched - // automatically on the next command via the has_process_exited() - // check in execute_command. reap_children(); } + _ = drain_interval.tick() => { + let mut s = state.lock().await; + if s.request_tracking || s.har_recording { + s.drain_cdp_events_background(); + } + } _ = async { if let Some(ref mut s) = sleep_pin { s.as_mut().await @@ -200,6 +215,12 @@ async fn run_socket_server( let port_path = socket_dir.join(format!("{}.port", session)); let _ = fs::write(&port_path, port.to_string()); + let stream_file: Option = if stream_server.is_some() { + Some(socket_dir.join(format!("{}.stream", session))) + } else { + None + }; + let state: std::sync::Arc> = std::sync::Arc::new( tokio::sync::Mutex::new(DaemonState::new_with_stream(stream_client, stream_server)), ); @@ -217,8 +238,9 @@ async fn run_socket_server( Ok((stream, _)) => { let state = state.clone(); let reset_tx = reset_tx.clone(); + let sf = stream_file.clone(); tokio::spawn(async move { - handle_connection(stream, state, reset_tx).await; + handle_connection(stream, state, reset_tx, sf).await; }); } Err(e) => { @@ -261,6 +283,7 @@ async fn handle_connection( stream: S, state: std::sync::Arc>, idle_reset_tx: Option>>, + stream_file_cleanup: Option, ) where S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, { @@ -314,6 +337,9 @@ async fn handle_connection( } if is_close { + if let Some(ref path) = stream_file_cleanup { + let _ = fs::remove_file(path); + } tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; process::exit(0); } diff --git a/cli/src/native/stream.rs b/cli/src/native/stream.rs index 7e50a7d..a5288db 100644 --- a/cli/src/native/stream.rs +++ b/cli/src/native/stream.rs @@ -1,13 +1,17 @@ use serde_json::{json, Value}; use std::net::SocketAddr; +use std::path::{Path, PathBuf}; use std::sync::Arc; use futures_util::{SinkExt, StreamExt}; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; use tokio::sync::{broadcast, watch, Mutex, Notify, RwLock}; use tokio_tungstenite::tungstenite::Message; use super::cdp::client::CdpClient; +use crate::connection::get_socket_dir; +use crate::install::get_dashboard_dir; /// Frame metadata from CDP Page.screencastFrame events. #[derive(Debug, Clone)] @@ -37,6 +41,7 @@ impl Default for FrameMetadata { pub struct StreamServer { port: u16, + session_name: String, frame_tx: broadcast::Sender, client_count: Arc>, client_slot: Arc>>>, @@ -46,6 +51,11 @@ pub struct StreamServer { screencasting: Arc>, viewport_width: Arc>, viewport_height: Arc>, + dashboard_dir: Option, + last_tabs: Arc>>, + last_engine: Arc>, + last_frame: Arc>>, + recording: Arc>, shutdown_tx: watch::Sender, accept_task: Mutex>>, cdp_task: Mutex>>, @@ -58,19 +68,33 @@ impl StreamServer { session_id: String, ) -> Result { let client_slot = Arc::new(RwLock::new(Some(client))); - let (server, _) = Self::start_inner(preferred_port, client_slot, session_id).await?; + let (server, _) = Self::start_inner(preferred_port, client_slot, session_id, true).await?; Ok(server) } - /// Start the stream server without a CDP client (e.g. at daemon startup before browser launch). + /// Start the stream server without a CDP client. /// Returns the server and a shared slot to set the client when the browser launches. /// Input messages are ignored until the client is set. + /// When `allow_port_fallback` is true, binding to an occupied port falls back to an + /// OS-assigned port (used by daemon startup). When false, the error propagates + /// (used by the runtime `stream_enable` command). pub async fn start_without_client( preferred_port: u16, session_id: String, + allow_port_fallback: bool, ) -> Result<(Self, Arc>>>), String> { let client_slot = Arc::new(RwLock::new(None::>)); - Self::start_inner(preferred_port, client_slot, session_id).await + Self::start_inner(preferred_port, client_slot, session_id, allow_port_fallback).await + } + + /// Resolve the dashboard directory if it exists. + fn resolve_dashboard_dir() -> Option { + let dir = dirs::home_dir()?.join(".agent-browser").join("dashboard"); + if dir.join("index.html").exists() { + Some(dir) + } else { + None + } } /// Notify the background CDP listener that the client has changed (browser launched/closed). @@ -90,9 +114,11 @@ impl StreamServer { } /// Update the stored viewport dimensions used by status messages and screencast. + /// Also notifies the screencast event loop to restart with the new dimensions. pub async fn set_viewport(&self, width: u32, height: u32) { *self.viewport_width.lock().await = width; *self.viewport_height.lock().await = height; + self.client_notify.notify_one(); } /// Get the current viewport dimensions. @@ -108,6 +134,15 @@ impl StreamServer { *guard = active; } + /// Update and broadcast the recording state. + pub async fn set_recording(&self, active: bool, engine: &str) { + *self.recording.lock().await = active; + let connected = self.client_slot.read().await.is_some(); + let sc = *self.screencasting.lock().await; + let (vw, vh) = self.viewport().await; + self.broadcast_status(connected, sc, vw, vh, engine).await; + } + /// Shut down the accept loop and background CDP listener, releasing the bound port. pub async fn shutdown(&self) { let _ = self.shutdown_tx.send(true); @@ -123,18 +158,27 @@ impl StreamServer { async fn start_inner( preferred_port: u16, client_slot: Arc>>>, - _session_id: String, + session_id: String, + allow_port_fallback: bool, ) -> Result<(Self, Arc>>>), String> { let addr = format!("127.0.0.1:{}", preferred_port); - let listener = TcpListener::bind(&addr) - .await - .map_err(|e| format!("Failed to bind stream server: {}", e))?; + let listener = match TcpListener::bind(&addr).await { + Ok(l) => l, + Err(_) if allow_port_fallback && preferred_port != 0 => { + TcpListener::bind("127.0.0.1:0") + .await + .map_err(|e| format!("Failed to bind stream server: {}", e))? + } + Err(e) => return Err(format!("Failed to bind stream server: {}", e)), + }; let actual_addr = listener .local_addr() .map_err(|e| format!("Failed to get stream address: {}", e))?; let port = actual_addr.port(); + let dashboard_dir = Self::resolve_dashboard_dir(); + let (frame_tx, _) = broadcast::channel::(64); let client_count = Arc::new(Mutex::new(0usize)); let client_notify = Arc::new(Notify::new()); @@ -142,6 +186,10 @@ impl StreamServer { let cdp_session_id = Arc::new(RwLock::new(None::)); let viewport_width = Arc::new(Mutex::new(1280u32)); let viewport_height = Arc::new(Mutex::new(720u32)); + let last_tabs = Arc::new(RwLock::new(Vec::::new())); + let last_engine = Arc::new(RwLock::new("chrome".to_string())); + let last_frame = Arc::new(RwLock::new(None::)); + let recording = Arc::new(Mutex::new(false)); let (shutdown_tx, shutdown_rx) = watch::channel(false); let frame_tx_clone = frame_tx.clone(); @@ -151,10 +199,15 @@ impl StreamServer { let screencasting_clone = screencasting.clone(); let cdp_session_clone = cdp_session_id.clone(); - // WebSocket accept loop let vw_clone = viewport_width.clone(); let vh_clone = viewport_height.clone(); + let dashboard_dir_clone = dashboard_dir.clone(); + let last_tabs_clone = last_tabs.clone(); + let last_engine_clone = last_engine.clone(); + let last_frame_clone = last_frame.clone(); + let recording_clone = recording.clone(); let accept_shutdown_rx = shutdown_rx.clone(); + let session_name_clone = session_id.clone(); let accept_task = tokio::spawn(async move { accept_loop( listener, @@ -166,7 +219,13 @@ impl StreamServer { cdp_session_clone, vw_clone, vh_clone, + dashboard_dir_clone, + last_tabs_clone, + last_engine_clone, + last_frame_clone, + recording_clone, accept_shutdown_rx, + session_name_clone, ) .await; }); @@ -180,6 +239,10 @@ impl StreamServer { let cdp_session_bg = cdp_session_id.clone(); let vw_bg = viewport_width.clone(); let vh_bg = viewport_height.clone(); + let last_frame_bg = last_frame.clone(); + let last_tabs_bg = last_tabs.clone(); + let last_engine_bg = last_engine.clone(); + let recording_bg = recording.clone(); let cdp_task = tokio::spawn(async move { cdp_event_loop( frame_tx_bg, @@ -190,6 +253,10 @@ impl StreamServer { cdp_session_bg, vw_bg, vh_bg, + last_frame_bg, + last_tabs_bg, + last_engine_bg, + recording_bg, shutdown_rx, ) .await; @@ -198,6 +265,7 @@ impl StreamServer { Ok(( Self { port, + session_name: session_id, frame_tx, client_count, client_slot: client_slot.clone(), @@ -206,6 +274,11 @@ impl StreamServer { screencasting, viewport_width, viewport_height, + dashboard_dir, + last_tabs, + last_engine, + last_frame, + recording, shutdown_tx, accept_task: Mutex::new(Some(accept_task)), cdp_task: Mutex::new(Some(cdp_task)), @@ -220,7 +293,11 @@ impl StreamServer { /// Broadcast a raw frame string (legacy). pub fn broadcast_frame(&self, frame_json: &str) { - let _ = self.frame_tx.send(frame_json.to_string()); + let s = frame_json.to_string(); + if let Ok(mut lf) = self.last_frame.try_write() { + *lf = Some(s.clone()); + } + let _ = self.frame_tx.send(s); } /// Broadcast a screencast frame with structured metadata. @@ -238,23 +315,35 @@ impl StreamServer { "timestamp": metadata.timestamp, } }); - let _ = self.frame_tx.send(msg.to_string()); + let s = msg.to_string(); + if let Ok(mut lf) = self.last_frame.try_write() { + *lf = Some(s.clone()); + } + let _ = self.frame_tx.send(s); } /// Broadcast a status message to all connected clients. - pub fn broadcast_status( + pub async fn broadcast_status( &self, connected: bool, screencasting: bool, viewport_width: u32, viewport_height: u32, + engine: &str, ) { + { + let mut guard = self.last_engine.write().await; + *guard = engine.to_string(); + } + let rec = *self.recording.lock().await; let msg = json!({ "type": "status", "connected": connected, "screencasting": screencasting, "viewportWidth": viewport_width, "viewportHeight": viewport_height, + "engine": engine, + "recording": rec, }); let _ = self.frame_tx.send(msg.to_string()); } @@ -267,6 +356,82 @@ impl StreamServer { }); let _ = self.frame_tx.send(msg.to_string()); } + + /// Broadcast a command event when a command begins executing. + pub fn broadcast_command(&self, action: &str, id: &str, params: &Value) { + let msg = json!({ + "type": "command", + "action": action, + "id": id, + "params": params, + "timestamp": timestamp_ms(), + }); + let _ = self.frame_tx.send(msg.to_string()); + } + + /// Broadcast a result event after a command finishes executing. + pub fn broadcast_result( + &self, + id: &str, + action: &str, + success: bool, + data: &Value, + duration_ms: u64, + ) { + let msg = json!({ + "type": "result", + "id": id, + "action": action, + "success": success, + "data": data, + "duration_ms": duration_ms, + "timestamp": timestamp_ms(), + }); + let _ = self.frame_tx.send(msg.to_string()); + } + + /// Broadcast a console event from the browser. + pub fn broadcast_console(&self, level: &str, text: &str) { + let msg = json!({ + "type": "console", + "level": level, + "text": text, + "timestamp": timestamp_ms(), + }); + let _ = self.frame_tx.send(msg.to_string()); + } + + /// Broadcast a page error (uncaught exception) from the browser. + pub fn broadcast_page_error(&self, text: &str, line: Option, column: Option) { + let msg = json!({ + "type": "page_error", + "text": text, + "line": line, + "column": column, + "timestamp": timestamp_ms(), + }); + let _ = self.frame_tx.send(msg.to_string()); + } + + /// Broadcast the current tab list so the dashboard can render a tab bar. + /// Also caches the list so newly connected WebSocket clients receive it immediately. + pub async fn broadcast_tabs(&self, tabs: &[Value]) { + { + let mut guard = self.last_tabs.write().await; + *guard = tabs.to_vec(); + } + let msg = json!({ + "type": "tabs", + "tabs": tabs, + "timestamp": timestamp_ms(), + }); + let _ = self.frame_tx.send(msg.to_string()); + } + + /// Whether the dashboard directory is available. + pub fn has_dashboard(&self) -> bool { + self.dashboard_dir.is_some() + } } #[allow(clippy::too_many_arguments)] @@ -280,8 +445,16 @@ async fn accept_loop( cdp_session_id: Arc>>, viewport_width: Arc>, viewport_height: Arc>, + dashboard_dir: Option, + last_tabs: Arc>>, + last_engine: Arc>, + last_frame: Arc>>, + recording: Arc>, mut shutdown_rx: watch::Receiver, + session_name: String, ) { + let dashboard_dir = dashboard_dir.map(Arc::from); + let session_name: Arc = Arc::from(session_name); loop { tokio::select! { changed = shutdown_rx.changed() => { @@ -293,7 +466,7 @@ async fn accept_loop( let Ok((stream, addr)) = accept_result else { break; }; - let frame_rx = frame_tx.subscribe(); + let frame_tx = frame_tx.clone(); let client_count = client_count.clone(); let client_slot = client_slot.clone(); let client_notify = client_notify.clone(); @@ -301,13 +474,19 @@ async fn accept_loop( let cdp_session_id = cdp_session_id.clone(); let vw = viewport_width.clone(); let vh = viewport_height.clone(); + let dd = dashboard_dir.clone(); + let lt = last_tabs.clone(); + let le = last_engine.clone(); + let lf = last_frame.clone(); + let rec = recording.clone(); let shutdown_rx = shutdown_rx.clone(); + let sn = session_name.clone(); tokio::spawn(async move { - handle_ws_client( + handle_connection( stream, addr, - frame_rx, + frame_tx, client_count, client_slot, client_notify, @@ -315,7 +494,13 @@ async fn accept_loop( cdp_session_id, vw, vh, + dd, + lt, + le, + lf, + rec, shutdown_rx, + sn, ) .await; }); @@ -324,6 +509,79 @@ async fn accept_loop( } } +fn is_websocket_upgrade(request: &str) -> bool { + request.lines().any(|line| { + if let Some((name, value)) = line.split_once(':') { + name.trim().eq_ignore_ascii_case("upgrade") + && value.trim().eq_ignore_ascii_case("websocket") + } else { + false + } + }) +} + +/// Peek at the TCP stream to dispatch between WebSocket upgrade and plain HTTP. +#[allow(clippy::too_many_arguments)] +async fn handle_connection( + stream: tokio::net::TcpStream, + addr: SocketAddr, + frame_tx: broadcast::Sender, + client_count: Arc>, + client_slot: Arc>>>, + client_notify: Arc, + screencasting: Arc>, + cdp_session_id: Arc>>, + viewport_width: Arc>, + viewport_height: Arc>, + dashboard_dir: Option>, + last_tabs: Arc>>, + last_engine: Arc>, + last_frame: Arc>>, + recording: Arc>, + shutdown_rx: watch::Receiver, + session_name: Arc, +) { + let mut buf = [0u8; 4096]; + let n = match stream.peek(&mut buf).await { + Ok(n) => n, + Err(_) => return, + }; + let request = String::from_utf8_lossy(&buf[..n]); + + if is_websocket_upgrade(&request) { + let frame_rx = frame_tx.subscribe(); + handle_ws_client( + stream, + addr, + frame_rx, + client_count, + client_slot, + client_notify, + screencasting, + cdp_session_id, + viewport_width, + viewport_height, + last_tabs, + last_engine, + last_frame, + recording, + shutdown_rx, + ) + .await; + } else { + handle_http_request( + stream, + &request, + n, + dashboard_dir.as_deref().map(|p| p.as_path()), + &last_tabs, + &last_engine, + &session_name, + ) + .await; + } +} + #[allow(clippy::result_large_err, clippy::too_many_arguments)] async fn handle_ws_client( stream: tokio::net::TcpStream, @@ -336,6 +594,10 @@ async fn handle_ws_client( cdp_session_id: Arc>>, viewport_width: Arc>, viewport_height: Arc>, + last_tabs: Arc>>, + last_engine: Arc>, + last_frame: Arc>>, + recording: Arc>, mut shutdown_rx: watch::Receiver, ) { let callback = @@ -376,14 +638,33 @@ async fn handle_ws_client( let sc = *screencasting.lock().await; let vw = *viewport_width.lock().await; let vh = *viewport_height.lock().await; + let eng = last_engine.read().await.clone(); + let rec = *recording.lock().await; let status = json!({ "type": "status", "connected": connected, "screencasting": sc, "viewportWidth": vw, "viewportHeight": vh, + "engine": eng, + "recording": rec, }); let _ = ws_tx.send(Message::Text(status.to_string())).await; + + let tabs = last_tabs.read().await; + if !tabs.is_empty() { + let tabs_msg = json!({ + "type": "tabs", + "tabs": *tabs, + "timestamp": timestamp_ms(), + }); + let _ = ws_tx.send(Message::Text(tabs_msg.to_string())).await; + } + + // Send the most recent screencast frame so new clients see content immediately + if let Some(ref cached) = *last_frame.read().await { + let _ = ws_tx.send(Message::Text(cached.clone())).await; + } } // Notify the CDP event loop that a client connected (may trigger auto-start screencast) @@ -448,6 +729,10 @@ async fn cdp_event_loop( cdp_session_id: Arc>>, viewport_width: Arc>, viewport_height: Arc>, + last_frame: Arc>>, + last_tabs: Arc>>, + last_engine: Arc>, + recording: Arc>, mut shutdown_rx: watch::Receiver, ) { loop { @@ -509,12 +794,16 @@ async fn cdp_event_loop( } // Broadcast screencasting:true status with current viewport + let eng = last_engine.read().await.clone(); + let rec = *recording.lock().await; let status = json!({ "type": "status", "connected": true, "screencasting": true, "viewportWidth": vw, "viewportHeight": vh, + "engine": eng, + "recording": rec, }); let _ = frame_tx.send(status.to_string()); @@ -535,8 +824,33 @@ async fn cdp_event_loop( event = event_rx.recv() => { match event { Ok(evt) => { - if evt.method == "Page.screencastFrame" { - // Ack immediately (like 0.19.0) + if evt.method == "Page.frameNavigated" { + if let Some(frame) = evt.params.get("frame") { + let is_main = frame + .get("parentId") + .and_then(|v| v.as_str()) + .is_none_or(|s| s.is_empty()); + if is_main { + if let Some(url) = frame.get("url").and_then(|v| v.as_str()) { + // Update the cached tab list so the active tab URL is current + { + let mut tabs = last_tabs.write().await; + for tab in tabs.iter_mut() { + if tab.get("active").and_then(|v| v.as_bool()).unwrap_or(false) { + tab.as_object_mut().map(|o| o.insert("url".to_string(), json!(url))); + } + } + } + let msg = json!({ + "type": "url", + "url": url, + "timestamp": timestamp_ms(), + }); + let _ = frame_tx.send(msg.to_string()); + } + } + } + } else if evt.method == "Page.screencastFrame" { if let Some(sid) = evt.params.get("sessionId").and_then(|v| v.as_i64()) { let _ = client_arc.send_command( "Page.screencastFrameAck", @@ -545,7 +859,6 @@ async fn cdp_event_loop( ).await; } - // Broadcast frame to WS clients if let Some(data) = evt.params.get("data").and_then(|v| v.as_str()) { let meta = evt.params.get("metadata"); let msg = json!({ @@ -561,20 +874,73 @@ async fn cdp_event_loop( "timestamp": meta.and_then(|m| m.get("timestamp")).and_then(|v| v.as_u64()).unwrap_or(0), } }); + let msg_str = msg.to_string(); + { + let mut lf = last_frame.write().await; + *lf = Some(msg_str.clone()); + } + let _ = frame_tx.send(msg_str); + } + } else if evt.method == "Runtime.consoleAPICalled" { + let level = evt.params.get("type") + .and_then(|v| v.as_str()) + .unwrap_or("log"); + let text = evt.params.get("args") + .and_then(|v| v.as_array()) + .map(|args| { + args.iter() + .filter_map(|arg| { + arg.get("value") + .map(|v| match v { + Value::String(s) => s.clone(), + other => other.to_string(), + }) + .or_else(|| arg.get("description").and_then(|v| v.as_str()).map(|s| s.to_string())) + }) + .collect::>() + .join(" ") + }) + .unwrap_or_default(); + if !text.is_empty() { + let msg = json!({ + "type": "console", + "level": level, + "text": text, + "timestamp": timestamp_ms(), + }); let _ = frame_tx.send(msg.to_string()); } + } else if evt.method == "Runtime.exceptionThrown" { + let text = evt.params.get("exceptionDetails") + .and_then(|d| { + d.get("exception") + .and_then(|e| e.get("description").and_then(|v| v.as_str())) + .or_else(|| d.get("text").and_then(|v| v.as_str())) + }) + .unwrap_or("Unknown error"); + let line = evt.params.get("exceptionDetails") + .and_then(|d| d.get("lineNumber").and_then(|v| v.as_i64())); + let column = evt.params.get("exceptionDetails") + .and_then(|d| d.get("columnNumber").and_then(|v| v.as_i64())); + let msg = json!({ + "type": "page_error", + "text": text, + "line": line, + "column": column, + "timestamp": timestamp_ms(), + }); + let _ = frame_tx.send(msg.to_string()); } } Err(broadcast::error::RecvError::Lagged(_)) => continue, Err(broadcast::error::RecvError::Closed) => break, } } - // Also check for notify (client count change or CDP client change) + // Also check for notify (client count change, CDP client change, session switch, or viewport change) _ = client_notify.notified() => { let count = *client_count.lock().await; - let session_id = cdp_session_id.read().await.clone(); + let new_session_id = cdp_session_id.read().await.clone(); if count == 0 { - // All WS clients gone — stop screencast let _ = client_arc .send_command_no_params("Page.stopScreencast", session_id.as_deref()) .await; @@ -582,7 +948,6 @@ async fn cdp_event_loop( *sc = false; break; } - // Check if CDP client changed (browser closed/relaunched) let client_changed = { let guard = client_slot.read().await; let same = guard @@ -590,14 +955,17 @@ async fn cdp_event_loop( .is_some_and(|c| Arc::ptr_eq(c, &client_arc)); !same }; - if client_changed { - // CDP client changed — stop our screencast and restart loop + let session_changed = new_session_id != session_id; + let new_vw = *viewport_width.lock().await; + let new_vh = *viewport_height.lock().await; + let viewport_changed = new_vw != vw || new_vh != vh; + if client_changed || session_changed || viewport_changed { + // Stop screencast, restart loop to pick up new settings let _ = client_arc .send_command_no_params("Page.stopScreencast", session_id.as_deref()) .await; let mut sc = screencasting.lock().await; *sc = false; - // Re-notify so we pick up the new client in the outer loop client_notify.notify_one(); break; } @@ -662,6 +1030,7 @@ async fn handle_client_message(msg: &str, client: &CdpClient, session_id: Option "key": parsed.get("key"), "code": parsed.get("code"), "text": parsed.get("text"), + "windowsVirtualKeyCode": parsed.get("windowsVirtualKeyCode").and_then(|v| v.as_i64()).unwrap_or(0), "modifiers": parsed.get("modifiers").and_then(|v| v.as_i64()).unwrap_or(0), })), session_id, @@ -688,6 +1057,342 @@ async fn handle_client_message(msg: &str, client: &CdpClient, session_id: Option } } +const CORS_HEADERS: &str = "Access-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET, POST, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type\r\n"; + +/// Serve an HTTP request for dashboard static files or the fallback page. +async fn handle_http_request( + mut stream: tokio::net::TcpStream, + request: &str, + peeked_len: usize, + dashboard_dir: Option<&Path>, + last_tabs: &Arc>>, + last_engine: &Arc>, + session_name: &str, +) { + let mut discard = vec![0u8; peeked_len]; + let _ = stream.read_exact(&mut discard).await; + + let first_line = request.lines().next().unwrap_or(""); + let method = first_line.split_whitespace().next().unwrap_or("GET"); + let path = first_line.split_whitespace().nth(1).unwrap_or("/"); + + // Handle CORS preflight + if method == "OPTIONS" { + let response = format!( + "HTTP/1.1 204 No Content\r\n{CORS_HEADERS}Access-Control-Max-Age: 86400\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ); + let _ = stream.write_all(response.as_bytes()).await; + return; + } + + // Handle POST /api/sessions (spawn new session) + if method == "POST" && path == "/api/sessions" { + let body_str = extract_http_body(request).unwrap_or(""); + let result = spawn_session(body_str).await; + let (status, resp_body) = match result { + Ok(msg) => ("200 OK", msg), + Err(e) => ( + "400 Bad Request", + format!( + r#"{{"success":false,"error":{}}}"#, + serde_json::to_string(&e).unwrap_or_else(|_| format!("\"{}\"", e)) + ), + ), + }; + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n{CORS_HEADERS}\r\n", + resp_body.len() + ); + let _ = stream.write_all(response.as_bytes()).await; + let _ = stream.write_all(resp_body.as_bytes()).await; + return; + } + + // Handle POST /api/command + if method == "POST" && path == "/api/command" { + let body = extract_http_body(request).unwrap_or(""); + let result = relay_command_to_daemon(session_name, body).await; + let (status, resp_body) = match result { + Ok(resp) => ("200 OK", resp), + Err(e) => ( + "502 Bad Gateway", + format!( + r#"{{"success":false,"error":{}}}"#, + serde_json::to_string(&e).unwrap_or_else(|_| format!("\"{}\"", e)) + ), + ), + }; + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n{CORS_HEADERS}\r\n", + resp_body.len() + ); + let _ = stream.write_all(response.as_bytes()).await; + let _ = stream.write_all(resp_body.as_bytes()).await; + return; + } + + let (status, content_type, body): (&str, &str, Vec) = if path == "/api/sessions" { + ( + "200 OK", + "application/json; charset=utf-8", + discover_sessions().into_bytes(), + ) + } else if path == "/api/tabs" { + let tabs = last_tabs.read().await; + ( + "200 OK", + "application/json; charset=utf-8", + serde_json::to_string(&*tabs) + .unwrap_or_else(|_| "[]".to_string()) + .into_bytes(), + ) + } else if path == "/api/status" { + let engine = last_engine.read().await; + ( + "200 OK", + "application/json; charset=utf-8", + format!(r#"{{"engine":"{}"}}"#, *engine).into_bytes(), + ) + } else { + match dashboard_dir { + Some(dir) => serve_static_file(dir, path), + None => ( + "200 OK", + "text/html; charset=utf-8", + DASHBOARD_NOT_INSTALLED_HTML.as_bytes().to_vec(), + ), + } + }; + + let response = format!( + "HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n{CORS_HEADERS}\r\n", + status, + content_type, + body.len() + ); + let _ = stream.write_all(response.as_bytes()).await; + let _ = stream.write_all(&body).await; +} + +/// Extract the HTTP body from a raw request string (headers + body in one buffer). +fn extract_http_body(request: &str) -> Option<&str> { + // Body starts after the first blank line (\r\n\r\n) + request + .find("\r\n\r\n") + .map(|pos| &request[pos + 4..]) + .or_else(|| request.find("\n\n").map(|pos| &request[pos + 2..])) +} + +/// Relay a command JSON body to the daemon's Unix socket and return the response. +async fn relay_command_to_daemon(session_name: &str, body: &str) -> Result { + let mut cmd: Value = serde_json::from_str(body).map_err(|e| format!("Invalid JSON: {}", e))?; + + if cmd.get("id").is_none() { + let id = format!( + "dash-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + ); + cmd["id"] = json!(id); + } + + let socket_path = get_socket_dir().join(format!("{}.sock", session_name)); + + let stream = tokio::net::UnixStream::connect(&socket_path) + .await + .map_err(|e| format!("Failed to connect to daemon: {}", e))?; + + let (reader, mut writer) = tokio::io::split(stream); + + let mut json_str = serde_json::to_string(&cmd).map_err(|e| e.to_string())?; + json_str.push('\n'); + + writer + .write_all(json_str.as_bytes()) + .await + .map_err(|e| format!("Failed to send command: {}", e))?; + + let mut buf_reader = tokio::io::BufReader::new(reader); + let mut response_line = String::new(); + buf_reader + .read_line(&mut response_line) + .await + .map_err(|e| format!("Failed to read response: {}", e))?; + + Ok(response_line.trim().to_string()) +} + +fn serve_static_file(dir: &Path, url_path: &str) -> (&'static str, &'static str, Vec) { + let clean = url_path.trim_start_matches('/'); + let file_path = if clean.is_empty() { + dir.join("index.html") + } else { + let joined = dir.join(clean); + if joined.is_file() { + joined + } else { + dir.join("index.html") + } + }; + + match std::fs::read(&file_path) { + Ok(content) => { + let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or(""); + let ct = match ext { + "html" => "text/html; charset=utf-8", + "js" => "application/javascript; charset=utf-8", + "css" => "text/css; charset=utf-8", + "json" => "application/json; charset=utf-8", + "svg" => "image/svg+xml", + "png" => "image/png", + "ico" => "image/x-icon", + _ => "application/octet-stream", + }; + ("200 OK", ct, content) + } + Err(_) => ( + "404 Not Found", + "text/html; charset=utf-8", + b"

404 Not Found

".to_vec(), + ), + } +} + +const DASHBOARD_NOT_INSTALLED_HTML: &str = r#" + +agent-browser + + + +
+

Dashboard not installed

+

Run agent-browser dashboard install to download the dashboard.

+
+ +"#; + +/// Discover all active streaming sessions by reading `*.stream` files. +/// Stale entries (dead process) are removed on the fly. +fn discover_sessions() -> String { + let dir = get_socket_dir(); + let mut sessions = Vec::new(); + + if let Ok(entries) = std::fs::read_dir(&dir) { + for entry in entries.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if let Some(session) = name_str.strip_suffix(".stream") { + if let Ok(port_str) = std::fs::read_to_string(entry.path()) { + if let Ok(port) = port_str.trim().parse::() { + let pid_path = dir.join(format!("{}.pid", session)); + if is_process_alive(&pid_path) { + let engine_path = dir.join(format!("{}.engine", session)); + let engine = std::fs::read_to_string(&engine_path) + .ok() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "chrome".to_string()); + + let extensions = read_extensions_metadata(&dir, session); + + let mut entry = json!({ + "session": session, + "port": port, + "engine": engine.trim(), + }); + if !extensions.is_empty() { + entry["extensions"] = json!(extensions); + } + sessions.push(entry); + } else { + let _ = std::fs::remove_file(entry.path()); + } + } + } + } + } + } + + serde_json::to_string(&sessions).unwrap_or_else(|_| "[]".to_string()) +} + +fn read_extensions_metadata(dir: &std::path::Path, session: &str) -> Vec { + let ext_path = dir.join(format!("{}.extensions", session)); + let ext_str = match std::fs::read_to_string(&ext_path) { + Ok(s) => s, + Err(_) => return Vec::new(), + }; + + ext_str + .split(',') + .map(|p| p.trim()) + .filter(|p| !p.is_empty()) + .filter_map(|path| { + let manifest_path = std::path::Path::new(path).join("manifest.json"); + let manifest_str = std::fs::read_to_string(&manifest_path).ok()?; + let manifest: Value = serde_json::from_str(&manifest_str).ok()?; + + let name = manifest + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(); + let version = manifest + .get("version") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let description = manifest + .get("description") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let mut ext = json!({ + "name": name, + "version": version, + "path": path, + }); + if let Some(desc) = description { + ext["description"] = json!(desc); + } + Some(ext) + }) + .collect() +} + +fn is_process_alive(pid_path: &Path) -> bool { + let pid_str = match std::fs::read_to_string(pid_path) { + Ok(s) => s, + Err(_) => return false, + }; + let pid: u32 = match pid_str.trim().parse() { + Ok(p) => p, + Err(_) => return false, + }; + #[cfg(unix)] + { + unsafe { libc::kill(pid as i32, 0) == 0 } + } + #[cfg(not(unix))] + { + let _ = pid; + // On non-Unix, just check if the pid file exists + true + } +} + +fn timestamp_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + pub fn is_allowed_origin(origin: Option<&str>) -> bool { match origin { None => true, @@ -751,6 +1456,277 @@ pub async fn ack_screencast_frame( Ok(()) } +/// Standalone dashboard HTTP server (no browser, no WebSocket streaming). +/// Serves static files and `/api/sessions` for session discovery. +pub async fn run_dashboard_server(port: u16) { + let addr = format!("127.0.0.1:{}", port); + let listener = match TcpListener::bind(&addr).await { + Ok(l) => l, + Err(e) => { + eprintln!("Failed to bind dashboard server on {}: {}", addr, e); + return; + } + }; + + let dashboard_dir = { + let dir = get_dashboard_dir(); + if dir.join("index.html").exists() { + Some(Arc::from(dir)) + } else { + None + } + }; + + loop { + let Ok((stream, _addr)) = listener.accept().await else { + break; + }; + let dash_dir = dashboard_dir.clone(); + tokio::spawn(async move { + handle_dashboard_connection(stream, dash_dir).await; + }); + } +} + +async fn handle_dashboard_connection( + mut stream: tokio::net::TcpStream, + dashboard_dir: Option>, +) { + use tokio::io::AsyncReadExt; + + let mut buf = vec![0u8; 8192]; + let n = match stream.read(&mut buf).await { + Ok(n) if n > 0 => n, + _ => return, + }; + + let first_line = std::str::from_utf8(&buf[..n]) + .unwrap_or("") + .lines() + .next() + .unwrap_or("") + .to_string(); + let method = first_line.split_whitespace().next().unwrap_or("GET"); + let path = first_line.split_whitespace().nth(1).unwrap_or("/"); + + if method == "OPTIONS" { + let response = format!( + "HTTP/1.1 204 No Content\r\n{CORS_HEADERS}Access-Control-Max-Age: 86400\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ); + let _ = stream.write_all(response.as_bytes()).await; + return; + } + + if method == "POST" && (path == "/api/sessions" || path == "/api/exec" || path == "/api/kill") { + let body_str = read_post_body(&mut stream, &buf, n).await; + let result = if path == "/api/exec" { + exec_cli(&body_str).await + } else if path == "/api/kill" { + kill_session(&body_str).await + } else { + spawn_session(&body_str).await + }; + let (status, resp_body) = match result { + Ok(msg) => ("200 OK", msg), + Err(e) => ( + "400 Bad Request", + format!( + r#"{{"success":false,"error":{}}}"#, + serde_json::to_string(&e).unwrap_or_else(|_| format!("\"{}\"", e)) + ), + ), + }; + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n{CORS_HEADERS}\r\n", + resp_body.len() + ); + let _ = stream.write_all(response.as_bytes()).await; + let _ = stream.write_all(resp_body.as_bytes()).await; + return; + } + + let dir_ref = dashboard_dir.as_deref(); + let (status, content_type, body): (&str, &str, Vec) = if path == "/api/sessions" { + ( + "200 OK", + "application/json; charset=utf-8", + discover_sessions().into_bytes(), + ) + } else { + match dir_ref { + Some(dir) => serve_static_file(dir, path), + None => ( + "200 OK", + "text/html; charset=utf-8", + DASHBOARD_NOT_INSTALLED_HTML.as_bytes().to_vec(), + ), + } + }; + + let response = format!( + "HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n{CORS_HEADERS}\r\n", + status, + content_type, + body.len() + ); + let _ = stream.write_all(response.as_bytes()).await; + let _ = stream.write_all(&body).await; +} + +/// Read the full POST body from a request. First checks if the body is already +/// present in the initial read buffer; if not, reads remaining bytes based on +/// Content-Length. +async fn read_post_body(stream: &mut tokio::net::TcpStream, initial: &[u8], n: usize) -> String { + use tokio::io::AsyncReadExt; + let header_str = String::from_utf8_lossy(&initial[..n]); + let body = extract_http_body(&header_str).unwrap_or("").to_string(); + + if !body.is_empty() { + return body; + } + + let cl = header_str + .lines() + .find_map(|l| { + let lower = l.to_lowercase(); + lower + .strip_prefix("content-length:") + .map(|v| v.trim().parse::().unwrap_or(0)) + }) + .unwrap_or(0); + + if cl > 0 { + let mut remaining = vec![0u8; cl]; + if stream.read_exact(&mut remaining).await.is_ok() { + return String::from_utf8_lossy(&remaining).to_string(); + } + } + + String::new() +} + +/// Execute an agent-browser CLI command and return JSON with stdout/stderr. +async fn exec_cli(body: &str) -> Result { + let parsed: Value = serde_json::from_str(body).map_err(|e| format!("Invalid JSON: {}", e))?; + let args: Vec = parsed + .get("args") + .and_then(|v| v.as_array()) + .ok_or("Missing \"args\" array")? + .iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect(); + + if args.is_empty() { + return Err("Empty args array".to_string()); + } + + let exe = std::env::current_exe().map_err(|e| format!("Cannot resolve executable: {}", e))?; + + let mut cmd = tokio::process::Command::new(&exe); + cmd.args(&args) + .arg("--json") + .env_remove("AGENT_BROWSER_DASHBOARD") + .env_remove("AGENT_BROWSER_DASHBOARD_PORT") + .env_remove("AGENT_BROWSER_STREAM_PORT"); + + let output = cmd + .output() + .await + .map_err(|e| format!("Failed to execute: {}", e))?; + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + + Ok(json!({ + "success": output.status.success(), + "exit_code": output.status.code(), + "stdout": stdout, + "stderr": stderr, + }) + .to_string()) +} + +/// Kill a session daemon by sending SIGTERM, then SIGKILL if it survives. +/// Cleans up socket/pid/stream/engine files afterward. +async fn kill_session(body: &str) -> Result { + let parsed: Value = serde_json::from_str(body).map_err(|e| format!("Invalid JSON: {}", e))?; + let session = parsed + .get("session") + .and_then(|v| v.as_str()) + .ok_or("Missing \"session\" field")?; + + if session.is_empty() || session.len() > 64 { + return Err("Session name must be 1-64 characters".to_string()); + } + + let dir = get_socket_dir(); + let pid_path = dir.join(format!("{}.pid", session)); + + let pid_str = std::fs::read_to_string(&pid_path) + .map_err(|_| format!("No PID file for session '{}'", session))?; + let pid: u32 = pid_str + .trim() + .parse() + .map_err(|_| format!("Invalid PID in file: {}", pid_str.trim()))?; + + #[cfg(unix)] + { + unsafe { + libc::kill(pid as i32, libc::SIGTERM); + } + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + if unsafe { libc::kill(pid as i32, 0) } == 0 { + unsafe { + libc::kill(pid as i32, libc::SIGKILL); + } + } + } + + for ext in &["pid", "sock", "stream", "engine", "extensions"] { + let _ = std::fs::remove_file(dir.join(format!("{}.{}", session, ext))); + } + + Ok(json!({ "success": true, "killed_pid": pid }).to_string()) +} + +/// Spawn a new session daemon from a POST /api/sessions request. +async fn spawn_session(body: &str) -> Result { + let parsed: Value = serde_json::from_str(body).map_err(|e| format!("Invalid JSON: {}", e))?; + let session = parsed + .get("session") + .and_then(|v| v.as_str()) + .ok_or("Missing \"session\" field")?; + + if session.is_empty() || session.len() > 64 { + return Err("Session name must be 1-64 characters".to_string()); + } + + let exe = std::env::current_exe().map_err(|e| format!("Cannot resolve executable: {}", e))?; + + let mut cmd = tokio::process::Command::new(&exe); + cmd.arg("open") + .arg("about:blank") + .arg("--session") + .arg(session); + + cmd.stdout(std::process::Stdio::null()); + cmd.stderr(std::process::Stdio::null()); + + let status = cmd + .status() + .await + .map_err(|e| format!("Failed to spawn session: {}", e))?; + + if status.success() { + Ok(format!( + r#"{{"success":true,"session":{}}}"#, + serde_json::to_string(session).unwrap_or_default() + )) + } else { + Err(format!("Session process exited with {}", status)) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/cli/src/output.rs b/cli/src/output.rs index f0cf9ab..e357cff 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -1596,12 +1596,15 @@ Examples: r##" agent-browser close - Close the browser -Usage: agent-browser close +Usage: agent-browser close [options] Closes the browser instance for the current session. Aliases: quit, exit +Options: + --all Close all active sessions + Global Options: --json Output as JSON --session Use specific session @@ -1609,6 +1612,7 @@ Global Options: Examples: agent-browser close agent-browser close --session mysession + agent-browser close --all "## } @@ -2388,6 +2392,40 @@ Examples: "## } + // === Dashboard === + "dashboard" => { + r##" +agent-browser dashboard - Observability dashboard + +Usage: agent-browser dashboard [start|stop|install] [options] + +Manage the observability dashboard, a local web UI that shows live +browser viewports and command activity feeds for all sessions. + +Subcommands: + start [--port ] Start the dashboard server (default port: 4848) + stop Stop the dashboard server + install Download and install the dashboard to ~/.agent-browser/dashboard/ + +Running 'agent-browser dashboard' with no subcommand is equivalent to 'dashboard start'. + +The dashboard runs as a standalone background process, independent of +browser sessions. All sessions automatically stream to the dashboard. + +Options: + --port Port for the dashboard server (default: 4848) + +Global Options: + --json Output as JSON + +Examples: + agent-browser dashboard install + agent-browser dashboard start + agent-browser dashboard start --port 8080 + agent-browser dashboard stop +"## + } + // === Connect === "connect" => { r##" @@ -2446,8 +2484,8 @@ Notes: - 'stream enable' creates the WebSocket server. - WebSocket clients trigger frame streaming automatically. - 'screencast_start' and 'screencast_stop' still control explicit CDP screencasts. - - AGENT_BROWSER_STREAM_PORT only affects daemon startup; use 'stream enable' - for sessions that are already running. + - Streaming is always enabled. Set AGENT_BROWSER_STREAM_PORT to bind to a + specific port instead of the default OS-assigned port. Global Options: --json Output as JSON @@ -2652,7 +2690,7 @@ Core Commands: snapshot Accessibility tree with refs (for AI) eval Run JavaScript connect Connect to browser via CDP - close Close browser + close [--all] Close browser (--all closes every session) Navigation: back Go back @@ -2729,10 +2767,16 @@ Sessions: session Show current session name session list List active sessions +Dashboard: + dashboard [start] Start the dashboard server (default port: 4848) + dashboard start --port Start on a specific port + dashboard stop Stop the dashboard server + Setup: install Install browser binaries install --with-deps Also install system dependencies (Linux) upgrade Upgrade to the latest version + dashboard install Install the observability dashboard Snapshot Options: -i, --interactive Only interactive elements @@ -2827,7 +2871,7 @@ Environment: AGENT_BROWSER_SESSION_NAME Auto-save/load state persistence name AGENT_BROWSER_STATE_EXPIRE_DAYS Auto-delete saved states older than N days (default: 30) AGENT_BROWSER_ENCRYPTION_KEY 64-char hex key for AES-256-GCM session encryption - AGENT_BROWSER_STREAM_PORT Enable WebSocket streaming on port (e.g., 9223) + AGENT_BROWSER_STREAM_PORT Override WebSocket streaming port (default: OS-assigned) AGENT_BROWSER_IDLE_TIMEOUT_MS Auto-shutdown daemon after N ms of inactivity (disabled by default) AGENT_BROWSER_IOS_DEVICE Default iOS device name AGENT_BROWSER_IOS_UDID Default iOS device UDID diff --git a/docs/src/app/commands/page.mdx b/docs/src/app/commands/page.mdx index dab13ad..a3e1d05 100644 --- a/docs/src/app/commands/page.mdx +++ b/docs/src/app/commands/page.mdx @@ -34,6 +34,7 @@ agent-browser stream enable [--port ] # Start runtime WebSocket streaming agent-browser stream status # Show runtime streaming state and bound port agent-browser stream disable # Stop runtime WebSocket streaming agent-browser close # Close browser (aliases: quit, exit) +agent-browser close --all # Close all active sessions ``` ## Get info @@ -240,7 +241,7 @@ agent-browser stream status # Show enabled state, port, browser connec agent-browser stream disable # Stop runtime streaming and remove the .stream metadata file ``` -Use `stream enable` for sessions that are already running. If you need streaming from daemon startup, set `AGENT_BROWSER_STREAM_PORT` before the first command in that session. +Streaming is enabled automatically for all sessions. Use these commands to check status, re-enable on a specific port, or disable streaming. ## Debug @@ -325,6 +326,15 @@ agent-browser session # Show current session name agent-browser session list # List active sessions ``` +## Dashboard + +```bash +agent-browser dashboard [start] # Start the dashboard server (default port: 4848) +agent-browser dashboard start --port # Start on a specific port +agent-browser dashboard stop # Stop the dashboard server +agent-browser dashboard install # Install the dashboard files +``` + ## Navigation ```bash diff --git a/docs/src/app/configuration/page.mdx b/docs/src/app/configuration/page.mdx index fd61271..36ca990 100644 --- a/docs/src/app/configuration/page.mdx +++ b/docs/src/app/configuration/page.mdx @@ -173,7 +173,7 @@ These environment variables configure additional daemon and runtime behavior:
- + diff --git a/docs/src/app/observe/page.mdx b/docs/src/app/observe/page.mdx new file mode 100644 index 0000000..4285454 --- /dev/null +++ b/docs/src/app/observe/page.mdx @@ -0,0 +1,130 @@ +# Observability Dashboard + +Monitor agent-browser sessions in real time with a local web dashboard showing a live browser viewport and command activity feed. + +## Install + +Download the dashboard once: + +```bash +agent-browser dashboard install +``` + +This downloads the dashboard to `~/.agent-browser/dashboard/` and is served directly by the daemon when streaming is enabled. + +## Usage + +Start the dashboard server and open any session -- it appears automatically: + +```bash +agent-browser dashboard start +agent-browser open example.com +``` + +Then open `http://localhost:4848` in your browser to see the live dashboard. + +All sessions automatically stream to the dashboard. No extra flags are needed. + +### Custom stream port + +By default each session binds its WebSocket stream server to an OS-assigned port. To use a specific port, set the `AGENT_BROWSER_STREAM_PORT` environment variable: + +```bash +AGENT_BROWSER_STREAM_PORT=9223 agent-browser open example.com +``` + +You can also use the runtime commands to control streaming on a running session: + +```bash +agent-browser stream enable --port 9223 +agent-browser stream status +agent-browser stream disable +``` + +## Dashboard features + +The dashboard is a single-page web app with three areas: + +
AGENT_BROWSER_ENCRYPTION_KEY64-char hex key for AES-256-GCM session encryption.(none)
AGENT_BROWSER_EXTENSIONSComma-separated browser extension paths. Extensions work in both headed and headless mode.(none)
AGENT_BROWSER_HEADEDShow browser window instead of running headless (1 to enable).(disabled)
AGENT_BROWSER_STREAM_PORTEnable WebSocket streaming at daemon startup on the specified port (e.g., 9223). For an already-running session, use agent-browser stream enable.(disabled)
AGENT_BROWSER_STREAM_PORTOverride the WebSocket streaming port. By default, an OS-assigned port is used. Set this to bind to a specific port (e.g., 9223).OS-assigned
AGENT_BROWSER_IDLE_TIMEOUT_MSAuto-shutdown the daemon after N ms of inactivity (no commands received). Useful for ephemeral environments.(disabled)
AGENT_BROWSER_IOS_DEVICEDefault iOS device name for the ios provider.(none)
AGENT_BROWSER_IOS_UDIDDefault iOS device UDID for the ios provider.(none)
+ + + + + + + + + + + + + + + + + + + + +
AreaDescription
Live viewportReal-time JPEG frames from the browser, rendered to a canvas element
Activity feedChronological stream of commands, results, and console messages with expandable details
Status barConnection status, viewport dimensions, and WebSocket endpoint
+ +## WebSocket protocol + +The dashboard connects to the same WebSocket endpoint used by [Streaming](/streaming), with additional message types for observability: + +### Command events + +Sent when a command begins executing: + +```json +{ + "type": "command", + "action": "click", + "id": "r123", + "params": { "selector": "@e5" }, + "timestamp": 1711367000000 +} +``` + +### Result events + +Sent when a command finishes: + +```json +{ + "type": "result", + "id": "r123", + "action": "click", + "success": true, + "data": {}, + "duration_ms": 45, + "timestamp": 1711367000045 +} +``` + +### Console events + +Sent when the browser logs to the console: + +```json +{ + "type": "console", + "level": "log", + "text": "Page loaded", + "timestamp": 1711367000100 +} +``` + +These are in addition to the existing `frame`, `status`, and `error` message types documented on the [Streaming](/streaming) page. + +## Architecture + +The dashboard is a Next.js static export (`output: 'export'`) that produces plain HTML, CSS, and JS. It lives at `packages/dashboard/` in the monorepo and is built with: + +```bash +pnpm build:dashboard +``` + +The built files are served by the daemon's stream server on the same port used for WebSocket connections. Plain HTTP requests serve the dashboard, while WebSocket upgrade requests are handled as before. + +When the dashboard is not installed, visiting the HTTP endpoint shows instructions to run `agent-browser dashboard install`. diff --git a/docs/src/app/streaming/page.mdx b/docs/src/app/streaming/page.mdx index 221148d..a507d51 100644 --- a/docs/src/app/streaming/page.mdx +++ b/docs/src/app/streaming/page.mdx @@ -3,27 +3,25 @@ Stream the browser viewport via WebSocket for live preview or "pair browsing" where a human can watch and interact alongside an AI agent. -## Enable streaming +## Streaming -For an already-running session, enable streaming at runtime: +Every session automatically starts a WebSocket stream server on an OS-assigned port. The server streams viewport frames and accepts input events (mouse, keyboard, touch). -```bash -agent-browser stream enable -agent-browser stream status -agent-browser stream disable -``` - -`stream enable` binds an available localhost port automatically unless you pass `--port `. `stream status` returns the enabled state, active port, browser connection state, and whether screencasting is active. `stream disable` tears the server down and removes the session's `.stream` metadata file. - -If you want the WebSocket server to exist from daemon startup, set `AGENT_BROWSER_STREAM_PORT` before the first command in that session: +To bind to a specific port, set `AGENT_BROWSER_STREAM_PORT`: ```bash AGENT_BROWSER_STREAM_PORT=9223 agent-browser open example.com ``` -The environment variable only affects daemon startup. For sessions that are already running, use `agent-browser stream enable` instead. +You can also manage streaming at runtime: -Once enabled, the server streams viewport frames and accepts input events (mouse, keyboard, touch). +```bash +agent-browser stream status # Show streaming state and bound port +agent-browser stream enable --port 9223 # Re-enable on a specific port +agent-browser stream disable # Stop streaming for the session +``` + +`stream status` returns the enabled state, active port, browser connection state, and whether screencasting is active. `stream disable` tears the server down and removes the session's `.stream` metadata file. ## Runtime status response diff --git a/package.json b/package.json index 52d0f22..c7d9f1f 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ "postinstall": "node scripts/postinstall.js", "changeset": "changeset", "ci:version": "changeset version && pnpm run version:sync && pnpm install --no-frozen-lockfile", - "ci:publish": "pnpm run version:sync && changeset publish" + "ci:publish": "pnpm run version:sync && changeset publish", + "build:dashboard": "cd packages/dashboard && pnpm build" }, "keywords": [ "browser", diff --git a/packages/dashboard/components.json b/packages/dashboard/components.json new file mode 100644 index 0000000..2a42785 --- /dev/null +++ b/packages/dashboard/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "radix-nova", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/packages/dashboard/next-env.d.ts b/packages/dashboard/next-env.d.ts new file mode 100644 index 0000000..9edff1c --- /dev/null +++ b/packages/dashboard/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/packages/dashboard/next.config.ts b/packages/dashboard/next.config.ts new file mode 100644 index 0000000..b82bf84 --- /dev/null +++ b/packages/dashboard/next.config.ts @@ -0,0 +1,9 @@ +import type { NextConfig } from "next"; + +const config: NextConfig = { + output: "export", + images: { unoptimized: true }, + devIndicators: false, +}; + +export default config; diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json new file mode 100644 index 0000000..8bec845 --- /dev/null +++ b/packages/dashboard/package.json @@ -0,0 +1,32 @@ +{ + "name": "dashboard", + "version": "0.22.2", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "jotai": "^2.19.0", + "lucide-react": "^1.7.0", + "next": "16.1.1", + "radix-ui": "^1.4.3", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-resizable-panels": "^4.7.6", + "shadcn": "^4.1.0", + "tailwind-merge": "^3.5.0", + "tw-animate-css": "^1.4.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.1.3", + "@types/node": "^22.14.0", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "tailwindcss": "^4.1.3", + "typescript": "^5.8.3" + } +} diff --git a/packages/dashboard/postcss.config.mjs b/packages/dashboard/postcss.config.mjs new file mode 100644 index 0000000..61e3684 --- /dev/null +++ b/packages/dashboard/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/packages/dashboard/public/lightpanda.svg b/packages/dashboard/public/lightpanda.svg new file mode 100644 index 0000000..e66b42b --- /dev/null +++ b/packages/dashboard/public/lightpanda.svg @@ -0,0 +1 @@ +IconLightpanda \ No newline at end of file diff --git a/packages/dashboard/src/app/globals.css b/packages/dashboard/src/app/globals.css new file mode 100644 index 0000000..89ec4c2 --- /dev/null +++ b/packages/dashboard/src/app/globals.css @@ -0,0 +1,188 @@ +@import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; + +@custom-variant dark (&:is(.dark *)); + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); + --success: #22c55e; + --warning: #eab308; +} + +.dark { + --background: #0a0a0a; + --foreground: #e5e5e5; + --card: #141414; + --card-foreground: #e5e5e5; + --popover: #141414; + --popover-foreground: #e5e5e5; + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: #1a1a1a; + --secondary-foreground: #e5e5e5; + --muted: #1a1a1a; + --muted-foreground: #737373; + --accent: #1a1a1a; + --accent-foreground: #e5e5e5; + --destructive: #ef4444; + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --sidebar: #141414; + --sidebar-foreground: #e5e5e5; + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: #1a1a1a; + --sidebar-accent-foreground: #e5e5e5; + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); + --success: #22c55e; + --warning: #eab308; +} + +body { + font-family: system-ui, -apple-system, sans-serif; + margin: 0; + overflow: hidden; +} + +::-webkit-scrollbar { + width: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 3px; +} + +@theme inline { + --font-heading: var(--font-sans); + --font-sans: var(--font-sans); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --color-foreground: var(--foreground); + --color-background: var(--background); + --color-success: var(--success); + --color-warning: var(--warning); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + html { + @apply font-sans; + } +} + +button { + cursor: pointer; +} + +.json-key { + color: #d6409f; +} +.json-string { + color: #067a6e; +} +.json-number, +.json-bool, +.json-null { + color: #0070c0; +} +.json-punct { + color: #6b7280; +} + +:is(.dark *).json-key { + color: #ff4d8d; +} +:is(.dark *).json-string { + color: #00ca50; +} +:is(.dark *).json-number, +:is(.dark *).json-bool, +:is(.dark *).json-null { + color: #47a8ff; +} +:is(.dark *).json-punct { + color: #a1a1a1; +} diff --git a/packages/dashboard/src/app/layout.tsx b/packages/dashboard/src/app/layout.tsx new file mode 100644 index 0000000..04185fc --- /dev/null +++ b/packages/dashboard/src/app/layout.tsx @@ -0,0 +1,29 @@ +import type { Metadata } from "next"; +import "./globals.css"; +import { Geist } from "next/font/google"; +import { cn } from "@/lib/utils"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { JotaiProvider } from "@/store/provider"; + +const geist = Geist({ subsets: ["latin"], variable: "--font-sans" }); + +export const metadata: Metadata = { + title: "agent-browser", + description: "Observability dashboard for agent-browser", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + + {children} + + + + ); +} diff --git a/packages/dashboard/src/app/page.tsx b/packages/dashboard/src/app/page.tsx new file mode 100644 index 0000000..84bb6d6 --- /dev/null +++ b/packages/dashboard/src/app/page.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { useAtomValue } from "jotai/react"; +import { activePortAtom } from "@/store/sessions"; +import { useSessionsSync } from "@/store/sessions"; +import { useStreamSync, hasConsoleErrorsAtom, consoleLogsAtom } from "@/store/stream"; +import { useActivitySync } from "@/store/activity"; +import { activeExtensionsAtom } from "@/store/sessions"; +import { useMediaQuery } from "@/hooks/use-media-query"; +import { Viewport } from "@/components/viewport"; +import { ActivityFeed } from "@/components/activity-feed"; +import { ConsolePanel } from "@/components/console-panel"; +import { StoragePanel } from "@/components/storage-panel"; +import { ExtensionsPanel } from "@/components/extensions-panel"; +import { NetworkPanel } from "@/components/network-panel"; +import { SessionTree } from "@/components/session-tree"; +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from "@/components/ui/resizable"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; + +export default function DashboardPage() { + const activePort = useAtomValue(activePortAtom); + useStreamSync(activePort); + useSessionsSync(); + useActivitySync(); + + const isDesktop = useMediaQuery("(min-width: 768px)"); + const hasConsoleErrors = useAtomValue(hasConsoleErrorsAtom); + const activeExtensions = useAtomValue(activeExtensionsAtom); + + const sidePanel = ( + +
+ + Activity + + Console + {hasConsoleErrors && ( + + )} + + Network + Storage + + Extensions + {activeExtensions.length > 0 && ( + {activeExtensions.length} + )} + + +
+ + + + + + + + + + + + + + + +
+ ); + + if (isDesktop) { + return ( +
+ + + + + + + + + + + {sidePanel} + + +
+ ); + } + + return ( +
+ +
+ + Sessions + Viewport + Activity + +
+ + + + + + + + {sidePanel} + +
+
+ ); +} diff --git a/packages/dashboard/src/components/activity-feed.tsx b/packages/dashboard/src/components/activity-feed.tsx new file mode 100644 index 0000000..e9b8de6 --- /dev/null +++ b/packages/dashboard/src/components/activity-feed.tsx @@ -0,0 +1,247 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useAtomValue, useSetAtom } from "jotai/react"; +import type { ActivityEvent } from "@/types"; +import { combinedEventsAtom, persistActivityAtom, togglePersistAtom, clearActivityAtom } from "@/store/activity"; +import { Bookmark, Trash2 } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Separator } from "@/components/ui/separator"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { JsonSyntax } from "@/components/json-syntax"; + +function formatTime(ts: number): string { + const d = new Date(ts); + return d.toLocaleTimeString("en-US", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} + +function highlightRefs(text: string): React.ReactNode { + const parts = text.split(/(@e\d+)/g); + return parts.map((part, i) => + part.match(/^@e\d+$/) ? ( + + {part} + + ) : ( + part + ), + ); +} + +function CommandEntry({ + event, +}: { + event: ActivityEvent & { type: "command" }; +}) { + const [expanded, setExpanded] = useState(false); + + const label = event.action; + const hasParams = + event.params && + Object.keys(event.params).filter((k) => k !== "action" && k !== "id") + .length > 0; + + return ( + +
+ + + {formatTime(event.timestamp)} + + + {highlightRefs(label)} + + {hasParams && ( + + {expanded ? "-" : "+"} + + )} + + + {hasParams && ( +
+               k !== "action" && k !== "id",
+                  ),
+                )}
+              />
+            
+ )} +
+
+ +
+ ); +} + +function ResultEntry({ + event, +}: { + event: ActivityEvent & { type: "result" }; +}) { + const [expanded, setExpanded] = useState(false); + + return ( + +
+ + + {formatTime(event.timestamp)} + + + {event.action} + + {event.duration_ms}ms + + + + {expanded ? "-" : "+"} + + + + {event.data != null && ( +
+              {typeof event.data === "string"
+                ? event.data
+                : }
+            
+ )} +
+
+ +
+ ); +} + +const LEVEL_STYLES: Record = { + error: "text-destructive", + warn: "text-warning", + warning: "text-warning", + info: "text-accent-foreground", + log: "text-muted-foreground", +}; + +function ConsoleEntry({ + event, +}: { + event: ActivityEvent & { type: "console" }; +}) { + return ( + <> +
+ + {formatTime(event.timestamp)} + + + {event.level} + + {event.text} +
+ + + ); +} + +export function ActivityFeed() { + const events = useAtomValue(combinedEventsAtom); + const persist = useAtomValue(persistActivityAtom); + const togglePersist = useSetAtom(togglePersistAtom); + const clearActivity = useSetAtom(clearActivityAtom); + + const bottomRef = useRef(null); + const containerRef = useRef(null); + const autoScrollRef = useRef(true); + + useEffect(() => { + if (autoScrollRef.current) { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + } + }, [events.length]); + + const handleScroll = () => { + const el = containerRef.current; + if (!el) return; + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40; + autoScrollRef.current = atBottom; + }; + + return ( +
+
+ Activity + + {events.length} + + + +
+ + +
+ {events.length === 0 ? ( +
+ Waiting for events... +
+ ) : ( + events.map((event, i) => { + const key = event.type === "console" + ? `console-${event.timestamp}-${i}` + : `${event.type}-${event.id}`; + switch (event.type) { + case "command": + return ; + case "result": + return ; + case "console": + return ; + } + }) + )} +
+
+
+ ); +} diff --git a/packages/dashboard/src/components/console-panel.tsx b/packages/dashboard/src/components/console-panel.tsx new file mode 100644 index 0000000..3c343e5 --- /dev/null +++ b/packages/dashboard/src/components/console-panel.tsx @@ -0,0 +1,306 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useAtomValue, useSetAtom } from "jotai/react"; +import type { ConsoleEntry } from "@/types"; +import { consoleLogsAtom, clearConsoleLogsAtom } from "@/store/stream"; +import { activeSessionNameAtom } from "@/store/sessions"; +import { execCommand, sessionArgs } from "@/lib/exec"; +import { cn } from "@/lib/utils"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { CornerDownLeft, Loader2, Trash2 } from "lucide-react"; + +type FilterLevel = "all" | "errors" | "warnings" | "info" | "log"; + +const FILTER_MATCH: Record boolean> = { + all: () => true, + errors: (e) => e.type === "page_error" || (e.type === "console" && e.level === "error"), + warnings: (e) => e.type === "console" && (e.level === "warn" || e.level === "warning"), + info: (e) => e.type === "console" && e.level === "info", + log: (e) => e.type === "console" && (e.level === "log" || e.level === "debug"), +}; + +const LEVEL_COLORS: Record = { + error: "text-destructive", + page_error: "text-destructive", + warn: "text-warning", + warning: "text-warning", + info: "text-blue-400", + log: "text-muted-foreground", + debug: "text-muted-foreground/60", +}; + +function formatTime(ts: number): string { + const d = new Date(ts); + return d.toLocaleTimeString("en-US", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} + +function entryLevel(e: ConsoleEntry): string { + return e.type === "page_error" ? "error" : e.level; +} + +function entryText(e: ConsoleEntry): string { + if (e.type === "page_error") { + let text = e.text; + if (e.line != null) { + text += ` (${e.line}`; + if (e.column != null) text += `:${e.column}`; + text += ")"; + } + return text; + } + return e.text; +} + +interface EvalEntry { + id: number; + expression: string; + result?: string; + error?: string; + pending: boolean; + timestamp: number; +} + +let evalIdCounter = 0; + +export function ConsolePanel() { + const entries = useAtomValue(consoleLogsAtom); + const clearConsoleLogs = useSetAtom(clearConsoleLogsAtom); + const sessionName = useAtomValue(activeSessionNameAtom); + + const [filter, setFilter] = useState("all"); + const bottomRef = useRef(null); + const containerRef = useRef(null); + const autoScrollRef = useRef(true); + const [evalInput, setEvalInput] = useState(""); + const [evalEntries, setEvalEntries] = useState([]); + const [evaluating, setEvaluating] = useState(false); + const textareaRef = useRef(null); + + const filtered = entries.filter(FILTER_MATCH[filter]); + + const errorCount = entries.filter(FILTER_MATCH.errors).length; + const warnCount = entries.filter(FILTER_MATCH.warnings).length; + + useEffect(() => { + if (autoScrollRef.current) { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + } + }, [filtered.length, evalEntries.length]); + + const handleScroll = () => { + const el = containerRef.current; + if (!el) return; + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40; + autoScrollRef.current = atBottom; + }; + + const handleEval = useCallback(async () => { + const expr = evalInput.trim(); + if (!expr || !sessionName || evaluating) return; + + const id = ++evalIdCounter; + const entry: EvalEntry = { id, expression: expr, pending: true, timestamp: Date.now() }; + setEvalEntries((prev) => [...prev, entry]); + setEvalInput(""); + setEvaluating(true); + + if (textareaRef.current) { + textareaRef.current.style.height = "auto"; + } + + try { + const res = await execCommand(sessionArgs(sessionName, "eval", expr)); + setEvalEntries((prev) => + prev.map((e) => + e.id === id + ? { ...e, pending: false, result: res.stdout.trim(), error: res.stderr.trim() || undefined } + : e, + ), + ); + } catch { + setEvalEntries((prev) => + prev.map((e) => + e.id === id ? { ...e, pending: false, error: "Failed to execute" } : e, + ), + ); + } finally { + setEvaluating(false); + } + }, [evalInput, sessionName, evaluating]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleEval(); + } + }, + [handleEval], + ); + + const filters: { key: FilterLevel; label: string; count?: number }[] = [ + { key: "all", label: "All" }, + { key: "errors", label: "Errors", count: errorCount }, + { key: "warnings", label: "Warnings", count: warnCount }, + { key: "info", label: "Info" }, + { key: "log", label: "Log" }, + ]; + + return ( +
+
+ {filters.map((f) => ( + + ))} + +
+ + +
+ {filtered.length === 0 && evalEntries.length === 0 ? ( +
+ No console output +
+ ) : ( + <> + {filtered.map((entry, i) => { + const level = entryLevel(entry); + const color = LEVEL_COLORS[level] ?? "text-muted-foreground"; + return ( +
+ + {formatTime(entry.timestamp)} + + + {entry.type === "page_error" ? "error" : entry.level} + + + {entryText(entry)} + +
+ ); + })} + {evalEntries.map((entry) => ( +
+
+ + {formatTime(entry.timestamp)} + + > + + {entry.expression} + +
+ {entry.pending ? ( +
+ +
+ ) : entry.error ? ( +
+ + {entry.error} + +
+ ) : null} + {entry.result && ( +
+ + {entry.result} + +
+ )} +
+ ))} + + )} +
+
+ + +
+