diff --git a/e2e/coverage-map.mjs b/e2e/coverage-map.mjs index b5eb53c8..92507ac0 100644 --- a/e2e/coverage-map.mjs +++ b/e2e/coverage-map.mjs @@ -378,6 +378,16 @@ export const commandCoverage = { "agent_schedules::test_agent_schedule_notice", ], }, + restorePoints: { + suite: "sync", + level: "integration", + commands: [ + "sync::restore_points::list_restore_points", + "sync::restore_points::create_restore_point", + "sync::restore_points::delete_restore_point", + "sync::restore_points::restore_restore_point", + ], + }, teamActivity: { suite: "integrations", level: "contract", diff --git a/e2e/tests/browser.test.mjs b/e2e/tests/browser.test.mjs index 5ba02bd4..a7ec7b12 100644 --- a/e2e/tests/browser.test.mjs +++ b/e2e/tests/browser.test.mjs @@ -782,6 +782,37 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc }); assert.equal(missing.met, false); + // Each listed element carries an id that stays the same while it lives; + // a batch acts on one by id and reports each step. + const listing = (await tool("get_interactive_elements")).text; + const buttonLine = listing + .split("\n") + .find((line) => line.includes('id="fixture-button"')); + const elementId = /^\[\d+ (e\d+)/.exec(buttonLine ?? "")?.[1]; + assert.ok(elementId, listing); + assert.doesNotMatch( + (await tool("get_interactive_elements")).text, + / new[\] ]/, + "nothing is new on a second listing of the same page", + ); + await cdp.evaluate( + "delete document.querySelector('#fixture-button').dataset.clicked; true", + ); + const batch = await toolJson("run_actions", { + actions: [ + { tool: "click_by_index", arguments: { element_id: elementId } }, + { tool: "get_page_info" }, + ], + }); + assert.equal(batch.completed, 2, JSON.stringify(batch)); + assert.equal(batch.stopped, null); + assert.equal( + await cdp.evaluate( + "document.querySelector('#fixture-button').dataset.clicked", + ), + "yes", + ); + const shot = await tool("screenshot"); assert.equal(shot.mimeType, "image/jpeg"); const jpeg = Buffer.from(shot.data, "base64"); @@ -790,6 +821,37 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc const visited = await tool("navigate", { url: `${fixtureUrl}/again` }); assert.match(visited.text, /Page: Donut E2E Browser Fixture <.*\/again>/); + // A running browser is taken over as it is: a second run hands back the + // same DevTools port with the URL in a new tab, and never restarts it. + const takeover = await request(`${base}/v1/profiles/${profile.id}/run`, { + method: "POST", + token: saved.api_token, + body: { url: `${fixtureUrl}/taken-over` }, + }); + assert.equal(takeover.response.status, 200, JSON.stringify(takeover.value)); + assert.equal(takeover.value.attached, true); + assert.equal( + takeover.value.remote_debugging_port, + launched.value.remote_debugging_port, + ); + const listed = await toolJson("list_tabs"); + const takenTab = listed.tabs.find((tab) => tab.url.endsWith("/taken-over")); + assert.ok(takenTab, JSON.stringify(listed)); + assert.equal( + takenTab.selected, + true, + "the new tab is the one the tools drive", + ); + const otherTab = listed.tabs.find((tab) => tab.id !== takenTab.id); + const switched = await toolJson("switch_tab", { tab_id: otherTab.id }); + assert.equal(switched.selected, true); + assert.match((await tool("get_page_info")).text, /again/); + await tool("close_tab", { tab_id: takenTab.id }); + assert.equal( + (await toolJson("list_tabs")).tabs.some((tab) => tab.id === takenTab.id), + false, + ); + const runningProfile = (await app.invoke("list_browser_profiles")).find( (item) => item.id === profile.id, ); @@ -877,39 +939,22 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc }, }); assert.equal(batchRun.response.status, 200); - // A profile carrying a whole stored device is migrated into an identity - // plus overrides. Some override values are currently rejected by the - // browser at launch, and the launcher reports that with the property - // named, so this asserts the reported failure rather than pretending the - // launch worked. If the launch succeeds instead, the else branch takes - // over and the batch is asserted in full. - const batchBlockedByBrowser = - !batchRun.value.results[0].ok && - /was not applied: \w+/.test(batchRun.value.results[0].error ?? ""); - if (batchBlockedByBrowser) { - console.log( - `[donut-e2e] Batch profile could not launch: ${batchRun.value.results[0].error}`, - ); - assert.match( - batchRun.value.results[0].error, - /WAYFERN_IDENTITY_REFUSED|WAYFERN_FINGERPRINT_APPLY_FAILED/, - "a refused device must reach the caller as a coded error, never as a silent success", - ); - } else { - assert.equal( - batchRun.value.results[0].ok, - true, - batchRun.value.results[0].error, - ); - const batchCdp = await CdpClient.connect( - batchRun.value.results[0].remote_debugging_port, - ); - assert.equal( - await batchCdp.waitFor("window.__fixtureReady === true"), - true, - ); - batchCdp.close(); - } + // A whole supplied device becomes an identity plus overrides. The browser + // refuses some override values and names each one; the launcher drops it + // and launches again, so the batch launches. + assert.equal( + batchRun.value.results[0].ok, + true, + batchRun.value.results[0].error, + ); + const batchCdp = await CdpClient.connect( + batchRun.value.results[0].remote_debugging_port, + ); + assert.equal( + await batchCdp.waitFor("window.__fixtureReady === true"), + true, + ); + batchCdp.close(); const batchStop = await request(`${base}/v1/profiles/batch/stop`, { method: "POST", token: saved.api_token, @@ -2052,11 +2097,47 @@ test("Local Storage that Donut writes, Wayfern reads, and what Wayfern writes ex { name: "imported", value: "from Donut" }, { name: "unicode", value: "日本語 🍩" }, ], + // Playwright's shape: a store with an in-line key and an index, + // and one with out-of-line keys and an encoded (Date) value. + indexedDB: [ + { + name: "shop", + version: 2, + stores: [ + { + name: "cart", + autoIncrement: false, + keyPath: "id", + records: [{ value: { id: 1, item: "donut" } }], + indexes: [ + { + name: "by_item", + keyPath: "item", + multiEntry: false, + unique: false, + }, + ], + }, + { + name: "kv", + autoIncrement: false, + records: [ + { + key: "when", + valueEncoded: { d: "2026-10-12T00:00:00.000Z" }, + }, + ], + indexes: [], + }, + ], + }, + ], }, ], }), }); assert.equal(imported.items, 2); + assert.equal(imported.indexed_db_records, 2); const launched = await app.invoke("launch_browser_profile", { profile: (await app.invoke("list_browser_profiles")).find( @@ -2082,6 +2163,38 @@ test("Local Storage that Donut writes, Wayfern reads, and what Wayfern writes ex "日本語 🍩", ); await cdp.evaluate("localStorage.setItem('written', 'by Wayfern'); true"); + const inShop = (body) => + cdp.evaluate(`(async () => { + const db = await new Promise((resolve, reject) => { + const open = indexedDB.open("shop"); + open.onsuccess = () => resolve(open.result); + open.onerror = () => reject(open.error); + }); + const read = (store, key) => new Promise((resolve, reject) => { + const get = db.transaction(store).objectStore(store).get(key); + get.onsuccess = () => resolve(get.result); + get.onerror = () => reject(get.error); + }); + const result = await (${body})(db, read); + db.close(); + return result; + })()`); + assert.equal( + await inShop("async (db, read) => (await read('cart', 1)).item"), + "donut", + ); + assert.equal( + await inShop( + "async (db, read) => (await read('kv', 'when')) instanceof Date", + ), + true, + ); + await inShop(`(db) => new Promise((resolve, reject) => { + const tx = db.transaction("cart", "readwrite"); + tx.objectStore("cart").put({ id: 2, item: "cruller" }); + tx.oncomplete = () => resolve(true); + tx.onerror = () => reject(tx.error); + })`); // Chromium commits Local Storage to disk a few seconds after a change. await new Promise((resolve) => setTimeout(resolve, 8_000)); cdp.close?.(); @@ -2109,6 +2222,39 @@ test("Local Storage that Donut writes, Wayfern reads, and what Wayfern writes ex ); assert.equal(items.imported, "from Donut"); assert.equal(items.written, "by Wayfern"); + assert.equal(site.indexedDB, undefined, "IndexedDB only when asked for"); + + // With IndexedDB the closed profile opens headless for a moment. + const withDatabases = JSON.parse( + await app.invoke("export_profile_storage_state", { + profileId: profile.id, + includeCookies: false, + includeIndexedDb: true, + }), + ); + const shop = withDatabases.origins + .find((o) => o.origin === origin) + ?.indexedDB?.find((db) => db.name === "shop"); + assert.ok(shop, JSON.stringify(withDatabases.origins)); + assert.equal(shop.version, 2); + const cart = shop.stores.find((store) => store.name === "cart"); + assert.equal(cart.keyPath, "id"); + assert.deepEqual(cart.records.map((record) => record.value.item).sort(), [ + "cruller", + "donut", + ]); + assert.equal(cart.indexes[0].name, "by_item"); + const kv = shop.stores.find((store) => store.name === "kv"); + assert.deepEqual(kv.records, [ + { key: "when", valueEncoded: { d: "2026-10-12T00:00:00.000Z" } }, + ]); + assert.equal( + (await app.invoke("list_browser_profiles")).find( + (p) => p.id === profile.id, + ).process_id, + null, + "the headless browser is closed again", + ); await app.invoke("delete_profile", { profileId: profile.id, permanent: true, diff --git a/sdk/node/src/client.mts b/sdk/node/src/client.mts index 3b01b70d..76cbf6ef 100644 --- a/sdk/node/src/client.mts +++ b/sdk/node/src/client.mts @@ -1214,7 +1214,11 @@ export class DonutClient { throw error; } finally { try { - await this.killProfile(profileId); + // A browser the person had open stays open: it was taken over, not + // started here. + if (!session.attached) { + await this.killProfile(profileId); + } } catch (cleanupError) { session.cleanupError = cleanupError; if (!failed) { @@ -1240,6 +1244,8 @@ export class RunSession { readonly remoteDebuggingPort: number; /** Whether the browser actually started headless. */ readonly headless: boolean; + /** Whether a running browser was taken over; the session then leaves it open. */ + readonly attached: boolean; /** A failure while stopping the browser, kept rather than thrown. */ cleanupError: unknown = undefined; @@ -1249,6 +1255,7 @@ export class RunSession { this.response = response; this.remoteDebuggingPort = response.remote_debugging_port; this.headless = response.headless; + this.attached = response.attached === true; } /** @@ -1262,6 +1269,8 @@ export class RunSession { } async [Symbol.asyncDispose](): Promise { - await this.client.killProfile(this.profileId); + if (!this.attached) { + await this.client.killProfile(this.profileId); + } } } diff --git a/sdk/node/src/types.mts b/sdk/node/src/types.mts index af3dcd36..d32d45f0 100644 --- a/sdk/node/src/types.mts +++ b/sdk/node/src/types.mts @@ -240,6 +240,11 @@ export interface RunProfileResponse { profile_id: string; remote_debugging_port: number; headless: boolean; + /** + * True when the profile was already running and was taken over as it is, + * with no restart. Older apps omit it. + */ + attached?: boolean; } export interface RunRemoteResponse { diff --git a/sdk/node/test/session.test.mts b/sdk/node/test/session.test.mts index b9a73fbd..86a8279f 100644 --- a/sdk/node/test/session.test.mts +++ b/sdk/node/test/session.test.mts @@ -132,3 +132,18 @@ test("a session also disposes itself", async () => { ); }); }); + +test("a browser taken over is left running", async () => { + await withClient(async (client, fake) => { + fake.enqueueJson({ ...RUN_BODY, attached: true }); + + await client.withProfile("p1", {}, async (session) => { + assert.equal(session.attached, true); + }); + + assert.deepEqual( + fake.requests.map((sent) => `${sent.method} ${sent.path}`), + ["POST /v1/profiles/p1/run"], + ); + }); +}); diff --git a/sdk/python/src/donutbrowser/client.py b/sdk/python/src/donutbrowser/client.py index aa8dde1d..ff2bc013 100644 --- a/sdk/python/src/donutbrowser/client.py +++ b/sdk/python/src/donutbrowser/client.py @@ -1336,6 +1336,9 @@ class RunSession: self.remote_debugging_port: Optional[int] = None #: Whether the browser actually started headless. self.headless: Optional[bool] = None + #: Whether a running browser was taken over; the block then leaves it + #: open. + self.attached: bool = False #: A failure while stopping the browser, kept rather than raised when #: the block itself was already failing. self.cleanup_error: Optional[DonutError] = None @@ -1358,6 +1361,7 @@ class RunSession: self.response = response self.remote_debugging_port = response["remote_debugging_port"] self.headless = response["headless"] + self.attached = bool(response.get("attached", False)) return self def __exit__( @@ -1366,6 +1370,9 @@ class RunSession: exc: Optional[BaseException], traceback: Optional[TracebackType], ) -> bool: + if self.attached: + # The person had this browser open; it was taken over, not started. + return False try: self.client.kill_profile(self.profile_id) except DonutError as failure: diff --git a/sdk/python/src/donutbrowser/models.py b/sdk/python/src/donutbrowser/models.py index b711fd16..2c2c1b36 100644 --- a/sdk/python/src/donutbrowser/models.py +++ b/sdk/python/src/donutbrowser/models.py @@ -315,12 +315,18 @@ class DownloadBrowserResponse(TypedDict): status: str -class RunProfileResponse(TypedDict): +class _RunProfileResponseRequired(TypedDict): profile_id: str remote_debugging_port: int headless: bool +class RunProfileResponse(_RunProfileResponseRequired, total=False): + #: True when the profile was already running and was taken over as it is, + #: with no restart. Older apps omit it. + attached: bool + + class RunRemoteResponse(TypedDict): profile_id: str session_id: str diff --git a/sdk/python/tests/test_session.py b/sdk/python/tests/test_session.py index b1bb4728..421ec73a 100644 --- a/sdk/python/tests/test_session.py +++ b/sdk/python/tests/test_session.py @@ -90,3 +90,14 @@ def test_the_cdp_url_is_refused_before_the_block(client: DonutClient) -> None: session = client.run("p1") with pytest.raises(DonutError): _ = session.cdp_url + + +def test_a_browser_taken_over_is_left_running(client: DonutClient, fake: FakeDonut) -> None: + fake.enqueue_json({**RUN_BODY, "attached": True}) + + with client.run("p1") as session: + assert session.attached is True + + assert [(sent.method, sent.path) for sent in fake.requests] == [ + ("POST", "/v1/profiles/p1/run"), + ] diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index ff1c6183..816fadfa 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -290,6 +290,8 @@ struct ApiStorageState { struct StorageStateQuery { #[serde(default)] include_cookies: bool, + #[serde(default)] + indexed_db: bool, } #[derive(Debug, Deserialize, ToSchema)] @@ -438,7 +440,11 @@ pub struct ToastPayload { struct RunProfileResponse { profile_id: String, remote_debugging_port: u16, + /// The mode asked for. A browser that was already running keeps its own. headless: bool, + /// True when the profile was already running and was taken over as it is, + /// with no restart. + attached: bool, } #[derive(Debug, Serialize, Deserialize, ToSchema)] @@ -3513,6 +3519,26 @@ async fn run_profile( )); } + // A browser the person already opened is handed over as it is, never + // restarted: its DevTools port is the answer, with the URL in a new tab. + if let Some(port) = crate::browser_tabs::local_port(profile).await { + if let Some(url) = url.as_deref() { + crate::browser_tabs::open(profile, url) + .await + .map_err(manager_error_response)?; + } + log::info!( + "Running browser taken over profile={} cdp_port={port}", + profile.id + ); + return Ok(Json(RunProfileResponse { + profile_id: profile.id.to_string(), + remote_debugging_port: port, + headless, + attached: true, + })); + } + // 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. @@ -3547,6 +3573,7 @@ async fn run_profile( profile_id: updated_profile.id.to_string(), remote_debugging_port, headless, + attached: false, })) } @@ -5013,14 +5040,15 @@ async fn import_profiles_api( path = "/v1/profiles/{id}/storage-state", params( ("id" = String, Path, description = "Profile ID"), - ("include_cookies" = Option, Query, description = "Also export the profile's cookies; false when left out") + ("include_cookies" = Option, Query, description = "Also export the profile's cookies; false when left out"), + ("indexed_db" = Option, Query, description = "Also export each origin's IndexedDB (`origins[].indexedDB`). The profile must be closed; it opens headless for a moment and no request leaves the computer. False when left out") ), responses( - (status = 200, description = "Playwright storageState: each origin's Local Storage, and the cookies when asked for. Readable while the browser runs; writes from the last seconds may not be on disk yet", body = ApiStorageState), - (status = 400, description = "The profile is ephemeral and keeps no Local Storage on disk"), + (status = 200, description = "Playwright storageState: each origin's Local Storage, and the cookies and IndexedDB when asked for. Readable while the browser runs unless IndexedDB is asked for; writes from the last seconds may not be on disk yet", body = ApiStorageState), + (status = 400, description = "The profile is ephemeral and keeps no Local Storage on disk, the IndexedDB is over 24 MiB, or the browser could not read it"), (status = 401, description = "Unauthorized"), (status = 404, description = "Profile not found"), - (status = 409, description = "The profile is password-protected"), + (status = 409, description = "The profile is password-protected, IndexedDB was asked for while the browser runs, or a remote session owns it"), (status = 500, description = "Internal server error") ), security( @@ -5029,14 +5057,17 @@ async fn import_profiles_api( tag = "cookies" )] async fn export_storage_state( + State(state): State, Path(id): Path, Query(query): Query, ) -> Result, (StatusCode, String)> { - let text = tokio::task::spawn_blocking(move || { - crate::local_storage::export_storage_state(&id, query.include_cookies) - }) + let text = crate::local_storage::export_storage_state_with( + &state.app_handle, + &id, + query.include_cookies, + query.indexed_db, + ) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .map_err(manager_error_response)?; serde_json::from_str(&text) .map(Json) diff --git a/src-tauri/src/browser_tabs.rs b/src-tauri/src/browser_tabs.rs new file mode 100644 index 00000000..68021f83 --- /dev/null +++ b/src-tauri/src/browser_tabs.rs @@ -0,0 +1,243 @@ +//! The tabs of a browser running on this computer, for an agent or an API +//! client that takes it over: list them, open one, bring one to the front, +//! close one. Every Wayfern launch listens for DevTools on 127.0.0.1, so a +//! profile the person opened can be driven without a restart. +//! +//! The tab a caller picked is the one the MCP browser tools act on from then +//! on, until it closes or another is picked. + +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::Duration; + +use serde::Serialize; +use serde_json::Value; + +use crate::profile::{BrowserProfile, ProfileManager}; + +const HTTP_TIMEOUT: Duration = Duration::from_secs(5); + +/// The tab each profile's tools act on, by profile id. +static SELECTED: Mutex>> = Mutex::new(None); + +#[derive(Debug, Clone, Serialize, PartialEq, utoipa::ToSchema)] +pub struct BrowserTab { + pub id: String, + pub title: String, + pub url: String, + /// The tab the agent tools act on. + pub selected: bool, +} + +fn code(code: &str) -> String { + crate::backend_error(code) +} + +/// The DevTools port of the browser running for `profile` here, if any. +pub async fn local_port(profile: &BrowserProfile) -> Option { + if !crate::browser_runner::browser_is_running_for(&profile.id.to_string()) { + return None; + } + let path = profile.get_profile_data_path(&ProfileManager::instance().get_profiles_dir()); + crate::wayfern_manager::WayfernManager::instance() + .get_cdp_port(&path.to_string_lossy()) + .await +} + +fn http() -> reqwest::Client { + reqwest::Client::builder() + .timeout(HTTP_TIMEOUT) + .no_proxy() + .build() + .unwrap_or_default() +} + +fn unreachable(e: impl std::fmt::Display) -> String { + crate::backend_error_with_detail("BROWSER_TABS_UNAVAILABLE", e) +} + +/// Pages a person would call tabs: not DevTools, not extension backgrounds. +fn tabs_of(targets: &[Value], selected: Option<&str>) -> Vec { + targets + .iter() + .filter(|t| t.get("type").and_then(Value::as_str) == Some("page")) + .filter_map(|t| { + let url = t.get("url").and_then(Value::as_str).unwrap_or_default(); + if url.starts_with("devtools://") { + return None; + } + let id = t.get("id").and_then(Value::as_str)?.to_string(); + Some(BrowserTab { + selected: selected == Some(id.as_str()), + title: t + .get("title") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + url: url.to_string(), + id, + }) + }) + .collect() +} + +async fn targets(port: u16) -> Result, String> { + http() + .get(format!("http://127.0.0.1:{port}/json/list")) + .send() + .await + .map_err(unreachable)? + .json::>() + .await + .map_err(unreachable) +} + +fn selected_for(profile_id: &str) -> Option { + SELECTED.lock().ok()?.as_ref()?.get(profile_id).cloned() +} + +fn select(profile_id: &str, tab_id: Option<&str>) { + if let Ok(mut guard) = SELECTED.lock() { + let map = guard.get_or_insert_with(HashMap::new); + match tab_id { + Some(id) => map.insert(profile_id.to_string(), id.to_string()), + None => map.remove(profile_id), + }; + } +} + +/// The page socket of the tab picked for `profile_id`, when it is still open. +pub fn selected_socket(profile_id: &str, targets: &[Value]) -> Option { + let selected = selected_for(profile_id)?; + targets + .iter() + .find(|t| t.get("id").and_then(Value::as_str) == Some(selected.as_str())) + .and_then(|t| t.get("webSocketDebuggerUrl").and_then(Value::as_str)) + .map(str::to_string) +} + +async fn port_of(profile: &BrowserProfile) -> Result { + local_port(profile) + .await + .ok_or_else(|| code("BROWSER_NOT_RUNNING")) +} + +pub async fn list(profile: &BrowserProfile) -> Result, String> { + let port = port_of(profile).await?; + let profile_id = profile.id.to_string(); + let tabs = tabs_of(&targets(port).await?, selected_for(&profile_id).as_deref()); + if !tabs.iter().any(|tab| tab.selected) { + select(&profile_id, None); + } + Ok(tabs) +} + +/// Open `url` in a new tab, pick it, and bring it to the front. +pub async fn open(profile: &BrowserProfile, url: &str) -> Result { + let port = port_of(profile).await?; + let created: Value = http() + .put(format!( + "http://127.0.0.1:{port}/json/new?{}", + urlencoding::encode(url) + )) + .send() + .await + .map_err(unreachable)? + .json() + .await + .map_err(unreachable)?; + let id = created + .get("id") + .and_then(Value::as_str) + .ok_or_else(|| code("BROWSER_TABS_UNAVAILABLE"))? + .to_string(); + select(&profile.id.to_string(), Some(&id)); + log::info!("Tab opened profile={}", profile.id); + Ok(BrowserTab { + id, + title: String::new(), + url: url.to_string(), + selected: true, + }) +} + +/// Pick the tab `tab_id` and bring it to the front. +pub async fn switch(profile: &BrowserProfile, tab_id: &str) -> Result { + let port = port_of(profile).await?; + let tab = tabs_of(&targets(port).await?, None) + .into_iter() + .find(|tab| tab.id == tab_id) + .ok_or_else(|| code("BROWSER_TAB_NOT_FOUND"))?; + let response = http() + .get(format!( + "http://127.0.0.1:{port}/json/activate/{}", + urlencoding::encode(tab_id) + )) + .send() + .await + .map_err(unreachable)?; + if !response.status().is_success() { + return Err(code("BROWSER_TAB_NOT_FOUND")); + } + select(&profile.id.to_string(), Some(tab_id)); + Ok(BrowserTab { + selected: true, + ..tab + }) +} + +pub async fn close(profile: &BrowserProfile, tab_id: &str) -> Result<(), String> { + let port = port_of(profile).await?; + let response = http() + .get(format!( + "http://127.0.0.1:{port}/json/close/{}", + urlencoding::encode(tab_id) + )) + .send() + .await + .map_err(unreachable)?; + if !response.status().is_success() { + return Err(code("BROWSER_TAB_NOT_FOUND")); + } + if selected_for(&profile.id.to_string()).as_deref() == Some(tab_id) { + select(&profile.id.to_string(), None); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn listing() -> Vec { + vec![ + json!({ "id": "A", "type": "page", "title": "Shop", "url": "https://shop.example/", "webSocketDebuggerUrl": "ws://a" }), + json!({ "id": "D", "type": "page", "title": "DevTools", "url": "devtools://devtools/x" }), + json!({ "id": "S", "type": "service_worker", "url": "https://shop.example/sw.js" }), + json!({ "id": "B", "type": "page", "title": "Mail", "url": "https://mail.example/", "webSocketDebuggerUrl": "ws://b" }), + ] + } + + #[test] + fn tabs_are_pages_a_person_sees() { + let tabs = tabs_of(&listing(), Some("B")); + assert_eq!( + tabs.iter().map(|t| t.id.as_str()).collect::>(), + ["A", "B"] + ); + assert!(tabs[1].selected && !tabs[0].selected); + } + + #[test] + fn the_picked_tab_is_the_one_the_tools_drive() { + select("p-tabs-test", Some("B")); + assert_eq!( + selected_socket("p-tabs-test", &listing()).as_deref(), + Some("ws://b") + ); + select("p-tabs-test", Some("gone")); + assert_eq!(selected_socket("p-tabs-test", &listing()), None); + select("p-tabs-test", None); + } +} diff --git a/src-tauri/src/cdp_target.rs b/src-tauri/src/cdp_target.rs index 77ac61c8..0d741335 100644 --- a/src-tauri/src/cdp_target.rs +++ b/src-tauri/src/cdp_target.rs @@ -283,7 +283,11 @@ async fn local_page_ws_url(profile: &BrowserProfile, patience: Patience) -> Opti { Ok(response) => match response.json::>().await { Ok(targets) => { - if let Some(ws_url) = pick_local_page_socket(&targets) { + // The tab an agent picked comes first; then any drivable page. + if let Some(ws_url) = + crate::browser_tabs::selected_socket(&profile.id.to_string(), &targets) + .or_else(|| pick_local_page_socket(&targets)) + { return Some(ws_url); } last_err = "no page target found in browser".to_string(); @@ -560,14 +564,119 @@ const _: () = { }; /// Run one command on a fresh connection and hand back its result. +/// A socket kept open after a one-shot command, for the next one to the same +/// page. Dialing costs little on this computer, but a remote session pays a +/// relay dial and a page attach each time. +struct PooledConnection { + connection: CdpConnection, + next_id: u64, + last_used: tokio::time::Instant, +} + +/// How long an unused socket is kept. +const POOL_IDLE: Duration = Duration::from_secs(30); +/// The first command id on a pooled socket, apart from the fixed ids the +/// one-shot helpers use, so a late reply never matches a new command. +const POOL_FIRST_ID: u64 = 1_000; + +static POOL: std::sync::LazyLock< + tokio::sync::Mutex>, +> = std::sync::LazyLock::new(Default::default); + +impl CdpTarget { + /// The socket a pooled connection to this target serves. + fn pool_key(&self) -> String { + match self { + Self::Local { ws_url } => ws_url.clone(), + Self::Remote { + ws_url, session_id, .. + } => format!("{ws_url}#{session_id}"), + } + } +} + +/// Whether a failure on a pooled socket means the command never reached the +/// browser, so a fresh socket may send it again. +fn not_delivered(error: &CdpError) -> bool { + matches!(error, CdpError::Transport(message) + if message.starts_with("failed to send CDP command") + || message.contains("connection closed")) +} + +/// Whether the socket is still fit for the next command after `result`. +fn reusable(result: &Result) -> bool { + matches!(result, Ok(_) | Err(CdpError::Protocol(_))) +} + +async fn give_back(key: String, pooled: PooledConnection) { + let mut pool = POOL.lock().await; + pool.retain(|_, kept| kept.last_used.elapsed() < POOL_IDLE); + if let Some(extra) = pool.insert(key, pooled) { + drop(pool); + extra.connection.close().await; + } +} + pub async fn run_command( target: &CdpTarget, method: &str, params: Value, ) -> Result { - let mut connection = target.connect().await?; - let result = connection.call(RUN_COMMAND_ID, method, params).await; - connection.close().await; + let key = target.pool_key(); + // Tests run each case on a runtime of its own, and a socket kept from one + // is dead in the next, so the pool only serves the app's one runtime. + let kept = if cfg!(test) { + None + } else { + POOL + .lock() + .await + .remove(&key) + .filter(|kept| kept.last_used.elapsed() < POOL_IDLE) + }; + if let Some(mut pooled) = kept { + pooled.next_id += 1; + // A bound on the whole call, the send included: a socket that stopped + // being driven must fail the command, never hang it. + let result = tokio::time::timeout( + COMMAND_TIMEOUT + Duration::from_secs(5), + pooled + .connection + .call(pooled.next_id, method, params.clone()), + ) + .await + .unwrap_or_else(|_| { + Err(CdpError::Transport( + "timed out waiting for a CDP response".to_string(), + )) + }); + match &result { + Err(error) if not_delivered(error) => { + log::debug!("Pooled CDP socket was closed, dialing again method={method}"); + } + _ => { + if reusable(&result) { + pooled.last_used = tokio::time::Instant::now(); + give_back(key, pooled).await; + } else { + pooled.connection.close().await; + } + return result; + } + } + } + let mut pooled = PooledConnection { + connection: target.connect().await?, + next_id: POOL_FIRST_ID, + last_used: tokio::time::Instant::now(), + }; + let result = pooled.connection.call(pooled.next_id, method, params).await; + if reusable(&result) && !cfg!(test) { + pooled.last_used = tokio::time::Instant::now(); + give_back(key, pooled).await; + } else { + pooled.connection.close().await; + } result } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 55bebd1a..7b4ae08f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -80,6 +80,7 @@ mod auto_updater; mod automation_rate_limiter; mod browser; mod browser_runner; +mod browser_tabs; mod browser_version_manager; mod cdp_target; mod checksum; @@ -99,6 +100,7 @@ mod geolocation; mod group_bookmarks; mod group_manager; mod human_typing; +mod indexed_db; mod ip_utils; mod launch_gate; mod launch_gate_prefs; @@ -2383,10 +2385,34 @@ pub fn run() { run_with_builder(|builder| builder); } +/// The async runtime, set before anything spawns on it. +/// +/// A debug build keeps every local of a large `async fn` in one poll frame, +/// and the MCP tool dispatcher's frame, nested under the HTTP and bridge +/// layers, passes the 2 MiB that tokio gives a worker by default: the app +/// aborted with a stack overflow on a tool call. 8 MiB is address space a +/// thread reserves, not memory it uses. +fn install_runtime() { + const WORKER_STACK_BYTES: usize = 8 * 1024 * 1024; + match tokio::runtime::Builder::new_multi_thread() + .enable_all() + .thread_stack_size(WORKER_STACK_BYTES) + .build() + { + Ok(runtime) => { + tauri::async_runtime::set(runtime.handle().clone()); + // Lives as long as the app; Tauri holds only a handle. + std::mem::forget(runtime); + } + Err(e) => eprintln!("Async runtime with larger stacks unavailable, using the default: {e}"), + } +} + #[doc(hidden)] pub fn run_with_builder( configure_builder: impl FnOnce(tauri::Builder) -> tauri::Builder, ) { + install_runtime(); let args: Vec = env::args().collect(); let startup_url = urls_from_args(args.iter()).into_iter().next(); @@ -3659,6 +3685,10 @@ pub fn run_with_builder( unlock_profile, lock_profile, is_profile_locked, + sync::restore_points::list_restore_points, + sync::restore_points::create_restore_point, + sync::restore_points::delete_restore_point, + sync::restore_points::restore_restore_point, team_activity::list_team_activity, team_activity::export_team_activity_csv, team_members::list_team_members, diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index 6b53e569..3692a9b4 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -47,6 +47,20 @@ const INTERACTIVE_ELEMENTS_JS: &str = r#"(() => { const lines = []; let truncated = false; let total = 0; + // An id per element that stays the same across listings while the element + // lives, so a caller can act on `e12` after the page moved things around, + // and can tell which elements are new since its last listing. + let ids = window.__donut_element_ids; + if (!ids) { + ids = { map: new WeakMap(), next: 1, seen: {} }; + try { + Object.defineProperty(window, '__donut_element_ids', { value: ids, enumerable: false, configurable: true }); + } catch (e) { + window.__donut_element_ids = ids; + } + } + const seenBefore = ids.seen[__CACHE__]; + const seenNow = new Set(); const nodes = document.querySelectorAll(SELECTORS); for (const el of nodes) { if (el.disabled) continue; @@ -65,13 +79,28 @@ const INTERACTIVE_ELEMENTS_JS: &str = r#"(() => { text = (el.innerText || el.textContent || '').trim().replace(/\s+/g,' ').slice(0,100); } const idx = interactive.length; - const line = '[' + idx + ']<' + tag + (parts.length ? ' ' + parts.join(' ') : '') + '>' + text + ''; + let eid = ids.map.get(el); + if (!eid) { eid = ids.next++; ids.map.set(el, eid); } + const cx = r.left + r.width / 2; + const cy = r.top + r.height / 2; + const inView = cx >= 0 && cy >= 0 && cx < window.innerWidth && cy < window.innerHeight; + let covered = false; + if (inView) { + const hit = document.elementFromPoint(cx, cy); + covered = !!hit && hit !== el && !el.contains(hit) && !(hit.tagName === 'LABEL' && hit.control === el); + } + const marks = ' e' + eid + + (seenBefore && !seenBefore.has(eid) ? ' new' : '') + + (!inView ? ' offscreen' : covered ? ' covered' : ''); + const line = '[' + idx + marks + ']<' + tag + (parts.length ? ' ' + parts.join(' ') : '') + '>' + text + ''; if (total + line.length + 1 > MAX_CHARS) { truncated = true; break; } total += line.length + 1; interactive.push(el); lines.push(line); + seenNow.add(eid); } window[__CACHE__] = interactive; + ids.seen[__CACHE__] = seenNow; // Bound how many snapshots one page carries. Each slot holds live element // references, so it pins every node in it, including nodes the page has // since detached, for as long as the tab lives, and NOTHING on the page @@ -2084,6 +2113,34 @@ fn click_report(prefix: &str, navigated: bool) -> String { } } +/// The tools `run_actions` may run, and how many steps one call may hold. +const BATCH_TOOLS: &[&str] = &[ + "navigate", + "go_back", + "scroll", + "press_key", + "wait_for", + "click_element", + "click_by_index", + "click_locator", + "type_text", + "type_by_index", + "type_locator", + "get_page_info", +]; +const MAX_BATCH_ACTIONS: usize = 20; +/// How much of each step's answer a batch reports. +const BATCH_RESULT_CHARS: usize = 2_000; + +/// A tab helper's coded refusal as a tool error. +fn tab_error(message: String) -> McpError { + McpError { + code: -32000, + message, + data: None, + } +} + /// Where an action left the page, read in one round trip after it. const PAGE_STATE_SCRIPT: &str = "(() => ({ url: location.href, title: document.title, text: (document.body ? document.body.innerText : '').slice(0, 1500) }))()"; @@ -2757,7 +2814,7 @@ impl McpServer { }, McpTool { name: "run_profile".to_string(), - description: "Launch a browser profile with an optional URL. Requires an active Pro subscription.".to_string(), + description: "Launch a browser profile with an optional URL. A profile that is already running is taken over as it is, with no restart. Requires an active Pro subscription.".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { @@ -3882,6 +3939,80 @@ impl McpServer { "required": ["profile_id"] }), }, + McpTool { + name: "run_actions".to_string(), + description: "Run several browser actions in one call, in order, on one profile: navigate, go_back, scroll, press_key, wait_for, the click and type tools, get_page_info. Stops at the first failure and, unless stop_on_change is false, as soon as the page address changes, since later steps were planned for the old page. Returns each step's result.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "profile_id": { "type": "string", "description": "The UUID of the running profile" }, + "actions": { + "type": "array", + "maxItems": 20, + "items": { + "type": "object", + "properties": { + "tool": { "type": "string", "description": "The tool to run, e.g. click_by_index" }, + "arguments": { "type": "object", "description": "That tool's arguments, without profile_id" } + }, + "required": ["tool"] + } + }, + "stop_on_change": { + "type": "boolean", + "description": "Stop when the page address changes (default: true)" + } + }, + "required": ["profile_id", "actions"] + }), + }, + McpTool { + name: "list_tabs".to_string(), + description: "List the open tabs of a running profile: id, title, URL, and which one the browser tools act on (selected).".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "profile_id": { "type": "string", "description": "The UUID of the running profile" } + }, + "required": ["profile_id"] + }), + }, + McpTool { + name: "open_tab".to_string(), + description: "Open a URL in a new tab of a running profile and make it the tab the browser tools act on.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "profile_id": { "type": "string", "description": "The UUID of the running profile" }, + "url": { "type": "string", "description": "The URL to open" } + }, + "required": ["profile_id", "url"] + }), + }, + McpTool { + name: "switch_tab".to_string(), + description: "Bring a tab to the front and make it the tab the browser tools act on. Take the id from list_tabs.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "profile_id": { "type": "string", "description": "The UUID of the running profile" }, + "tab_id": { "type": "string", "description": "The tab id from list_tabs" } + }, + "required": ["profile_id", "tab_id"] + }), + }, + McpTool { + name: "close_tab".to_string(), + description: "Close a tab of a running profile. Take the id from list_tabs.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "profile_id": { "type": "string", "description": "The UUID of the running profile" }, + "tab_id": { "type": "string", "description": "The tab id from list_tabs" } + }, + "required": ["profile_id", "tab_id"] + }), + }, McpTool { name: "scroll".to_string(), description: "Scroll the page with the mouse wheel at the middle of the viewport, so an inner scroll area under it scrolls too. Returns the window's new position and whether it reached the top or bottom; call get_interactive_elements again for what came into view.".to_string(), @@ -4098,7 +4229,7 @@ impl McpServer { }, McpTool { name: "get_interactive_elements".to_string(), - description: "Enumerate visible interactive elements on the page (buttons, links, inputs, etc.) as a compact indexed list. The returned indices are stable for the current page and can be used with click_by_index and type_by_index instead of guessing CSS selectors. Call this before click_by_index / type_by_index, and re-call after any navigation or major DOM change. Far cheaper in tokens than get_page_content for agentic browsing.".to_string(), + description: "Enumerate visible interactive elements on the page (buttons, links, inputs, etc.) as a compact indexed list. The returned indices are stable for the current page and can be used with click_by_index and type_by_index instead of guessing CSS selectors. Call this before click_by_index / type_by_index, and re-call after any navigation or major DOM change. Far cheaper in tokens than get_page_content for agentic browsing. Each line starts with [index eN marks]: eN stays the same while the element lives (pass it as element_id), `new` marks an element that appeared since your last listing, `covered` one that something else sits on, `offscreen` one outside the viewport.".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { @@ -4124,12 +4255,16 @@ impl McpServer { "type": "string", "description": "The UUID of the running profile" }, + "element_id": { + "type": "string", + "description": "Instead of index: the stable id (e12) from the last get_interactive_elements response" + }, "index": { "type": "integer", "description": "Zero-based index from the last get_interactive_elements response" } }, - "required": ["profile_id", "index"] + "required": ["profile_id"] }), }, McpTool { @@ -4142,6 +4277,10 @@ impl McpServer { "type": "string", "description": "The UUID of the running profile" }, + "element_id": { + "type": "string", + "description": "Instead of index: the stable id (e12) from the last get_interactive_elements response" + }, "index": { "type": "integer", "description": "Zero-based index from the last get_interactive_elements response" @@ -4163,7 +4302,7 @@ impl McpServer { "description": "Target words per minute for human typing (default: 80)" } }, - "required": ["profile_id", "index", "text"] + "required": ["profile_id", "text"] }), }, // Remote fleet. An agent that could drive a remote profile but not start @@ -5195,6 +5334,46 @@ impl McpServer { .await?; self.handle_screenshot(arguments).await } + "run_actions" => { + Self::require_capability( + "Browser automation", + CLOUD_AUTH.can_use_browser_automation().await, + ) + .await?; + self.handle_run_actions(caller, arguments).await + } + "list_tabs" => { + Self::require_capability( + "Browser automation", + CLOUD_AUTH.can_use_browser_automation().await, + ) + .await?; + self.handle_list_tabs(arguments).await + } + "open_tab" => { + Self::require_capability( + "Browser automation", + CLOUD_AUTH.can_use_browser_automation().await, + ) + .await?; + self.handle_open_tab(arguments).await + } + "switch_tab" => { + Self::require_capability( + "Browser automation", + CLOUD_AUTH.can_use_browser_automation().await, + ) + .await?; + self.handle_switch_tab(arguments).await + } + "close_tab" => { + Self::require_capability( + "Browser automation", + CLOUD_AUTH.can_use_browser_automation().await, + ) + .await?; + self.handle_close_tab(arguments).await + } "scroll" => { Self::require_capability( "Browser automation", @@ -5516,6 +5695,29 @@ impl McpServer { }); } + // A browser the person already opened is taken over as it is, never + // restarted: the tools reach it on its DevTools port. + if let Some(port) = crate::browser_tabs::local_port(profile).await { + if let Some(url) = url { + crate::browser_tabs::open(profile, url) + .await + .map_err(tab_error)?; + } + log::info!( + "Running browser taken over profile={} cdp_port={port}", + profile.id + ); + return Ok(serde_json::json!({ + "content": [{ + "type": "text", + "text": format!( + "Browser profile '{}' was already running; the tools now drive it. Use list_tabs to see its tabs.", + profile.name + ) + }] + })); + } + // Team lock check crate::team_lock::acquire_team_lock_if_needed(profile) .await @@ -8554,6 +8756,221 @@ impl McpServer { Ok(LayoutMetrics::from_reply(&reply)) } + /// The running profile a tab tool names. + fn tab_profile(arguments: &serde_json::Value) -> Result { + let profile_id = Self::require_str(arguments, "profile_id")?; + ProfileManager::instance() + .list_profiles() + .map_err(|e| McpError { + code: -32000, + message: format!("Failed to list profiles: {e}"), + data: None, + })? + .into_iter() + .find(|p| p.id.to_string() == profile_id) + .ok_or_else(|| McpError { + code: -32000, + message: format!("Profile not found: {profile_id}"), + data: None, + }) + } + + async fn handle_list_tabs( + &self, + arguments: &serde_json::Value, + ) -> Result { + let profile = Self::tab_profile(arguments)?; + let tabs = crate::browser_tabs::list(&profile) + .await + .map_err(tab_error)?; + Self::json_content(&serde_json::json!({ "tabs": tabs })) + } + + async fn handle_open_tab( + &self, + arguments: &serde_json::Value, + ) -> Result { + let profile = Self::tab_profile(arguments)?; + let url = Self::require_navigable_url(arguments, "url")?; + let tab = crate::browser_tabs::open(&profile, url) + .await + .map_err(tab_error)?; + Self::json_content(&tab) + } + + async fn handle_switch_tab( + &self, + arguments: &serde_json::Value, + ) -> Result { + let profile = Self::tab_profile(arguments)?; + let tab_id = Self::require_str(arguments, "tab_id")?; + let tab = crate::browser_tabs::switch(&profile, tab_id) + .await + .map_err(tab_error)?; + Self::json_content(&tab) + } + + async fn handle_close_tab( + &self, + arguments: &serde_json::Value, + ) -> Result { + let profile = Self::tab_profile(arguments)?; + let tab_id = Self::require_str(arguments, "tab_id")?; + crate::browser_tabs::close(&profile, tab_id) + .await + .map_err(tab_error)?; + Ok(serde_json::json!({ "content": [{ "type": "text", "text": "Tab closed" }] })) + } + + /// The index the caller named, directly or as a stable element id (`e12`) + /// from the last listing. + async fn element_index( + &self, + profile_id: &str, + cache: &str, + arguments: &serde_json::Value, + ) -> Result { + if let Some(index) = arguments.get("index").and_then(|v| v.as_u64()) { + return Ok(index); + } + let raw = arguments.get("element_id").ok_or_else(|| McpError { + code: -32602, + message: "Missing index or element_id".to_string(), + data: None, + })?; + let id = raw + .as_u64() + .or_else(|| { + raw + .as_str() + .and_then(|s| s.trim_start_matches('e').parse().ok()) + }) + .ok_or_else(|| McpError { + code: -32602, + message: "element_id is an id like e12 from get_interactive_elements".to_string(), + data: None, + })?; + let target = self.resolve_cdp_target(profile_id).await?; + let reply = self + .send_cdp( + &target, + "Runtime.evaluate", + serde_json::json!({ + "expression": format!( + "(() => {{ const arr = window[{cache}]; const ids = window.__donut_element_ids; if (!arr || !ids) return -1; return arr.findIndex((el) => ids.map.get(el) === {id}); }})()" + ), + "returnByValue": true, + }), + ) + .await?; + match reply.pointer("/result/value").and_then(|v| v.as_i64()) { + Some(index) if index >= 0 => Ok(index as u64), + _ => Err(McpError { + code: -32000, + message: format!( + "No element e{id} in the last listing. Call get_interactive_elements again." + ), + data: None, + }), + } + } + + /// Several browser actions in one call, in order, on one profile. Stops at + /// the first failure, and by default once the page's address changes, since + /// what follows was planned against the page before it. + async fn handle_run_actions( + &self, + caller: McpCaller<'_>, + arguments: &serde_json::Value, + ) -> Result { + let profile_id = Self::require_str(arguments, "profile_id")?; + let actions = arguments + .get("actions") + .and_then(|v| v.as_array()) + .filter(|list| !list.is_empty() && list.len() <= MAX_BATCH_ACTIONS) + .ok_or_else(|| McpError { + code: -32602, + message: format!("actions is a list of 1 to {MAX_BATCH_ACTIONS} steps"), + data: None, + })?; + let mut steps = Vec::with_capacity(actions.len()); + for action in actions { + let tool = action + .get("tool") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + if !BATCH_TOOLS.contains(&tool) { + return Err(McpError { + code: -32602, + message: format!( + "{tool:?} cannot run in a batch. Batchable tools: {}", + BATCH_TOOLS.join(", ") + ), + data: None, + }); + } + let mut step_arguments = action + .get("arguments") + .cloned() + .filter(|v| v.is_object()) + .unwrap_or_else(|| serde_json::json!({})); + step_arguments["profile_id"] = serde_json::json!(profile_id); + steps.push((tool, step_arguments)); + } + let stop_on_change = arguments + .get("stop_on_change") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + + let target = self.resolve_cdp_target(profile_id).await?; + let mut url = self.page_state(&target).await.map(|state| state.url); + let mut results = Vec::new(); + let mut stopped: Option = None; + for (position, (tool, step_arguments)) in steps.iter().enumerate() { + if let Err(refusal) = crate::agent_console::check_call(false, step_arguments) { + let error = Self::console_refusal(refusal); + results.push(serde_json::json!({ "tool": tool, "ok": false, "error": error.message })); + stopped = Some("the person paused or took over".to_string()); + break; + } + match Box::pin(self.dispatch_tool_call(caller, tool, step_arguments)).await { + Ok(value) => { + let text: String = value + .pointer("/content/0/text") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .chars() + .take(BATCH_RESULT_CHARS) + .collect(); + results.push(serde_json::json!({ "tool": tool, "ok": true, "result": text })); + } + Err(error) => { + results.push(serde_json::json!({ "tool": tool, "ok": false, "error": error.message })); + stopped = Some("a step failed".to_string()); + break; + } + } + if stop_on_change && position + 1 < steps.len() { + let now = self.page_state(&target).await.map(|state| state.url); + if now.is_some() && url.is_some() && now != url { + stopped = Some(format!( + "the page changed to {}", + now.clone().unwrap_or_default() + )); + break; + } + url = now; + } + } + let done = results.len(); + Self::json_content(&serde_json::json!({ + "results": results, + "completed": done, + "remaining": steps.len() - done, + "stopped": stopped, + })) + } + async fn handle_scroll( &self, arguments: &serde_json::Value, @@ -9464,14 +9881,7 @@ impl McpServer { message: "Missing profile_id".to_string(), data: None, })?; - let index = arguments - .get("index") - .and_then(|v| v.as_u64()) - .ok_or_else(|| McpError { - code: -32602, - message: "Missing index".to_string(), - data: None, - })?; + let index = self.element_index(profile_id, &cache, arguments).await?; let ctx = self.agent_context(profile_id).await?; @@ -9554,14 +9964,7 @@ impl McpServer { message: "Missing profile_id".to_string(), data: None, })?; - let index = arguments - .get("index") - .and_then(|v| v.as_u64()) - .ok_or_else(|| McpError { - code: -32602, - message: "Missing index".to_string(), - data: None, - })?; + let index = self.element_index(profile_id, &cache, arguments).await?; let text = arguments .get("text") .and_then(|v| v.as_str()) @@ -10899,6 +11302,25 @@ mod tests { assert!(wait_for_script(Some("x"), Some("#y")).is_none()); } + /// Whether a scanned method body is the tool listing rather than a handler. + fn is_tool_listing(body: &str) -> bool { + body.trim_start().starts_with("pub fn get_tools") + } + + #[test] + fn every_batchable_tool_exists_and_none_reads_files() { + let names: Vec = McpServer::new() + .get_tools() + .into_iter() + .map(|t| t.name) + .collect(); + for tool in BATCH_TOOLS { + assert!(names.iter().any(|name| name == tool), "{tool}"); + assert!(!LOCAL_PATH_TOOLS.contains(tool), "{tool}"); + } + assert!(!BATCH_TOOLS.contains(&"run_actions"), "a batch never nests"); + } + #[test] fn test_mcp_tools_count() { let server = McpServer::new(); @@ -10912,7 +11334,7 @@ mod tests { // tool was added or removed on purpose. assert_eq!( tools.len(), - 102, + 107, "the tool list is a published contract; update this number deliberately" ); @@ -11609,7 +12031,7 @@ mod tests { let body = &source[begin..stop]; // ANY method that reads a "url" argument from the caller, however it // spells the read. - if !(body.contains("arguments") && body.contains(r#""url""#)) { + if !(body.contains("arguments") && body.contains(r#""url""#)) || is_tool_listing(body) { continue; } let name = body @@ -11644,6 +12066,7 @@ mod tests { vec![ "handle_batch_run_profiles", "handle_navigate", + "handle_open_tab", "handle_run_profile", "handle_run_profile_remote", ], @@ -11737,8 +12160,8 @@ mod tests { production.matches("window[__CACHE__]").count() + production.matches("window[{cache}]").count() + production.matches("window[{slot}]").count(), - 7, - "the seven cache sites (one write, four reads, two in the teardown) must \ + 8, + "the eight cache sites (one write, five reads, two in the teardown) must \ all be parameterised" ); } @@ -12314,7 +12737,8 @@ mod tests { for (n, &begin) in starts.iter().enumerate() { let stop = starts.get(n + 1).copied().unwrap_or(source.len()); let body = &source[begin..stop]; - if !body.contains("arguments") { + // `get_tools` only lists schemas; a batch tool's schema names `arguments`. + if !body.contains("arguments") || is_tool_listing(body) { continue; } if body.contains(r#""path""#) || body.contains(r#""folder""#) { diff --git a/src-tauri/src/mcp_toolsets.rs b/src-tauri/src/mcp_toolsets.rs index 73ab7c29..4bf29358 100644 --- a/src-tauri/src/mcp_toolsets.rs +++ b/src-tauri/src/mcp_toolsets.rs @@ -37,6 +37,11 @@ pub const TOOLSETS: &[(&str, &[&str])] = &[ "batch_run_profiles", "batch_stop_profiles", "navigate", + "run_actions", + "list_tabs", + "open_tab", + "switch_tab", + "close_tab", "go_back", "scroll", "press_key", diff --git a/src-tauri/src/team_roles.rs b/src-tauri/src/team_roles.rs index d64a3f40..d08d8973 100644 --- a/src-tauri/src/team_roles.rs +++ b/src-tauri/src/team_roles.rs @@ -52,6 +52,9 @@ const TEAM_DATA_COMMANDS: &[&str] = &[ "set_profile_password", "change_profile_password", "remove_profile_password", + "create_restore_point", + "delete_restore_point", + "restore_restore_point", // Proxies "create_stored_proxy", "update_stored_proxy", diff --git a/src/components/profile-info-dialog.tsx b/src/components/profile-info-dialog.tsx index 8f8509ae..00e76f6e 100644 --- a/src/components/profile-info-dialog.tsx +++ b/src/components/profile-info-dialog.tsx @@ -48,10 +48,12 @@ import { ProfileHandoffStatus } from "@/components/profile-handoff-status"; import { ProfileLaunchActivity } from "@/components/profile-launch-activity"; import { ProfileMetadataCard } from "@/components/profile-metadata-card"; import { ExportProfileSection } from "@/components/profile-transfer"; +import { RestorePoints } from "@/components/restore-points"; import { SharedFingerprintConfigForm } from "@/components/shared-fingerprint-config-form"; import { ProfileTotpSection } from "@/components/totp-code"; import { AnimatedSwitch } from "@/components/ui/animated-switch"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { ColorPicker, ColorPickerEyeDropper, @@ -1867,6 +1869,9 @@ function SyncSectionInline({ )} )} + {syncMode !== "Disabled" && ( + + )} {error &&

