mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-03 17:58:42 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29cb83d063 |
@@ -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",
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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))),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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"}"#);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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>) =>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -345,6 +345,10 @@
|
||||
"nameDesc": "Name (Z–A)",
|
||||
"newest": "Newest first",
|
||||
"oldest": "Oldest first"
|
||||
},
|
||||
"remote": {
|
||||
"runningTooltip": "Running on a remote machine. Stop it to bring the profile back here.",
|
||||
"pendingSyncTooltip": "Downloading what the remote session changed. Available again when it finishes."
|
||||
}
|
||||
},
|
||||
"createProfile": {
|
||||
@@ -1880,7 +1884,12 @@
|
||||
"cookieBotUnsupportedPlatform": "The cookie bot cannot run {{platform}} profiles. Only Windows and macOS profiles are supported.",
|
||||
"cookieBotRequiresExitNode": "Attach a proxy or VPN first. Without one the run would come from a datacenter address, which damages the profile's identity.",
|
||||
"unknownCode": "Something went wrong: {{code}}",
|
||||
"cookieBotTouchFingerprintUnsupported": "This profile claims a touch device, which the bot cannot drive. Use a desktop fingerprint."
|
||||
"cookieBotTouchFingerprintUnsupported": "This profile claims a touch device, which the bot cannot drive. Use a desktop fingerprint.",
|
||||
"profileRunningRemotely": "This profile is running on a remote machine. Stop the remote session first.",
|
||||
"profileRemoteSyncPending": "A remote session just finished. Waiting for its changes to download before this profile can open here.",
|
||||
"profileLockedByMember": "This profile is in use by {{email}}.",
|
||||
"profileLockedElsewhere": "This profile is in use on another device.",
|
||||
"profileLockUnavailable": "Could not check whether this profile is in use elsewhere. Check your connection and try again."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profiles",
|
||||
|
||||
@@ -345,6 +345,10 @@
|
||||
"nameDesc": "Nombre (Z–A)",
|
||||
"newest": "Más recientes primero",
|
||||
"oldest": "Más antiguos primero"
|
||||
},
|
||||
"remote": {
|
||||
"runningTooltip": "Ejecutándose en una máquina remota. Deténlo para recuperar el perfil aquí.",
|
||||
"pendingSyncTooltip": "Descargando lo que cambió la sesión remota. Disponible de nuevo cuando termine."
|
||||
}
|
||||
},
|
||||
"createProfile": {
|
||||
@@ -1887,7 +1891,12 @@
|
||||
"cookieBotUnsupportedPlatform": "Cookie Bot no puede ejecutar perfiles de {{platform}}. Solo se admiten perfiles de Windows y macOS.",
|
||||
"cookieBotRequiresExitNode": "Asigna primero un proxy o una VPN. Sin ninguno, la ejecución saldría desde una dirección de centro de datos, lo que daña la identidad del perfil.",
|
||||
"unknownCode": "Algo salió mal: {{code}}",
|
||||
"cookieBotTouchFingerprintUnsupported": "Este perfil declara un dispositivo táctil, que el bot no puede controlar. Usa una huella de escritorio."
|
||||
"cookieBotTouchFingerprintUnsupported": "Este perfil declara un dispositivo táctil, que el bot no puede controlar. Usa una huella de escritorio.",
|
||||
"profileRunningRemotely": "Este perfil se está ejecutando en una máquina remota. Detén primero la sesión remota.",
|
||||
"profileRemoteSyncPending": "Una sesión remota acaba de terminar. Esperando a que se descarguen sus cambios antes de abrir este perfil aquí.",
|
||||
"profileLockedByMember": "Este perfil está siendo usado por {{email}}.",
|
||||
"profileLockedElsewhere": "Este perfil está en uso en otro dispositivo.",
|
||||
"profileLockUnavailable": "No se pudo comprobar si este perfil está en uso en otro lugar. Revisa tu conexión e inténtalo de nuevo."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Perfiles",
|
||||
|
||||
@@ -345,6 +345,10 @@
|
||||
"nameDesc": "Nom (Z–A)",
|
||||
"newest": "Plus récents d’abord",
|
||||
"oldest": "Plus anciens d’abord"
|
||||
},
|
||||
"remote": {
|
||||
"runningTooltip": "En cours d'exécution sur une machine distante. Arrêtez-la pour récupérer le profil ici.",
|
||||
"pendingSyncTooltip": "Téléchargement des modifications de la session distante. De nouveau disponible une fois terminé."
|
||||
}
|
||||
},
|
||||
"createProfile": {
|
||||
@@ -1887,7 +1891,12 @@
|
||||
"cookieBotUnsupportedPlatform": "Cookie Bot ne peut pas exécuter de profils {{platform}}. Seuls les profils Windows et macOS sont pris en charge.",
|
||||
"cookieBotRequiresExitNode": "Associez d'abord un proxy ou un VPN. Sans cela, l'exécution proviendrait d'une adresse de centre de données, ce qui abîme l'identité du profil.",
|
||||
"unknownCode": "Une erreur est survenue : {{code}}",
|
||||
"cookieBotTouchFingerprintUnsupported": "Ce profil déclare un appareil tactile, que le bot ne peut pas piloter. Utilisez une empreinte de bureau."
|
||||
"cookieBotTouchFingerprintUnsupported": "Ce profil déclare un appareil tactile, que le bot ne peut pas piloter. Utilisez une empreinte de bureau.",
|
||||
"profileRunningRemotely": "Ce profil s'exécute sur une machine distante. Arrêtez d'abord la session distante.",
|
||||
"profileRemoteSyncPending": "Une session distante vient de se terminer. Ses modifications doivent être téléchargées avant d'ouvrir ce profil ici.",
|
||||
"profileLockedByMember": "Ce profil est utilisé par {{email}}.",
|
||||
"profileLockedElsewhere": "Ce profil est utilisé sur un autre appareil.",
|
||||
"profileLockUnavailable": "Impossible de vérifier si ce profil est utilisé ailleurs. Vérifiez votre connexion et réessayez."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profils",
|
||||
|
||||
@@ -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": "プロファイル",
|
||||
|
||||
@@ -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": "프로필",
|
||||
|
||||
@@ -345,6 +345,10 @@
|
||||
"nameDesc": "Nome (Z–A)",
|
||||
"newest": "Mais recentes primeiro",
|
||||
"oldest": "Mais antigos primeiro"
|
||||
},
|
||||
"remote": {
|
||||
"runningTooltip": "Em execução numa máquina remota. Pare-a para trazer o perfil de volta para aqui.",
|
||||
"pendingSyncTooltip": "A transferir o que a sessão remota alterou. Disponível novamente quando terminar."
|
||||
}
|
||||
},
|
||||
"createProfile": {
|
||||
@@ -1887,7 +1891,12 @@
|
||||
"cookieBotUnsupportedPlatform": "O Cookie Bot não pode executar perfis de {{platform}}. Somente perfis Windows e macOS são suportados.",
|
||||
"cookieBotRequiresExitNode": "Anexe primeiro um proxy ou VPN. Sem isso, a execução sairia de um endereço de data center, o que prejudica a identidade do perfil.",
|
||||
"unknownCode": "Algo deu errado: {{code}}",
|
||||
"cookieBotTouchFingerprintUnsupported": "Este perfil declara um dispositivo de toque, que o bot não consegue controlar. Use uma impressão digital de computador."
|
||||
"cookieBotTouchFingerprintUnsupported": "Este perfil declara um dispositivo de toque, que o bot não consegue controlar. Use uma impressão digital de computador.",
|
||||
"profileRunningRemotely": "Este perfil está em execução numa máquina remota. Pare primeiro a sessão remota.",
|
||||
"profileRemoteSyncPending": "Uma sessão remota acabou de terminar. A aguardar a transferência das alterações antes de abrir este perfil aqui.",
|
||||
"profileLockedByMember": "Este perfil está a ser utilizado por {{email}}.",
|
||||
"profileLockedElsewhere": "Este perfil está a ser utilizado noutro dispositivo.",
|
||||
"profileLockUnavailable": "Não foi possível verificar se este perfil está a ser utilizado noutro local. Verifique a ligação e tente novamente."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Perfis",
|
||||
|
||||
@@ -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": "Профили",
|
||||
|
||||
@@ -345,6 +345,10 @@
|
||||
"nameDesc": "Ad (Z–A)",
|
||||
"newest": "Önce en yeni",
|
||||
"oldest": "Önce en eski"
|
||||
},
|
||||
"remote": {
|
||||
"runningTooltip": "Uzak bir makinede çalışıyor. Profili buraya geri getirmek için durdurun.",
|
||||
"pendingSyncTooltip": "Uzak oturumun değiştirdikleri indiriliyor. Bittiğinde yeniden kullanılabilir olacak."
|
||||
}
|
||||
},
|
||||
"createProfile": {
|
||||
@@ -1880,7 +1884,12 @@
|
||||
"cookieBotUnsupportedPlatform": "Cookie Bot, {{platform}} profillerini çalıştıramaz. Yalnızca Windows ve macOS profilleri desteklenir.",
|
||||
"cookieBotRequiresExitNode": "Önce bir proxy veya VPN ekleyin. Aksi hâlde çalışma bir veri merkezi adresinden çıkar ve bu, profilin kimliğine zarar verir.",
|
||||
"unknownCode": "Bir sorun oluştu: {{code}}",
|
||||
"cookieBotTouchFingerprintUnsupported": "Bu profil dokunmatik bir cihaz olduğunu bildiriyor ve bot bunu süremez. Masaüstü parmak izi kullanın."
|
||||
"cookieBotTouchFingerprintUnsupported": "Bu profil dokunmatik bir cihaz olduğunu bildiriyor ve bot bunu süremez. Masaüstü parmak izi kullanın.",
|
||||
"profileRunningRemotely": "Bu profil uzak bir makinede çalışıyor. Önce uzak oturumu durdurun.",
|
||||
"profileRemoteSyncPending": "Uzak oturum az önce bitti. Bu profili burada açmadan önce değişikliklerinin inmesi bekleniyor.",
|
||||
"profileLockedByMember": "Bu profil {{email}} tarafından kullanılıyor.",
|
||||
"profileLockedElsewhere": "Bu profil başka bir cihazda kullanılıyor.",
|
||||
"profileLockUnavailable": "Bu profilin başka bir yerde kullanılıp kullanılmadığı denetlenemedi. Bağlantınızı kontrol edip yeniden deneyin."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profiller",
|
||||
|
||||
@@ -345,6 +345,10 @@
|
||||
"nameDesc": "Tên (Z–A)",
|
||||
"newest": "Mới nhất trước",
|
||||
"oldest": "Cũ nhất trước"
|
||||
},
|
||||
"remote": {
|
||||
"runningTooltip": "Đang chạy trên máy từ xa. Dừng lại để đưa hồ sơ về đây.",
|
||||
"pendingSyncTooltip": "Đang tải về những gì phiên từ xa đã thay đổi. Sẽ dùng lại được khi hoàn tất."
|
||||
}
|
||||
},
|
||||
"createProfile": {
|
||||
@@ -1880,7 +1884,12 @@
|
||||
"cookieBotUnsupportedPlatform": "Cookie Bot không chạy được hồ sơ {{platform}}. Chỉ hỗ trợ hồ sơ Windows và macOS.",
|
||||
"cookieBotRequiresExitNode": "Hãy gán proxy hoặc VPN trước. Nếu không, lần chạy sẽ đi ra từ địa chỉ trung tâm dữ liệu, gây hại cho danh tính hồ sơ.",
|
||||
"unknownCode": "Đã xảy ra lỗi: {{code}}",
|
||||
"cookieBotTouchFingerprintUnsupported": "Hồ sơ này khai báo là thiết bị cảm ứng, bot không điều khiển được. Hãy dùng vân tay máy tính để bàn."
|
||||
"cookieBotTouchFingerprintUnsupported": "Hồ sơ này khai báo là thiết bị cảm ứng, bot không điều khiển được. Hãy dùng vân tay máy tính để bàn.",
|
||||
"profileRunningRemotely": "Hồ sơ này đang chạy trên máy từ xa. Hãy dừng phiên từ xa trước.",
|
||||
"profileRemoteSyncPending": "Một phiên từ xa vừa kết thúc. Đang chờ tải các thay đổi về trước khi mở hồ sơ này tại đây.",
|
||||
"profileLockedByMember": "Hồ sơ này đang được {{email}} sử dụng.",
|
||||
"profileLockedElsewhere": "Hồ sơ này đang được sử dụng trên thiết bị khác.",
|
||||
"profileLockUnavailable": "Không thể kiểm tra hồ sơ này có đang được dùng ở nơi khác hay không. Hãy kiểm tra kết nối và thử lại."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profile",
|
||||
|
||||
@@ -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": "配置文件",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user