From 29cb83d063e997d8a9d32441243de185882aa6a9 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:44:24 +0400 Subject: [PATCH] refactor: cleanup --- e2e/coverage-map.mjs | 1 + e2e/tests/integrations.test.mjs | 16 + src-tauri/src/api_server.rs | 356 ++++- src-tauri/src/browser.rs | 84 +- src-tauri/src/browser_runner.rs | 97 ++ src-tauri/src/cdp_target.rs | 1380 +++++++++++++++++ src-tauri/src/dns_blocklist.rs | 187 ++- src-tauri/src/downloaded_browsers_registry.rs | 178 ++- src-tauri/src/downloader.rs | 11 + src-tauri/src/lib.rs | 33 +- src-tauri/src/mcp_server.rs | 689 ++++---- src-tauri/src/remote_handoff.rs | 543 +++++++ src-tauri/src/remote_session.rs | 591 ++++++- src-tauri/src/sync/engine.rs | 99 +- src-tauri/src/sync/manifest.rs | 107 +- src-tauri/src/sync/mod.rs | 14 +- src-tauri/src/team_lock.rs | 108 +- src/components/cookie-bot-shared.tsx | 18 + src/components/profile-data-table.tsx | 52 +- src/hooks/use-remote-handoff.ts | 51 + src/i18n/locales/en.json | 11 +- src/i18n/locales/es.json | 11 +- src/i18n/locales/fr.json | 11 +- src/i18n/locales/ja.json | 11 +- src/i18n/locales/ko.json | 11 +- src/i18n/locales/pt.json | 11 +- src/i18n/locales/ru.json | 11 +- src/i18n/locales/tr.json | 11 +- src/i18n/locales/vi.json | 11 +- src/i18n/locales/zh.json | 11 +- src/lib/backend-errors.ts | 17 + src/lib/remote-sessions.ts | 36 + 32 files changed, 4227 insertions(+), 551 deletions(-) create mode 100644 src-tauri/src/cdp_target.rs create mode 100644 src-tauri/src/remote_handoff.rs create mode 100644 src/hooks/use-remote-handoff.ts diff --git a/e2e/coverage-map.mjs b/e2e/coverage-map.mjs index 1628f0e..ec7b098 100644 --- a/e2e/coverage-map.mjs +++ b/e2e/coverage-map.mjs @@ -255,6 +255,7 @@ export const commandCoverage = { "list_remote_sessions", "get_remote_session", "stop_remote_session", + "get_remote_handoff_states", "start_remote_session_events", "stop_remote_session_events", "get_remote_session_events_status", diff --git a/e2e/tests/integrations.test.mjs b/e2e/tests/integrations.test.mjs index c465188..179573e 100644 --- a/e2e/tests/integrations.test.mjs +++ b/e2e/tests/integrations.test.mjs @@ -316,6 +316,13 @@ test("MCP Streamable HTTP initialization, auth, discovery, calls, and isolated a "update_proxy", "get_page_content", "get_interactive_elements", + // The remote loop has to be complete from MCP alone: start a session, + // watch it become usable, drive it with the interaction tools above, stop + // it. Any one of these missing leaves an agent able to lease a host it + // cannot use, or unable to lease one at all. + "run_profile_remote", + "get_remote_session", + "stop_remote_session", ]) { assert.ok(names.includes(name), `MCP is missing ${name}`); } @@ -671,6 +678,15 @@ test("offline cloud, update, team-lock, trial, and synchronizer contracts are de }), notSignedIn, ); + // The local-launch gate. Nothing has run remotely in this session, so it + // is empty — but it must answer, because a UI that cannot read it shows + // an enabled Run button over a profile the backend will refuse. + const handoff = await app.invoke("get_remote_handoff_states"); + assert.ok( + handoff && typeof handoff === "object" && !Array.isArray(handoff), + "the handoff gate must answer with a profile-keyed object", + ); + assert.equal(Object.keys(handoff).length, 0); // The transition stream is what the desktop uses instead of polling, so // its subscriber has to start, report itself, and stop on demand. Both diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index 166459a..a090285 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -5,7 +5,10 @@ use crate::profile::manager::ProfileManager; use crate::proxy_manager::PROXY_MANAGER; use crate::tag_manager::TAG_MANAGER; use axum::{ - extract::{Path, Query, State}, + extract::{ + ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}, + Path, Query, State, + }, http::{header, HeaderMap, Method, StatusCode}, middleware::{self, Next}, response::{IntoResponse, Json, Response}, @@ -509,6 +512,7 @@ struct ImportProxiesResponse { run_profile, run_profile_remote, stop_remote_session, + remote_session_cdp, list_remote_sessions_api, get_remote_session_api, get_remote_hours, @@ -794,6 +798,7 @@ fn build_v1_router() -> Router { // `/v1/remote-sessions/{id}`, and registering them separately would have // the second overwrite the first. .routes(routes!(get_remote_session_api, stop_remote_session)) + .routes(routes!(remote_session_cdp)) .routes(routes!(list_remote_sessions_api)) .routes(routes!(get_remote_hours)) .routes(routes!(set_profile_cloud_sync)) @@ -1061,6 +1066,20 @@ pub async fn get_api_server_status() -> Result, String> { /// bare status code. Matching is on message content because the managers /// return plain strings (some are the JSON `{"code": ...}` strings shared /// with the Tauri commands). +/// Codes meaning "this profile is held by someone else right now". +/// +/// Kept as one list so the REST layer, which has no other way to tell a refusal +/// apart from a validation failure, cannot drift from the guards that produce +/// them. `PROFILE_REMOTE_SYNC_PENDING` in particular is temporary by nature: the +/// pull that clears it is already running. +const LAUNCH_CONFLICT_CODES: [&str; 5] = [ + "PROFILE_RUNNING", + "PROFILE_RUNNING_REMOTELY", + "PROFILE_REMOTE_SYNC_PENDING", + "PROFILE_LOCKED_BY_MEMBER", + "PROFILE_LOCKED_ELSEWHERE", +]; + fn manager_error_response(err: impl std::fmt::Display) -> (StatusCode, String) { let msg = err.to_string(); @@ -1069,8 +1088,19 @@ fn manager_error_response(err: impl std::fmt::Display) -> (StatusCode, String) { if let Some(code) = value.get("code").and_then(|c| c.as_str()) { let status = if code.ends_with("_NOT_FOUND") { StatusCode::NOT_FOUND + } else if LAUNCH_CONFLICT_CODES.contains(&code) { + // Someone or something else holds this profile: another team member, a + // browser already open, or a remote session whose work has not been + // pulled back yet. All of them are "try again later", not "your request + // was malformed", and 400 would tell an automation client to give up. + StatusCode::CONFLICT } else if code == "INTERNAL_ERROR" { StatusCode::INTERNAL_SERVER_ERROR + } else if code == "PROFILE_LOCK_UNAVAILABLE" { + // The lock service could not be reached. The launch is refused because + // it cannot be proven safe, which is an upstream failure, not the + // caller's fault. + StatusCode::SERVICE_UNAVAILABLE } else if code.ends_with("_REQUIRES_PRO") || code.ends_with("_PAYMENT_REQUIRED") { // Paid-feature gates (FINGERPRINT_REQUIRES_PRO, PROXY_PAYMENT_REQUIRED). // Mapping them here lets the gate live in the shared manager instead of @@ -2295,8 +2325,9 @@ async fn delete_extension_group_api( (status = 401, description = "Unauthorized"), (status = 402, description = "Active paid plan with browser automation required"), (status = 404, description = "Profile not found"), - (status = 409, description = "Profile is locked by another team member"), + (status = 409, description = "Profile is locked by another team member, running on the remote fleet, or waiting for a finished remote session to be pulled back"), (status = 429, description = "Automation request rate limit exceeded"), + (status = 503, description = "The profile lock service could not be reached"), (status = 500, description = "Internal server error") ), security( @@ -2308,12 +2339,12 @@ async fn run_profile( Path(id): Path, State(state): State, Json(request): Json, -) -> Result, StatusCode> { +) -> Result, (StatusCode, String)> { if !crate::cloud_auth::CLOUD_AUTH .can_use_browser_automation() .await { - return Err(StatusCode::PAYMENT_REQUIRED); + return Err((StatusCode::PAYMENT_REQUIRED, String::new())); } let headless = request.headless.unwrap_or(false); @@ -2322,29 +2353,34 @@ async fn run_profile( let profile_manager = ProfileManager::instance(); let profiles = profile_manager .list_profiles() - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + .map_err(manager_error_response)?; let profile = profiles .iter() .find(|p| p.id.to_string() == id) - .ok_or(StatusCode::NOT_FOUND)?; + .ok_or((StatusCode::NOT_FOUND, "profile not found".to_string()))?; if profile.is_cross_os() { - return Err(StatusCode::BAD_REQUEST); + return Err(( + StatusCode::BAD_REQUEST, + "cannot launch a cross-OS profile locally; use /run-remote".to_string(), + )); } - // Team lock check + // Team lock check. Routed through the shared mapper so a profile held by the + // user's OWN remote session is a 409 that says so, rather than a bare status + // with no body, which is what an automation client had to guess from. crate::team_lock::acquire_team_lock_if_needed(profile) .await - .map_err(|_| StatusCode::CONFLICT)?; + .map_err(manager_error_response)?; let remote_debugging_port = { let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + .map_err(manager_error_response)?; let port = listener .local_addr() - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .map_err(manager_error_response)? .port(); drop(listener); port @@ -2352,7 +2388,7 @@ async fn run_profile( // Use the same launch path as the main app, but force a fresh instance with // remote debugging enabled so the returned port is the one the browser binds. - match crate::browser_runner::launch_browser_profile_impl( + let updated_profile = crate::browser_runner::launch_browser_profile_impl( state.app_handle.clone(), profile.clone(), url, @@ -2361,14 +2397,13 @@ async fn run_profile( true, ) .await - { - Ok(updated_profile) => Ok(Json(RunProfileResponse { - profile_id: updated_profile.id.to_string(), - remote_debugging_port, - headless, - })), - Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), - } + .map_err(manager_error_response)?; + + Ok(Json(RunProfileResponse { + profile_id: updated_profile.id.to_string(), + remote_debugging_port, + headless, + })) } // API Handler - Launch this profile on a REMOTE VM of its own operating system @@ -2734,6 +2769,157 @@ fn status_for_code(code: &str) -> StatusCode { } } +// API Handler - Attach a CDP client (Playwright, Puppeteer, chrome-remote-interface) +// to a remote session. +// +// This is what makes `run-remote` usable. Without it the endpoint hands back a +// session id that nothing outside this app can do anything with: the fleet's +// relay only accepts the user's Donut cloud credential, an automation client +// does not have one, and it must not be given one — an API token is scoped to +// "drive my browsers", not "act as my account". +// +// So the socket is opened here with the credential this process already holds +// and the frames are pumped verbatim in both directions. The caller presents +// the ordinary API bearer token and gets a browser-level CDP endpoint at +// `ws://127.0.0.1:/v1/remote-sessions/{id}/cdp`: +// +// const browser = await chromium.connectOverCDP({ +// endpointURL: `ws://127.0.0.1:10108/v1/remote-sessions/${id}/cdp`, +// headers: { Authorization: `Bearer ${API_TOKEN}` }, +// }); +// +// Nothing is attached to a page first, deliberately: Playwright drives +// `Target.setAutoAttach` and builds its own session map, and a socket already +// bound to one page would hide every other target from it. +#[utoipa::path( + get, + path = "/v1/remote-sessions/{id}/cdp", + params( + ("id" = String, Path, description = "Remote session ID from run-remote") + ), + responses( + (status = 101, description = "Switching Protocols; a browser-level CDP WebSocket follows"), + (status = 401, description = "Unauthorized"), + (status = 402, description = "Active paid plan with browser automation required"), + (status = 404, description = "No such remote session, or it is not attachable yet"), + (status = 502, description = "The relay could not be reached"), + (status = 426, description = "Not a WebSocket upgrade request") + ), + security( + ("bearer_auth" = []) + ), + tag = "remote-sessions" +)] +async fn remote_session_cdp( + Path(id): Path, + upgrade: WebSocketUpgrade, +) -> Result { + if !crate::cloud_auth::CLOUD_AUTH + .can_use_browser_automation() + .await + { + return Err((StatusCode::PAYMENT_REQUIRED, String::new())); + } + + // Dialled BEFORE the upgrade is accepted, so a session that is not attachable + // is an HTTP status the client can read. Accepting the upgrade first would + // turn every such failure into a socket that opens and immediately closes, + // which is what a CDP client reports as "browser closed unexpectedly". + let upstream = crate::cdp_target::open_relay_socket(&id) + .await + .map_err(cdp_error_response)?; + + Ok( + upgrade + .max_message_size(crate::cdp_target::MAX_RELAY_MESSAGE_BYTES) + .max_frame_size(crate::cdp_target::MAX_RELAY_MESSAGE_BYTES) + .on_upgrade(move |client| pump_cdp(id, client, upstream)), + ) +} + +fn cdp_error_response(err: crate::cdp_target::CdpError) -> (StatusCode, String) { + use crate::cdp_target::CdpError; + let status = match err { + CdpError::Unauthorized(_) => StatusCode::UNAUTHORIZED, + // "Not drivable" covers a session that is still provisioning and one that + // is not the caller's. Both are 404 to a CDP client: there is no browser at + // this address right now. + CdpError::NotDrivable(_) => StatusCode::NOT_FOUND, + CdpError::Unreachable(_) => StatusCode::BAD_GATEWAY, + CdpError::Transport(_) | CdpError::Protocol(_) => StatusCode::BAD_GATEWAY, + }; + (status, err.to_string()) +} + +/// Copy CDP frames between the local client and the fleet relay until either +/// side hangs up. +/// +/// Verbatim in both directions. This proxy deliberately understands nothing +/// about CDP: a client that speaks a newer protocol, or a target type this +/// build has never heard of, must keep working without a Donut release. +async fn pump_cdp(session_id: String, client: WebSocket, upstream: crate::cdp_target::RelaySocket) { + use futures_util::{SinkExt, StreamExt}; + use tokio_tungstenite::tungstenite::Message as RelayMessage; + + let (mut client_tx, mut client_rx) = client.split(); + let (mut relay_tx, mut relay_rx) = upstream.split(); + + let to_relay = async { + while let Some(Ok(message)) = client_rx.next().await { + let forwarded = match message { + WsMessage::Text(text) => RelayMessage::Text(text.as_str().into()), + WsMessage::Binary(bytes) => RelayMessage::Binary(bytes), + WsMessage::Ping(bytes) => RelayMessage::Ping(bytes), + WsMessage::Pong(bytes) => RelayMessage::Pong(bytes), + WsMessage::Close(_) => break, + }; + if relay_tx.send(forwarded).await.is_err() { + break; + } + } + let _ = relay_tx.close().await; + }; + + let to_client = async { + while let Some(Ok(message)) = relay_rx.next().await { + let forwarded = match message { + RelayMessage::Text(text) => WsMessage::Text(text.as_str().into()), + RelayMessage::Binary(bytes) => WsMessage::Binary(bytes), + RelayMessage::Ping(bytes) => WsMessage::Ping(bytes), + RelayMessage::Pong(bytes) => WsMessage::Pong(bytes), + // A relay close carries the only diagnosis the server gives (1008 is a + // rejected credential, 1013 is "not up yet"), so it is passed through + // rather than swallowed into a bare disconnect. + RelayMessage::Close(frame) => { + let _ = client_tx + .send(WsMessage::Close(frame.map(|f| { + axum::extract::ws::CloseFrame { + code: u16::from(f.code), + reason: f.reason.as_str().into(), + } + }))) + .await; + return; + } + RelayMessage::Frame(_) => continue, + }; + if client_tx.send(forwarded).await.is_err() { + break; + } + } + let _ = client_tx.close().await; + }; + + // Either direction ending means the conversation is over. Waiting for both + // would hold a relay socket open — and one of the session's four allowed + // attachments with it — after the client had gone. + tokio::select! { + () = to_relay => {} + () = to_client => {} + } + log::info!("CDP proxy for remote session {session_id} closed"); +} + // API Handler - Every remote session this account currently owns #[utoipa::path( get, @@ -3240,6 +3426,11 @@ async fn get_cookie_bot_usage( } // API Handler - Open URL in existing browser +// +// Works against a profile running here OR one running on the leased fleet: a +// remote session is navigated over the same CDP path the automation tools use, +// so a caller does not have to know where the browser is. The cross-OS refusal +// therefore only applies to a profile that would have to be launched locally. #[utoipa::path( post, path = "/v1/profiles/{id}/open-url", @@ -3248,12 +3439,14 @@ async fn get_cookie_bot_usage( ), request_body = OpenUrlRequest, responses( - (status = 200, description = "URL opened successfully"), - (status = 400, description = "Cannot open URL with a cross-OS profile"), + (status = 200, description = "URL opened successfully, locally or on the profile's remote session"), + (status = 400, description = "Cannot open URL with a cross-OS profile that is not running remotely"), (status = 401, description = "Unauthorized"), (status = 402, description = "Active paid plan with browser automation required"), (status = 404, description = "Profile not found"), + (status = 409, description = "Profile is locked by another team member, or waiting for a finished remote session to be pulled back"), (status = 429, description = "Automation request rate limit exceeded"), + (status = 503, description = "The profile lock service could not be reached"), (status = 500, description = "Internal server error") ), security( @@ -3284,6 +3477,12 @@ async fn open_url_in_profile( } // API Handler - Kill browser process +// +// Stops the browser wherever it is. A profile open on the leased fleet is ended +// through the backend, which is what makes this endpoint mean "stop this +// profile" rather than "stop this profile if it happens to be on this machine" — +// the latter reported success, killed nothing, and left the session billing to +// its two-hour cap. #[utoipa::path( post, path = "/v1/profiles/{id}/kill", @@ -3291,11 +3490,12 @@ async fn open_url_in_profile( ("id" = String, Path, description = "Profile ID") ), responses( - (status = 204, description = "Browser process killed successfully"), + (status = 204, description = "Browser stopped, locally or on the profile's remote session"), (status = 401, description = "Unauthorized"), (status = 402, description = "Active paid plan required"), (status = 404, description = "Profile not found"), (status = 429, description = "Automation request rate limit exceeded"), + (status = 503, description = "The fleet could not be reached; the remote browser is still running"), (status = 500, description = "Internal server error") ), security( @@ -3306,31 +3506,41 @@ async fn open_url_in_profile( async fn kill_profile( Path(id): Path, State(state): State, -) -> Result { +) -> Result { // Programmatically launching and stopping profiles is a paid feature; the // run/open-url handlers gate the same way. if !crate::cloud_auth::CLOUD_AUTH .can_use_browser_automation() .await { - return Err(StatusCode::PAYMENT_REQUIRED); + return Err((StatusCode::PAYMENT_REQUIRED, String::new())); } let profile_manager = ProfileManager::instance(); let profiles = profile_manager .list_profiles() - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + .map_err(manager_error_response)?; let profile = profiles .iter() .find(|p| p.id.to_string() == id) - .ok_or(StatusCode::NOT_FOUND)?; + .ok_or((StatusCode::NOT_FOUND, "profile not found".to_string()))?; let browser_runner = crate::browser_runner::BrowserRunner::instance(); browser_runner .kill_browser_process(state.app_handle.clone(), profile) .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + .map_err(|e| { + let message = e.to_string(); + // The backend refuses to retire a session it could not stop on the fleet. + // Reporting that as a 500 invites a retry loop against a browser that is + // still running; 503 says "it is still up, try again". + if message.contains("REMOTE_") { + (StatusCode::SERVICE_UNAVAILABLE, message) + } else { + (StatusCode::INTERNAL_SERVER_ERROR, message) + } + })?; crate::team_lock::release_team_lock_if_needed(profile).await; @@ -4331,6 +4541,96 @@ mod tests { // list, not from the router — endpoints registered on the router but missing // from ApiDoc silently disappear from the spec. Lock in the ones that were // once dropped, and that removed endpoints stay gone. + #[test] + fn a_profile_held_elsewhere_is_a_conflict_not_a_bad_request() { + // These four refusals all mean "come back in a moment". Answering 400 tells + // an automation client its request was malformed and to stop retrying, and + // that is what every one of them did before they had codes at all. + for code in [ + "PROFILE_RUNNING_REMOTELY", + "PROFILE_REMOTE_SYNC_PENDING", + "PROFILE_LOCKED_BY_MEMBER", + "PROFILE_LOCKED_ELSEWHERE", + ] { + let (status, body) = manager_error_response(serde_json::json!({ "code": code }).to_string()); + assert_eq!(status, StatusCode::CONFLICT, "{code} must be a 409"); + assert!(body.contains(code), "{code} must reach the caller"); + } + } + + #[test] + fn an_unreachable_lock_service_is_not_the_callers_fault() { + let (status, _) = + manager_error_response(serde_json::json!({ "code": "PROFILE_LOCK_UNAVAILABLE" }).to_string()); + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + } + + #[test] + fn a_remote_session_exposes_a_cdp_endpoint_an_external_client_can_attach_to() { + // Without this route `run-remote` hands back a session id that nothing + // outside the app can use: the fleet relay accepts only the user's cloud + // credential, which an API consumer does not have and must not be given. + // A Playwright user reads the spec to find this, so it has to be in it. + let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes"); + let operation = &spec["paths"]["/v1/remote-sessions/{id}/cdp"]["get"]; + assert!( + operation.is_object(), + "the CDP attach endpoint must be in the served spec" + ); + assert!( + operation["responses"].get("101").is_some(), + "a WebSocket endpoint must document its upgrade" + ); + assert_eq!(operation["tags"][0], "remote-sessions"); + } + + #[test] + fn a_cdp_attach_failure_is_not_reported_as_a_broken_relay() { + // A CDP client retries a 502 and gives up on a 404. Reporting "this session + // is not up yet" as a gateway failure sends it into a loop against a + // session that is doing exactly what it should. + use crate::cdp_target::CdpError; + assert_eq!( + cdp_error_response(CdpError::NotDrivable("provisioning".into())).0, + StatusCode::NOT_FOUND + ); + assert_eq!( + cdp_error_response(CdpError::Unauthorized("no token".into())).0, + StatusCode::UNAUTHORIZED + ); + assert_eq!( + cdp_error_response(CdpError::Unreachable("dns".into())).0, + StatusCode::BAD_GATEWAY + ); + } + + #[test] + fn the_kill_route_documents_that_it_can_fail_to_stop_a_remote_browser() { + // The backend refuses to retire a session it could not stop on the fleet, so + // stopping can genuinely fail with the browser still running. A spec that + // only lists 204 tells a client that never happens. + let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes"); + let responses = &spec["paths"]["/v1/profiles/{id}/kill"]["post"]["responses"]; + assert!( + responses.get("503").is_some(), + "kill must document that the fleet may be unreachable" + ); + } + + #[test] + fn the_local_launch_routes_document_their_conflict() { + // A profile waiting on a finished remote session refuses a local launch. + // Undocumented, that reaches an integrator as an unexplained 409. + let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes"); + for path in ["/v1/profiles/{id}/run", "/v1/profiles/{id}/open-url"] { + let responses = &spec["paths"][path]["post"]["responses"]; + assert!( + responses.get("409").is_some(), + "{path} must document its conflict" + ); + } + } + #[test] fn openapi_spec_covers_registered_routes() { let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes"); diff --git a/src-tauri/src/browser.rs b/src-tauri/src/browser.rs index 6d8e063..9f998c6 100644 --- a/src-tauri/src/browser.rs +++ b/src-tauri/src/browser.rs @@ -231,7 +231,7 @@ mod windows { pub fn is_wayfern_version_downloaded(install_dir: &Path) -> bool { if wayfern_executable_candidates(install_dir) .iter() - .any(|exe_path| exe_path.exists() && exe_path.is_file()) + .any(|exe_path| exe_path.exists() && exe_path.is_file() && has_sibling_dll(exe_path)) { return true; } @@ -239,7 +239,8 @@ mod windows { // Check for any .exe file that looks like the browser if let Ok(entries) = std::fs::read_dir(install_dir) { for entry in entries.flatten() { - if is_wayfern_exe(&entry.path()) { + let path = entry.path(); + if is_wayfern_exe(&path) && has_sibling_dll(&path) { return true; } } @@ -380,6 +381,33 @@ impl BrowserFactory { } } +/// Whether the directory holding `exe_path` also contains at least one `.dll`. +/// +/// A Chromium build on Windows cannot start without its sibling libraries +/// (`chrome.dll` and friends) and its `.manifest`; a lone `.exe` is a gutted +/// install, and launching it fails inside the Windows loader with os error +/// 14001 (`ERROR_SXS_CANT_GEN_ACTCTX`, "side-by-side configuration is +/// incorrect"). Treating such a directory as downloaded is what made that state +/// permanent: the registry rescan re-added it as a healthy install, so no +/// re-download was ever offered. The check is scoped to the executable's own +/// directory because the payload may sit at the version root or in a `bin/`, +/// `wayfern/`, `wayfern-win/` or `chrome-win/` subdirectory. +#[cfg(any(target_os = "windows", test))] +fn has_sibling_dll(exe_path: &Path) -> bool { + let Some(dir) = exe_path.parent() else { + return false; + }; + let Ok(entries) = std::fs::read_dir(dir) else { + return false; + }; + entries.flatten().any(|entry| { + entry + .path() + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("dll")) + }) +} + /// Check if a file is a valid PE executable by reading its magic bytes (MZ). /// Returns false for archive files (.zip starts with PK, etc.) that were /// incorrectly named with a .exe extension. @@ -575,6 +603,58 @@ mod tests { assert!(exe.ends_with(std::path::Path::new("wayfern-win").join("wayfern.exe"))); } + /// A gutted Windows install (the `.exe` survived a cleanup pass that deleted + /// every `.dll` and the `.manifest`) must not read as downloaded, otherwise it + /// is re-registered as healthy and launching it fails with os error 14001. + /// Runs on every platform because the predicate is platform-independent. + #[test] + fn test_lone_exe_is_not_a_valid_windows_install() { + use tempfile::TempDir; + let temp = TempDir::new().unwrap(); + let install_dir = temp.path(); + + let exe = install_dir.join("chrome.exe"); + std::fs::File::create(&exe).unwrap(); + assert!( + !has_sibling_dll(&exe), + "an .exe with no sibling .dll is a gutted install" + ); + + std::fs::File::create(install_dir.join("chrome.dll")).unwrap(); + assert!( + has_sibling_dll(&exe), + "an .exe next to its libraries is a complete install" + ); + } + + /// The DLL check is scoped to the executable's own directory, so the nested + /// `chrome-win/` and `wayfern-win/` layouts are not falsely rejected because + /// the version root happens to hold no libraries. + #[test] + fn test_sibling_dll_check_is_scoped_to_the_executable_directory() { + use tempfile::TempDir; + let temp = TempDir::new().unwrap(); + let install_dir = temp.path(); + + let subdir = install_dir.join("chrome-win"); + std::fs::create_dir_all(&subdir).unwrap(); + let exe = subdir.join("chrome.exe"); + std::fs::File::create(&exe).unwrap(); + std::fs::File::create(subdir.join("CHROME.DLL")).unwrap(); + + assert!( + has_sibling_dll(&exe), + "libraries beside the executable count regardless of case or nesting" + ); + + let root_exe = install_dir.join("chrome.exe"); + std::fs::File::create(&root_exe).unwrap(); + assert!( + !has_sibling_dll(&root_exe), + "libraries in a sibling subdirectory must not validate a bare root .exe" + ); + } + #[test] fn test_proxy_settings_serialization() { let proxy = ProxySettings { diff --git a/src-tauri/src/browser_runner.rs b/src-tauri/src/browser_runner.rs index b6bf56f..4ba6789 100644 --- a/src-tauri/src/browser_runner.rs +++ b/src-tauri/src/browser_runner.rs @@ -15,6 +15,13 @@ static PROFILE_LAUNCH_LOCKS: LazyLock< tokio::sync::Mutex>>>, > = LazyLock::new(|| tokio::sync::Mutex::new(HashMap::new())); +/// How long a remote navigation waits for the page to settle. +/// +/// A relayed round trip crosses two networks and the page load itself happens +/// on hardware in another country, so this is deliberately the same budget the +/// automation tools give a navigation rather than a loopback-sized one. +const REMOTE_NAVIGATE_TIMEOUT_SECS: u64 = 30; + async fn lock_profile_launch(profile_id: &str) -> tokio::sync::OwnedMutexGuard<()> { let lock = { let mut locks = PROFILE_LAUNCH_LOCKS.lock().await; @@ -829,11 +836,64 @@ impl BrowserRunner { profile: &BrowserProfile, ) -> Result<(), Box> { let _profile_launch_guard = lock_profile_launch(&profile.id.to_string()).await; + + // "Stop this profile" has to mean the browser that is actually running, and + // for a profile on the leased fleet that browser is not on this machine. + // Without this, stopping reported success, killed nothing, and left the + // session running to its two-hour cap — billing the user for every minute + // and holding their profile lock the whole time. + if self.stop_remote_session_for(&app_handle, profile).await? { + return Ok(()); + } + self .kill_browser_process_unlocked(app_handle, profile) .await } + /// Stop this profile's fleet session, if it has one. Returns whether it did. + /// + /// Guarded on there being no local process so a locally running profile never + /// pays for the lookup, exactly as the open-URL path is: the profile lock + /// makes a local and a remote browser mutually exclusive. + async fn stop_remote_session_for( + &self, + app_handle: &tauri::AppHandle, + profile: &BrowserProfile, + ) -> Result> { + if profile.process_id.is_some() { + return Ok(false); + } + let profile_id = profile.id.to_string(); + let Some(session_id) = crate::remote_handoff::running_session_for_profile(&profile_id) else { + return Ok(false); + }; + + log::info!( + "Stopping remote session {session_id} for profile {} ({profile_id})", + profile.name + ); + crate::remote_session::end_remote_session(&session_id) + .await + .map_err(|e| -> Box { + // Surfaced rather than swallowed. The backend refuses to retire a + // session it could not stop on the fleet, so a failure here means the + // browser is STILL RUNNING; reporting success would tell the user their + // profile is free when a host is still writing to it. + log::warn!("Failed to stop remote session {session_id}: {e}"); + e.to_error_json().into() + })?; + + // The session is down and its work is in cloud storage. This is what puts + // the profile into "pending sync" and starts the pull, so the user is not + // handed back a profile directory that predates the session they just ran. + // + // The session's own profile lock is released by the backend when it retires + // the row; nothing is released from here, because this client never held it. + crate::remote_session::note_session_stopped(app_handle, &session_id); + Ok(true) + } + async fn kill_browser_process_unlocked( &self, app_handle: tauri::AppHandle, @@ -1222,6 +1282,29 @@ impl BrowserRunner { .ok_or_else(|| format!("Profile '{profile_id}' not found"))?; let _profile_launch_guard = lock_profile_launch(&profile.id.to_string()).await; + // A profile already open on the leased fleet is driven, not launched. This + // sits above the cross-OS guard on purpose: a Windows profile cannot run on + // this Mac, which is the whole reason it is running remotely, and refusing + // to point it at a URL for that reason would make the remote session + // unusable from the one endpoint that exists to use it. + // + // Guarded on there being no local process, so a profile running here never + // pays for the lookup: a local launch records a pid, and the profile lock + // keeps a local and a remote session mutually exclusive. + if profile.process_id.is_none() { + if let Ok(target) = crate::cdp_target::resolve(&profile).await { + if target.is_remote() { + log::info!("Opening URL through {}", target.describe()); + return crate::cdp_target::navigate(&target, &url, REMOTE_NAVIGATE_TIMEOUT_SECS) + .await + .map_err(|e| { + log::warn!("Failed to open a URL on the remote browser: {e}"); + format!("Failed to open URL with profile: {e}") + }); + } + } + } + if profile.is_cross_os() { return Err(format!( "Cannot open URL with profile '{}': this profile was created on {} and cannot be used on a different operating system", @@ -1230,6 +1313,14 @@ impl BrowserRunner { )); } + // Past this point a local browser is about to be launched, and until now + // this was the ONE launch path that took neither the profile lock nor any + // notice of the fleet. A remote session whose state could not be read (a + // dropped event stream plus an unreachable backend) fell straight through + // to a local launch on a profile a host was writing to. + crate::remote_handoff::ensure_local_launch_allowed(&profile.id.to_string())?; + crate::team_lock::acquire_team_lock_if_needed(&profile).await?; + log::info!("Opening URL with selected profile"); // Use launch_or_open_url which handles both launching new instances and opening in existing ones @@ -1281,6 +1372,12 @@ pub async fn launch_browser_profile_impl( )); } + // Refuse a launch that would run over work a remote session has not handed + // back yet. Checked before the profile lock because it answers without a + // round trip and because it stays true after the session's lock is released: + // the lock protects the browser, this protects the bytes it wrote. + crate::remote_handoff::ensure_local_launch_allowed(&profile.id.to_string())?; + // Team lock check: if profile is sync-enabled and user is on a team, acquire lock crate::team_lock::acquire_team_lock_if_needed(&profile).await?; diff --git a/src-tauri/src/cdp_target.rs b/src-tauri/src/cdp_target.rs new file mode 100644 index 0000000..1fa8baa --- /dev/null +++ b/src-tauri/src/cdp_target.rs @@ -0,0 +1,1380 @@ +//! Where a profile's browser actually is, and how to talk to it. +//! +//! Until this module existed, every automation tool answered "where is this +//! browser?" by reading a LOCAL debugging port out of the LOCAL profile +//! directory. A profile launched on a leased host has no local port and no +//! local process, so a customer who paid for remote execution could start a +//! session and then do nothing with it — the one thing the feature exists for. +//! +//! There is exactly one resolver here, [`resolve`], and one connection type, +//! [`CdpConnection`]. Tools ask for a target and get either a page socket on +//! this machine or a relayed socket to the fleet; nothing above this module +//! branches on which. That is deliberate: a parallel set of remote-only tools +//! would drift from the local ones within a release. +//! +//! The remote arm reaches donutbrowser-infra with the USER's own access token. +//! The desktop holds no fleet credential and knows no fleet hostname — infra +//! verifies the session belongs to the caller and relays onward with its own +//! service credential. That boundary is why this is a relay and not a direct +//! connection. + +use crate::profile::types::BrowserProfile; +use serde_json::Value; +use std::time::Duration; +use tokio::net::TcpStream; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::handshake::client::Request as WsRequest; +use tokio_tungstenite::tungstenite::protocol::WebSocketConfig; +use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; + +/// How long the WebSocket handshake may take. +/// +/// A remote attach crosses desktop → infra → wayfern → agent → the VM, so this +/// is far longer than a loopback connect needs. It matches the relay's own +/// upstream handshake budget: waiting longer than the server does can only +/// report a timeout the server already reported. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(20); + +/// How long one CDP command may wait for its reply. +/// +/// Without a cap, a browser that never answers holds the caller until the +/// socket dies — 90 seconds on the relay, indefinitely on loopback. An +/// automation client that hangs is worse than one that fails. +const COMMAND_TIMEOUT: Duration = Duration::from_secs(60); + +/// Attempts at establishing a connection before giving up. +const CONNECT_ATTEMPTS: u32 = 3; + +/// Delay before the second connect attempt; doubles for the third. +const CONNECT_RETRY_BASE: Duration = Duration::from_millis(400); + +/// Ceiling on a relayed CDP message. +/// +/// Matches the relay's client-facing cap, which matches the fleet's upstream +/// frame cap. Lower, and a screenshot the server was willing to carry is +/// dropped on arrival; higher buys nothing, because the frame never crosses the +/// relay in the first place. +const REMOTE_MAX_MESSAGE_BYTES: usize = 16 * 1024 * 1024; + +/// Command ids for the two messages the remote arm sends before any tool does. +/// +/// Held far above anything a caller uses, so a late reply to the attach +/// handshake can never be mistaken for a tool's answer: both travel on one +/// socket, the handshake on the BROWSER session and every tool on the page +/// session. +const HANDSHAKE_GET_TARGETS_ID: u64 = 9_000_001; +const HANDSHAKE_ATTACH_ID: u64 = 9_000_002; + +/// Where a profile's browser is, and what is needed to reach it. +#[derive(Debug, Clone)] +pub enum CdpTarget { + /// A browser on this machine. The URL is a PAGE-level socket, so commands + /// carry no CDP session id. + Local { ws_url: String }, + /// A browser on the fleet, reached through the infra relay. The relay bridges + /// a BROWSER-level socket, so the connection attaches to a page and stamps + /// every subsequent message with the resulting session id. + Remote { + ws_url: String, + bearer: String, + session_id: String, + }, +} + +impl CdpTarget { + /// True when this browser is on the leased fleet rather than this machine. + pub fn is_remote(&self) -> bool { + matches!(self, Self::Remote { .. }) + } + + /// A short label for logs and errors. Never carries the credential. + pub fn describe(&self) -> String { + match self { + Self::Local { .. } => "local browser".to_string(), + Self::Remote { session_id, .. } => format!("remote session {session_id}"), + } + } +} + +/// Why a target could not be reached, or a command could not be run. +/// +/// The variants exist so a caller can tell "come back when it is up" from "that +/// credential is no good" from "the socket broke". Collapsing them into one +/// string is how a session that is merely still provisioning gets reported as a +/// broken one. +#[derive(Debug)] +pub enum CdpError { + /// Nothing is listening, or the relay could not reach the browser. + Unreachable(String), + /// The relay refused the credential. + Unauthorized(String), + /// The session exists but is not in a state that can be driven. + NotDrivable(String), + /// The socket broke, or a reply never arrived. + Transport(String), + /// The browser answered with a CDP `error` object. + Protocol(String), +} + +impl std::fmt::Display for CdpError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Unreachable(m) => write!(f, "browser unreachable: {m}"), + Self::Unauthorized(m) => write!(f, "not authorised to drive this browser: {m}"), + Self::NotDrivable(m) => write!(f, "browser is not drivable yet: {m}"), + Self::Transport(m) => write!(f, "CDP transport failed: {m}"), + Self::Protocol(m) => write!(f, "CDP error: {m}"), + } + } +} + +impl CdpError { + /// Whether a fresh connection attempt could plausibly succeed. + /// + /// A refused credential and a session that is still provisioning are answers, + /// not failures. Retrying either spends the caller's time and, on the relay, + /// burns one of the four attachments a session is allowed — so the retry can + /// make the next honest attempt fail too. + fn is_retryable(&self) -> bool { + matches!(self, Self::Unreachable(_) | Self::Transport(_)) + } +} + +/// Why a profile could not be resolved to a browser at all. +#[derive(Debug)] +pub enum ResolveError { + /// The profile is not one this app can drive. + Unsupported(String), + /// Neither a local process nor a live remote session. + NotRunning(String), + /// A remote session exists but its endpoint could not be read. + Endpoint(String), +} + +impl std::fmt::Display for ResolveError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Unsupported(m) | Self::NotRunning(m) | Self::Endpoint(m) => write!(f, "{m}"), + } + } +} + +/// Whether the caller is willing to wait for a browser that is still coming up. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Patience { + /// One attempt, used to decide local-vs-remote without stalling. + Immediate, + /// The full retry budget, used once the answer is known to be local. + WaitForLaunch, +} + +impl Patience { + fn attempts(self, waiting: u32) -> u32 { + match self { + Self::Immediate => 1, + Self::WaitForLaunch => waiting, + } + } +} + +/// Find the browser for `profile`, wherever it is running. +/// +/// Local wins when both look possible: the profile lock makes a genuine overlap +/// impossible, and a browser on this machine is free to drive while a relayed +/// one crosses two networks. +/// +/// The local check is deliberately split in two. One cheap probe decides the +/// arm, so a profile running on the fleet is not held behind twenty-five +/// seconds of local retries; only once remote has been ruled out does the local +/// probe spend its full budget waiting for a browser that is still starting. +/// The same split covers a stale `process_id` left by a crash — nothing answers +/// on the recorded port, so the fleet session is found instead of a dead one. +pub async fn resolve(profile: &BrowserProfile) -> Result { + if profile.browser != "wayfern" { + return Err(ResolveError::Unsupported(format!( + "Profile '{}' runs {}, which cannot be driven over CDP", + profile.name, profile.browser + ))); + } + + let has_local_process = profile.process_id.is_some(); + + if has_local_process { + if let Some(ws_url) = local_page_ws_url(profile, Patience::Immediate).await { + return Ok(CdpTarget::Local { ws_url }); + } + } + + if let Some(session) = + crate::remote_session::live_session_for_profile(&profile.id.to_string()).await + { + let endpoint = crate::remote_session::cdp_endpoint(&session.session_id) + .await + .map_err(|e| ResolveError::Endpoint(e.to_string()))?; + let bearer = crate::remote_session::access_token_for_cdp().map_err(ResolveError::Endpoint)?; + log::info!( + "Driving profile '{}' through remote session {}", + profile.name, + session.session_id + ); + return Ok(CdpTarget::Remote { + ws_url: endpoint.ws_url, + bearer, + session_id: session.session_id, + }); + } + + if has_local_process { + return match local_page_ws_url(profile, Patience::WaitForLaunch).await { + Some(ws_url) => Ok(CdpTarget::Local { ws_url }), + None => Err(ResolveError::NotRunning(format!( + "No CDP connection available for profile '{}'. Make sure the browser is running.", + profile.name + ))), + }; + } + + Err(ResolveError::NotRunning(format!( + "Profile '{}' is not running", + profile.name + ))) +} + +/// The debugging port a locally launched browser registered for this profile. +async fn local_cdp_port(profile: &BrowserProfile, patience: Patience) -> Option { + let profiles_dir = crate::profile::manager::ProfileManager::instance().get_profiles_dir(); + let profile_path = profile.get_profile_data_path(&profiles_dir); + let profile_path_str = profile_path.to_string_lossy().to_string(); + + // Port info is written once the process is up, so a tool called straight + // after a launch has to wait for it. + for attempt in 0..patience.attempts(10) { + if attempt > 0 { + tokio::time::sleep(Duration::from_secs(1)).await; + } + if let Some(port) = crate::wayfern_manager::WayfernManager::instance() + .get_cdp_port(&profile_path_str) + .await + { + return Some(port); + } + } + None +} + +/// A page-level socket on a locally running browser. +/// +/// Returns `None` rather than an error: a miss is how the resolver decides the +/// browser is not local, and a port whose process has since died answers +/// nothing, which is exactly the signal that decision needs. +async fn local_page_ws_url(profile: &BrowserProfile, patience: Patience) -> Option { + let port = local_cdp_port(profile, patience).await?; + let listing = format!("http://127.0.0.1:{port}/json"); + let client = reqwest::Client::new(); + + let mut last_err = String::new(); + for attempt in 0..patience.attempts(15) { + if attempt > 0 { + tokio::time::sleep(Duration::from_secs(1)).await; + } + match client + .get(&listing) + .timeout(Duration::from_secs(3)) + .send() + .await + { + Ok(response) => match response.json::>().await { + Ok(targets) => { + if let Some(ws_url) = pick_local_page_socket(&targets) { + return Some(ws_url); + } + last_err = "no page target found in browser".to_string(); + } + Err(e) => last_err = format!("failed to parse CDP targets: {e}"), + }, + Err(e) => last_err = format!("failed to reach the browser's CDP endpoint: {e}"), + } + } + + if patience == Patience::WaitForLaunch { + log::warn!("Local CDP discovery on port {port} gave up: {last_err}"); + } + None +} + +/// Pick a drivable page from what `/json` lists on a local browser. +pub fn pick_local_page_socket(targets: &[Value]) -> Option { + targets + .iter() + .find(|t| t.get("type").and_then(Value::as_str) == Some("page")) + .and_then(|t| t.get("webSocketDebuggerUrl")) + .and_then(Value::as_str) + .map(str::to_string) +} + +/// Pick a drivable page from a `Target.getTargets` reply. +/// +/// DevTools' own frontend is a page target too, and attaching to it drives the +/// inspector instead of the site — a failure that reports success and moves +/// nothing. +pub fn pick_remote_page_target(result: &Value) -> Option { + result + .get("targetInfos") + .and_then(Value::as_array)? + .iter() + .find(|info| { + let is_page = info.get("type").and_then(Value::as_str) == Some("page"); + let url = info.get("url").and_then(Value::as_str).unwrap_or_default(); + is_page && !url.starts_with("devtools://") + }) + .and_then(|info| info.get("targetId")) + .and_then(Value::as_str) + .map(str::to_string) +} + +/// One outgoing CDP message, addressed to a page when a session id is in play. +/// +/// Flattened sessions keep `method` at the top level on the way back, so event +/// matching is identical on both arms and no caller has to know which it is on. +pub fn cdp_frame(session: Option<&str>, id: u64, method: &str, params: Value) -> Value { + let mut message = serde_json::json!({ "id": id, "method": method, "params": params }); + if let Some(session) = session { + message["sessionId"] = Value::String(session.to_string()); + } + message +} + +/// Why the peer hung up. +#[derive(Debug, Clone)] +struct CloseInfo { + code: u16, + reason: String, +} + +/// An open CDP conversation with one browser, local or relayed. +pub struct CdpConnection { + stream: WebSocketStream>, + /// Set only for a relayed connection: stamped onto every outgoing message so + /// page-level commands reach the page rather than the browser. + cdp_session: Option, + closed: Option, +} + +impl CdpConnection { + fn new(stream: WebSocketStream>) -> Self { + Self { + stream, + cdp_session: None, + closed: None, + } + } + + /// Send `method` as command `id`. + pub async fn send_command( + &mut self, + id: u64, + method: &str, + params: Value, + ) -> Result<(), CdpError> { + use futures_util::sink::SinkExt; + let frame = cdp_frame(self.cdp_session.as_deref(), id, method, params); + self + .stream + .send(Message::Text(frame.to_string().into())) + .await + .map_err(|e| CdpError::Transport(format!("failed to send CDP command: {e}"))) + } + + /// The next text message, or `None` once the peer has gone. + /// + /// Binary frames, pings and pongs are consumed silently; a close is recorded + /// so its reason survives into whatever error the caller builds. + pub async fn next_text(&mut self) -> Option> { + use futures_util::stream::StreamExt; + loop { + match self.stream.next().await? { + Ok(Message::Text(text)) => return Some(Ok(text.to_string())), + Ok(Message::Close(frame)) => { + self.closed = frame.map(|f| CloseInfo { + code: u16::from(f.code), + reason: f.reason.to_string(), + }); + return None; + } + Ok(_) => continue, + Err(e) => { + return Some(Err(CdpError::Transport(format!( + "CDP WebSocket error: {e}" + )))) + } + } + } + } + + /// Turn a hang-up into the error it means. + /// + /// The relay's close codes are its whole vocabulary: 1008 is "that credential + /// is no good", 1013 is "come back when the session is up". Reporting either + /// as a generic transport failure throws away the only actionable thing the + /// server said. + pub fn closed_error(&self, context: &str) -> CdpError { + match &self.closed { + Some(info) if info.reason.is_empty() => { + classify_close(info.code, format!("{context} (close {})", info.code)) + } + Some(info) => classify_close( + info.code, + format!("{context} ({}: {})", info.code, info.reason), + ), + None => CdpError::Transport(format!("{context} (connection closed)")), + } + } + + /// Send a command and read its reply, bounded by [`COMMAND_TIMEOUT`]. + pub async fn call(&mut self, id: u64, method: &str, params: Value) -> Result { + self.send_command(id, method, params).await?; + self.await_reply(id, COMMAND_TIMEOUT).await + } + + /// Read until the reply to `id` arrives, discarding events on the way. + pub async fn await_reply(&mut self, id: u64, timeout: Duration) -> Result { + let deadline = tokio::time::Instant::now() + timeout; + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + return Err(CdpError::Transport( + "timed out waiting for a CDP response".to_string(), + )); + } + let text = match tokio::time::timeout(remaining, self.next_text()).await { + Err(_) => { + return Err(CdpError::Transport( + "timed out waiting for a CDP response".to_string(), + )) + } + Ok(None) => return Err(self.closed_error("no response received from CDP")), + Ok(Some(result)) => result?, + }; + + let response: Value = serde_json::from_str(&text) + .map_err(|e| CdpError::Protocol(format!("failed to parse CDP response: {e}")))?; + if response.get("id") != Some(&Value::from(id)) { + continue; + } + if let Some(error) = response.get("error") { + return Err(CdpError::Protocol(error.to_string())); + } + return Ok( + response + .get("result") + .cloned() + .unwrap_or_else(|| serde_json::json!({})), + ); + } + } + + /// Hang up politely so the peer releases its side immediately. + /// + /// On the relay every open socket costs a real stream on the leased host and + /// counts against the session's attachment cap, so dropping the TCP + /// connection and letting it time out is not good enough. + pub async fn close(mut self) { + let _ = self.stream.close(None).await; + } + + /// Move a browser-level socket onto a page. + /// + /// The relay bridges `/devtools/browser/`. Every tool here speaks + /// `Page.*`, `Runtime.*` and `Input.*`, which a browser socket answers with + /// `'Page.navigate' wasn't found`. Attaching flat, and stamping the resulting + /// session id onto everything after it, is what makes the tools this app + /// already has work remotely without a single per-tool change. + async fn attach_to_page(&mut self) -> Result<(), CdpError> { + let targets = self + .call( + HANDSHAKE_GET_TARGETS_ID, + "Target.getTargets", + serde_json::json!({}), + ) + .await?; + let target_id = pick_remote_page_target(&targets) + .ok_or_else(|| CdpError::NotDrivable("the remote browser has no page open".to_string()))?; + + let attached = self + .call( + HANDSHAKE_ATTACH_ID, + "Target.attachToTarget", + serde_json::json!({ "targetId": target_id, "flatten": true }), + ) + .await?; + + let session = attached + .get("sessionId") + .and_then(Value::as_str) + .ok_or_else(|| { + CdpError::Protocol("Target.attachToTarget returned no sessionId".to_string()) + })?; + self.cdp_session = Some(session.to_string()); + Ok(()) + } +} + +/// Ids used by the one-shot command runners below. +/// +/// A caller never picks these, so they are stated once here rather than being +/// re-derived at each call site. +const RUN_PAGE_ENABLE_ID: u64 = 1; +const RUN_COMMAND_ID: u64 = 2; +const RUN_PAGE_DISABLE_ID: u64 = 3; + +/// The attach handshake and the commands after it share one socket, so a +/// handshake reply carrying a command's id would be handed back as that +/// command's result. Checked at compile time because the failure it prevents is +/// silent: the wrong reply is still a well-formed reply. +const _: () = { + assert!(HANDSHAKE_GET_TARGETS_ID != HANDSHAKE_ATTACH_ID); + assert!(HANDSHAKE_GET_TARGETS_ID != RUN_PAGE_ENABLE_ID); + assert!(HANDSHAKE_GET_TARGETS_ID != RUN_COMMAND_ID); + assert!(HANDSHAKE_GET_TARGETS_ID != RUN_PAGE_DISABLE_ID); + assert!(HANDSHAKE_ATTACH_ID != RUN_PAGE_ENABLE_ID); + assert!(HANDSHAKE_ATTACH_ID != RUN_COMMAND_ID); + assert!(HANDSHAKE_ATTACH_ID != RUN_PAGE_DISABLE_ID); +}; + +/// Run one command on a fresh connection and hand back its result. +pub async fn run_command( + target: &CdpTarget, + method: &str, + params: Value, +) -> Result { + let mut connection = target.connect().await?; + let result = connection.call(RUN_COMMAND_ID, method, params).await; + connection.close().await; + result +} + +/// Run one command, then wait for the page to finish loading. +/// +/// Used for anything that might navigate: `Page.navigate` obviously, but also a +/// click or a script that turns out to follow a link. When nothing navigates, +/// the wait simply expires and the command's own result is returned — that is +/// the intended path, not a failure. +pub async fn run_command_awaiting_load( + target: &CdpTarget, + method: &str, + params: Value, + timeout_secs: u64, +) -> Result { + let mut connection = target.connect().await?; + + // Page events have to be on before the command runs, or `loadEventFired` + // for a fast navigation is missed and the wait runs to its full timeout. + connection + .call(RUN_PAGE_ENABLE_ID, "Page.enable", serde_json::json!({})) + .await?; + connection + .send_command(RUN_COMMAND_ID, method, params) + .await?; + + let mut command_result = None; + let mut failure = None; + let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs); + + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + break; + } + let text = match tokio::time::timeout(remaining, connection.next_text()).await { + Ok(Some(Ok(text))) => text, + Ok(Some(Err(e))) => { + failure = Some(e); + break; + } + // The peer hung up, or the wait expired. Either way whatever the command + // already answered is the best result available. + Ok(None) | Err(_) => break, + }; + + let response: Value = serde_json::from_str(&text).unwrap_or_default(); + + if response.get("id") == Some(&Value::from(RUN_COMMAND_ID)) { + if let Some(error) = response.get("error") { + failure = Some(CdpError::Protocol(error.to_string())); + break; + } + command_result = Some( + response + .get("result") + .cloned() + .unwrap_or_else(|| serde_json::json!({})), + ); + } + + // Flattened remote sessions keep `method` at the top level, so this match + // is identical on both arms. + if response.get("method") == Some(&Value::from("Page.loadEventFired")) { + break; + } + } + + let _ = connection + .send_command(RUN_PAGE_DISABLE_ID, "Page.disable", serde_json::json!({})) + .await; + let closed = connection.closed_error("no response received from CDP"); + connection.close().await; + + if let Some(error) = failure { + return Err(error); + } + command_result.ok_or(closed) +} + +/// Point a browser at a URL and wait for it to settle. +/// +/// This is what "open a URL in that profile" means once the browser is already +/// up, wherever it is. A remote session navigates its existing page rather than +/// opening a tab: a tab opened on a leased host that nobody can see or close is +/// not a feature, it is litter on hardware the user is paying for by the hour. +pub async fn navigate(target: &CdpTarget, url: &str, timeout_secs: u64) -> Result<(), CdpError> { + run_command_awaiting_load( + target, + "Page.navigate", + serde_json::json!({ "url": url }), + timeout_secs, + ) + .await + .map(|_| ()) +} + +/// Map a WebSocket close code onto what the caller should do about it. +pub fn classify_close(code: u16, detail: String) -> CdpError { + match code { + 1008 => CdpError::Unauthorized(detail), + 1013 => CdpError::NotDrivable(detail), + 1009 => CdpError::Transport(format!("{detail} — message too large")), + 1011 => CdpError::Unreachable(detail), + _ => CdpError::Transport(detail), + } +} + +impl CdpTarget { + /// Open a conversation with this browser. + /// + /// Retries a connection that failed for a reason a retry could fix, and never + /// one that failed because the answer was no. + pub async fn connect(&self) -> Result { + let mut attempt = 0u32; + loop { + let error = match self.connect_once().await { + Ok(connection) => return Ok(connection), + Err(e) => e, + }; + attempt += 1; + if attempt >= CONNECT_ATTEMPTS || !error.is_retryable() { + return Err(error); + } + let delay = CONNECT_RETRY_BASE * 2u32.pow(attempt - 1); + log::warn!( + "CDP connect to {} failed ({error}); retrying in {}ms", + self.describe(), + delay.as_millis() + ); + tokio::time::sleep(delay).await; + } + } + + async fn connect_once(&self) -> Result { + match self { + Self::Local { ws_url } => { + let request = ws_url + .as_str() + .into_client_request() + .map_err(|e| CdpError::Unreachable(format!("invalid CDP endpoint: {e}")))?; + Ok(CdpConnection::new(dial(request, None).await?)) + } + Self::Remote { + ws_url, + bearer, + session_id, + } => { + let mut connection = dial_relay(ws_url, bearer).await?; + if let Err(e) = connection.attach_to_page().await { + log::warn!("Could not attach to a page in remote session {session_id}: {e}"); + return Err(e); + } + Ok(connection) + } + } + } +} + +/// A relay socket with nothing done to it yet. +pub type RelaySocket = WebSocketStream>; + +/// Open a session's BROWSER-level relay socket and hand it back untouched. +/// +/// Deliberately skips the page attach that [`CdpTarget::connect`] performs. The +/// tools in this app all speak `Page.*` and need a page session stamped onto +/// every message; an external automation client does not, and must not have +/// one. Playwright's `connectOverCDP` expects the browser endpoint: it drives +/// `Target.setAutoAttach` and `Target.getTargets` itself and builds its own +/// session map, so a socket already attached to one page would hide every other +/// target from it and stamp a session id onto messages it did not address. +/// +/// This is what makes a remote session usable from outside the app at all. The +/// relay only accepts the user's cloud credential, which no API consumer holds +/// and none should — so the socket is opened here, with the credential this +/// process already has, and proxied to the caller. +pub async fn open_relay_socket(session_id: &str) -> Result { + let endpoint = crate::remote_session::cdp_endpoint(session_id) + .await + .map_err(endpoint_lookup_error)?; + let bearer = crate::remote_session::access_token_for_cdp().map_err(CdpError::Unauthorized)?; + + let config = relay_socket_config(); + let refused = match dial(relay_request(&endpoint.ws_url, &bearer)?, Some(config)).await { + Ok(stream) => return Ok(stream), + Err(CdpError::Unauthorized(reason)) => reason, + Err(e) => return Err(e), + }; + + log::info!("The CDP relay refused the stored access token; refreshing and retrying once"); + crate::cloud_auth::CLOUD_AUTH + .refresh_access_token() + .await + .map_err(|e| CdpError::Unauthorized(format!("{refused}; token refresh failed: {e}")))?; + let token = crate::remote_session::access_token_for_cdp() + .map_err(|e| CdpError::Unauthorized(format!("{refused}; {e}")))?; + dial(relay_request(&endpoint.ws_url, &token)?, Some(config)).await +} + +/// Why the backend would not say where to attach. +/// +/// Collapsing this into "unreachable" is what made a session the user had +/// already stopped answer 502, so an automation client read a finished session +/// as a broken gateway and retried it. A session that is over, or that is not +/// the caller's, is a 404: there is no browser at this address. +fn endpoint_lookup_error(err: crate::remote_session::RemoteSessionError) -> CdpError { + use crate::remote_session::RemoteSessionError; + match err { + RemoteSessionError::NotAuthorised(m) => CdpError::Unauthorized(m), + RemoteSessionError::Conflict(m) => CdpError::NotDrivable(m), + RemoteSessionError::NoCapacity(m) => CdpError::Unreachable(m), + RemoteSessionError::Other(m) => { + // `Other` carries the backend's own envelope. A 404 for a closed or + // foreign session arrives here, and it is the common case rather than an + // exotic one, so it is read back out rather than lumped in with a + // genuine transport failure. + if m.contains("REMOTE_SESSION_NOT_FOUND") || m.contains("404") { + CdpError::NotDrivable(m) + } else { + CdpError::Unreachable(m) + } + } + } +} + +/// Frame limits for a relay socket. Matches the relay's own client-facing cap. +pub fn relay_socket_config() -> WebSocketConfig { + WebSocketConfig::default() + .max_message_size(Some(REMOTE_MAX_MESSAGE_BYTES)) + .max_frame_size(Some(REMOTE_MAX_MESSAGE_BYTES)) +} + +/// The ceiling a proxied CDP message may reach, so both ends agree. +pub const MAX_RELAY_MESSAGE_BYTES: usize = REMOTE_MAX_MESSAGE_BYTES; + +/// Open the relay socket, refreshing the access token once if it is refused. +/// +/// The access token lives long enough that an app left open overnight still +/// holds a valid-looking one after it has been rotated. Failing a whole tool +/// call for that — when the very next HTTP request would have refreshed it +/// silently — is a bug the user reads as "remote driving is flaky". +async fn dial_relay(ws_url: &str, bearer: &str) -> Result { + let config = relay_socket_config(); + + let refused = match dial(relay_request(ws_url, bearer)?, Some(config)).await { + Ok(stream) => return Ok(CdpConnection::new(stream)), + Err(CdpError::Unauthorized(reason)) => reason, + Err(e) => return Err(e), + }; + + log::info!("The CDP relay refused the stored access token; refreshing and retrying once"); + crate::cloud_auth::CLOUD_AUTH + .refresh_access_token() + .await + .map_err(|e| CdpError::Unauthorized(format!("{refused}; token refresh failed: {e}")))?; + let token = crate::remote_session::access_token_for_cdp() + .map_err(|e| CdpError::Unauthorized(format!("{refused}; {e}")))?; + let stream = dial(relay_request(ws_url, &token)?, Some(config)).await?; + Ok(CdpConnection::new(stream)) +} + +/// Build the upgrade request for the relay. +/// +/// The credential goes in a header, never the query string: a URL that grants +/// full control of a live browser must not reach a proxy access log. +fn relay_request(ws_url: &str, bearer: &str) -> Result { + let mut request = ws_url + .into_client_request() + .map_err(|e| CdpError::Unreachable(format!("invalid relay endpoint: {e}")))?; + let value = format!("Bearer {bearer}").parse().map_err(|_| { + CdpError::Unauthorized("the stored access token is not a valid header value".to_string()) + })?; + request.headers_mut().insert( + tokio_tungstenite::tungstenite::http::header::AUTHORIZATION, + value, + ); + Ok(request) +} + +/// Perform the handshake, bounded by [`CONNECT_TIMEOUT`]. +async fn dial( + request: WsRequest, + config: Option, +) -> Result>, CdpError> { + let connect = tokio_tungstenite::connect_async_with_config(request, config, false); + match tokio::time::timeout(CONNECT_TIMEOUT, connect).await { + Err(_) => Err(CdpError::Unreachable(format!( + "the CDP endpoint did not answer within {}s", + CONNECT_TIMEOUT.as_secs() + ))), + Ok(Ok((stream, _response))) => Ok(stream), + Ok(Err(tokio_tungstenite::tungstenite::Error::Http(response))) => { + Err(classify_handshake_status(response.status().as_u16())) + } + Ok(Err(e)) => Err(CdpError::Unreachable(e.to_string())), + } +} + +/// Map a refused upgrade onto the error it means. +/// +/// A 401 is a credential problem the caller can fix by signing in again; a 404 +/// means the session is not theirs or no longer exists. Surfacing either as +/// "connection failed" is what makes an automation client retry forever. +pub fn classify_handshake_status(status: u16) -> CdpError { + match status { + 401 | 403 => { + CdpError::Unauthorized(format!("the relay refused the credential (HTTP {status})")) + } + 404 => CdpError::NotDrivable( + "no such remote session, or it does not belong to this account".to_string(), + ), + 409 => CdpError::NotDrivable("the remote session is not drivable yet".to_string()), + 429 => CdpError::NotDrivable("too many attachments to this remote session".to_string()), + other => CdpError::Unreachable(format!("the relay answered HTTP {other}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_page_target_is_preferred_over_the_devtools_frontend() { + // Attaching to devtools:// drives the inspector, not the site, and the + // failure is silent: navigate returns success and nothing moves. + let targets = serde_json::json!({ + "targetInfos": [ + { "targetId": "t-devtools", "type": "page", "url": "devtools://devtools/bundled/x.html" }, + { "targetId": "t-page", "type": "page", "url": "https://example.com/" }, + ] + }); + assert_eq!(pick_remote_page_target(&targets).as_deref(), Some("t-page")); + } + + #[test] + fn service_workers_and_browser_targets_are_not_pages() { + let targets = serde_json::json!({ + "targetInfos": [ + { "targetId": "t-sw", "type": "service_worker", "url": "https://example.com/sw.js" }, + { "targetId": "t-browser", "type": "browser", "url": "" }, + ] + }); + assert!(pick_remote_page_target(&targets).is_none()); + } + + #[test] + fn a_fresh_browser_showing_only_about_blank_is_still_drivable() { + // The first thing a remote launch has open is about:blank. Refusing it + // would leave every session unusable until the user navigated by hand — + // which they cannot do, because navigating is what needs the attach. + let targets = serde_json::json!({ + "targetInfos": [{ "targetId": "t-blank", "type": "page", "url": "about:blank" }] + }); + assert_eq!( + pick_remote_page_target(&targets).as_deref(), + Some("t-blank") + ); + } + + #[test] + fn an_empty_reply_resolves_to_no_target_rather_than_panicking() { + assert!(pick_remote_page_target(&serde_json::json!({})).is_none()); + assert!(pick_remote_page_target(&serde_json::json!({ "targetInfos": [] })).is_none()); + } + + #[test] + fn the_local_page_socket_is_read_from_the_json_listing() { + let targets = vec![ + serde_json::json!({ "type": "background_page", "webSocketDebuggerUrl": "ws://x/bg" }), + serde_json::json!({ "type": "page", "webSocketDebuggerUrl": "ws://127.0.0.1:1/devtools/page/A" }), + ]; + assert_eq!( + pick_local_page_socket(&targets).as_deref(), + Some("ws://127.0.0.1:1/devtools/page/A") + ); + assert!(pick_local_page_socket(&[]).is_none()); + } + + #[test] + fn a_remote_frame_addresses_the_page_and_a_local_one_does_not() { + // A page-level command sent on the relay's BROWSER socket comes back as + // "'Page.navigate' wasn't found". One missing sessionId on one message is + // enough to make a single tool fail while every other tool works — a + // partial failure that reads as a flaky VM. + let remote = cdp_frame( + Some("SESSION-42"), + 7, + "Page.navigate", + serde_json::json!({ "url": "https://example.com" }), + ); + assert_eq!(remote["sessionId"], "SESSION-42"); + assert_eq!(remote["id"], 7); + assert_eq!(remote["method"], "Page.navigate"); + assert_eq!(remote["params"]["url"], "https://example.com"); + + let local = cdp_frame(None, 7, "Page.navigate", serde_json::json!({})); + assert!(local.get("sessionId").is_none()); + assert_eq!(local["id"], 7); + } + + #[test] + fn a_relay_close_says_what_the_caller_should_do_about_it() { + // These codes are the relay's entire vocabulary. Collapsing them into one + // transport failure is how "your session is still provisioning" and "you + // are signed out" both become "something went wrong". + assert!(matches!( + classify_close(1008, "x".into()), + CdpError::Unauthorized(_) + )); + assert!(matches!( + classify_close(1013, "x".into()), + CdpError::NotDrivable(_) + )); + assert!(matches!( + classify_close(1011, "x".into()), + CdpError::Unreachable(_) + )); + assert!(matches!( + classify_close(1009, "x".into()), + CdpError::Transport(_) + )); + assert!(matches!( + classify_close(1000, "x".into()), + CdpError::Transport(_) + )); + } + + #[test] + fn only_the_failures_a_retry_could_fix_are_retried() { + assert!(CdpError::Unreachable("x".into()).is_retryable()); + assert!(CdpError::Transport("x".into()).is_retryable()); + assert!(!CdpError::Unauthorized("x".into()).is_retryable()); + assert!(!CdpError::NotDrivable("x".into()).is_retryable()); + assert!(!CdpError::Protocol("x".into()).is_retryable()); + } + + #[test] + fn a_refused_upgrade_is_not_reported_as_an_unreachable_browser() { + assert!(matches!( + classify_handshake_status(401), + CdpError::Unauthorized(_) + )); + assert!(matches!( + classify_handshake_status(404), + CdpError::NotDrivable(_) + )); + assert!(matches!( + classify_handshake_status(409), + CdpError::NotDrivable(_) + )); + assert!(matches!( + classify_handshake_status(429), + CdpError::NotDrivable(_) + )); + assert!(matches!( + classify_handshake_status(502), + CdpError::Unreachable(_) + )); + } + + #[test] + fn a_relay_endpoint_carries_the_credential_in_a_header() { + // In the query string it would reach every proxy log between here and the + // origin, and this credential grants full control of a live browser. + let request = relay_request( + "wss://api.donutbrowser.com/api/remote-sessions/cdp?session_id=s1", + "secret-token", + ) + .expect("a wss endpoint must build a request"); + assert_eq!( + request + .headers() + .get("authorization") + .and_then(|v| v.to_str().ok()), + Some("Bearer secret-token") + ); + assert!(!request.uri().to_string().contains("secret-token")); + } + + #[test] + fn a_session_that_is_over_is_not_reported_as_a_broken_gateway() { + // Observed against the real backend: attaching to a session the user had + // just stopped answered 502, so a CDP client read "this is finished" as + // "the gateway is down" and retried it. + use crate::remote_session::RemoteSessionError; + assert!(matches!( + endpoint_lookup_error(RemoteSessionError::Other( + r#"(404) {"code":"REMOTE_SESSION_NOT_FOUND"}"#.to_string() + )), + CdpError::NotDrivable(_) + )); + assert!(matches!( + endpoint_lookup_error(RemoteSessionError::Conflict("already open".into())), + CdpError::NotDrivable(_) + )); + assert!(matches!( + endpoint_lookup_error(RemoteSessionError::NotAuthorised("signed out".into())), + CdpError::Unauthorized(_) + )); + // A genuine transport failure must still read as one. + assert!(matches!( + endpoint_lookup_error(RemoteSessionError::Other( + "reach backend: connection refused".to_string() + )), + CdpError::Unreachable(_) + )); + } + + #[test] + fn a_malformed_endpoint_is_refused_rather_than_dialled() { + assert!(relay_request("not a url", "t").is_err()); + } + + #[test] + fn a_target_describes_itself_without_leaking_the_credential() { + let remote = CdpTarget::Remote { + ws_url: "wss://api.donutbrowser.com/api/remote-sessions/cdp?session_id=s1".to_string(), + bearer: "secret-token".to_string(), + session_id: "s1".to_string(), + }; + assert!(remote.is_remote()); + let described = remote.describe(); + assert!(described.contains("s1")); + assert!(!described.contains("secret-token")); + + let local = CdpTarget::Local { + ws_url: "ws://127.0.0.1:1/devtools/page/A".to_string(), + }; + assert!(!local.is_remote()); + } + + #[test] + fn a_hasty_probe_tries_once_and_a_patient_one_waits() { + // The split is what stops a profile running on the fleet from being held + // behind twenty-five seconds of local retries before anyone looks remote. + assert_eq!(Patience::Immediate.attempts(10), 1); + assert_eq!(Patience::WaitForLaunch.attempts(10), 10); + } + + // --- Against a real socket ----------------------------------------------- + // + // Everything above is pure. These drive the client against a WebSocket + // server that answers the way the relay does, because the failure this whole + // module exists to fix — page commands sent on a browser-level socket coming + // back as "'Page.navigate' wasn't found" — cannot be caught by inspecting a + // JSON value. It only shows up when something actually answers. + + /// What the fake relay observed. + #[derive(Debug, Default)] + struct RelayLog { + /// The credential the client presented on the upgrade. + authorization: Option, + /// Every message the client sent, in order. + received: Vec, + } + + /// How the fake relay should behave once a client connects. + #[derive(Clone, Copy, PartialEq, Eq)] + enum RelayBehaviour { + /// Answer the attach handshake, then echo every command back. + Cooperative, + /// Hang up the way a session that is not yet up does. + RefuseAsNotDrivable, + } + + /// The CDP session id the fake relay hands out for a flat attach. + const FAKE_CDP_SESSION: &str = "CDP-SESSION-1"; + + /// A stand-in for the infra relay bridged onto a browser-level socket. + /// + /// Answers `Target.getTargets` and `Target.attachToTarget` exactly as a real + /// browser endpoint does, then echoes each command back so the test can read + /// what was actually on the wire. + // + // The large-Err allow is forced by tungstenite's server-callback signature: + // its `ErrorResponse` is a full `http::Response`, and the callback is the + // only place the upgrade request's headers are visible. + #[allow(clippy::result_large_err)] + async fn fake_relay(behaviour: RelayBehaviour) -> (String, tokio::task::JoinHandle) { + use futures_util::sink::SinkExt; + use futures_util::stream::StreamExt; + use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode; + use tokio_tungstenite::tungstenite::protocol::CloseFrame; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("the fake relay must bind"); + let port = listener.local_addr().expect("a bound port").port(); + + let handle = tokio::spawn(async move { + let mut log = RelayLog::default(); + let Ok((socket, _)) = listener.accept().await else { + return log; + }; + + let seen = std::sync::Arc::new(std::sync::Mutex::new(None::)); + let captured = seen.clone(); + let Ok(mut stream) = tokio_tungstenite::accept_hdr_async( + socket, + |request: &WsRequest, + response: tokio_tungstenite::tungstenite::handshake::server::Response| { + *captured.lock().unwrap() = request + .headers() + .get("authorization") + .and_then(|value| value.to_str().ok()) + .map(str::to_string); + Ok(response) + }, + ) + .await + else { + return log; + }; + log.authorization = seen.lock().unwrap().clone(); + + if behaviour == RelayBehaviour::RefuseAsNotDrivable { + let _ = stream + .close(Some(CloseFrame { + code: CloseCode::Library(1013), + reason: "session is provisioning, not drivable".into(), + })) + .await; + return log; + } + + while let Some(Ok(message)) = stream.next().await { + let Message::Text(text) = message else { + continue; + }; + let Ok(request) = serde_json::from_str::(&text) else { + continue; + }; + log.received.push(request.clone()); + + let id = request.get("id").cloned().unwrap_or(Value::Null); + let reply = match request.get("method").and_then(Value::as_str) { + Some("Target.getTargets") => serde_json::json!({ + "id": id, + "result": { "targetInfos": [ + { "targetId": "page-1", "type": "page", "url": "https://example.com/" } + ]} + }), + Some("Target.attachToTarget") => serde_json::json!({ + "id": id, + "result": { "sessionId": FAKE_CDP_SESSION } + }), + // Everything else is handed straight back, so the test can assert on + // the exact frame the client put on the wire. + _ => serde_json::json!({ + "id": id, + "sessionId": request.get("sessionId").cloned().unwrap_or(Value::Null), + "result": { "echo": request } + }), + }; + if stream + .send(Message::Text(reply.to_string().into())) + .await + .is_err() + { + break; + } + + // A real browser follows a navigation with the load event, flattened + // onto the same socket. Emitting it here is what proves the wait + // actually terminates on the event rather than on its timeout. + if request.get("method").and_then(Value::as_str) == Some("Page.navigate") { + let loaded = serde_json::json!({ + "method": "Page.loadEventFired", + "sessionId": request.get("sessionId").cloned().unwrap_or(Value::Null), + "params": { "timestamp": 1.0 } + }); + if stream + .send(Message::Text(loaded.to_string().into())) + .await + .is_err() + { + break; + } + } + } + log + }); + + (format!("ws://127.0.0.1:{port}"), handle) + } + + #[tokio::test] + async fn a_relayed_page_command_is_attached_and_stamped_with_its_session() { + // This is the whole feature. The relay bridges /devtools/browser/, so + // without the flat attach and the sessionId stamp every existing tool + // answers "'Page.navigate' wasn't found" and a paid remote session cannot + // be used for anything. + let (ws_url, server) = fake_relay(RelayBehaviour::Cooperative).await; + let target = CdpTarget::Remote { + ws_url, + bearer: "user-access-token".to_string(), + session_id: "sess-1".to_string(), + }; + + let result = run_command( + &target, + "Page.navigate", + serde_json::json!({ "url": "https://example.com" }), + ) + .await + .expect("a relayed navigate must succeed"); + + assert_eq!(result["echo"]["method"], "Page.navigate"); + assert_eq!(result["echo"]["sessionId"], FAKE_CDP_SESSION); + assert_eq!(result["echo"]["params"]["url"], "https://example.com"); + + let log = server.await.expect("the fake relay must finish"); + assert_eq!( + log.authorization.as_deref(), + Some("Bearer user-access-token") + ); + let methods: Vec<&str> = log + .received + .iter() + .filter_map(|m| m.get("method").and_then(Value::as_str)) + .collect(); + assert_eq!( + methods, + vec![ + "Target.getTargets", + "Target.attachToTarget", + "Page.navigate" + ] + ); + // The handshake runs on the BROWSER session and must not be addressed to a + // page, or the browser answers it with "no such session". + assert!(log.received[0].get("sessionId").is_none()); + assert!(log.received[1].get("sessionId").is_none()); + } + + #[tokio::test] + async fn a_relayed_navigation_waits_for_the_page_to_load() { + // The load wait shares one implementation with the local arm, so a + // flattened event that failed to match here would strand every navigate + // for its full timeout. + let (ws_url, server) = fake_relay(RelayBehaviour::Cooperative).await; + let target = CdpTarget::Remote { + ws_url, + bearer: "t".to_string(), + session_id: "sess-1".to_string(), + }; + + let started = std::time::Instant::now(); + navigate(&target, "https://example.com", 30) + .await + .expect("a relayed navigate must resolve"); + // It must return on the load event, not by outliving the timeout: a client + // that always waits the full budget turns every navigation into a stall. + assert!( + started.elapsed() < Duration::from_secs(10), + "navigate waited out its timeout instead of matching the load event" + ); + + let log = server.await.expect("the fake relay must finish"); + let methods: Vec<&str> = log + .received + .iter() + .filter_map(|m| m.get("method").and_then(Value::as_str)) + .collect(); + assert!(methods.contains(&"Page.enable")); + assert!(methods.contains(&"Page.navigate")); + // Every page-domain message must carry the session, not just the command. + for message in &log.received { + let method = message.get("method").and_then(Value::as_str).unwrap_or(""); + if method.starts_with("Page.") { + assert_eq!( + message.get("sessionId").and_then(Value::as_str), + Some(FAKE_CDP_SESSION), + "{method} was not addressed to the attached page" + ); + } + } + } + + #[tokio::test] + async fn a_session_that_is_not_up_yet_is_reported_as_such_not_as_a_broken_one() { + // 1013 is the relay saying "come back when it is live". Surfacing it as a + // transport failure would send an automation client into a retry loop + // against a session that is doing exactly what it should. + let (ws_url, _server) = fake_relay(RelayBehaviour::RefuseAsNotDrivable).await; + let target = CdpTarget::Remote { + ws_url, + bearer: "t".to_string(), + session_id: "sess-1".to_string(), + }; + + let error = run_command(&target, "Page.navigate", serde_json::json!({})) + .await + .expect_err("a refused session must not look like a success"); + assert!( + matches!(error, CdpError::NotDrivable(_)), + "expected NotDrivable, got {error:?}" + ); + } + + #[tokio::test] + async fn a_local_command_skips_the_attach_and_carries_no_session() { + // The local arm talks to a PAGE socket. Sending it a sessionId, or making + // it pay for an attach handshake it does not need, would be a regression + // in the path that already worked. + let (ws_url, server) = fake_relay(RelayBehaviour::Cooperative).await; + let target = CdpTarget::Local { ws_url }; + + let result = run_command( + &target, + "Runtime.evaluate", + serde_json::json!({ "expression": "1" }), + ) + .await + .expect("a local command must succeed"); + assert_eq!(result["echo"]["method"], "Runtime.evaluate"); + + let log = server.await.expect("the fake relay must finish"); + let methods: Vec<&str> = log + .received + .iter() + .filter_map(|m| m.get("method").and_then(Value::as_str)) + .collect(); + assert_eq!(methods, vec!["Runtime.evaluate"]); + assert!(log.received[0].get("sessionId").is_none()); + } +} diff --git a/src-tauri/src/dns_blocklist.rs b/src-tauri/src/dns_blocklist.rs index 25e0088..4be4d26 100644 --- a/src-tauri/src/dns_blocklist.rs +++ b/src-tauri/src/dns_blocklist.rs @@ -59,25 +59,48 @@ impl BlocklistLevel { } } - pub fn url(&self) -> Option<&'static str> { + /// Where this tier's `domains/*.txt` list is fetched from. + /// + /// `raw.githubusercontent.com` only, deliberately. This used to be a jsDelivr + /// URL against `hagezi/dns-blocklists`, and it broke every blocklisted launch: + /// that repo grew past jsDelivr's 150 MB package-resolution limit, so + /// `@latest` began answering `403 Package size exceeded the configured limit + /// of 150 MB` for every tier. Nothing was wrong locally and nothing a user + /// could do would fix it — a third party's repo got too big and a CDN's + /// package resolver gave up. + /// + /// raw.githubusercontent.com serves the file straight from the ref and + /// resolves no package at all, so it cannot fail that way. The + /// `domains/*.txt` format now lives in `hagezi/dns-blocklists-legacy`. + /// + /// Returned as a slice so the fetch path can try several sources if one is + /// ever added; today there is exactly one on purpose. + pub fn urls(&self) -> &'static [&'static str] { match self { - Self::None | Self::Custom => None, + Self::None | Self::Custom => &[], Self::Light => { - Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/light.txt") + &["https://raw.githubusercontent.com/hagezi/dns-blocklists-legacy/main/domains/light.txt"] } Self::Normal => { - Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/multi.txt") + &["https://raw.githubusercontent.com/hagezi/dns-blocklists-legacy/main/domains/multi.txt"] } - Self::Pro => Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/pro.txt"), - Self::ProPlus => { - Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/pro.plus.txt") - } - Self::Ultimate => { - Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/ultimate.txt") + Self::Pro => { + &["https://raw.githubusercontent.com/hagezi/dns-blocklists-legacy/main/domains/pro.txt"] } + Self::ProPlus => &[ + "https://raw.githubusercontent.com/hagezi/dns-blocklists-legacy/main/domains/pro.plus.txt", + ], + Self::Ultimate => &[ + "https://raw.githubusercontent.com/hagezi/dns-blocklists-legacy/main/domains/ultimate.txt", + ], } } + /// The preferred source, for callers that only need to name one. + pub fn url(&self) -> Option<&'static str> { + self.urls().first().copied() + } + pub fn filename(&self) -> Option<&'static str> { match self { Self::None => None, @@ -295,49 +318,85 @@ impl BlocklistManager { } pub async fn fetch_blocklist(level: BlocklistLevel) -> Result { - let production_url = level - .url() - .ok_or_else(|| format!("No URL for level {:?}", level))?; + let production_urls: Vec = level.urls().iter().map(|u| (*u).to_string()).collect(); + if production_urls.is_empty() { + return Err(format!("No URL for level {:?}", level)); + } #[cfg(feature = "e2e")] - let url = std::env::var("DONUT_E2E_DNS_BLOCKLIST_BASE_URL") + let urls = std::env::var("DONUT_E2E_DNS_BLOCKLIST_BASE_URL") .ok() .filter(|base| !base.is_empty()) .map(|base| { - format!( + vec![format!( "{}/{}", base.trim_end_matches('/'), level.filename().unwrap_or("blocklist.txt") - ) + )] }) - .unwrap_or_else(|| production_url.to_string()); + .unwrap_or(production_urls); #[cfg(not(feature = "e2e"))] - let url = production_url.to_string(); + let urls = production_urls; let path = Self::cached_file_path(level).ok_or_else(|| format!("No filename for level {:?}", level))?; let cache_dir = Self::cache_dir(); std::fs::create_dir_all(&cache_dir).map_err(|e| format!("Failed to create cache dir: {e}"))?; - log::info!( - "[dns-blocklist] Fetching {} from {}", - level.display_name(), - url - ); + // Try each source in turn. A tier is only a failure once EVERY source has + // refused it: the outage this replaced was one CDN answering 403 for a + // reason that had nothing to do with the user, and falling back would have + // made it invisible. + let mut body: Option = None; + let mut failures: Vec = Vec::new(); - let response = HTTP_CLIENT - .get(&url) - .send() - .await - .map_err(|e| format!("Failed to fetch blocklist: {e}"))?; + for url in &urls { + log::info!( + "[dns-blocklist] Fetching {} from {}", + level.display_name(), + url + ); - if !response.status().is_success() { - return Err(format!("HTTP {} when fetching {}", response.status(), url)); + let response = match HTTP_CLIENT.get(url).send().await { + Ok(response) => response, + Err(e) => { + failures.push(format!("{url}: {e}")); + continue; + } + }; + + if !response.status().is_success() { + failures.push(format!("{url}: HTTP {}", response.status())); + continue; + } + + match response.text().await { + Ok(text) => { + if failures.is_empty() { + log::info!("[dns-blocklist] {} fetched", level.display_name()); + } else { + // Worth saying out loud: the primary source is down and somebody + // should know before the backup goes too. + log::warn!( + "[dns-blocklist] {} came from a fallback source after {} failure(s): {}", + level.display_name(), + failures.len(), + failures.join("; ") + ); + } + body = Some(text); + break; + } + Err(e) => failures.push(format!("{url}: {e}")), + } } - let body = response - .text() - .await - .map_err(|e| format!("Failed to read response body: {e}"))?; + let Some(body) = body else { + return Err(format!( + "Failed to fetch blocklist {} from any source ({})", + level.display_name(), + failures.join("; ") + )); + }; // Write atomically: write to temp file, then rename let tmp_path = path.with_extension("tmp"); @@ -796,6 +855,66 @@ mod tests { assert!(BlocklistLevel::None.filename().is_none()); } + #[test] + fn every_tier_is_served_only_from_raw_githubusercontent() { + // jsDelivr is deliberately not a source. It resolves a whole package to + // serve one file, so when `hagezi/dns-blocklists` grew past its 150 MB + // limit every tier began answering 403 — an outage nothing local could fix. + // raw.githubusercontent.com serves the file straight from the ref and + // resolves no package, so it cannot fail that way. + for &level in BlocklistLevel::all_downloadable() { + let urls = level.urls(); + assert_eq!( + urls.len(), + 1, + "{} should have exactly one source: {urls:?}", + level.as_str() + ); + for url in urls { + assert!( + url.starts_with("https://raw.githubusercontent.com/"), + "{} must be served from raw.githubusercontent.com: {url}", + level.as_str() + ); + assert!( + !url.contains("jsdelivr"), + "{} must not reintroduce jsDelivr: {url}", + level.as_str() + ); + } + } + } + + #[test] + fn no_tier_points_at_the_oversized_upstream_repo() { + // The `domains/*.txt` format moved to `-legacy`, which is small enough for + // jsDelivr to resolve. Pointing any tier back at the original repo + // reintroduces the 403. + for &level in BlocklistLevel::all_downloadable() { + for url in level.urls() { + assert!( + !url.contains("/hagezi/dns-blocklists@") && !url.contains("/hagezi/dns-blocklists/"), + "{} still points at the oversized repo: {url}", + level.as_str() + ); + assert!( + url.contains("dns-blocklists-legacy"), + "{} should read the legacy list repo: {url}", + level.as_str() + ); + assert!( + url.ends_with( + level + .filename() + .expect("downloadable tiers have a filename") + ), + "{} source must serve its own tier file: {url}", + level.as_str() + ); + } + } + } + #[test] fn test_cache_status_returns_all_levels() { let statuses = BlocklistManager::get_cache_status(); diff --git a/src-tauri/src/downloaded_browsers_registry.rs b/src-tauri/src/downloaded_browsers_registry.rs index 23a2565..3655fb7 100644 --- a/src-tauri/src/downloaded_browsers_registry.rs +++ b/src-tauri/src/downloaded_browsers_registry.rs @@ -26,6 +26,29 @@ pub struct DownloadedBrowsersRegistry { geoip_downloader: &'static GeoIPDownloader, } +/// Filename suffixes that identify a *downloaded artifact* — the container we +/// fetched from the network — rather than a file belonging to the extracted +/// install. Cleanup preserves these so a manually placed archive survives. +/// +/// `.exe` and `.AppImage` are deliberately absent even though both can be +/// downloaded. On Windows the extracted Wayfern payload is flat at the version +/// root (`extraction::ensure_correct_directory_structure` returns early rather +/// than nesting it), so preserving `.exe` kept `chrome.exe` while deleting every +/// sibling `.dll`, the `.manifest`, `.pak` and `locales/` — a gutted install +/// that then failed to launch with os error 14001. On Linux the `.AppImage` +/// *is* the extracted payload. Cleanup must never leave behind something that +/// still reads as an installed browser; the archive is deleted right after a +/// successful download anyway, so nothing of value is lost. +const DOWNLOAD_ARTIFACT_SUFFIXES: [&str; 7] = + ["zip", "dmg", "tar.xz", "tar.gz", "tar.bz2", "pkg", "msi"]; + +fn is_download_artifact(file_name: &str) -> bool { + let lowered = file_name.to_lowercase(); + DOWNLOAD_ARTIFACT_SUFFIXES + .iter() + .any(|suffix| lowered.ends_with(suffix)) +} + impl DownloadedBrowsersRegistry { fn new() -> Self { Self { @@ -174,15 +197,19 @@ impl DownloadedBrowsersRegistry { browser: &str, version: &str, ) -> Result<(), Box> { + // Never delete files out from under a live download or extraction. Both the + // detached task that runs the moment a download completes and the periodic + // maintenance task land here, and a freshly downloaded version is referenced + // by no persisted profile while profile creation is still in flight. + if crate::downloader::is_downloading(browser, version) { + log::info!("Skipping cleanup of {browser} {version}: a download is in progress"); + return Ok(()); + } + if let Some(info) = self.remove_browser(browser, version) { // Clean up extracted binaries but preserve downloaded archives if info.file_path.exists() { if info.file_path.is_dir() { - // Allowed archive extensions to preserve - let archive_exts = [ - "zip", "dmg", "tar.xz", "tar.gz", "tar.bz2", "AppImage", "exe", "pkg", "msi", - ]; - for entry in fs::read_dir(&info.file_path)? { let entry = entry?; let path = entry.path(); @@ -192,16 +219,11 @@ impl DownloadedBrowsersRegistry { continue; } - // For files, preserve if they look like downloaded archives/installers + // For files, preserve only genuine downloaded archives/installers let keep = path .file_name() .and_then(|n| n.to_str()) - .map(|name| { - // Match suffixes (handles multi-part extensions like .tar.xz) - archive_exts - .iter() - .any(|ext| name.to_lowercase().ends_with(&ext.to_lowercase())) - }) + .map(is_download_artifact) .unwrap_or(false); if !keep { @@ -215,13 +237,7 @@ impl DownloadedBrowsersRegistry { .file_name() .and_then(|n| n.to_str()) .unwrap_or(""); - let archive_exts = [ - "zip", "dmg", "tar.xz", "tar.gz", "tar.bz2", "AppImage", "exe", "pkg", "msi", - ]; - let is_archive = archive_exts - .iter() - .any(|ext| file_name.to_lowercase().ends_with(&ext.to_lowercase())); - if !is_archive { + if !is_download_artifact(file_name) { fs::remove_file(&info.file_path)?; } } @@ -1230,6 +1246,130 @@ mod tests { ); } + /// The Windows payload is extracted flat at the version root, so preserving + /// every `*.exe` used to leave `chrome.exe` behind while deleting the `.dll` + /// files and the `.manifest` next to it. That gutted directory still passed + /// the "is it downloaded?" check, was re-registered as healthy, and launching + /// it failed in the Windows loader with os error 14001. + #[test] + fn test_cleanup_removes_the_browser_executable_not_just_its_libraries() { + use tempfile::TempDir; + let temp = TempDir::new().unwrap(); + let version_dir = temp.path().join("wayfern").join("140.0"); + std::fs::create_dir_all(&version_dir).unwrap(); + + for name in [ + "chrome.exe", + "wayfern.exe", + "notification_helper.exe", + "chrome.dll", + "chrome_elf.dll", + "chrome.exe.manifest", + "resources.pak", + ] { + std::fs::File::create(version_dir.join(name)).unwrap(); + } + std::fs::create_dir_all(version_dir.join("locales")).unwrap(); + + let registry = DownloadedBrowsersRegistry::new(); + registry.add_browser(DownloadedBrowserInfo { + browser: "wayfern".to_string(), + version: "140.0".to_string(), + file_path: version_dir.clone(), + }); + + registry + .cleanup_failed_download("wayfern", "140.0") + .expect("cleanup should succeed"); + + let leftovers: Vec = std::fs::read_dir(&version_dir) + .unwrap() + .flatten() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + assert!( + leftovers.is_empty(), + "cleanup must not leave a half-deleted install behind, found: {leftovers:?}" + ); + } + + /// The preserve rule still exists for its actual purpose: a downloaded + /// archive (including one placed there by hand) survives the cleanup. + #[test] + fn test_cleanup_preserves_a_downloaded_archive() { + use tempfile::TempDir; + let temp = TempDir::new().unwrap(); + let version_dir = temp.path().join("wayfern").join("141.0"); + std::fs::create_dir_all(&version_dir).unwrap(); + + std::fs::File::create(version_dir.join("wayfern-win64.zip")).unwrap(); + std::fs::File::create(version_dir.join("wayfern-mac.tar.xz")).unwrap(); + std::fs::File::create(version_dir.join("chrome.exe")).unwrap(); + std::fs::File::create(version_dir.join("chrome.dll")).unwrap(); + + let registry = DownloadedBrowsersRegistry::new(); + registry.add_browser(DownloadedBrowserInfo { + browser: "wayfern".to_string(), + version: "141.0".to_string(), + file_path: version_dir.clone(), + }); + + registry + .cleanup_failed_download("wayfern", "141.0") + .expect("cleanup should succeed"); + + assert!( + version_dir.join("wayfern-win64.zip").exists(), + "a downloaded archive must be preserved" + ); + assert!( + version_dir.join("wayfern-mac.tar.xz").exists(), + "multi-part archive extensions must still be recognised" + ); + assert!( + !version_dir.join("chrome.exe").exists(), + "the extracted executable must be removed" + ); + assert!( + !version_dir.join("chrome.dll").exists(), + "the extracted libraries must be removed" + ); + } + + /// Cleanup runs on a detached task the moment a download completes and again + /// on a periodic timer, either of which can land while an install is still + /// being written. It must stand down instead of deleting live files. + #[test] + fn test_cleanup_stands_down_while_a_download_is_in_progress() { + use tempfile::TempDir; + let temp = TempDir::new().unwrap(); + let version_dir = temp.path().join("wayfern").join("142.0"); + std::fs::create_dir_all(&version_dir).unwrap(); + std::fs::File::create(version_dir.join("chrome.exe")).unwrap(); + std::fs::File::create(version_dir.join("chrome.dll")).unwrap(); + + let registry = DownloadedBrowsersRegistry::new(); + registry.add_browser(DownloadedBrowserInfo { + browser: "wayfern".to_string(), + version: "142.0".to_string(), + file_path: version_dir.clone(), + }); + + crate::downloader::mark_downloading_for_test("wayfern", "142.0"); + let result = registry.cleanup_failed_download("wayfern", "142.0"); + crate::downloader::clear_download_state_for_browser("wayfern"); + result.expect("cleanup should succeed"); + + assert!( + version_dir.join("chrome.exe").exists() && version_dir.join("chrome.dll").exists(), + "an in-flight download must not be deleted out from under itself" + ); + assert!( + registry.is_browser_registered("wayfern", "142.0"), + "the registry entry must survive too, the version is still being installed" + ); + } + #[test] fn test_is_browser_registered_vs_downloaded() { let registry = DownloadedBrowsersRegistry::new(); diff --git a/src-tauri/src/downloader.rs b/src-tauri/src/downloader.rs index ad82150..9761b9b 100644 --- a/src-tauri/src/downloader.rs +++ b/src-tauri/src/downloader.rs @@ -879,6 +879,17 @@ pub fn is_downloading(browser: &str, version: &str) -> bool { downloading.contains(&download_key) } +/// Test-only: mark a browser-version pair as in flight so guards that consult +/// `is_downloading` can be exercised without running a real download. Clear it +/// again with `clear_download_state_for_browser`. +#[cfg(test)] +pub fn mark_downloading_for_test(browser: &str, version: &str) { + DOWNLOADING_BROWSERS + .lock() + .unwrap() + .insert(format!("{browser}-{version}")); +} + /// Clear all in-progress download bookkeeping for a browser. /// /// Used as a last-resort cleanup when a download future is abandoned (e.g. dropped diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 50d7bfa..8d3f48d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -51,6 +51,7 @@ mod automation_rate_limiter; mod browser; mod browser_runner; mod browser_version_manager; +mod cdp_target; mod default_browser; pub mod dns_blocklist; mod downloaded_browsers_registry; @@ -72,6 +73,7 @@ mod proxy_manager; pub mod proxy_runner; pub mod proxy_server; pub mod proxy_storage; +mod remote_handoff; mod remote_session; mod settings_manager; pub mod socks5_local; @@ -1333,11 +1335,27 @@ async fn get_remote_session( /// so a handful of short launches bills an allowance meant for a hundred. #[tauri::command] async fn stop_remote_session( + app_handle: tauri::AppHandle, session_id: String, ) -> Result { - remote_session::end_remote_session(&session_id) + let outcome = remote_session::end_remote_session(&session_id) .await - .map_err(|e| remote_session_error("stop", e)) + .map_err(|e| remote_session_error("stop", e))?; + // The stream normally reports the close, but a stop must not depend on a + // socket being up: without this the session's work would sit in cloud storage + // with nothing to pull it, and the profile would look ready to open locally + // while its local copy still predated the session. + remote_session::note_session_stopped(&app_handle, &session_id); + Ok(outcome) +} + +/// Which profiles cannot be launched locally right now, and why. +/// +/// Backed by the same store the launch gate reads, so the button the UI disables +/// and the refusal the backend would produce can never disagree. +#[tauri::command] +fn get_remote_handoff_states() -> std::collections::HashMap { + remote_handoff::states() } /// Subscribe to session transitions. Idempotent. @@ -2535,6 +2553,12 @@ pub fn run_with_builder( // and would only be refused on a loop; the frontend starts it again // through `start_remote_session_events` once the user signs in. remote_session::start_session_events(app_handle_cloud.clone()); + + // A session that finished while this machine was shut, or whose pull + // ran out of retries offline, leaves a profile blocked from launching + // with its work still in cloud storage. Signing in is the first moment + // that pull can succeed, so it is where it is retried. + remote_handoff::resume_pending_pulls(&app_handle_cloud); } cloud_auth::CloudAuthManager::start_sync_token_refresh_loop(app_handle_cloud).await; }); @@ -2731,6 +2755,7 @@ pub fn run_with_builder( list_remote_sessions, get_remote_session, stop_remote_session, + get_remote_handoff_states, start_remote_session_events, stop_remote_session_events, get_remote_session_events_status, @@ -2809,6 +2834,10 @@ mod tests { crate::remote_session::EVENT_SESSION_STATE, crate::remote_session::EVENT_SESSION_SNAPSHOT, crate::remote_session::EVENT_STREAM_STATUS, + // The launch gate is emitted from the same place for the same reason: a + // Run button that does not hear about it stays enabled over a profile the + // backend will refuse, or over unsynced work it must not open. + crate::remote_handoff::EVENT_REMOTE_HANDOFF, ] { assert!( client.contains(&format!("\"{event}\"")), diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index ba79c48..763e0ce 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -18,6 +18,7 @@ use tokio::sync::Mutex as AsyncMutex; use uuid::Uuid; use crate::browser::ProxySettings; +use crate::cdp_target::{CdpError, CdpTarget}; use crate::cloud_auth::CLOUD_AUTH; use crate::group_manager::GROUP_MANAGER; use crate::profile::{BrowserProfile, ProfileManager}; @@ -105,8 +106,28 @@ pub struct McpError { message: String, } +/// Surface a CDP failure to the agent with the reason intact. +/// +/// The distinction matters to whoever is on the other end: "the session is +/// still provisioning" invites a retry in a few seconds, "you are signed out" +/// does not, and flattening both into `-32000: something went wrong` is how an +/// automation client ends up retrying a refusal forever. +fn cdp_error(error: CdpError) -> McpError { + McpError { + code: -32000, + message: error.to_string(), + } +} + const DEFAULT_MCP_PORT: u16 = 51080; +/// How long a keystroke waits for its acknowledgement before moving on. +/// +/// Generous enough to absorb a relayed round trip, short enough that a browser +/// which stops answering does not leave the caller typing into a socket that +/// will never reply. +const KEYSTROKE_ACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + struct McpSession { initialized: bool, } @@ -521,6 +542,11 @@ impl McpServer { // hardware is spent per RUN and enforced server-side. | "run_cookie_bot_now" | "cancel_cookie_bot_run" + // Leasing a remote host is the single most expensive action here, and + // ending one reaches the same fleet. Both are metered exactly as their + // REST equivalents already are. + | "run_profile_remote" + | "stop_remote_session" ) } @@ -821,7 +847,7 @@ impl McpServer { }, McpTool { name: "get_profile_status".to_string(), - description: "Check if a browser profile is currently running".to_string(), + description: "Check whether a browser profile is running and can be driven. Returns is_running (true when the browser can be driven, wherever it is), location ('local', 'remote' or 'stopped'), is_running_locally, and remote_session_id when it is running on the remote fleet.".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { @@ -1658,9 +1684,44 @@ impl McpServer { "required": ["profile_id", "index", "text"] }), }, - // Remote fleet observability. `run_profile_remote` hands back a session - // id and the word "provisioning"; without these an agent can only learn - // that a session became usable by trying to drive it and failing. + // Remote fleet. An agent that could drive a remote profile but not start + // one had to be handed a session by something else — the REST API or the + // GUI — which is no use to an MCP client running on its own. + McpTool { + name: "run_profile_remote".to_string(), + description: "Start this profile on a remote host of its own operating system. The profile must have Regular cloud sync enabled. Returns a session id; poll get_remote_session until state is 'live', then drive it with navigate, screenshot, click_element and the rest exactly as you would a local profile.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "profile_id": { + "type": "string", + "description": "The UUID of the profile to run remotely" + }, + "url": { + "type": "string", + "description": "Optional URL to open once the browser is up" + } + }, + "required": ["profile_id"] + }), + }, + McpTool { + name: "stop_remote_session".to_string(), + description: "Stop a remote session and settle what it cost. A session left running bills until the fleet's two-hour cap, so stop one as soon as you are done with it".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Session id returned by run_profile_remote" + } + }, + "required": ["session_id"] + }), + }, + // Observability. `run_profile_remote` hands back a session id and the + // word "provisioning"; without these an agent can only learn that a + // session became usable by trying to drive it and failing. McpTool { name: "list_remote_sessions".to_string(), description: "List the remote browser sessions this account currently owns, with their live status".to_string(), @@ -2252,6 +2313,19 @@ impl McpServer { .await?; self.handle_type_by_index(arguments).await } + // Leasing a host is the most expensive thing this server can do, so it + // is gated exactly like the local launch it replaces. + "run_profile_remote" => { + Self::require_capability( + "Browser automation", + CLOUD_AUTH.can_use_browser_automation().await, + ) + .await?; + self.handle_run_profile_remote(arguments).await + } + // No capability gate on the stop. A lapsed plan must never be the reason + // an agent cannot end something that is spending hours. + "stop_remote_session" => Self::handle_stop_remote_session(arguments).await, // Remote fleet observability. Reads only, and free: being unable to see // that a session you are already paying for has become usable is not a // feature worth withholding. @@ -2999,14 +3073,48 @@ impl McpServer { }); } - let is_running = profile.process_id.is_some(); + // "Running" has to mean "drivable", not "has a process on this machine". + // A profile open on the leased fleet has no local process, and answering + // `is_running: false` for it tells an agent not to bother calling the very + // tools that would have worked. + let is_running_locally = profile.process_id.is_some(); + let remote_session = if is_running_locally { + None + } else { + crate::remote_session::live_session_for_profile(profile_id).await + }; + + let location = match (is_running_locally, &remote_session) { + (true, _) => "local", + (false, Some(_)) => "remote", + (false, None) => "stopped", + }; + + // Whether a LOCAL launch would be refused, and why. An agent that reads + // `location: "stopped"` and calls `run_profile` on a profile whose finished + // remote session has not been pulled back would get a bare 409 with nothing + // to act on; worse, before the gate existed it would have got a browser and + // silently destroyed the session's work. + let handoff = crate::remote_handoff::state_for(profile_id); + let can_launch_locally = handoff.is_none() && !is_running_locally; Ok(serde_json::json!({ "content": [{ "type": "text", "text": serde_json::json!({ "profile_id": profile_id, - "is_running": is_running + "is_running": location != "stopped", + "is_running_locally": is_running_locally, + "location": location, + "remote_session_id": remote_session.map(|session| session.session_id), + "can_launch_locally": can_launch_locally, + "local_launch_blocked_by": match handoff { + Some(crate::remote_handoff::HandoffState::Running) => Some("remote_session_running"), + Some(crate::remote_handoff::HandoffState::PendingSync) => { + Some("remote_session_changes_downloading") + } + None => None, + }, }).to_string() }] })) @@ -4481,164 +4589,43 @@ impl McpServer { // --- CDP utility methods for browser interaction --- - async fn get_cdp_port_for_profile(&self, profile: &BrowserProfile) -> Result { - let profiles_dir = ProfileManager::instance().get_profiles_dir(); - let profile_path = profile.get_profile_data_path(&profiles_dir); - let profile_path_str = profile_path.to_string_lossy(); - - // Retry a few times — port info may not be stored yet right after launch - for attempt in 0..10 { - if attempt > 0 { - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - } - let port = if profile.browser == "wayfern" { - crate::wayfern_manager::WayfernManager::instance() - .get_cdp_port(&profile_path_str) - .await - } else { - None - }; - if let Some(p) = port { - return Ok(p); - } - } - - Err(McpError { - code: -32000, - message: format!( - "No CDP connection available for profile '{}'. Make sure the browser is running.", - profile.name - ), - }) - } - - async fn get_cdp_ws_url(&self, port: u16) -> Result { - let url = format!("http://127.0.0.1:{port}/json"); - let client = reqwest::Client::new(); - - // Retry connecting to CDP endpoint (browser may still be starting up) - let max_attempts = 15; - let mut last_err = String::new(); - for attempt in 0..max_attempts { - if attempt > 0 { - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - } - match client - .get(&url) - .timeout(std::time::Duration::from_secs(3)) - .send() - .await - { - Ok(resp) => match resp.json::>().await { - Ok(targets) => { - if let Some(ws_url) = targets - .iter() - .find(|t| t.get("type").and_then(|v| v.as_str()) == Some("page")) - .and_then(|t| t.get("webSocketDebuggerUrl")) - .and_then(|v| v.as_str()) - { - return Ok(ws_url.to_string()); - } - last_err = "No page target found in browser".to_string(); - } - Err(e) => { - last_err = format!("Failed to parse CDP targets: {e}"); - } - }, - Err(e) => { - last_err = format!("Failed to connect to browser CDP endpoint: {e}"); - } - } - } - - Err(McpError { - code: -32000, - message: last_err, - }) + /// Where this profile's browser is: on this machine, or on the fleet. + /// + /// Every interaction tool goes through here, so a profile launched with + /// `run-remote` is driven by exactly the tools that drive a local one. The + /// alternative — a parallel set of remote-only tools — drifts from the local + /// set within a release and doubles every future change. + async fn resolve_cdp_target(&self, profile_id: &str) -> Result { + let profile = self.get_wayfern_profile(profile_id)?; + crate::cdp_target::resolve(&profile) + .await + .map_err(|e| McpError { + code: -32000, + message: e.to_string(), + }) } async fn send_cdp( &self, - ws_url: &str, + target: &CdpTarget, method: &str, params: serde_json::Value, ) -> Result { - use futures_util::sink::SinkExt; - use futures_util::stream::StreamExt; - use tokio_tungstenite::connect_async; - use tokio_tungstenite::tungstenite::Message; - - let (mut ws_stream, _) = connect_async(ws_url).await.map_err(|e| McpError { - code: -32000, - message: format!("Failed to connect to CDP WebSocket: {e}"), - })?; - - let command = serde_json::json!({ - "id": 1, - "method": method, - "params": params - }); - - ws_stream - .send(Message::Text(command.to_string().into())) + crate::cdp_target::run_command(target, method, params) .await - .map_err(|e| McpError { - code: -32000, - message: format!("Failed to send CDP command: {e}"), - })?; - - while let Some(msg) = ws_stream.next().await { - let msg = msg.map_err(|e| McpError { - code: -32000, - message: format!("CDP WebSocket error: {e}"), - })?; - if let Message::Text(text) = msg { - let response: serde_json::Value = - serde_json::from_str(text.as_str()).map_err(|e| McpError { - code: -32000, - message: format!("Failed to parse CDP response: {e}"), - })?; - if response.get("id") == Some(&serde_json::json!(1)) { - if let Some(error) = response.get("error") { - return Err(McpError { - code: -32000, - message: format!("CDP error: {error}"), - }); - } - return Ok( - response - .get("result") - .cloned() - .unwrap_or(serde_json::json!({})), - ); - } - } - } - - Err(McpError { - code: -32000, - message: "No response received from CDP".to_string(), - }) + .map_err(cdp_error) } async fn send_human_keystrokes( &self, - ws_url: &str, + target: &CdpTarget, text: &str, wpm: Option, ) -> Result<(), McpError> { use crate::human_typing::{MarkovTyper, TypingAction}; - use futures_util::sink::SinkExt; - use futures_util::stream::StreamExt; - use tokio_tungstenite::connect_async; - use tokio_tungstenite::tungstenite::Message; let events = MarkovTyper::new(text, wpm).run(); - - let (mut ws_stream, _) = connect_async(ws_url).await.map_err(|e| McpError { - code: -32000, - message: format!("Failed to connect to CDP WebSocket: {e}"), - })?; + let mut connection = target.connect().await.map_err(cdp_error)?; let mut cmd_id = 1u64; let mut last_time = 0.0; @@ -4650,234 +4637,82 @@ impl McpServer { } last_time = event.time; - match &event.action { + let (down, up) = match &event.action { TypingAction::Char(ch) => { - let text_str = ch.to_string(); - // keyDown - let down = serde_json::json!({ - "id": cmd_id, - "method": "Input.dispatchKeyEvent", - "params": { + let ch = ch.to_string(); + ( + serde_json::json!({ "type": "keyDown", - "text": text_str, - "key": text_str, - "unmodifiedText": text_str, - } - }); - cmd_id += 1; - ws_stream - .send(Message::Text(down.to_string().into())) - .await - .map_err(|e| McpError { - code: -32000, - message: format!("Failed to send key event: {e}"), - })?; - // Drain response - let _ = ws_stream.next().await; - - // keyUp - let up = serde_json::json!({ - "id": cmd_id, - "method": "Input.dispatchKeyEvent", - "params": { - "type": "keyUp", - "key": text_str, - } - }); - cmd_id += 1; - ws_stream - .send(Message::Text(up.to_string().into())) - .await - .map_err(|e| McpError { - code: -32000, - message: format!("Failed to send key event: {e}"), - })?; - let _ = ws_stream.next().await; + "text": ch, + "key": ch, + "unmodifiedText": ch, + }), + serde_json::json!({ "type": "keyUp", "key": ch }), + ) } - TypingAction::Backspace => { - let down = serde_json::json!({ - "id": cmd_id, - "method": "Input.dispatchKeyEvent", - "params": { - "type": "keyDown", - "key": "Backspace", - "code": "Backspace", - "windowsVirtualKeyCode": 8, - "nativeVirtualKeyCode": 8, - } - }); - cmd_id += 1; - ws_stream - .send(Message::Text(down.to_string().into())) - .await - .map_err(|e| McpError { - code: -32000, - message: format!("Failed to send key event: {e}"), - })?; - let _ = ws_stream.next().await; + TypingAction::Backspace => ( + serde_json::json!({ + "type": "keyDown", + "key": "Backspace", + "code": "Backspace", + "windowsVirtualKeyCode": 8, + "nativeVirtualKeyCode": 8, + }), + serde_json::json!({ + "type": "keyUp", + "key": "Backspace", + "code": "Backspace", + "windowsVirtualKeyCode": 8, + "nativeVirtualKeyCode": 8, + }), + ), + }; - let up = serde_json::json!({ - "id": cmd_id, - "method": "Input.dispatchKeyEvent", - "params": { - "type": "keyUp", - "key": "Backspace", - "code": "Backspace", - "windowsVirtualKeyCode": 8, - "nativeVirtualKeyCode": 8, - } - }); - cmd_id += 1; - ws_stream - .send(Message::Text(up.to_string().into())) - .await - .map_err(|e| McpError { - code: -32000, - message: format!("Failed to send key event: {e}"), - })?; - let _ = ws_stream.next().await; + for params in [down, up] { + if let Err(e) = connection + .send_command(cmd_id, "Input.dispatchKeyEvent", params) + .await + { + return Err(cdp_error(e)); } + // Drained rather than matched: the point is to keep reading so the + // browser is never writing into a full socket while the next keystroke + // is being timed. Bounded, because a reply that never comes must not + // freeze typing forever — the keystroke itself was already delivered. + let _ = tokio::time::timeout(KEYSTROKE_ACK_TIMEOUT, connection.next_text()).await; + cmd_id += 1; } } + connection.close().await; Ok(()) } /// Send a CDP command and wait for the page to finish loading. - /// Uses a single WebSocket connection to: enable Page events, send the command, - /// wait for the command response, then wait for `Page.loadEventFired`. + /// + /// Thin over the shared runner so a local and a remote profile take exactly + /// the same path: one implementation of "navigate then wait", not two that + /// drift. async fn send_cdp_and_wait_for_load( &self, - ws_url: &str, + target: &CdpTarget, method: &str, params: serde_json::Value, timeout_secs: u64, ) -> Result { - use futures_util::sink::SinkExt; - use futures_util::stream::StreamExt; - use tokio_tungstenite::connect_async; - use tokio_tungstenite::tungstenite::Message; - - let (mut ws_stream, _) = connect_async(ws_url).await.map_err(|e| McpError { - code: -32000, - message: format!("Failed to connect to CDP WebSocket: {e}"), - })?; - - // Enable Page domain events so we receive loadEventFired - let enable_cmd = serde_json::json!({ - "id": 1, - "method": "Page.enable", - "params": {} - }); - ws_stream - .send(Message::Text(enable_cmd.to_string().into())) + crate::cdp_target::run_command_awaiting_load(target, method, params, timeout_secs) .await - .map_err(|e| McpError { - code: -32000, - message: format!("Failed to send Page.enable: {e}"), - })?; - - // Wait for Page.enable response - loop { - let msg = ws_stream - .next() - .await - .ok_or_else(|| McpError { - code: -32000, - message: "WebSocket closed waiting for Page.enable response".to_string(), - })? - .map_err(|e| McpError { - code: -32000, - message: format!("CDP WebSocket error: {e}"), - })?; - if let Message::Text(text) = msg { - let resp: serde_json::Value = serde_json::from_str(text.as_str()).unwrap_or_default(); - if resp.get("id") == Some(&serde_json::json!(1)) { - break; - } - } - } - - // Send the actual command (e.g., Page.navigate) - let command = serde_json::json!({ - "id": 2, - "method": method, - "params": params - }); - ws_stream - .send(Message::Text(command.to_string().into())) - .await - .map_err(|e| McpError { - code: -32000, - message: format!("Failed to send CDP command: {e}"), - })?; - - // Wait for command response and then for Page.loadEventFired - let mut command_result = None; - let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(timeout_secs); - - loop { - let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); - if remaining.is_zero() { - // Timed out waiting for load — return the command result if we have it - break; - } - - let msg = match tokio::time::timeout(remaining, ws_stream.next()).await { - Ok(Some(Ok(msg))) => msg, - Ok(Some(Err(e))) => { - return Err(McpError { - code: -32000, - message: format!("CDP WebSocket error: {e}"), - }); - } - Ok(None) => break, // stream ended - Err(_) => break, // timeout - }; - - if let Message::Text(text) = msg { - let response: serde_json::Value = serde_json::from_str(text.as_str()).unwrap_or_default(); - - // Check for command response - if response.get("id") == Some(&serde_json::json!(2)) { - if let Some(error) = response.get("error") { - return Err(McpError { - code: -32000, - message: format!("CDP error: {error}"), - }); - } - command_result = Some( - response - .get("result") - .cloned() - .unwrap_or(serde_json::json!({})), - ); - } - - // Check for Page.loadEventFired — page is fully loaded - if response.get("method") == Some(&serde_json::json!("Page.loadEventFired")) { - break; - } - } - } - - // Disable Page domain events - let disable_cmd = serde_json::json!({ - "id": 3, - "method": "Page.disable", - "params": {} - }); - let _ = ws_stream - .send(Message::Text(disable_cmd.to_string().into())) - .await; - - command_result.ok_or_else(|| McpError { - code: -32000, - message: "No response received from CDP".to_string(), - }) + .map_err(cdp_error) } - fn get_running_profile(&self, profile_id: &str) -> Result { + /// The profile a browser-interaction tool refers to. + /// + /// Deliberately does NOT require a local process. That check used to live + /// here, and it is exactly the state a profile running on the fleet is in, so + /// it refused every remote tool call before resolution had a chance to find + /// the session. Whether a browser exists at all is [`resolve_cdp_target`]'s + /// answer to give, because only it can see both places one could be. + fn get_wayfern_profile(&self, profile_id: &str) -> Result { let profiles = ProfileManager::instance() .list_profiles() .map_err(|e| McpError { @@ -4900,13 +4735,6 @@ impl McpServer { }); } - if profile.process_id.is_none() { - return Err(McpError { - code: -32000, - message: format!("Profile '{}' is not running", profile.name), - }); - } - Ok(profile) } @@ -4931,13 +4759,11 @@ impl McpServer { message: "Missing url".to_string(), })?; - let profile = self.get_running_profile(profile_id)?; - let cdp_port = self.get_cdp_port_for_profile(&profile).await?; - let ws_url = self.get_cdp_ws_url(cdp_port).await?; + let target = self.resolve_cdp_target(profile_id).await?; self .send_cdp_and_wait_for_load( - &ws_url, + &target, "Page.navigate", serde_json::json!({ "url": url }), 30, @@ -4973,9 +4799,7 @@ impl McpServer { .and_then(|v| v.as_bool()) .unwrap_or(false); - let profile = self.get_running_profile(profile_id)?; - let cdp_port = self.get_cdp_port_for_profile(&profile).await?; - let ws_url = self.get_cdp_ws_url(cdp_port).await?; + let target = self.resolve_cdp_target(profile_id).await?; let mut params = serde_json::json!({ "format": format }); @@ -4985,7 +4809,7 @@ impl McpServer { if full_page { let layout = self - .send_cdp(&ws_url, "Page.getLayoutMetrics", serde_json::json!({})) + .send_cdp(&target, "Page.getLayoutMetrics", serde_json::json!({})) .await?; if let Some(content_size) = layout.get("contentSize") { @@ -5001,7 +4825,7 @@ impl McpServer { } let result = self - .send_cdp(&ws_url, "Page.captureScreenshot", params) + .send_cdp(&target, "Page.captureScreenshot", params) .await?; let data = result @@ -5045,9 +4869,7 @@ impl McpServer { .and_then(|v| v.as_bool()) .unwrap_or(false); - let profile = self.get_running_profile(profile_id)?; - let cdp_port = self.get_cdp_port_for_profile(&profile).await?; - let ws_url = self.get_cdp_ws_url(cdp_port).await?; + let target = self.resolve_cdp_target(profile_id).await?; let cdp_params = serde_json::json!({ "expression": expression, @@ -5057,11 +4879,11 @@ impl McpServer { let result = if wait_for_load { self - .send_cdp_and_wait_for_load(&ws_url, "Runtime.evaluate", cdp_params, 30) + .send_cdp_and_wait_for_load(&target, "Runtime.evaluate", cdp_params, 30) .await? } else { self - .send_cdp(&ws_url, "Runtime.evaluate", cdp_params) + .send_cdp(&target, "Runtime.evaluate", cdp_params) .await? }; @@ -5110,9 +4932,7 @@ impl McpServer { message: "Missing selector".to_string(), })?; - let profile = self.get_running_profile(profile_id)?; - let cdp_port = self.get_cdp_port_for_profile(&profile).await?; - let ws_url = self.get_cdp_ws_url(cdp_port).await?; + let target = self.resolve_cdp_target(profile_id).await?; let selector_escaped = selector.replace('\\', "\\\\").replace('\'', "\\'"); let js = format!( @@ -5131,7 +4951,7 @@ impl McpServer { // and we return immediately. let result = self .send_cdp_and_wait_for_load( - &ws_url, + &target, "Runtime.evaluate", serde_json::json!({ "expression": js, @@ -5197,9 +5017,7 @@ impl McpServer { .unwrap_or(false); let wpm = arguments.get("wpm").and_then(|v| v.as_f64()); - let profile = self.get_running_profile(profile_id)?; - let cdp_port = self.get_cdp_port_for_profile(&profile).await?; - let ws_url = self.get_cdp_ws_url(cdp_port).await?; + let target = self.resolve_cdp_target(profile_id).await?; let selector_escaped = selector.replace('\\', "\\\\").replace('\'', "\\'"); let focus_js = if clear_first { @@ -5230,7 +5048,7 @@ impl McpServer { let focus_result = self .send_cdp( - &ws_url, + &target, "Runtime.evaluate", serde_json::json!({ "expression": focus_js, @@ -5255,13 +5073,13 @@ impl McpServer { if instant { self .send_cdp( - &ws_url, + &target, "Input.insertText", serde_json::json!({ "text": text }), ) .await?; } else { - self.send_human_keystrokes(&ws_url, text, wpm).await?; + self.send_human_keystrokes(&target, text, wpm).await?; } Ok(serde_json::json!({ @@ -5294,9 +5112,7 @@ impl McpServer { .map(|n| n as usize) .unwrap_or(40_000); - let profile = self.get_running_profile(profile_id)?; - let cdp_port = self.get_cdp_port_for_profile(&profile).await?; - let ws_url = self.get_cdp_ws_url(cdp_port).await?; + let target = self.resolve_cdp_target(profile_id).await?; let js = if let Some(sel) = selector { let sel_escaped = sel.replace('\\', "\\\\").replace('\'', "\\'"); @@ -5325,7 +5141,7 @@ impl McpServer { let result = self .send_cdp( - &ws_url, + &target, "Runtime.evaluate", serde_json::json!({ "expression": js, @@ -5378,13 +5194,11 @@ impl McpServer { message: "Missing profile_id".to_string(), })?; - let profile = self.get_running_profile(profile_id)?; - let cdp_port = self.get_cdp_port_for_profile(&profile).await?; - let ws_url = self.get_cdp_ws_url(cdp_port).await?; + let target = self.resolve_cdp_target(profile_id).await?; let result = self .send_cdp( - &ws_url, + &target, "Runtime.evaluate", serde_json::json!({ "expression": "JSON.stringify({url: location.href, title: document.title, readyState: document.readyState})", @@ -5426,9 +5240,7 @@ impl McpServer { .map(|n| n as usize) .unwrap_or(40_000); - let profile = self.get_running_profile(profile_id)?; - let cdp_port = self.get_cdp_port_for_profile(&profile).await?; - let ws_url = self.get_cdp_ws_url(cdp_port).await?; + let target = self.resolve_cdp_target(profile_id).await?; // Walk the DOM for visible, non-disabled interactive elements, label them // with a zero-based index, and cache the live references on @@ -5438,7 +5250,7 @@ impl McpServer { let result = self .send_cdp( - &ws_url, + &target, "Runtime.evaluate", serde_json::json!({ "expression": js, @@ -5511,9 +5323,7 @@ impl McpServer { message: "Missing index".to_string(), })?; - let profile = self.get_running_profile(profile_id)?; - let cdp_port = self.get_cdp_port_for_profile(&profile).await?; - let ws_url = self.get_cdp_ws_url(cdp_port).await?; + let target = self.resolve_cdp_target(profile_id).await?; let js = format!( r#"(() => {{ @@ -5528,7 +5338,7 @@ impl McpServer { let result = self .send_cdp_and_wait_for_load( - &ws_url, + &target, "Runtime.evaluate", serde_json::json!({ "expression": js, @@ -5594,9 +5404,7 @@ impl McpServer { .unwrap_or(false); let wpm = arguments.get("wpm").and_then(|v| v.as_f64()); - let profile = self.get_running_profile(profile_id)?; - let cdp_port = self.get_cdp_port_for_profile(&profile).await?; - let ws_url = self.get_cdp_ws_url(cdp_port).await?; + let target = self.resolve_cdp_target(profile_id).await?; // Mirrors handle_type_text's focus step but resolves the element via the // cached index instead of a CSS selector. @@ -5628,7 +5436,7 @@ impl McpServer { let focus_result = self .send_cdp( - &ws_url, + &target, "Runtime.evaluate", serde_json::json!({ "expression": focus_js, @@ -5653,13 +5461,13 @@ impl McpServer { if instant { self .send_cdp( - &ws_url, + &target, "Input.insertText", serde_json::json!({ "text": text }), ) .await?; } else { - self.send_human_keystrokes(&ws_url, text, wpm).await?; + self.send_human_keystrokes(&target, text, wpm).await?; } Ok(serde_json::json!({ @@ -5933,6 +5741,63 @@ impl McpServer { Ok(profile) } + /// Start this profile on a host of its own operating system. + /// + /// Deliberately no `is_cross_os` guard: local `run_profile` refuses a foreign + /// profile because THIS machine is the wrong OS, and running it on a host of + /// its own OS is precisely what this exists for. + async fn handle_run_profile_remote( + &self, + arguments: &serde_json::Value, + ) -> Result { + let profile_id = Self::require_str(arguments, "profile_id")?; + let url = arguments + .get("url") + .and_then(|v| v.as_str()) + .map(str::to_string); + let profile = self.get_wayfern_profile(profile_id)?; + + // The host pulls the profile from cloud storage, so one that has never + // synced would launch an empty browser and push that emptiness back over + // the real one. Same rule the REST route applies, from the same place. + crate::api_server::remote_launch_precondition(&profile) + .await + .map_err(|message| McpError { + code: -32000, + message, + })?; + + let app = { + let inner = self.inner.lock().await; + inner.app_handle.clone().ok_or_else(|| McpError { + code: -32000, + message: "MCP server not properly initialized".to_string(), + })? + }; + + let outcome = crate::remote_session::start_remote_session(app, &profile, url) + .await + .map_err(|e| McpError { + code: -32000, + message: e.to_error_json(), + })?; + Self::json_content(&outcome) + } + + /// Stop a remote session and settle what it cost. + async fn handle_stop_remote_session( + arguments: &serde_json::Value, + ) -> Result { + let session_id = Self::require_str(arguments, "session_id")?; + let outcome = crate::remote_session::end_remote_session(session_id) + .await + .map_err(|e| McpError { + code: -32000, + message: e.to_error_json(), + })?; + Self::json_content(&outcome) + } + async fn handle_list_remote_sessions() -> Result { let sessions = crate::remote_session::list_remote_sessions() .await @@ -6245,7 +6110,11 @@ mod tests { assert!(tool_names.contains(&"type_text")); assert!(tool_names.contains(&"get_page_content")); assert!(tool_names.contains(&"get_page_info")); - // Remote fleet observability + // Remote fleet: an agent must be able to start a session, see it become + // usable, drive it with the tools above, and stop it. Any one of those + // missing makes remote driving unusable from MCP alone. + assert!(tool_names.contains(&"run_profile_remote")); + assert!(tool_names.contains(&"stop_remote_session")); assert!(tool_names.contains(&"list_remote_sessions")); assert!(tool_names.contains(&"get_remote_session")); assert!(tool_names.contains(&"get_remote_hours_quota")); @@ -6276,7 +6145,7 @@ mod tests { .get_tools() .into_iter() .map(|tool| tool.name) - .filter(|name| name.contains("cookie_bot") || name.contains("remote_")) + .filter(|name| name.contains("cookie_bot") || name.contains("remote")) .collect(); let dispatched = include_str!("mcp_server.rs"); @@ -6288,7 +6157,7 @@ mod tests { } assert_eq!( advertised.len(), - 13, + 15, "expected the full remote-fleet and cookie-bot set: {advertised:?}" ); } @@ -6448,8 +6317,10 @@ mod tests { // Leases a remote host for up to two hours and spends the pooled // remote-hour budget. "run_cookie_bot_now", + "run_profile_remote", // Reaches the fleet, like the remote-session stop it mirrors. "cancel_cookie_bot_run", + "stop_remote_session", ] { assert!( McpServer::is_automation_tool_call(&request("tools/call", Some(name))), diff --git a/src-tauri/src/remote_handoff.rs b/src-tauri/src/remote_handoff.rs new file mode 100644 index 0000000..823301f --- /dev/null +++ b/src-tauri/src/remote_handoff.rs @@ -0,0 +1,543 @@ +//! What a remote session owes this machine, and the gate that collects it. +//! +//! A profile that runs on the leased fleet is written by the host, not here. +//! The host pushes it back to cloud storage when the session ends, and until +//! this machine has pulled that push, the local profile directory is a stale +//! copy of something that has moved on. +//! +//! Opening that stale copy is not a cosmetic problem, it is destructive. The +//! local browser writes, every local mtime jumps past the host's push, and the +//! next ordinary sync therefore reads local as the newer side: it uploads the +//! pre-session files and puts everything the host wrote into +//! `files_to_delete_remote`. A night of cookie warming is deleted with no error +//! anywhere. Nothing in the manifest can prevent this, because by then the local +//! clock genuinely IS later. +//! +//! So the gate is here instead, and it is deliberately a LOCAL, per-machine +//! fact rather than a synced one. "This computer has not yet pulled" is true of +//! one computer at a time; putting it in the profile's synced metadata would let +//! a second device that had already pulled clear it for a first device that had +//! not. +//! +//! Two states, and the difference matters to the user: +//! +//! - [`HandoffState::Running`]: a session is live on the fleet. The profile lock +//! is held server-side, so a launch would be refused anyway; this makes the +//! refusal instant and legible instead of a round trip and a raw string. +//! - [`HandoffState::PendingSync`]: the session is over, the lock is released, +//! and the work is sitting in cloud storage. This is the window that used to +//! be wide open. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::RwLock; +use std::time::Duration; + +/// Emitted whenever the set of gated profiles changes. +pub const EVENT_REMOTE_HANDOFF: &str = "remote-handoff-changed"; + +/// Attempts at pulling a finished session's work before giving up for now. +/// +/// The entry survives a failure, so "giving up" only means this burst stops; +/// the next stream event, app start or manual sync tries again. What the retries +/// buy is the common case: the profile lock is released server-side a moment +/// before this machine's cached copy of it expires, and a single attempt would +/// hit `Skipped("profile is locked elsewhere")` and leave the user blocked for +/// no reason. +const PULL_ATTEMPTS: u32 = 5; + +/// Delay before the second pull attempt. Doubles, capped by [`PULL_RETRY_MAX`]. +const PULL_RETRY_BASE: Duration = Duration::from_secs(2); + +/// Ceiling on the pull backoff. Above the 30s profile-lock refresh, so a run of +/// attempts is guaranteed to span at least one refresh of the lock cache. +const PULL_RETRY_MAX: Duration = Duration::from_secs(45); + +/// Where a profile stands with respect to the fleet. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HandoffState { + /// A session is live on the fleet right now. + Running, + /// A session has finished and its work has not been pulled down yet. + PendingSync, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct HandoffEntry { + session_id: String, + state: HandoffState, + /// When this entry last changed, unix seconds. Diagnostics only; the gate + /// never expires on its own, because an entry that timed out would reopen + /// exactly the window it exists to close. + observed_at: u64, +} + +type Store = HashMap; + +static STORE: RwLock> = RwLock::new(None); + +fn store_path() -> std::path::PathBuf { + crate::app_dirs::settings_dir().join("remote_handoff.json") +} + +fn load_from_disk() -> Store { + let path = store_path(); + let Ok(bytes) = std::fs::read(&path) else { + return Store::new(); + }; + match serde_json::from_slice::(&bytes) { + Ok(store) => store, + Err(e) => { + // Losing the file means losing the gate, so say so loudly rather than + // starting empty and quietly permitting a launch over pending work. + log::error!( + "Could not read {}: {e}. Profiles with unsynced remote work will not be gated until \ + the next session event.", + path.display() + ); + Store::new() + } + } +} + +fn persist(store: &Store) { + let path = store_path(); + if let Some(parent) = path.parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + log::warn!("Could not create {}: {e}", parent.display()); + return; + } + } + match serde_json::to_vec_pretty(store) { + Ok(bytes) => { + if let Err(e) = crate::app_dirs::write_owner_only(&path, &bytes) { + log::warn!("Could not write {}: {e}", path.display()); + } + } + Err(e) => log::warn!("Could not encode the remote handoff store: {e}"), + } +} + +fn with_store(f: impl FnOnce(&mut Store) -> T) -> T { + let mut guard = STORE + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let store = guard.get_or_insert_with(load_from_disk); + f(store) +} + +/// Apply a mutation, and persist plus announce it only if it changed anything. +fn mutate(f: impl FnOnce(&mut Store) -> bool) { + let changed = with_store(|store| { + let changed = f(store); + if changed { + persist(store); + } + changed + }); + if changed { + announce(); + } +} + +fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn announce() { + let _ = crate::events::emit(EVENT_REMOTE_HANDOFF, states()); +} + +/// Every gated profile, for the UI and for one-shot reads. +pub fn states() -> HashMap { + with_store(|store| { + store + .iter() + .map(|(profile_id, entry)| (profile_id.clone(), entry.state)) + .collect() + }) +} + +/// Where this profile stands, if it is gated at all. +pub fn state_for(profile_id: &str) -> Option { + with_store(|store| store.get(profile_id).map(|entry| entry.state)) +} + +/// The session currently holding this profile on the fleet, if any. +/// +/// Answers for a `provisioning` session too, which the drivable-session index +/// deliberately does not. Stopping a session that has not finished coming up is +/// the single most common thing a user does after starting one by mistake, and +/// an index built for "where do I attach a CDP client" cannot serve it. +pub fn running_session_for_profile(profile_id: &str) -> Option { + with_store(|store| { + store + .get(profile_id) + .filter(|entry| entry.state == HandoffState::Running) + .map(|entry| entry.session_id.clone()) + }) +} + +/// Which profile a session belongs to, as this machine last recorded it. +/// +/// The backend's stop reply carries a session id and a duration but no profile, +/// and the caller that pressed stop needs to know whose work to pull. Reading it +/// back from the gate avoids a second round trip for something already known. +pub fn profile_for_session(session_id: &str) -> Option { + with_store(|store| { + store + .iter() + .find(|(_, entry)| entry.session_id == session_id) + .map(|(profile_id, _)| profile_id.clone()) + }) +} + +/// Record that a session is live on the fleet for this profile. +/// +/// Written to disk immediately, and this is the point of the whole store: if the +/// app is closed while a session runs, nothing on restart would otherwise +/// distinguish "this profile is fine" from "a host has been writing to this +/// profile for the last hour". +pub fn note_running(profile_id: &str, session_id: &str) { + mutate(|store| { + let entry = store.get(profile_id); + if entry + .is_some_and(|held| held.state == HandoffState::Running && held.session_id == session_id) + { + return false; + } + store.insert( + profile_id.to_string(), + HandoffEntry { + session_id: session_id.to_string(), + state: HandoffState::Running, + observed_at: now_secs(), + }, + ); + true + }); +} + +/// Record that a session has finished and its work is waiting in cloud storage. +/// +/// Returns whether this call is the one that moved the profile into +/// `PendingSync`, so the caller starts exactly one pull for a transition that +/// the stream may well deliver more than once. +pub fn note_ended(profile_id: &str, session_id: &str) -> bool { + let mut transitioned = false; + mutate(|store| { + // Only a session this machine was watching can hand work over to it. + // + // No entry means one of two things and both say "do nothing": the pull for + // this session already completed and cleared the gate, or this machine + // never held the profile. The backend's listing returns closed sessions + // alongside live ones, so the snapshot on every reconnect replays each + // finished session — treating those as fresh handoffs would gate a + // perfectly current profile on every app start, and keep it blocked for as + // long as the machine happened to be offline. + let Some(entry) = store.get(profile_id) else { + return false; + }; + // A late `closed` for a session that has already been replaced by a newer + // one must not mark the newer one's profile as finished. + if entry.session_id != session_id { + return false; + } + if entry.state == HandoffState::PendingSync { + return false; + } + transitioned = true; + store.insert( + profile_id.to_string(), + HandoffEntry { + session_id: session_id.to_string(), + state: HandoffState::PendingSync, + observed_at: now_secs(), + }, + ); + true + }); + transitioned +} + +/// Drop the gate. Called only after a pull has actually completed. +pub fn clear(profile_id: &str) { + mutate(|store| store.remove(profile_id).is_some()); +} + +/// Bring stored `Running` entries back in line with what the backend reports. +/// +/// The stream is how a transition normally arrives, and it cannot deliver one +/// that happened while the app was shut. Any profile this machine last saw +/// running, whose session the backend no longer reports as live, finished +/// without being observed — and its work is sitting in cloud storage unpulled. +/// Returns the profiles that just moved into `PendingSync`. +pub fn reconcile(live_session_ids: &std::collections::HashSet) -> Vec { + let mut ended = Vec::new(); + mutate(|store| { + let stale: Vec<(String, String)> = store + .iter() + .filter(|(_, entry)| entry.state == HandoffState::Running) + .filter(|(_, entry)| !live_session_ids.contains(&entry.session_id)) + .map(|(profile_id, entry)| (profile_id.clone(), entry.session_id.clone())) + .collect(); + for (profile_id, session_id) in stale { + log::info!( + "Remote session {session_id} for profile {profile_id} ended while this machine was not \ + watching; its work is still in cloud storage" + ); + store.insert( + profile_id.clone(), + HandoffEntry { + session_id, + state: HandoffState::PendingSync, + observed_at: now_secs(), + }, + ); + ended.push(profile_id); + } + !ended.is_empty() + }); + ended +} + +/// Refuse a local launch that would run over unsynced remote work. +/// +/// Returns the `{"code":…}` string a Tauri command and the REST layer both +/// surface. Every local launch path calls this: the two that did not are how a +/// profile could be opened locally while a host was still writing to it. +pub fn ensure_local_launch_allowed(profile_id: &str) -> Result<(), String> { + match state_for(profile_id) { + None => Ok(()), + Some(HandoffState::Running) => Err(crate::backend_error("PROFILE_RUNNING_REMOTELY")), + Some(HandoffState::PendingSync) => Err(crate::backend_error("PROFILE_REMOTE_SYNC_PENDING")), + } +} + +/// Restart the pull for every profile still waiting on one. +/// +/// A pull can fail for as long as the machine is offline, and its retries are +/// bounded, so without this a profile could stay blocked from launching until +/// the user found the manual sync button. Called whenever the app has a cloud +/// session again, which is exactly when a previously impossible pull becomes +/// possible. +pub fn resume_pending_pulls(app_handle: &tauri::AppHandle) { + let pending: Vec = with_store(|store| { + store + .iter() + .filter(|(_, entry)| entry.state == HandoffState::PendingSync) + .map(|(profile_id, _)| profile_id.clone()) + .collect() + }); + for profile_id in pending { + log::info!("Resuming the post-session pull for profile {profile_id}"); + schedule_pull(app_handle.clone(), profile_id); + } +} + +/// Pull one profile's finished session down, then lift its gate. +/// +/// Spawned rather than awaited by its callers: a stream frame and a stop button +/// must not block on a transfer that can take minutes. The gate stays up for the +/// whole attempt, so there is no window in which the user can open the stale +/// copy while this is in flight. +pub fn schedule_pull(app_handle: tauri::AppHandle, profile_id: String) { + tauri::async_runtime::spawn(async move { + for attempt in 0..PULL_ATTEMPTS { + if state_for(&profile_id) != Some(HandoffState::PendingSync) { + // A new session started, or another pull got there first. + return; + } + if attempt > 0 { + let delay = PULL_RETRY_BASE + .saturating_mul(1u32 << (attempt - 1).min(16)) + .min(PULL_RETRY_MAX); + tokio::time::sleep(delay).await; + } + + match crate::sync::pull_profile_after_remote_session(&app_handle, &profile_id).await { + Ok(outcome) if outcome.is_completed() => { + log::info!("Pulled remote session work for profile {profile_id}"); + clear(&profile_id); + return; + } + Ok(crate::sync::ProfileSyncOutcome::Skipped(reason)) => { + log::info!("Post-session pull for profile {profile_id} did nothing ({reason}); retrying"); + } + Ok(_) => unreachable!("is_completed covers every completed outcome"), + Err(e) => { + log::warn!("Post-session pull for profile {profile_id} failed: {e}"); + } + } + } + + log::warn!( + "Could not pull remote session work for profile {profile_id} yet; it stays blocked from \ + launching locally until the pull succeeds" + ); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + /// Serialises the tests. + /// + /// `TEST_DATA_DIR` is thread-local but [`STORE`] is process-global, so two + /// tests running at once would share one store while pointing at different + /// directories. That fails intermittently, which is the worst way for a test + /// guarding a data-loss bug to fail. + static TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + /// Point the store at a scratch directory and start it empty. + /// + /// Everything returned must outlive the test body: dropping the guard + /// restores the real data directory, and a test that let it drop early would + /// write a gate file into the developer's own app data. + fn isolated() -> ( + tempfile::TempDir, + crate::app_dirs::TestDirGuard, + std::sync::MutexGuard<'static, ()>, + ) { + let lock = TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let dir = tempfile::TempDir::new().expect("a scratch directory"); + let guard = crate::app_dirs::set_test_data_dir(dir.path().to_path_buf()); + *STORE + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Store::new()); + (dir, guard, lock) + } + + #[test] + fn a_live_session_blocks_a_local_launch() { + let _iso = isolated(); + note_running("p1", "s1"); + let err = ensure_local_launch_allowed("p1").expect_err("a live session must block a launch"); + assert!(err.contains("PROFILE_RUNNING_REMOTELY")); + } + + #[test] + fn a_finished_session_still_blocks_until_the_work_is_pulled() { + // The whole point. The profile lock is released the moment the session + // closes, so without this the user can open the stale copy and the next + // sync deletes everything the host wrote. + let _iso = isolated(); + note_running("p1", "s1"); + assert!(note_ended("p1", "s1")); + let err = ensure_local_launch_allowed("p1").expect_err("pending work must block a launch"); + assert!(err.contains("PROFILE_REMOTE_SYNC_PENDING")); + + clear("p1"); + assert!(ensure_local_launch_allowed("p1").is_ok()); + } + + #[test] + fn an_ungated_profile_is_not_blocked() { + let _iso = isolated(); + note_running("p1", "s1"); + assert!(ensure_local_launch_allowed("p2").is_ok()); + } + + #[test] + fn the_end_transition_is_reported_once_however_often_the_frame_arrives() { + // The stream re-delivers a snapshot on every reconnect, and `closed` can + // arrive alongside it. Starting a pull per frame would run several + // concurrent transfers of the same profile. + let _iso = isolated(); + note_running("p1", "s1"); + assert!(note_ended("p1", "s1")); + assert!(!note_ended("p1", "s1")); + assert!(!note_ended("p1", "s1")); + } + + #[test] + fn a_closed_session_this_machine_never_watched_does_not_gate_anything() { + // `listForUser` returns closed sessions next to live ones, so the snapshot + // on every reconnect replays every session that ever finished. Treating + // those as fresh handoffs would block the Run button on a perfectly current + // profile at each app start, and block it indefinitely while offline. + let _iso = isolated(); + assert!(!note_ended("p1", "s-finished-last-week")); + assert_eq!(state_for("p1"), None); + assert!(ensure_local_launch_allowed("p1").is_ok()); + } + + #[test] + fn a_pulled_profile_is_not_re_gated_by_a_replayed_close() { + // Same frame, one step later: the pull completed and cleared the gate. The + // next reconnect must not put it back. + let _iso = isolated(); + note_running("p1", "s1"); + note_ended("p1", "s1"); + clear("p1"); + assert!(!note_ended("p1", "s1")); + assert!(ensure_local_launch_allowed("p1").is_ok()); + } + + #[test] + fn a_late_close_for_a_replaced_session_does_not_gate_the_new_one() { + // Session s1 finished and was pulled; s2 is now live on the same profile. A + // straggling `closed` for s1 must not declare s2's profile finished, or the + // gate lifts while a host is still writing. + let _iso = isolated(); + note_running("p1", "s2"); + assert!(!note_ended("p1", "s1")); + assert_eq!(state_for("p1"), Some(HandoffState::Running)); + } + + #[test] + fn a_session_that_ended_while_the_app_was_shut_is_recovered() { + // Nothing streams a transition to a process that is not running. Without + // this the profile reads as still-running for ever and can never be + // launched again, and its work is never pulled. + let _iso = isolated(); + note_running("p1", "s1"); + let live: HashSet = HashSet::new(); + assert_eq!(reconcile(&live), vec!["p1".to_string()]); + assert_eq!(state_for("p1"), Some(HandoffState::PendingSync)); + } + + #[test] + fn reconcile_leaves_a_session_that_is_genuinely_still_live() { + let _iso = isolated(); + note_running("p1", "s1"); + let live: HashSet = ["s1".to_string()].into_iter().collect(); + assert!(reconcile(&live).is_empty()); + assert_eq!(state_for("p1"), Some(HandoffState::Running)); + } + + #[test] + fn reconcile_does_not_reopen_a_pending_profile() { + // `PendingSync` is not a session state and no listing will ever contain it. + // Re-deriving it from the snapshot would report the same handoff as new on + // every reconnect and start a pull each time. + let _iso = isolated(); + note_running("p1", "s1"); + note_ended("p1", "s1"); + let live: HashSet = HashSet::new(); + assert!(reconcile(&live).is_empty()); + } + + #[test] + fn the_gate_survives_a_restart() { + // Held on disk precisely because the dangerous window outlives the process: + // an app killed mid-session comes back with no memory of it. + let (_dir, _guard, _lock) = isolated(); + note_running("p1", "s1"); + note_ended("p1", "s1"); + + *STORE + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = None; + + assert_eq!(state_for("p1"), Some(HandoffState::PendingSync)); + } +} diff --git a/src-tauri/src/remote_session.rs b/src-tauri/src/remote_session.rs index 8c5e590..2113c8f 100644 --- a/src-tauri/src/remote_session.rs +++ b/src-tauri/src/remote_session.rs @@ -9,6 +9,7 @@ use crate::cloud_errors::{self, FailureCodes}; use crate::profile::types::BrowserProfile; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Mutex; use std::time::Duration; @@ -137,7 +138,7 @@ pub async fn start_remote_session( let key = idempotency_key(&profile_id, &uuid::Uuid::new_v4().to_string()); let endpoint = format!("{}/api/remote-sessions", crate::cloud_auth::CLOUD_API_URL); - crate::cloud_auth::CLOUD_AUTH + let outcome = crate::cloud_auth::CLOUD_AUTH .api_call_with_retry(|token| { let endpoint = endpoint.clone(); let body = StartRemoteRequest { @@ -169,7 +170,14 @@ pub async fn start_remote_session( } }) .await - .map_err(|e| classify_error_string(&e)) + .map_err(|e| classify_error_string(&e))?; + + // Gate the profile here rather than waiting for the stream to say so. A host + // starts pulling this profile the instant the backend accepts, and the first + // transition can arrive seconds later or, on a machine whose stream is down, + // not at all. Those seconds are enough for a user to press Run. + note_session_started(&profile.id.to_string(), &outcome.session_id); + Ok(outcome) } /// What the backend returns when a session is stopped. @@ -181,6 +189,30 @@ pub struct EndRemoteSessionOutcome { pub billed_seconds: u64, } +/// Gate a profile the moment a launch is accepted, and pull when one is stopped. +/// +/// The event stream is the normal way this machine learns a session's state, but +/// it is not the only way a session starts or ends and it is not guaranteed to +/// be connected. Both of these are called directly by the launch and stop paths +/// so the gate never depends on a socket being up: a launch whose first +/// transition is missed would leave the profile openable locally while a host +/// wrote to it, and a stop whose `closed` frame is missed would leave the +/// session's work sitting in cloud storage with nothing to pull it. +pub fn note_session_started(profile_id: &str, session_id: &str) { + crate::remote_handoff::note_running(profile_id, session_id); +} + +pub fn note_session_stopped(app: &AppHandle, session_id: &str) { + let Some(profile_id) = crate::remote_handoff::profile_for_session(session_id) else { + // A session this machine never saw start. There is nothing recorded to + // pull for, and inventing a profile id would gate the wrong profile. + return; + }; + if crate::remote_handoff::note_ended(&profile_id, session_id) { + crate::remote_handoff::schedule_pull(app.clone(), profile_id); + } +} + /// Ask donutbrowser-infra to stop a remote session. /// /// Without this the only thing that ends a session is the fleet's own two-hour @@ -343,6 +375,257 @@ async fn get_json( .map_err(|e| classify_error_string(&e)) } +// --- Driving a session ------------------------------------------------------ + +/// Where to attach a CDP client for one session. +/// +/// The descriptor is deliberately OPAQUE and server-decided. The desktop knows +/// nothing about the fleet — not its hostname, not its paths, not a credential +/// it would accept — and switches only on `auth`. That is what lets the server +/// move the endpoint, or hand out a different kind of credential, without a +/// desktop release; a hard-coded URL in a shipped binary could not be moved at +/// all. +#[derive(Debug, Clone, Deserialize)] +pub struct CdpEndpoint { + #[serde(default)] + pub session_id: String, + pub ws_url: String, + /// Wire protocol the endpoint speaks. + #[serde(default)] + pub protocol: String, + /// How to authenticate: `bearer` means the same access token used for REST. + #[serde(default)] + pub auth: String, +} + +/// The only credential scheme this build can present. +const AUTH_BEARER: &str = "bearer"; + +/// The only relay protocol this build speaks. +const PROTOCOL_CDP_RELAY_1: &str = "cdp-relay/1"; + +/// Endpoints already resolved, keyed by session id. +/// +/// A session's endpoint does not move while it lives, and every tool call would +/// otherwise pay a cloud round trip before it could send its first byte. +static CDP_ENDPOINTS: Mutex>> = Mutex::new(None); + +/// Ask the backend where to attach for `session_id`. +pub async fn cdp_endpoint(session_id: &str) -> Result { + if let Some(cached) = with_endpoints(|map| map.get(session_id).cloned()) { + return Ok(cached); + } + + let endpoint = format!( + "{}/api/remote-sessions/{}/cdp", + crate::cloud_auth::CLOUD_API_URL, + urlencoding::encode(session_id) + ); + let mut resolved: CdpEndpoint = get_json(endpoint).await?; + if resolved.session_id.is_empty() { + resolved.session_id = session_id.to_string(); + } + + if let Some(reason) = unsupported_descriptor(&resolved) { + return Err(RemoteSessionError::Other(reason)); + } + + with_endpoints(|map| map.insert(session_id.to_string(), resolved.clone())); + Ok(resolved) +} + +/// Why this build cannot use a descriptor, if it cannot. +/// +/// A scheme or protocol this version does not implement has to fail loudly. +/// Guessing at a credential scheme would send the user's access token somewhere +/// it was never meant to go, and ignoring the fields would present the wrong +/// credential on a wire expecting another — both of which read as "remote +/// driving is broken" rather than "this app is out of date". +/// +/// An empty field means the server stated nothing, which is how a descriptor +/// that predates the field looks; the historic behaviour is then the answer. +fn unsupported_descriptor(endpoint: &CdpEndpoint) -> Option { + if !endpoint.auth.is_empty() && endpoint.auth != AUTH_BEARER { + return Some(format!( + "this version cannot attach to a remote browser using {:?} authentication; update Donut Browser", + endpoint.auth + )); + } + if !endpoint.protocol.is_empty() && endpoint.protocol != PROTOCOL_CDP_RELAY_1 { + return Some(format!( + "this version does not speak {:?}; update Donut Browser", + endpoint.protocol + )); + } + None +} + +fn with_endpoints(f: impl FnOnce(&mut HashMap) -> T) -> T { + let mut guard = CDP_ENDPOINTS + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + f(guard.get_or_insert_with(HashMap::new)) +} + +fn forget_endpoint(session_id: &str) { + with_endpoints(|map| map.remove(session_id)); +} + +/// The access token a relay attach presents. +/// +/// One place, so the credential a WebSocket carries is provably the same one +/// every REST call already carries, and no second copy of the load-and-check +/// logic can drift from it. +pub fn access_token_for_cdp() -> Result { + crate::cloud_auth::CloudAuthManager::load_access_token()? + .filter(|token| !token.is_empty()) + .ok_or_else(|| "not signed in to Donut cloud".to_string()) +} + +/// Sessions that can be driven right now, keyed by the profile they hold. +/// +/// Maintained from the event stream so deciding "is this profile running on the +/// fleet?" costs a lock rather than a cloud round trip on every tool call. +static LIVE_BY_PROFILE: Mutex>> = Mutex::new(None); + +/// Whether the stream has delivered a snapshot and has not dropped since. +/// +/// Without this the index cannot distinguish "no session for that profile" from +/// "nothing has told us about any session yet", and the second answered as the +/// first is exactly how a live remote profile reports itself as not running. +static INDEX_AUTHORITATIVE: AtomicBool = AtomicBool::new(false); + +fn with_index(f: impl FnOnce(&mut HashMap) -> T) -> T { + let mut guard = LIVE_BY_PROFILE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + f(guard.get_or_insert_with(HashMap::new)) +} + +/// A session that is up AND attachable. +/// +/// `provisioning` and `ready` are both "the browser is not there yet"; treating +/// either as drivable is what makes a client attach into a connection that +/// never establishes. +pub fn is_drivable(session: &RemoteSessionState) -> bool { + session.state == "live" && session.cdp_ready +} + +/// A session that will never write to the profile again. +/// +/// Deliberately NOT the negation of [`is_drivable`]. A `provisioning` session +/// has already taken the profile lock and its host is about to pull the profile +/// down and launch a browser on it, so it owns the profile every bit as much as +/// a `live` one does — it is simply not attachable yet. Treating "not drivable" +/// as "finished" would lift the local launch gate during the one minute a host +/// spends starting up, which is the window in which two writers do the most +/// damage. +pub fn is_terminal(session: &RemoteSessionState) -> bool { + matches!(session.state.as_str(), "closed" | "error") +} + +/// Apply one session to the index, and to the local launch gate. +/// +/// A session that stopped being drivable is removed, but only by the session +/// that owns the slot: a late `closed` for a finished session must not evict +/// the live one that replaced it. +fn index_session(app: Option<&AppHandle>, session: &RemoteSessionState) { + let Some(profile_id) = session.profile_id.clone() else { + return; + }; + + // The gate is maintained from the same frames as the index, because these are + // the only frames that exist. It is deliberately keyed off `is_terminal` + // rather than `is_drivable`: a provisioning host already owns the profile. + if is_terminal(session) { + if crate::remote_handoff::note_ended(&profile_id, &session.session_id) { + if let Some(app) = app { + crate::remote_handoff::schedule_pull(app.clone(), profile_id.clone()); + } + } + } else { + crate::remote_handoff::note_running(&profile_id, &session.session_id); + } + + if is_drivable(session) { + with_index(|map| map.insert(profile_id, session.clone())); + return; + } + forget_endpoint(&session.session_id); + with_index(|map| { + let owns_slot = map + .get(&profile_id) + .is_some_and(|held| held.session_id == session.session_id); + if owns_slot { + map.remove(&profile_id); + } + }); +} + +/// Replace the whole index from a full listing, and reconcile the launch gate. +fn reindex(app: Option<&AppHandle>, sessions: &[RemoteSessionState]) { + // Every session the backend still considers unfinished. A profile this + // machine last saw running whose session is not in here finished while + // nothing was watching — its work is in cloud storage and has not been pulled. + let unfinished: std::collections::HashSet = sessions + .iter() + .filter(|session| !is_terminal(session)) + .map(|session| session.session_id.clone()) + .collect(); + + for session in sessions { + index_session(app, session); + } + for profile_id in crate::remote_handoff::reconcile(&unfinished) { + if let Some(app) = app { + crate::remote_handoff::schedule_pull(app.clone(), profile_id); + } + } + + let next: HashMap = sessions + .iter() + .filter(|session| is_drivable(session)) + .filter_map(|session| { + session + .profile_id + .clone() + .map(|profile_id| (profile_id, session.clone())) + }) + .collect(); + let live: std::collections::HashSet<&str> = next + .values() + .map(|session| session.session_id.as_str()) + .collect(); + with_endpoints(|map| map.retain(|session_id, _| live.contains(session_id.as_str()))); + with_index(|map| *map = next); +} + +/// The drivable session holding `profile_id`, if there is one. +/// +/// Consults the in-process index first. Only when the stream is not delivering +/// transitions does it spend a cloud round trip, because in that state the +/// index cannot be trusted to be complete and answering "not running" from it +/// would hide a session the user is already paying for. +pub async fn live_session_for_profile(profile_id: &str) -> Option { + if let Some(session) = with_index(|map| map.get(profile_id).cloned()) { + return Some(session); + } + if INDEX_AUTHORITATIVE.load(Ordering::SeqCst) { + return None; + } + + match list_remote_sessions().await { + Ok(sessions) => { + reindex(None, &sessions); + with_index(|map| map.get(profile_id).cloned()) + } + Err(e) => { + log::debug!("Could not refresh remote sessions while resolving a CDP target: {e}"); + None + } + } +} + // --- Live state, without polling ------------------------------------------- /// A session transition. Payload is the session as the backend sees it. @@ -550,6 +833,10 @@ pub fn start_session_events(app: AppHandle) { /// Stop receiving. Safe to call when nothing is running. pub fn stop_session_events() { + // Cleared unconditionally: unsubscribing is what sign-out does, and an index + // left marked authoritative would keep answering from state nothing is + // maintaining any more. + INDEX_AUTHORITATIVE.store(false, Ordering::SeqCst); if !STREAM_RUNNING.swap(false, Ordering::SeqCst) { return; } @@ -723,6 +1010,7 @@ fn dispatch_frame(app: &AppHandle, frame: &SseFrame) { let Some((target, payload)) = route_frame(frame.event.as_deref(), &frame.data) else { return; }; + apply_to_index(Some(app), target, &payload); use tauri::Emitter; if let Err(e) = app.emit(target, payload) { @@ -730,7 +1018,47 @@ fn dispatch_frame(app: &AppHandle, frame: &SseFrame) { } } +/// Keep the drivable-session index in step with what the stream just said. +/// +/// The same frames that tell the frontend a session went live are the only +/// thing that can tell the CDP resolver so without polling, and a resolver that +/// polls would put a cloud round trip in front of every automation call. +pub fn apply_to_index(app: Option<&AppHandle>, target: &str, payload: &serde_json::Value) { + if target == EVENT_SESSION_SNAPSHOT { + let Some(array) = payload.get("sessions").and_then(|v| v.as_array()) else { + // Marking the index authoritative off a frame that carried no list would + // answer "no session" for every profile until the next reconnect. + log::warn!("Ignoring a remote-session snapshot that carried no session list"); + return; + }; + let mut sessions = Vec::with_capacity(array.len()); + for value in array { + match serde_json::from_value::(value.clone()) { + Ok(session) => sessions.push(session), + Err(e) => log::warn!("Skipping an undecodable session in the snapshot: {e}"), + } + } + reindex(app, &sessions); + INDEX_AUTHORITATIVE.store(true, Ordering::SeqCst); + return; + } + + if target == EVENT_SESSION_STATE { + match serde_json::from_value::(payload.clone()) { + Ok(session) => index_session(app, &session), + Err(e) => log::warn!("Ignoring an undecodable session transition: {e}"), + } + } +} + fn emit_stream_status(app: &AppHandle, connected: bool, reason: Option<&str>) { + if !connected { + // A dropped stream means transitions are being missed, so the index stops + // being an answer and becomes a cache: a miss now costs one cloud read + // rather than silently reporting a live session as absent. + INDEX_AUTHORITATIVE.store(false, Ordering::SeqCst); + } + use tauri::Emitter; let payload = serde_json::json!({ "connected": connected, "reason": reason }); if let Err(e) = app.emit(EVENT_STREAM_STATUS, payload) { @@ -1123,4 +1451,263 @@ mod tests { stop_session_events(); assert!(!session_events_running()); } + + // --- The drivable-session index ------------------------------------------ + // + // This index is what lets an automation call decide "is this profile running + // on the fleet?" without a cloud round trip. Everything below drives it + // through the SAME two steps production uses — decode the wire, route the + // frame, apply it — because the whole class of bug this replaced came from a + // test that agreed with the client and neither agreeing with the server. + + /// The statics below are process-wide, and `cargo test` runs these threads in + /// parallel. Without this every index test would be racing every other one. + static INDEX_TESTS: Mutex<()> = Mutex::new(()); + + fn index_test(body: impl FnOnce() -> T) -> T { + let _guard = INDEX_TESTS + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + with_index(|map| map.clear()); + with_endpoints(|map| map.clear()); + INDEX_AUTHORITATIVE.store(false, Ordering::SeqCst); + body() + } + + /// Decode, route and apply a literal wire capture, exactly as + /// `dispatch_frame` does minus the emit to the frontend. + fn feed(bytes: &[u8]) { + let mut decoder = SseDecoder::new(); + for frame in decoder.push(bytes) { + if let Some((target, payload)) = route_frame(frame.event.as_deref(), &frame.data) { + apply_to_index(None, target, &payload); + } + } + } + + fn indexed(profile_id: &str) -> Option { + with_index(|map| map.get(profile_id).cloned()) + } + + fn transition(session_id: &str, profile_id: &str, state: &str, cdp_ready: bool) -> Vec { + format!( + "data: {{\"type\":\"state\",\"session\":{{\"session_id\":\"{session_id}\",\"profile_id\":\"{profile_id}\",\"state\":\"{state}\",\"cdp_ready\":{cdp_ready}}}}}\n\n" + ) + .into_bytes() + } + + #[test] + fn a_session_becoming_drivable_is_indexed_by_the_profile_it_holds() { + index_test(|| { + feed(&transition("sess-1", "p1", "live", true)); + let held = indexed("p1").expect("a live session must be resolvable by profile"); + assert_eq!(held.session_id, "sess-1"); + }); + } + + #[test] + fn a_browser_that_is_up_but_not_attachable_is_not_offered_for_driving() { + index_test(|| { + // `ready` without CDP is a browser that exists and cannot be driven. + // Offering it is what makes a client attach into a connection that never + // establishes, and then blame the fleet for the timeout. + feed(&transition("sess-1", "p1", "ready", false)); + assert!(indexed("p1").is_none()); + feed(&transition("sess-1", "p1", "provisioning", false)); + assert!(indexed("p1").is_none()); + }); + } + + #[test] + fn a_session_that_closes_frees_the_profile_and_forgets_its_endpoint() { + index_test(|| { + feed(&transition("sess-1", "p1", "live", true)); + with_endpoints(|map| { + map.insert( + "sess-1".to_string(), + CdpEndpoint { + session_id: "sess-1".to_string(), + ws_url: "wss://example/cdp".to_string(), + protocol: PROTOCOL_CDP_RELAY_1.to_string(), + auth: AUTH_BEARER.to_string(), + }, + ) + }); + + feed(&transition("sess-1", "p1", "closed", false)); + assert!(indexed("p1").is_none()); + // A cached endpoint for a dead session would be handed to the next + // attach, which would then fail against a relay that has nothing left to + // relay to. + assert!(with_endpoints(|map| map.get("sess-1").cloned()).is_none()); + }); + } + + #[test] + fn a_late_close_for_a_finished_session_does_not_evict_the_one_that_replaced_it() { + index_test(|| { + feed(&transition("sess-1", "p1", "live", true)); + feed(&transition("sess-1", "p1", "closed", false)); + feed(&transition("sess-2", "p1", "live", true)); + + // Out-of-order frames are normal: the reconciler polls the fleet while + // the user is already starting the next session. A stale close arriving + // after the new session went live must not make a working browser + // unreachable. + feed(&transition("sess-1", "p1", "closed", false)); + assert_eq!( + indexed("p1").map(|s| s.session_id), + Some("sess-2".to_string()) + ); + }); + } + + #[test] + fn the_opening_snapshot_replaces_the_index_and_makes_it_authoritative() { + index_test(|| { + feed(&transition("stale", "p-gone", "live", true)); + feed( + concat!( + r#"data: {"type":"snapshot","at":"2026-08-03T00:00:00.000Z","sessions":["#, + r#"{"session_id":"sess-1","profile_id":"p1","state":"live","cdp_ready":true},"#, + r#"{"session_id":"sess-2","profile_id":"p2","state":"ready","cdp_ready":false}]}"#, + "\n\n" + ) + .as_bytes(), + ); + + assert_eq!( + indexed("p1").map(|s| s.session_id), + Some("sess-1".to_string()) + ); + // Not attachable, so not in the index even though the snapshot listed it. + assert!(indexed("p2").is_none()); + // A session the snapshot did not mention is gone, however live the index + // last believed it to be. + assert!(indexed("p-gone").is_none()); + assert!(INDEX_AUTHORITATIVE.load(Ordering::SeqCst)); + }); + } + + #[test] + fn a_snapshot_carrying_no_session_list_does_not_blind_the_resolver() { + index_test(|| { + feed(&transition("sess-1", "p1", "live", true)); + // Trusting a malformed snapshot would answer "no session" for every + // profile until the next reconnect, which is exactly the blindness the + // index exists to remove. + apply_to_index(None, EVENT_SESSION_SNAPSHOT, &serde_json::json!({})); + assert_eq!( + indexed("p1").map(|s| s.session_id), + Some("sess-1".to_string()) + ); + assert!(!INDEX_AUTHORITATIVE.load(Ordering::SeqCst)); + }); + } + + #[test] + fn one_undecodable_session_does_not_cost_the_whole_snapshot() { + index_test(|| { + feed( + concat!( + r#"data: {"type":"snapshot","sessions":[{"nonsense":true},"#, + r#"{"session_id":"sess-1","profile_id":"p1","state":"live","cdp_ready":true}]}"#, + "\n\n" + ) + .as_bytes(), + ); + assert_eq!( + indexed("p1").map(|s| s.session_id), + Some("sess-1".to_string()) + ); + assert!(INDEX_AUTHORITATIVE.load(Ordering::SeqCst)); + }); + } + + #[test] + fn unsubscribing_stops_the_index_being_an_answer() { + index_test(|| { + INDEX_AUTHORITATIVE.store(true, Ordering::SeqCst); + STREAM_RUNNING.store(false, Ordering::SeqCst); + // Sign-out unsubscribes. An index still marked authoritative would keep + // answering from state nothing is maintaining any more, so a session + // started by the next account would report as absent. + stop_session_events(); + assert!(!INDEX_AUTHORITATIVE.load(Ordering::SeqCst)); + }); + } + + #[test] + fn a_session_with_no_profile_is_ignored_rather_than_indexed_under_nothing() { + index_test(|| { + feed(b"data: {\"type\":\"state\",\"session\":{\"session_id\":\"s1\",\"state\":\"live\",\"cdp_ready\":true}}\n\n"); + assert!(with_index(|map| map.is_empty())); + }); + } + + // --- The CDP endpoint descriptor ----------------------------------------- + + #[test] + fn the_endpoint_descriptor_matches_what_the_backend_sends() { + // Pinned against `GET /api/remote-sessions/:id/cdp` in donutbrowser-infra. + // A field name that does not match makes every remote attach fail at the + // decode step, and the desktop reports a live session as undrivable. + let endpoint: CdpEndpoint = serde_json::from_str( + r#"{"session_id":"sess-1", + "ws_url":"wss://api.donutbrowser.com/api/remote-sessions/cdp?session_id=sess-1", + "protocol":"cdp-relay/1","auth":"bearer"}"#, + ) + .expect("the backend's CDP descriptor must deserialize"); + assert_eq!(endpoint.session_id, "sess-1"); + assert!(endpoint.ws_url.starts_with("wss://")); + assert!(unsupported_descriptor(&endpoint).is_none()); + } + + #[test] + fn a_descriptor_this_build_cannot_honour_is_refused_rather_than_guessed_at() { + // The descriptor is opaque and server-decided so the endpoint can move + // without a desktop release. The other side of that bargain is that a + // scheme this build does not implement must say so, not present the user's + // access token on a wire that expected something else. + let ticketed = CdpEndpoint { + session_id: "sess-1".to_string(), + ws_url: "wss://fleet.example/cdp".to_string(), + protocol: PROTOCOL_CDP_RELAY_1.to_string(), + auth: "ticket".to_string(), + }; + assert!(unsupported_descriptor(&ticketed) + .expect("an unknown auth scheme must be refused") + .contains("update Donut Browser")); + + let future_protocol = CdpEndpoint { + auth: AUTH_BEARER.to_string(), + protocol: "cdp-relay/2".to_string(), + ..ticketed + }; + assert!(unsupported_descriptor(&future_protocol).is_some()); + } + + #[test] + fn a_descriptor_that_states_nothing_is_treated_as_todays_behaviour() { + // An older backend that predates the fields must keep working; the fields + // are a forward-compatibility hook, not a required handshake. + let bare: CdpEndpoint = serde_json::from_str(r#"{"ws_url":"wss://example/cdp"}"#) + .expect("a descriptor with only a URL must deserialize"); + assert!(bare.session_id.is_empty()); + assert!(unsupported_descriptor(&bare).is_none()); + } + + #[test] + fn only_a_session_that_is_both_live_and_attachable_is_drivable() { + let mut session: RemoteSessionState = + serde_json::from_str(r#"{"session_id":"s1","state":"live","cdp_ready":true}"#).unwrap(); + assert!(is_drivable(&session)); + + session.cdp_ready = false; + assert!(!is_drivable(&session)); + + session.cdp_ready = true; + session.state = "ready".to_string(); + assert!(!is_drivable(&session)); + } } diff --git a/src-tauri/src/sync/engine.rs b/src-tauri/src/sync/engine.rs index 081f29b..b81c837 100644 --- a/src-tauri/src/sync/engine.rs +++ b/src-tauri/src/sync/engine.rs @@ -1,6 +1,8 @@ use super::client::SyncClient; use super::encryption; -use super::manifest::{compute_diff, generate_manifest, get_cache_path, HashCache, SyncManifest}; +use super::manifest::{ + compute_diff_with_bias, generate_manifest, get_cache_path, DiffBias, HashCache, SyncManifest, +}; use super::types::*; use crate::events; use crate::profile::types::{BrowserProfile, SyncMode}; @@ -20,6 +22,22 @@ use tokio::sync::{Mutex as TokioMutex, Semaphore}; /// (last-write-wins) from a HEAD request without downloading the object body. const UPDATED_AT_META_KEY: &str = "updated-at"; +/// What one profile reconcile actually did. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProfileSyncOutcome { + /// The local directory and the remote copy now agree. + Completed, + /// Nothing was transferred, and the reason is not an error. A caller waiting + /// on the remote copy has NOT got it and must try again. + Skipped(&'static str), +} + +impl ProfileSyncOutcome { + pub fn is_completed(&self) -> bool { + matches!(self, Self::Completed) + } +} + lazy_static::lazy_static! { static ref SYNC_CANCEL_FLAGS: StdMutex>> = StdMutex::new(HashMap::new()); @@ -450,13 +468,35 @@ impl SyncEngine { app_handle: &tauri::AppHandle, profile: &BrowserProfile, ) -> SyncResult<()> { + self + .sync_profile_with_bias(app_handle, profile, DiffBias::Auto) + .await + .map(|_| ()) + } + + /// Reconcile a profile, stating which side wins and whether anything happened. + /// + /// The outcome matters to exactly one caller: the pull that follows a remote + /// session. Every skip below returns `Ok(())` from `sync_profile`, so a caller + /// that treated success as "the profile is now current" would clear the local + /// launch gate without having downloaded a single byte — and the user would + /// then open a stale profile over the session's work. `Skipped` says so. + pub async fn sync_profile_with_bias( + &self, + app_handle: &tauri::AppHandle, + profile: &BrowserProfile, + bias: DiffBias, + ) -> SyncResult { if profile.is_cross_os() { log::info!( "Cross-OS profile: {} ({}) — syncing metadata only", profile.name, profile.id ); - return self.sync_cross_os_metadata(app_handle, profile).await; + self.sync_cross_os_metadata(app_handle, profile).await?; + // The browser files are the thing a remote session changes, and a cross-OS + // profile syncs none of them here, so this is not a completed pull. + return Ok(ProfileSyncOutcome::Skipped("cross-OS profile")); } // Skip team profiles for self-hosted sync @@ -466,7 +506,9 @@ impl SyncEngine { profile.name, profile.id ); - return Ok(()); + return Ok(ProfileSyncOutcome::Skipped( + "team profile, self-hosted sync", + )); } // Skip if profile is currently running locally @@ -476,20 +518,21 @@ impl SyncEngine { profile.name, profile.id ); - return Ok(()); + return Ok(ProfileSyncOutcome::Skipped("profile is running locally")); } - // Skip if profile is locked by another team member + // Skip if profile is locked by another team member, or by one of this + // user's own remote sessions. if crate::team_lock::TEAM_LOCK .is_locked_by_another(&profile.id.to_string()) .await { log::info!( - "Skipping sync for profile locked by another team member: {} ({})", + "Skipping sync for profile locked by another holder: {} ({})", profile.name, profile.id ); - return Ok(()); + return Ok(ProfileSyncOutcome::Skipped("profile is locked elsewhere")); } let reconciled_profile = self.reconcile_profile_metadata(profile).await?; @@ -591,7 +634,7 @@ impl SyncEngine { .await?; // Compute diff - let diff = compute_diff(&local_manifest, remote_manifest.as_ref()); + let diff = compute_diff_with_bias(&local_manifest, remote_manifest.as_ref(), bias); if diff.is_empty() { log::info!("Profile {} is already in sync", profile_id); @@ -603,7 +646,9 @@ impl SyncEngine { "status": "synced" }), ); - return Ok(()); + // Nothing to transfer IS a completed reconcile: the local copy already + // matches what the host pushed, which is exactly what the caller waits for. + return Ok(ProfileSyncOutcome::Completed); } let upload_bytes: u64 = diff.files_to_upload.iter().map(|f| f.size).sum(); @@ -769,7 +814,7 @@ impl SyncEngine { ); log::info!("Profile {} synced successfully", profile_id); - Ok(()) + Ok(ProfileSyncOutcome::Completed) } async fn download_manifest( @@ -3546,6 +3591,40 @@ pub async fn trigger_sync_for_profile( Ok(()) } +/// Pull a profile back down after a remote session wrote to it. +/// +/// Not `trigger_sync_for_profile` with a different name. Two things differ, and +/// both of them are the reason the session's work used to be destroyed: +/// +/// - The diff is biased to the remote copy. The host has just written the +/// authoritative profile; local mtimes may nonetheless be newer, and under the +/// ordinary rule that uploads the stale copy and deletes the host's files. +/// - The outcome is reported. Every skip inside `sync_profile` returns success, +/// so the caller could otherwise mark the profile current without a byte +/// having moved. +pub async fn pull_profile_after_remote_session( + app_handle: &tauri::AppHandle, + profile_id: &str, +) -> Result { + let engine = SyncEngine::create_from_settings(app_handle) + .await + .map_err(|e| format!("Failed to create sync engine: {e}"))?; + + let profile_uuid = + uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?; + let profile = ProfileManager::instance() + .list_profiles() + .map_err(|e| format!("Failed to list profiles: {e}"))? + .into_iter() + .find(|p| p.id == profile_uuid) + .ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?; + + engine + .sync_profile_with_bias(app_handle, &profile, DiffBias::PreferRemote) + .await + .map_err(|e| format!("Sync failed: {e}")) +} + #[tauri::command] pub async fn set_proxy_sync_enabled( app_handle: tauri::AppHandle, diff --git a/src-tauri/src/sync/manifest.rs b/src-tauri/src/sync/manifest.rs index 42fbcf4..ec2ebb6 100644 --- a/src-tauri/src/sync/manifest.rs +++ b/src-tauri/src/sync/manifest.rs @@ -414,11 +414,41 @@ impl ManifestDiff { } /// Compute what needs to be synced between local and remote +/// Which side a sync should believe when both have moved. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DiffBias { + /// Newest `updated_at` wins. What an ordinary background sync uses. + #[default] + Auto, + /// Remote wins regardless of timestamps. + /// + /// Used for exactly one thing: the pull that follows a remote session. A + /// leased host has just written the authoritative copy of this profile, and + /// the local directory is whatever it was before the session started. If the + /// user launched locally in between, local mtimes are NEWER than the host's + /// push, so `Auto` would upload the stale copy and put every file the host + /// wrote into `files_to_delete_remote` — the whole session's work destroyed, + /// silently. There is no timestamp comparison that gets this right, because + /// the local clock genuinely is later; only the caller knows that the remote + /// copy is the one that matters. + PreferRemote, +} + pub fn compute_diff(local: &SyncManifest, remote: Option<&SyncManifest>) -> ManifestDiff { + compute_diff_with_bias(local, remote, DiffBias::Auto) +} + +pub fn compute_diff_with_bias( + local: &SyncManifest, + remote: Option<&SyncManifest>, + bias: DiffBias, +) -> ManifestDiff { let mut diff = ManifestDiff::default(); let Some(remote) = remote else { - // No remote manifest - upload everything + // No remote manifest - upload everything. Even under PreferRemote: there is + // no remote copy to prefer, and refusing to upload would leave the profile + // with no cloud copy at all. diff.files_to_upload = local.files.clone(); return diff; }; @@ -446,11 +476,14 @@ pub fn compute_diff(local: &SyncManifest, remote: Option<&SyncManifest>) -> Mani let local_updated = local.updated_at_datetime(); let remote_updated = remote.updated_at_datetime(); - let local_is_newer = match (local_updated, remote_updated) { - (Some(l), Some(r)) => l > r, - (Some(_), None) => true, - (None, Some(_)) => false, - (None, None) => true, // Default to uploading + let local_is_newer = match bias { + DiffBias::PreferRemote => false, + DiffBias::Auto => match (local_updated, remote_updated) { + (Some(l), Some(r)) => l > r, + (Some(_), None) => true, + (None, Some(_)) => false, + (None, None) => true, // Default to uploading + }, }; if local_is_newer { @@ -674,6 +707,68 @@ mod tests { assert!(diff.files_to_delete_remote.is_empty()); } + /// A manifest with one file, at a stated time. + fn manifest_at(updated_at: &str, files: &[(&str, &str)]) -> SyncManifest { + SyncManifest { + version: 1, + profile_id: "test".to_string(), + generated_at: updated_at.to_string(), + updated_at: updated_at.to_string(), + exclude_globs: vec![], + files: files + .iter() + .map(|(path, hash)| ManifestFileEntry { + path: (*path).to_string(), + size: 10, + mtime: 1000, + hash: (*hash).to_string(), + }) + .collect(), + encrypted: false, + } + } + + #[test] + fn prefer_remote_downloads_even_though_the_local_clock_is_later() { + // The exact shape of the data-loss bug. A remote session finishes and the + // host pushes the profile; the user then launches locally before the pull + // lands, so every local mtime is newer than the host's write. Under Auto + // that uploads the stale copy and deletes the session's own files. + let local = manifest_at("2026-01-02T00:00:00Z", &[("Cookies", "before-session")]); + let remote = manifest_at( + "2026-01-01T00:00:00Z", + &[("Cookies", "after-session"), ("History", "warmed")], + ); + + let lossy = compute_diff_with_bias(&local, Some(&remote), DiffBias::Auto); + assert_eq!(lossy.files_to_delete_remote, vec!["History".to_string()]); + assert_eq!(lossy.files_to_upload.len(), 1); + + let safe = compute_diff_with_bias(&local, Some(&remote), DiffBias::PreferRemote); + assert!( + safe.files_to_delete_remote.is_empty(), + "a post-session pull must never delete what the host just wrote" + ); + assert!(safe.files_to_upload.is_empty()); + let downloaded: Vec<&str> = safe + .files_to_download + .iter() + .map(|f| f.path.as_str()) + .collect(); + assert_eq!(downloaded.len(), 2); + assert!(downloaded.contains(&"Cookies")); + assert!(downloaded.contains(&"History")); + } + + #[test] + fn prefer_remote_still_uploads_when_there_is_no_remote_copy() { + // Nothing to prefer. Refusing to upload here would leave a profile with no + // cloud copy because a session once ran against it. + let local = manifest_at("2026-01-02T00:00:00Z", &[("Cookies", "only-local")]); + let diff = compute_diff_with_bias(&local, None, DiffBias::PreferRemote); + assert_eq!(diff.files_to_upload.len(), 1); + } + #[test] fn test_compute_diff_detect_changes() { let old_time = "2024-01-01T00:00:00Z"; diff --git a/src-tauri/src/sync/mod.rs b/src-tauri/src/sync/mod.rs index ca49fb9..d277b40 100644 --- a/src-tauri/src/sync/mod.rs +++ b/src-tauri/src/sync/mod.rs @@ -15,12 +15,16 @@ pub use engine::{ enable_proxy_sync_if_needed, enable_sync_for_all_entities, enable_vpn_sync_if_needed, get_unsynced_entity_counts, is_group_in_use_by_synced_profile, is_group_used_by_synced_profile, is_proxy_in_use_by_synced_profile, is_proxy_used_by_synced_profile, is_sync_configured, - is_vpn_in_use_by_synced_profile, is_vpn_used_by_synced_profile, request_profile_sync, - rollover_encryption_for_all_entities, set_extension_group_sync_enabled, - set_extension_sync_enabled, set_group_sync_enabled, set_profile_sync_mode, - set_proxy_sync_enabled, set_vpn_sync_enabled, sync_profile, trigger_sync_for_profile, SyncEngine, + is_vpn_in_use_by_synced_profile, is_vpn_used_by_synced_profile, + pull_profile_after_remote_session, request_profile_sync, rollover_encryption_for_all_entities, + set_extension_group_sync_enabled, set_extension_sync_enabled, set_group_sync_enabled, + set_profile_sync_mode, set_proxy_sync_enabled, set_vpn_sync_enabled, sync_profile, + trigger_sync_for_profile, ProfileSyncOutcome, SyncEngine, +}; +pub use manifest::{ + compute_diff, compute_diff_with_bias, generate_manifest, DiffBias, HashCache, ManifestDiff, + SyncManifest, }; -pub use manifest::{compute_diff, generate_manifest, HashCache, ManifestDiff, SyncManifest}; pub use scheduler::{get_global_scheduler, set_global_scheduler, SyncScheduler}; pub use subscription::{SubscriptionManager, SyncWorkItem}; pub use types::{SyncError, SyncResult}; diff --git a/src-tauri/src/team_lock.rs b/src-tauri/src/team_lock.rs index daa10f2..338eab4 100644 --- a/src-tauri/src/team_lock.rs +++ b/src-tauri/src/team_lock.rs @@ -95,8 +95,8 @@ impl ProfileLockManager { pub async fn acquire_lock(&self, profile_id: &str) -> Result<(), String> { let client = Client::new(); - let access_token = - CloudAuthManager::load_access_token()?.ok_or_else(|| "Not logged in".to_string())?; + let access_token = CloudAuthManager::load_access_token()? + .ok_or_else(|| crate::backend_error("PROFILE_LOCK_UNAVAILABLE"))?; let url = format!("{CLOUD_API_URL}/api/profile-locks/{profile_id}"); let response = client @@ -104,24 +104,29 @@ impl ProfileLockManager { .header("Authorization", format!("Bearer {access_token}")) .send() .await - .map_err(|e| format!("Failed to acquire lock: {e}"))?; + .map_err(|e| { + log::warn!("Failed to acquire profile lock for {profile_id}: {e}"); + crate::backend_error("PROFILE_LOCK_UNAVAILABLE") + })?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); - return Err(format!("Lock acquisition failed ({status}): {body}")); + log::warn!("Profile lock acquisition for {profile_id} failed ({status}): {body}"); + return Err(crate::backend_error("PROFILE_LOCK_UNAVAILABLE")); } - let result: AcquireLockResponse = response - .json() - .await - .map_err(|e| format!("Failed to parse lock response: {e}"))?; + let result: AcquireLockResponse = response.json().await.map_err(|e| { + log::warn!("Could not parse the profile lock response for {profile_id}: {e}"); + crate::backend_error("PROFILE_LOCK_UNAVAILABLE") + })?; if !result.success { - let email = result - .locked_by_email - .unwrap_or_else(|| "another device".to_string()); - return Err(format!("Profile is in use by {email}")); + return Err(lock_conflict_error( + profile_id, + result.locked_by.as_deref(), + result.locked_by_email.as_deref(), + )); } // Update local cache @@ -274,6 +279,39 @@ impl ProfileLockManager { } } +/// Separator the backend puts between a user id and a non-desktop holder's +/// sub-identity. Mirrors `HOLDER_SEPARATOR` in donutbrowser-infra's +/// `profile-locks.service.ts`. +/// +/// A remote VM session takes the lock under `:vm:` so it +/// contends with this desktop instead of silently sharing its lock. That makes +/// the holder string the one place a client can tell "a teammate has this open" +/// apart from "this is my own profile, running on the fleet" — two refusals that +/// need completely different words. +const VM_HOLDER_SEPARATOR: &str = ":vm:"; + +/// The `{"code":…}` for a lock this caller could not take. +fn lock_conflict_error( + profile_id: &str, + holder: Option<&str>, + holder_email: Option<&str>, +) -> String { + if holder.is_some_and(|id| id.contains(VM_HOLDER_SEPARATOR)) { + // The user's own remote session. Saying "in use by you@example.com" here, + // which is what the raw backend message did, reads as a bug. + log::info!("Profile {profile_id} is held by a remote session"); + return crate::backend_error("PROFILE_RUNNING_REMOTELY"); + } + match holder_email { + Some(email) if !email.is_empty() => serde_json::json!({ + "code": "PROFILE_LOCKED_BY_MEMBER", + "params": { "email": email } + }) + .to_string(), + _ => crate::backend_error("PROFILE_LOCKED_ELSEWHERE"), + } +} + /// Acquire profile lock if profile is sync-enabled and user has a paid subscription. pub async fn acquire_team_lock_if_needed( profile: &crate::profile::BrowserProfile, @@ -294,10 +332,12 @@ pub async fn acquire_team_lock_if_needed( .is_locked_by_another(&profile.id.to_string()) .await { - if let Some(lock) = PROFILE_LOCK.get_lock_status(&profile.id.to_string()).await { - return Err(format!("Profile is in use by {}", lock.locked_by_email)); - } - return Err("Profile is in use on another device".to_string()); + let held = PROFILE_LOCK.get_lock_status(&profile.id.to_string()).await; + return Err(lock_conflict_error( + &profile.id.to_string(), + held.as_ref().map(|lock| lock.locked_by.as_str()), + held.as_ref().map(|lock| lock.locked_by_email.as_str()), + )); } PROFILE_LOCK.acquire_lock(&profile.id.to_string()).await @@ -328,3 +368,39 @@ pub async fn get_team_locks() -> Result, String> { pub async fn get_team_lock_status(profile_id: String) -> Result, String> { Ok(PROFILE_LOCK.get_lock_status(&profile_id).await) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_users_own_remote_session_is_not_reported_as_a_teammate() { + // The holder for a fleet session is `:vm:` and the row + // carries the OWNER's email, so the previous message read "Profile is in use + // by you@example.com" — the user's own address, about their own profile. + let err = lock_conflict_error( + "p1", + Some("11111111-2222-3333-4444-555555555555:vm:run-remote:p1:abc"), + Some("owner@example.com"), + ); + assert_eq!(err, r#"{"code":"PROFILE_RUNNING_REMOTELY"}"#); + assert!(!err.contains("owner@example.com")); + } + + #[test] + fn a_teammates_lock_names_them_through_a_translatable_code() { + let err = lock_conflict_error("p1", Some("other-user-id"), Some("mate@example.com")); + let json: serde_json::Value = serde_json::from_str(&err).expect("a code envelope"); + assert_eq!(json["code"], "PROFILE_LOCKED_BY_MEMBER"); + assert_eq!(json["params"]["email"], "mate@example.com"); + } + + #[test] + fn a_lock_with_no_identifiable_holder_still_produces_a_code() { + // Raw English here is what reaches a Russian user untranslated. + for holder in [None, Some("")] { + let err = lock_conflict_error("p1", holder, None); + assert_eq!(err, r#"{"code":"PROFILE_LOCKED_ELSEWHERE"}"#); + } + } +} diff --git a/src/components/cookie-bot-shared.tsx b/src/components/cookie-bot-shared.tsx index 141d21d..cd3c09a 100644 --- a/src/components/cookie-bot-shared.tsx +++ b/src/components/cookie-bot-shared.tsx @@ -237,6 +237,24 @@ export function preflight(profile: BrowserProfile): PreflightResult { return ELIGIBLE; } +/** + * Whether this profile could be launched on a remote host. + * + * A strict subset of {@link preflight}: a remote session needs the profile to + * exist in cloud storage in a form a host can read, and nothing more. The bot's + * extra requirement — an exit node — exists because a night of unattended + * traffic from a datacenter address is worse for the profile than not warming + * it, and that reasoning does not apply to a session the user is driving. + * + * Mirrors `remote_launch_profile_rules` in `api_server.rs`, which is + * authoritative; this only avoids offering an action that would be refused. + */ +export function canLaunchRemotely(profile: BrowserProfile): boolean { + const syncMode = profile.sync_mode ?? "Disabled"; + if (syncMode === "Disabled" || syncMode === "Encrypted") return false; + return resolvedOs(profile) !== null; +} + export function preflightReason(t: TFunction, result: PreflightResult): string { switch (result.code) { case "syncOff": diff --git a/src/components/profile-data-table.tsx b/src/components/profile-data-table.tsx index 58a5216..1c1a411 100644 --- a/src/components/profile-data-table.tsx +++ b/src/components/profile-data-table.tsx @@ -101,6 +101,7 @@ import { useBrowserState } from "@/hooks/use-browser-state"; import { useCloudAuth } from "@/hooks/use-cloud-auth"; import { cookieBotScopeFor, useCookieBot } from "@/hooks/use-cookie-bot"; import { useProxyEvents } from "@/hooks/use-proxy-events"; +import { useRemoteHandoff } from "@/hooks/use-remote-handoff"; import { useScrollFade } from "@/hooks/use-scroll-fade"; import { useTableSorting } from "@/hooks/use-table-sorting"; import { useTeamLocks } from "@/hooks/use-team-locks"; @@ -121,6 +122,7 @@ import { import { DNS_BLOCKLIST_LEVELS } from "@/lib/dns-blocklist-levels"; import { canUseCookieBot } from "@/lib/entitlements"; import { formatRelativeTime } from "@/lib/flag-utils"; +import type { RemoteHandoffState } from "@/lib/remote-sessions"; import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; import { cn } from "@/lib/utils"; import type { @@ -273,6 +275,15 @@ interface TableMeta { isProfileLockedByAnother: (profileId: string) => boolean; getProfileLockEmail: (profileId: string) => string | undefined; + // Remote execution. + // + // `getRemoteHandoff` is the authoritative answer to "can this be opened + // here", read from the same store the backend gate reads. The team-lock cache + // above cannot serve it: it refreshes on a 30-second poll and says nothing at + // all about a session that has finished but whose work has not been pulled + // back yet. + getRemoteHandoff: (profileId: string) => RemoteHandoffState | null; + // Synchronizer getProfileSyncInfo: (profileId: string) => | { @@ -1585,6 +1596,10 @@ export function ProfilesDataTable({ const { vpnConfigs } = useVpnEvents(); const { user } = useCloudAuth(); const { isProfileLocked, getLockInfo } = useTeamLocks(user?.id); + // Which profiles cannot be opened on this computer, and why. Event-driven and + // read from the backend's own gate, so the button state and the refusal the + // backend would give can never disagree. + const { handoffFor } = useRemoteHandoff(); // Cookie Bot. Enrolments and live runs both live server-side, so the table // reads them from the shared store rather than from BrowserProfile. @@ -2445,6 +2460,9 @@ export function ProfilesDataTable({ getProfileLockEmail: (profileId: string) => getLockInfo(profileId)?.lockedByEmail, + // Remote execution + getRemoteHandoff: handoffFor, + // Synchronizer getProfileSyncInfo: getProfileSyncInfo ?? (() => undefined), onLaunchWithSync: @@ -2524,6 +2542,7 @@ export function ProfilesDataTable({ handleCreateCountryProxy, isProfileLocked, getLockInfo, + handoffFor, getProfileSyncInfo, onLaunchWithSync, cookieBotUnlocked, @@ -2725,20 +2744,37 @@ export function ProfilesDataTable({ cell: ({ row, table }) => { const meta = table.options.meta as TableMeta; const profile = row.original; + const handoff = meta.getRemoteHandoff(profile.id); + // A profile open on the fleet IS running, and the button has to say + // so: it is the control that stops it, and stopping now reaches the + // remote browser rather than looking for a local process that was + // never there. + const isRunningRemotely = handoff === "running"; + const isPendingRemotePull = handoff === "pending_sync"; const isRunning = - meta.isClient && meta.runningProfiles.has(profile.id); + (meta.isClient && meta.runningProfiles.has(profile.id)) || + isRunningRemotely; const isLaunching = meta.launchingProfiles.has(profile.id); const isStopping = meta.stoppingProfiles.has(profile.id); const isLockedByAnother = meta.isProfileLockedByAnother(profile.id); const isSyncing = meta.syncStatuses[profile.id]?.status === "syncing"; - const canLaunch = - meta.browserState.canLaunchProfile(profile) && - !isLockedByAnother && - !isSyncing; + // A remote session holds the profile lock under its own holder id, so + // `isLockedByAnother` is true for the user's OWN fleet session. That + // must not disable the control that stops it. + const canLaunch = isRunningRemotely + ? true + : meta.browserState.canLaunchProfile(profile) && + !isPendingRemotePull && + !isLockedByAnother && + !isSyncing; const lockEmail = meta.getProfileLockEmail(profile.id); - const tooltipContent = isLockedByAnother - ? meta.t("sync.team.cannotLaunchLocked", { email: lockEmail }) - : meta.browserState.getLaunchTooltipContent(profile); + const tooltipContent = isRunningRemotely + ? meta.t("profiles.remote.runningTooltip") + : isPendingRemotePull + ? meta.t("profiles.remote.pendingSyncTooltip") + : isLockedByAnother + ? meta.t("sync.team.cannotLaunchLocked", { email: lockEmail }) + : meta.browserState.getLaunchTooltipContent(profile); const handleProfileStop = async (profile: BrowserProfile) => { meta.setStoppingProfiles((prev: Set) => diff --git a/src/hooks/use-remote-handoff.ts b/src/hooks/use-remote-handoff.ts new file mode 100644 index 0000000..92d368d --- /dev/null +++ b/src/hooks/use-remote-handoff.ts @@ -0,0 +1,51 @@ +import { useCallback, useEffect, useState } from "react"; +import { + getRemoteHandoffStates, + onRemoteHandoffChanged, + type RemoteHandoffState, +} from "@/lib/remote-sessions"; + +/** + * Which profiles cannot be opened on this computer right now. + * + * Reads the same store the backend launch gate reads, so the button this + * disables and the refusal the backend would produce can never disagree. That + * matters more than it sounds: the previous signal was the profile-lock cache, + * which refreshes on a 30-second server poll and only refetches on this + * device's own lock events. A profile running on the fleet therefore looked + * launchable for up to half a minute, and a profile whose finished session had + * not been pulled back looked launchable indefinitely. + * + * Updates arrive as an event rather than a poll because every transition that + * can change this already emits one. + */ +export function useRemoteHandoff() { + const [states, setStates] = useState>({}); + + const refresh = useCallback(async () => { + try { + setStates(await getRemoteHandoffStates()); + } catch (error) { + // Not signed in, or the app is still starting. The backend gate still + // applies; the button is simply not pre-disabled. + console.warn("Could not read remote handoff state:", error); + } + }, []); + + useEffect(() => { + void refresh(); + const unlisten = onRemoteHandoffChanged(setStates); + return () => { + void unlisten.then((off) => { + off(); + }); + }; + }, [refresh]); + + const handoffFor = useCallback( + (profileId: string): RemoteHandoffState | null => states[profileId] ?? null, + [states], + ); + + return { handoffStates: states, handoffFor, refreshHandoff: refresh }; +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 8eb492f..f8f573a 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -345,6 +345,10 @@ "nameDesc": "Name (Z–A)", "newest": "Newest first", "oldest": "Oldest first" + }, + "remote": { + "runningTooltip": "Running on a remote machine. Stop it to bring the profile back here.", + "pendingSyncTooltip": "Downloading what the remote session changed. Available again when it finishes." } }, "createProfile": { @@ -1880,7 +1884,12 @@ "cookieBotUnsupportedPlatform": "The cookie bot cannot run {{platform}} profiles. Only Windows and macOS profiles are supported.", "cookieBotRequiresExitNode": "Attach a proxy or VPN first. Without one the run would come from a datacenter address, which damages the profile's identity.", "unknownCode": "Something went wrong: {{code}}", - "cookieBotTouchFingerprintUnsupported": "This profile claims a touch device, which the bot cannot drive. Use a desktop fingerprint." + "cookieBotTouchFingerprintUnsupported": "This profile claims a touch device, which the bot cannot drive. Use a desktop fingerprint.", + "profileRunningRemotely": "This profile is running on a remote machine. Stop the remote session first.", + "profileRemoteSyncPending": "A remote session just finished. Waiting for its changes to download before this profile can open here.", + "profileLockedByMember": "This profile is in use by {{email}}.", + "profileLockedElsewhere": "This profile is in use on another device.", + "profileLockUnavailable": "Could not check whether this profile is in use elsewhere. Check your connection and try again." }, "rail": { "profiles": "Profiles", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index a6329ee..eda6882 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -345,6 +345,10 @@ "nameDesc": "Nombre (Z–A)", "newest": "Más recientes primero", "oldest": "Más antiguos primero" + }, + "remote": { + "runningTooltip": "Ejecutándose en una máquina remota. Deténlo para recuperar el perfil aquí.", + "pendingSyncTooltip": "Descargando lo que cambió la sesión remota. Disponible de nuevo cuando termine." } }, "createProfile": { @@ -1887,7 +1891,12 @@ "cookieBotUnsupportedPlatform": "Cookie Bot no puede ejecutar perfiles de {{platform}}. Solo se admiten perfiles de Windows y macOS.", "cookieBotRequiresExitNode": "Asigna primero un proxy o una VPN. Sin ninguno, la ejecución saldría desde una dirección de centro de datos, lo que daña la identidad del perfil.", "unknownCode": "Algo salió mal: {{code}}", - "cookieBotTouchFingerprintUnsupported": "Este perfil declara un dispositivo táctil, que el bot no puede controlar. Usa una huella de escritorio." + "cookieBotTouchFingerprintUnsupported": "Este perfil declara un dispositivo táctil, que el bot no puede controlar. Usa una huella de escritorio.", + "profileRunningRemotely": "Este perfil se está ejecutando en una máquina remota. Detén primero la sesión remota.", + "profileRemoteSyncPending": "Una sesión remota acaba de terminar. Esperando a que se descarguen sus cambios antes de abrir este perfil aquí.", + "profileLockedByMember": "Este perfil está siendo usado por {{email}}.", + "profileLockedElsewhere": "Este perfil está en uso en otro dispositivo.", + "profileLockUnavailable": "No se pudo comprobar si este perfil está en uso en otro lugar. Revisa tu conexión e inténtalo de nuevo." }, "rail": { "profiles": "Perfiles", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index b91b49e..5a53b24 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -345,6 +345,10 @@ "nameDesc": "Nom (Z–A)", "newest": "Plus récents d’abord", "oldest": "Plus anciens d’abord" + }, + "remote": { + "runningTooltip": "En cours d'exécution sur une machine distante. Arrêtez-la pour récupérer le profil ici.", + "pendingSyncTooltip": "Téléchargement des modifications de la session distante. De nouveau disponible une fois terminé." } }, "createProfile": { @@ -1887,7 +1891,12 @@ "cookieBotUnsupportedPlatform": "Cookie Bot ne peut pas exécuter de profils {{platform}}. Seuls les profils Windows et macOS sont pris en charge.", "cookieBotRequiresExitNode": "Associez d'abord un proxy ou un VPN. Sans cela, l'exécution proviendrait d'une adresse de centre de données, ce qui abîme l'identité du profil.", "unknownCode": "Une erreur est survenue : {{code}}", - "cookieBotTouchFingerprintUnsupported": "Ce profil déclare un appareil tactile, que le bot ne peut pas piloter. Utilisez une empreinte de bureau." + "cookieBotTouchFingerprintUnsupported": "Ce profil déclare un appareil tactile, que le bot ne peut pas piloter. Utilisez une empreinte de bureau.", + "profileRunningRemotely": "Ce profil s'exécute sur une machine distante. Arrêtez d'abord la session distante.", + "profileRemoteSyncPending": "Une session distante vient de se terminer. Ses modifications doivent être téléchargées avant d'ouvrir ce profil ici.", + "profileLockedByMember": "Ce profil est utilisé par {{email}}.", + "profileLockedElsewhere": "Ce profil est utilisé sur un autre appareil.", + "profileLockUnavailable": "Impossible de vérifier si ce profil est utilisé ailleurs. Vérifiez votre connexion et réessayez." }, "rail": { "profiles": "Profils", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 127c968..e7207da 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -345,6 +345,10 @@ "nameDesc": "名前 (Z→A)", "newest": "新しい順", "oldest": "古い順" + }, + "remote": { + "runningTooltip": "リモートマシンで実行中です。停止するとプロファイルがここに戻ります。", + "pendingSyncTooltip": "リモートセッションの変更をダウンロード中です。完了すると再び使用できます。" } }, "createProfile": { @@ -1880,7 +1884,12 @@ "cookieBotUnsupportedPlatform": "Cookie Bot は {{platform}} のプロファイルを実行できません。対応しているのは Windows と macOS のプロファイルのみです。", "cookieBotRequiresExitNode": "先にプロキシまたは VPN を設定してください。設定しないと通信がデータセンターのアドレスから出て、プロファイルの信頼性を損ないます。", "unknownCode": "エラーが発生しました: {{code}}", - "cookieBotTouchFingerprintUnsupported": "このプロファイルはタッチ端末を名乗っており、ボットは操作できません。デスクトップのフィンガープリントをお使いください。" + "cookieBotTouchFingerprintUnsupported": "このプロファイルはタッチ端末を名乗っており、ボットは操作できません。デスクトップのフィンガープリントをお使いください。", + "profileRunningRemotely": "このプロファイルはリモートマシンで実行中です。先にリモートセッションを停止してください。", + "profileRemoteSyncPending": "リモートセッションが終了しました。この profile をここで開く前に、変更のダウンロードを待っています。", + "profileLockedByMember": "このプロファイルは {{email}} が使用中です。", + "profileLockedElsewhere": "このプロファイルは別のデバイスで使用中です。", + "profileLockUnavailable": "このプロファイルが他で使用中か確認できませんでした。接続を確認して再試行してください。" }, "rail": { "profiles": "プロファイル", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index f979948..99198a4 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -345,6 +345,10 @@ "nameDesc": "이름 (Z→A)", "newest": "최신순", "oldest": "오래된순" + }, + "remote": { + "runningTooltip": "원격 머신에서 실행 중입니다. 중지하면 프로필이 여기로 돌아옵니다.", + "pendingSyncTooltip": "원격 세션이 변경한 내용을 내려받는 중입니다. 완료되면 다시 사용할 수 있습니다." } }, "createProfile": { @@ -1880,7 +1884,12 @@ "cookieBotUnsupportedPlatform": "Cookie Bot은 {{platform}} 프로필을 실행할 수 없습니다. Windows와 macOS 프로필만 지원합니다.", "cookieBotRequiresExitNode": "먼저 프록시나 VPN을 연결하세요. 없으면 실행 트래픽이 데이터센터 주소에서 나가 프로필 신뢰도를 해칩니다.", "unknownCode": "문제가 발생했습니다: {{code}}", - "cookieBotTouchFingerprintUnsupported": "이 프로필은 터치 기기를 표방하며, 봇이 조작할 수 없습니다. 데스크톱 지문을 사용하세요." + "cookieBotTouchFingerprintUnsupported": "이 프로필은 터치 기기를 표방하며, 봇이 조작할 수 없습니다. 데스크톱 지문을 사용하세요.", + "profileRunningRemotely": "이 프로필은 원격 머신에서 실행 중입니다. 먼저 원격 세션을 중지하세요.", + "profileRemoteSyncPending": "원격 세션이 방금 끝났습니다. 이 프로필을 여기서 열기 전에 변경 사항을 내려받는 중입니다.", + "profileLockedByMember": "이 프로필은 {{email}} 님이 사용 중입니다.", + "profileLockedElsewhere": "이 프로필은 다른 기기에서 사용 중입니다.", + "profileLockUnavailable": "이 프로필이 다른 곳에서 사용 중인지 확인할 수 없습니다. 연결을 확인한 뒤 다시 시도하세요." }, "rail": { "profiles": "프로필", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index eac135b..b1b4f4c 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -345,6 +345,10 @@ "nameDesc": "Nome (Z–A)", "newest": "Mais recentes primeiro", "oldest": "Mais antigos primeiro" + }, + "remote": { + "runningTooltip": "Em execução numa máquina remota. Pare-a para trazer o perfil de volta para aqui.", + "pendingSyncTooltip": "A transferir o que a sessão remota alterou. Disponível novamente quando terminar." } }, "createProfile": { @@ -1887,7 +1891,12 @@ "cookieBotUnsupportedPlatform": "O Cookie Bot não pode executar perfis de {{platform}}. Somente perfis Windows e macOS são suportados.", "cookieBotRequiresExitNode": "Anexe primeiro um proxy ou VPN. Sem isso, a execução sairia de um endereço de data center, o que prejudica a identidade do perfil.", "unknownCode": "Algo deu errado: {{code}}", - "cookieBotTouchFingerprintUnsupported": "Este perfil declara um dispositivo de toque, que o bot não consegue controlar. Use uma impressão digital de computador." + "cookieBotTouchFingerprintUnsupported": "Este perfil declara um dispositivo de toque, que o bot não consegue controlar. Use uma impressão digital de computador.", + "profileRunningRemotely": "Este perfil está em execução numa máquina remota. Pare primeiro a sessão remota.", + "profileRemoteSyncPending": "Uma sessão remota acabou de terminar. A aguardar a transferência das alterações antes de abrir este perfil aqui.", + "profileLockedByMember": "Este perfil está a ser utilizado por {{email}}.", + "profileLockedElsewhere": "Este perfil está a ser utilizado noutro dispositivo.", + "profileLockUnavailable": "Não foi possível verificar se este perfil está a ser utilizado noutro local. Verifique a ligação e tente novamente." }, "rail": { "profiles": "Perfis", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 2bfbeca..7094129 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -345,6 +345,10 @@ "nameDesc": "Имя (Я–А)", "newest": "Сначала новые", "oldest": "Сначала старые" + }, + "remote": { + "runningTooltip": "Выполняется на удалённой машине. Остановите, чтобы вернуть профиль сюда.", + "pendingSyncTooltip": "Загружаются изменения удалённого сеанса. Профиль снова будет доступен по завершении." } }, "createProfile": { @@ -1894,7 +1898,12 @@ "cookieBotUnsupportedPlatform": "Cookie Bot не может запускать профили {{platform}}. Поддерживаются только профили Windows и macOS.", "cookieBotRequiresExitNode": "Сначала назначьте прокси или VPN. Без них трафик пойдёт с адреса дата-центра, а это вредит репутации профиля.", "unknownCode": "Что-то пошло не так: {{code}}", - "cookieBotTouchFingerprintUnsupported": "Этот профиль выдаёт себя за сенсорное устройство, которым бот управлять не может. Используйте настольный отпечаток." + "cookieBotTouchFingerprintUnsupported": "Этот профиль выдаёт себя за сенсорное устройство, которым бот управлять не может. Используйте настольный отпечаток.", + "profileRunningRemotely": "Этот профиль запущен на удалённой машине. Сначала остановите удалённый сеанс.", + "profileRemoteSyncPending": "Удалённый сеанс только что завершился. Дождитесь загрузки его изменений, прежде чем открывать профиль здесь.", + "profileLockedByMember": "Этот профиль используется пользователем {{email}}.", + "profileLockedElsewhere": "Этот профиль используется на другом устройстве.", + "profileLockUnavailable": "Не удалось проверить, используется ли профиль где-то ещё. Проверьте подключение и попробуйте снова." }, "rail": { "profiles": "Профили", diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index b0f1b0c..f22e4e8 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -345,6 +345,10 @@ "nameDesc": "Ad (Z–A)", "newest": "Önce en yeni", "oldest": "Önce en eski" + }, + "remote": { + "runningTooltip": "Uzak bir makinede çalışıyor. Profili buraya geri getirmek için durdurun.", + "pendingSyncTooltip": "Uzak oturumun değiştirdikleri indiriliyor. Bittiğinde yeniden kullanılabilir olacak." } }, "createProfile": { @@ -1880,7 +1884,12 @@ "cookieBotUnsupportedPlatform": "Cookie Bot, {{platform}} profillerini çalıştıramaz. Yalnızca Windows ve macOS profilleri desteklenir.", "cookieBotRequiresExitNode": "Önce bir proxy veya VPN ekleyin. Aksi hâlde çalışma bir veri merkezi adresinden çıkar ve bu, profilin kimliğine zarar verir.", "unknownCode": "Bir sorun oluştu: {{code}}", - "cookieBotTouchFingerprintUnsupported": "Bu profil dokunmatik bir cihaz olduğunu bildiriyor ve bot bunu süremez. Masaüstü parmak izi kullanın." + "cookieBotTouchFingerprintUnsupported": "Bu profil dokunmatik bir cihaz olduğunu bildiriyor ve bot bunu süremez. Masaüstü parmak izi kullanın.", + "profileRunningRemotely": "Bu profil uzak bir makinede çalışıyor. Önce uzak oturumu durdurun.", + "profileRemoteSyncPending": "Uzak oturum az önce bitti. Bu profili burada açmadan önce değişikliklerinin inmesi bekleniyor.", + "profileLockedByMember": "Bu profil {{email}} tarafından kullanılıyor.", + "profileLockedElsewhere": "Bu profil başka bir cihazda kullanılıyor.", + "profileLockUnavailable": "Bu profilin başka bir yerde kullanılıp kullanılmadığı denetlenemedi. Bağlantınızı kontrol edip yeniden deneyin." }, "rail": { "profiles": "Profiller", diff --git a/src/i18n/locales/vi.json b/src/i18n/locales/vi.json index ecc5cf1..a847882 100644 --- a/src/i18n/locales/vi.json +++ b/src/i18n/locales/vi.json @@ -345,6 +345,10 @@ "nameDesc": "Tên (Z–A)", "newest": "Mới nhất trước", "oldest": "Cũ nhất trước" + }, + "remote": { + "runningTooltip": "Đang chạy trên máy từ xa. Dừng lại để đưa hồ sơ về đây.", + "pendingSyncTooltip": "Đang tải về những gì phiên từ xa đã thay đổi. Sẽ dùng lại được khi hoàn tất." } }, "createProfile": { @@ -1880,7 +1884,12 @@ "cookieBotUnsupportedPlatform": "Cookie Bot không chạy được hồ sơ {{platform}}. Chỉ hỗ trợ hồ sơ Windows và macOS.", "cookieBotRequiresExitNode": "Hãy gán proxy hoặc VPN trước. Nếu không, lần chạy sẽ đi ra từ địa chỉ trung tâm dữ liệu, gây hại cho danh tính hồ sơ.", "unknownCode": "Đã xảy ra lỗi: {{code}}", - "cookieBotTouchFingerprintUnsupported": "Hồ sơ này khai báo là thiết bị cảm ứng, bot không điều khiển được. Hãy dùng vân tay máy tính để bàn." + "cookieBotTouchFingerprintUnsupported": "Hồ sơ này khai báo là thiết bị cảm ứng, bot không điều khiển được. Hãy dùng vân tay máy tính để bàn.", + "profileRunningRemotely": "Hồ sơ này đang chạy trên máy từ xa. Hãy dừng phiên từ xa trước.", + "profileRemoteSyncPending": "Một phiên từ xa vừa kết thúc. Đang chờ tải các thay đổi về trước khi mở hồ sơ này tại đây.", + "profileLockedByMember": "Hồ sơ này đang được {{email}} sử dụng.", + "profileLockedElsewhere": "Hồ sơ này đang được sử dụng trên thiết bị khác.", + "profileLockUnavailable": "Không thể kiểm tra hồ sơ này có đang được dùng ở nơi khác hay không. Hãy kiểm tra kết nối và thử lại." }, "rail": { "profiles": "Profile", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 88e39e5..4ec28a2 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -345,6 +345,10 @@ "nameDesc": "名称 (Z–A)", "newest": "最新优先", "oldest": "最早优先" + }, + "remote": { + "runningTooltip": "正在远程计算机上运行。停止后配置文件会回到本机。", + "pendingSyncTooltip": "正在下载远程会话所做的更改。完成后即可再次使用。" } }, "createProfile": { @@ -1880,7 +1884,12 @@ "cookieBotUnsupportedPlatform": "Cookie Bot 无法运行 {{platform}} 配置文件。仅支持 Windows 和 macOS 配置文件。", "cookieBotRequiresExitNode": "请先绑定代理或 VPN。否则运行会从数据中心地址发出,损害配置文件的身份。", "unknownCode": "出现问题: {{code}}", - "cookieBotTouchFingerprintUnsupported": "该配置文件声称是触摸设备,机器人无法操作。请使用桌面端指纹。" + "cookieBotTouchFingerprintUnsupported": "该配置文件声称是触摸设备,机器人无法操作。请使用桌面端指纹。", + "profileRunningRemotely": "该配置文件正在远程计算机上运行。请先停止远程会话。", + "profileRemoteSyncPending": "远程会话刚刚结束。正在等待其更改下载完成后才能在此打开该配置文件。", + "profileLockedByMember": "该配置文件正在被 {{email}} 使用。", + "profileLockedElsewhere": "该配置文件正在另一台设备上使用。", + "profileLockUnavailable": "无法检查该配置文件是否正在别处使用。请检查网络连接后重试。" }, "rail": { "profiles": "配置文件", diff --git a/src/lib/backend-errors.ts b/src/lib/backend-errors.ts index 4fc2a33..169d96d 100644 --- a/src/lib/backend-errors.ts +++ b/src/lib/backend-errors.ts @@ -78,6 +78,11 @@ export type BackendErrorCode = | "REMOTE_SESSION_CONFLICT" | "REMOTE_SYNC_IN_PROGRESS" | "REMOTE_HOURS_EXHAUSTED" + | "PROFILE_RUNNING_REMOTELY" + | "PROFILE_REMOTE_SYNC_PENDING" + | "PROFILE_LOCKED_BY_MEMBER" + | "PROFILE_LOCKED_ELSEWHERE" + | "PROFILE_LOCK_UNAVAILABLE" | "NOT_TEAM_MEMBER" | "COOKIE_BOT_NOT_ENTITLED" | "COOKIE_BOT_NOT_ENROLLED" @@ -314,6 +319,18 @@ export function translateBackendError(t: TFunction, err: unknown): string { granted: parsed.params?.granted ?? "0", used: parsed.params?.used ?? "0", }); + case "PROFILE_RUNNING_REMOTELY": + return t("backendErrors.profileRunningRemotely"); + case "PROFILE_REMOTE_SYNC_PENDING": + return t("backendErrors.profileRemoteSyncPending"); + case "PROFILE_LOCKED_BY_MEMBER": + return t("backendErrors.profileLockedByMember", { + email: parsed.params?.email ?? "", + }); + case "PROFILE_LOCKED_ELSEWHERE": + return t("backendErrors.profileLockedElsewhere"); + case "PROFILE_LOCK_UNAVAILABLE": + return t("backendErrors.profileLockUnavailable"); case "NOT_TEAM_MEMBER": return t("backendErrors.notTeamMember"); case "COOKIE_BOT_NOT_ENTITLED": diff --git a/src/lib/remote-sessions.ts b/src/lib/remote-sessions.ts index f4ceed5..edf203b 100644 --- a/src/lib/remote-sessions.ts +++ b/src/lib/remote-sessions.ts @@ -47,6 +47,19 @@ export interface RemoteSessionEnded { billed_seconds: number; } +/** + * Why a profile cannot be opened on this computer right now. + * + * - `running`: a browser is open on the fleet holding this profile. + * - `pending_sync`: a session has finished and what it wrote is still being + * pulled down. Opening the local copy now would make the local files look + * newer than the host's push, and the next sync would then upload the stale + * copy over the session's work and delete the rest of it. + * + * Both states are temporary and neither is an error. + */ +export type RemoteHandoffState = "running" | "pending_sync"; + /** * States a session cannot leave under its own steam. * @@ -83,8 +96,31 @@ export const REMOTE_SESSION_EVENTS = { snapshot: "remote-session-snapshot", /** Stream connectivity. Payload: `RemoteSessionStreamStatus`. */ stream: "remote-session-stream", + /** + * The set of profiles that cannot be launched locally changed. + * Payload: `Record`. + */ + handoff: "remote-handoff-changed", } as const; +/** Which profiles are blocked from launching locally, and why. */ +export function getRemoteHandoffStates(): Promise< + Record +> { + return invoke>( + "get_remote_handoff_states", + ); +} + +export function onRemoteHandoffChanged( + handler: (states: Record) => void, +): Promise { + return listen>( + REMOTE_SESSION_EVENTS.handoff, + (event) => handler(event.payload), + ); +} + export function listRemoteSessions(): Promise { return invoke("list_remote_sessions"); }