Compare commits

...
Author SHA1 Message Date
zhom 29cb83d063 refactor: cleanup 2026-08-03 18:44:24 +04:00
32 changed files with 4227 additions and 551 deletions
+1
View File
@@ -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",
+16
View File
@@ -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
+328 -28
View File
@@ -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<ApiServerState> {
// `/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<Option<u16>, 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<String>,
State(state): State<ApiServerState>,
Json(request): Json<RunProfileRequest>,
) -> Result<Json<RunProfileResponse>, StatusCode> {
) -> Result<Json<RunProfileResponse>, (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:<api port>/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<String>,
upgrade: WebSocketUpgrade,
) -> Result<Response, (StatusCode, String)> {
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<String>,
State(state): State<ApiServerState>,
) -> Result<StatusCode, StatusCode> {
) -> Result<StatusCode, (StatusCode, String)> {
// 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");
+82 -2
View File
@@ -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 {
+97
View File
@@ -15,6 +15,13 @@ static PROFILE_LAUNCH_LOCKS: LazyLock<
tokio::sync::Mutex<HashMap<String, Arc<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<dyn std::error::Error + Send + Sync>> {
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<bool, Box<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync> {
// 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?;
File diff suppressed because it is too large Load Diff
+153 -34
View File
@@ -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<PathBuf, String> {
let production_url = level
.url()
.ok_or_else(|| format!("No URL for level {:?}", level))?;
let production_urls: Vec<String> = 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<String> = None;
let mut failures: Vec<String> = 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();
+159 -19
View File
@@ -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<dyn std::error::Error + Send + Sync>> {
// 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<String> = 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();
+11
View File
@@ -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
+31 -2
View File
@@ -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::EndRemoteSessionOutcome, String> {
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<String, remote_handoff::HandoffState> {
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}\"")),
+280 -409
View File
@@ -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<u16, McpError> {
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<String, McpError> {
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::<Vec<serde_json::Value>>().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<CdpTarget, McpError> {
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<serde_json::Value, McpError> {
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<f64>,
) -> 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<serde_json::Value, McpError> {
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<BrowserProfile, McpError> {
/// 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<BrowserProfile, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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<serde_json::Value, McpError> {
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))),
+543
View File
@@ -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<String, HandoffEntry>;
static STORE: RwLock<Option<Store>> = 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::<Store>(&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<T>(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<String, HandoffState> {
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<HandoffState> {
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<String> {
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<String> {
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<String>) -> Vec<String> {
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<String> = 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<String> = 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<String> = ["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<String> = 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));
}
}
+589 -2
View File
@@ -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<T: serde::de::DeserializeOwned>(
.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<Option<HashMap<String, CdpEndpoint>>> = Mutex::new(None);
/// Ask the backend where to attach for `session_id`.
pub async fn cdp_endpoint(session_id: &str) -> Result<CdpEndpoint, RemoteSessionError> {
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<String> {
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<T>(f: impl FnOnce(&mut HashMap<String, CdpEndpoint>) -> 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<String, String> {
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<Option<HashMap<String, RemoteSessionState>>> = 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<T>(f: impl FnOnce(&mut HashMap<String, RemoteSessionState>) -> 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<String> = 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<String, RemoteSessionState> = 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<RemoteSessionState> {
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::<RemoteSessionState>(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::<RemoteSessionState>(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<T>(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<RemoteSessionState> {
with_index(|map| map.get(profile_id).cloned())
}
fn transition(session_id: &str, profile_id: &str, state: &str, cdp_ready: bool) -> Vec<u8> {
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));
}
}
+89 -10
View File
@@ -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<HashMap<String, Arc<AtomicBool>>> =
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<ProfileSyncOutcome> {
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<ProfileSyncOutcome, String> {
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,
+101 -6
View File
@@ -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";
+9 -5
View File
@@ -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};
+92 -16
View File
@@ -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 `<user id>:vm:<session id>` 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<Vec<ProfileLockInfo>, String> {
pub async fn get_team_lock_status(profile_id: String) -> Result<Option<ProfileLockInfo>, 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 `<user id>:vm:<session id>` 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"}"#);
}
}
}
+18
View File
@@ -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":
+44 -8
View File
@@ -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<string>) =>
+51
View File
@@ -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<Record<string, RemoteHandoffState>>({});
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 };
}
+10 -1
View File
@@ -345,6 +345,10 @@
"nameDesc": "Name (ZA)",
"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",
+10 -1
View File
@@ -345,6 +345,10 @@
"nameDesc": "Nombre (ZA)",
"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",
+10 -1
View File
@@ -345,6 +345,10 @@
"nameDesc": "Nom (ZA)",
"newest": "Plus récents dabord",
"oldest": "Plus anciens dabord"
},
"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",
+10 -1
View File
@@ -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": "プロファイル",
+10 -1
View File
@@ -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": "프로필",
+10 -1
View File
@@ -345,6 +345,10 @@
"nameDesc": "Nome (ZA)",
"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",
+10 -1
View File
@@ -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": "Профили",
+10 -1
View File
@@ -345,6 +345,10 @@
"nameDesc": "Ad (ZA)",
"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",
+10 -1
View File
@@ -345,6 +345,10 @@
"nameDesc": "Tên (ZA)",
"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",
+10 -1
View File
@@ -345,6 +345,10 @@
"nameDesc": "名称 (ZA)",
"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": "配置文件",
+17
View File
@@ -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":
+36
View File
@@ -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<profileId, RemoteHandoffState>`.
*/
handoff: "remote-handoff-changed",
} as const;
/** Which profiles are blocked from launching locally, and why. */
export function getRemoteHandoffStates(): Promise<
Record<string, RemoteHandoffState>
> {
return invoke<Record<string, RemoteHandoffState>>(
"get_remote_handoff_states",
);
}
export function onRemoteHandoffChanged(
handler: (states: Record<string, RemoteHandoffState>) => void,
): Promise<UnlistenFn> {
return listen<Record<string, RemoteHandoffState>>(
REMOTE_SESSION_EVENTS.handoff,
(event) => handler(event.payload),
);
}
export function listRemoteSessions(): Promise<RemoteSessionState[]> {
return invoke<RemoteSessionState[]>("list_remote_sessions");
}