{error}

} ); @@ -2457,6 +2462,7 @@ function StorageStateBlock({ items: number; } | null>(null); const [busy, setBusy] = React.useState(false); + const [withIndexedDb, setWithIndexedDb] = React.useState(false); const loadStats = React.useCallback(async () => { try { @@ -2481,6 +2487,7 @@ function StorageStateBlock({ const content = await invoke("export_profile_storage_state", { profileId: profile.id, includeCookies: true, + includeIndexedDb: withIndexedDb, }); const filePath = await save({ defaultPath: `${profile.name}_storage_state.json`, @@ -2509,13 +2516,20 @@ function StorageStateBlock({ origins: number; items: number; cookies_imported: number; + indexed_db_records: number; }>("import_profile_storage_state", { profileId: profile.id, content }); showSuccessToast( - t("profileInfo.localStorage.imported", { - items: result.items, - origins: result.origins, - cookies: result.cookies_imported, - }), + t( + result.indexed_db_records > 0 + ? "profileInfo.localStorage.importedWithIndexedDb" + : "profileInfo.localStorage.imported", + { + items: result.items, + origins: result.origins, + cookies: result.cookies_imported, + records: result.indexed_db_records, + }, + ), ); void loadStats(); } catch (e) { @@ -2545,6 +2559,26 @@ function StorageStateBlock({

{t("profileInfo.localStorage.hint")}

+