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 <yongliang.xyl@alibaba-inc.com> * 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 <stefan@vanila.io> Co-authored-by: ctate <366502+ctate@users.noreply.github.com> Co-authored-by: zhanba <c5e1856@gmail.com> Co-authored-by: xuyongliang <478439790@qq.com> Co-authored-by: xuyongliang <yongliang.xyl@alibaba-inc.com> Co-authored-by: Thomas Kosiewski <thoma471@googlemail.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
ctate
github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Stefan Smiljkovic
zhanba
xuyongliang
xuyongliang
Thomas Kosiewski
parent
63f03b8e06
commit
f9174513c2
@@ -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);
|
||||
|
||||
@@ -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<u8>, 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(())
|
||||
}
|
||||
|
||||
+326
@@ -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::<u32>() {
|
||||
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<String> = 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::<u32>() {
|
||||
#[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<String> = 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::<Vec<_>>(),
|
||||
},
|
||||
}));
|
||||
} 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<String> = 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::<u16>().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) => {
|
||||
|
||||
+180
-15
@@ -212,6 +212,8 @@ pub struct DaemonState {
|
||||
pub stream_client: Option<Arc<RwLock<Option<Arc<CdpClient>>>>>,
|
||||
/// Stream server instance kept alive so the broadcast channel remains open.
|
||||
pub stream_server: Option<Arc<StreamServer>>,
|
||||
/// 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<Arc<StreamServer>>,
|
||||
) -> 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<Value, St
|
||||
*df = Some(DomainFilter::new(domains));
|
||||
}
|
||||
|
||||
state.engine = engine.as_deref().unwrap_or("chrome").to_string();
|
||||
write_engine_file(&state.session_id, &state.engine);
|
||||
write_extensions_file(&state.session_id);
|
||||
state.reset_input_state();
|
||||
state.browser = Some(BrowserManager::launch(options, engine.as_deref()).await?);
|
||||
state.subscribe_to_browser_events();
|
||||
@@ -1640,6 +1705,9 @@ async fn launch_ios(cmd: &Value, state: &mut DaemonState) -> Result<Value, Strin
|
||||
|
||||
state.appium = Some(appium);
|
||||
state.backend_type = BackendType::WebDriver;
|
||||
state.engine = "safari".to_string();
|
||||
write_engine_file(&state.session_id, &state.engine);
|
||||
write_extensions_file(&state.session_id);
|
||||
state.reset_input_state();
|
||||
|
||||
Ok(json!({
|
||||
@@ -1684,6 +1752,9 @@ async fn launch_safari(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
|
||||
state.safari_driver = Some(driver);
|
||||
state.webdriver_backend = Some(WebDriverBackend::new(client));
|
||||
state.backend_type = BackendType::WebDriver;
|
||||
state.engine = "safari".to_string();
|
||||
write_engine_file(&state.session_id, &state.engine);
|
||||
write_extensions_file(&state.session_id);
|
||||
state.reset_input_state();
|
||||
|
||||
Ok(json!({
|
||||
@@ -3215,7 +3286,27 @@ async fn handle_tab_switch(cmd: &Value, state: &mut DaemonState) -> Result<Value
|
||||
state.ref_map.clear();
|
||||
state.iframe_sessions.clear();
|
||||
state.active_frame_id = None;
|
||||
mgr.tab_switch(index).await
|
||||
let result = mgr.tab_switch(index).await?;
|
||||
|
||||
if let Some(ref server) = state.stream_server {
|
||||
if let Ok(dims) = mgr
|
||||
.evaluate(
|
||||
"JSON.stringify([window.innerWidth,window.innerHeight])",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
if let Some(s) = dims.get("result").and_then(|v| v.as_str()) {
|
||||
if let Ok(arr) = serde_json::from_str::<Vec<u32>>(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<Value, String> {
|
||||
@@ -3264,11 +3355,26 @@ async fn handle_set_media(cmd: &Value, state: &DaemonState) -> Result<Value, Str
|
||||
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
||||
let media = cmd.get("media").and_then(|v| v.as_str());
|
||||
|
||||
let features = cmd.get("features").and_then(|v| v.as_object()).map(|m| {
|
||||
m.iter()
|
||||
.map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string()))
|
||||
.collect::<Vec<(String, String)>>()
|
||||
});
|
||||
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<Value, String> {
|
||||
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<Value, String> {
|
||||
@@ -4256,15 +4372,21 @@ async fn handle_device(cmd: &Value, state: &DaemonState) -> Result<Value, String
|
||||
.ok_or("Missing 'name' parameter")?;
|
||||
|
||||
let (width, height, scale, mobile, ua) = match name.to_lowercase().as_str() {
|
||||
"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"),
|
||||
"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<Va
|
||||
};
|
||||
|
||||
let (server, client_slot) =
|
||||
StreamServer::start_without_client(requested_port, state.session_id.clone()).await?;
|
||||
StreamServer::start_without_client(requested_port, state.session_id.clone(), false).await?;
|
||||
let port = server.port();
|
||||
if let Err(err) = write_stream_file(&state.session_id, port) {
|
||||
server.shutdown().await;
|
||||
@@ -4365,6 +4518,7 @@ async fn handle_stream_enable(cmd: &Value, state: &mut DaemonState) -> Result<Va
|
||||
|
||||
state.stream_client = Some(client_slot);
|
||||
state.stream_server = Some(Arc::new(server));
|
||||
state.request_tracking = true;
|
||||
if state.screencasting {
|
||||
if let Some(ref server) = state.stream_server {
|
||||
server.set_screencasting(true).await;
|
||||
@@ -4384,6 +4538,7 @@ async fn handle_stream_disable(state: &mut DaemonState) -> Result<Value, String>
|
||||
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<Value, String
|
||||
if let Some(ref server) = state.stream_server {
|
||||
server.set_screencasting(false).await;
|
||||
let (vw, vh) = server.viewport().await;
|
||||
server.broadcast_status(true, false, vw, vh);
|
||||
server
|
||||
.broadcast_status(true, false, vw, vh, &state.engine)
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(json!({ "stopped": true }))
|
||||
|
||||
+48
-22
@@ -33,6 +33,8 @@ pub async fn run_daemon(session: &str) {
|
||||
|
||||
let stream_path = socket_dir.join(format!("{}.stream", session));
|
||||
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 Ok(days_str) = env::var("AGENT_BROWSER_STATE_EXPIRE_DAYS") {
|
||||
if let Ok(days) = days_str.parse::<u64>() {
|
||||
@@ -44,23 +46,20 @@ pub async fn run_daemon(session: &str) {
|
||||
|
||||
let mut stream_client: Option<Arc<RwLock<Option<Arc<CdpClient>>>>> = None;
|
||||
let mut stream_server_instance: Option<Arc<StreamServer>> = None;
|
||||
if let Ok(port_str) = env::var("AGENT_BROWSER_STREAM_PORT") {
|
||||
if let Ok(port) = port_str.parse::<u16>() {
|
||||
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::<u16>().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<Arc<RwLock<Option<Arc<CdpClient>>>>>,
|
||||
stream_server: Option<Arc<StreamServer>>,
|
||||
idle_timeout_ms: Option<u64>,
|
||||
@@ -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<PathBuf> = 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<tokio::sync::Mutex<DaemonState>> = 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<PathBuf> = if stream_server.is_some() {
|
||||
Some(socket_dir.join(format!("{}.stream", session)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let state: std::sync::Arc<tokio::sync::Mutex<DaemonState>> = 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<S>(
|
||||
stream: S,
|
||||
state: std::sync::Arc<tokio::sync::Mutex<DaemonState>>,
|
||||
idle_reset_tx: Option<Arc<mpsc::Sender<()>>>,
|
||||
stream_file_cleanup: Option<PathBuf>,
|
||||
) where
|
||||
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
|
||||
{
|
||||
@@ -314,6 +337,9 @@ async fn handle_connection<S>(
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
+1000
-24
File diff suppressed because it is too large
Load Diff
+49
-5
@@ -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 <name> 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 <n>] 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 <n> 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 <js> Run JavaScript
|
||||
connect <port|url> 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 <n> 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
|
||||
|
||||
Reference in New Issue
Block a user