mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-19 22:17:28 +02:00
feat: agent takeover, batches
This commit is contained in:
1 parent
e28afa5b13
commit
7658833904
28 files changed
+1512
-101
No files matched your search
@@ -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",
|
||||
|
||||
+179
-33
@@ -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,
|
||||
|
||||
+11
-2
@@ -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<void> {
|
||||
await this.client.killProfile(this.profileId);
|
||||
if (!this.attached) {
|
||||
await this.client.killProfile(this.profileId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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<bool>, Query, description = "Also export the profile's cookies; false when left out")
|
||||
("include_cookies" = Option<bool>, Query, description = "Also export the profile's cookies; false when left out"),
|
||||
("indexed_db" = Option<bool>, 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<ApiServerState>,
|
||||
Path(id): Path<String>,
|
||||
Query(query): Query<StorageStateQuery>,
|
||||
) -> Result<Json<ApiStorageState>, (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)
|
||||
|
||||
@@ -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<Option<HashMap<String, String>>> = 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<u16> {
|
||||
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<BrowserTab> {
|
||||
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<Vec<Value>, String> {
|
||||
http()
|
||||
.get(format!("http://127.0.0.1:{port}/json/list"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(unreachable)?
|
||||
.json::<Vec<Value>>()
|
||||
.await
|
||||
.map_err(unreachable)
|
||||
}
|
||||
|
||||
fn selected_for(profile_id: &str) -> Option<String> {
|
||||
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<String> {
|
||||
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<u16, String> {
|
||||
local_port(profile)
|
||||
.await
|
||||
.ok_or_else(|| code("BROWSER_NOT_RUNNING"))
|
||||
}
|
||||
|
||||
pub async fn list(profile: &BrowserProfile) -> Result<Vec<BrowserTab>, 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<BrowserTab, String> {
|
||||
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<BrowserTab, String> {
|
||||
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<Value> {
|
||||
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::<Vec<_>>(),
|
||||
["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);
|
||||
}
|
||||
}
|
||||
+113
-4
@@ -283,7 +283,11 @@ async fn local_page_ws_url(profile: &BrowserProfile, patience: Patience) -> Opti
|
||||
{
|
||||
Ok(response) => match response.json::<Vec<Value>>().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::collections::HashMap<String, PooledConnection>>,
|
||||
> = 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<Value, CdpError>) -> 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<Value, CdpError> {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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::Wry>) -> tauri::Builder<tauri::Wry>,
|
||||
) {
|
||||
install_runtime();
|
||||
let args: Vec<String> = 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,
|
||||
|
||||
+450
-26
@@ -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 + '</' + tag + '>';
|
||||
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 + '</' + tag + '>';
|
||||
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<BrowserProfile, McpError> {
|
||||
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<serde_json::Value, McpError> {
|
||||
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<serde_json::Value, McpError> {
|
||||
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<serde_json::Value, McpError> {
|
||||
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<serde_json::Value, McpError> {
|
||||
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<u64, McpError> {
|
||||
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<serde_json::Value, McpError> {
|
||||
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<String> = 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<String> = 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""#) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{syncMode !== "Disabled" && (
|
||||
<RestorePoints profileId={profile.id} isDisabled={isDisabled} />
|
||||
)}
|
||||
{error && <p className="text-xs text-destructive-text">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
@@ -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<string>("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({
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t("profileInfo.localStorage.hint")}
|
||||
</p>
|
||||
<label
|
||||
htmlFor={`storage-indexed-db-${profile.id}`}
|
||||
className="mt-1.5 flex cursor-pointer items-start gap-2 text-xs"
|
||||
>
|
||||
<Checkbox
|
||||
id={`storage-indexed-db-${profile.id}`}
|
||||
checked={withIndexedDb}
|
||||
disabled={isDisabled || busy}
|
||||
onCheckedChange={(checked) => setWithIndexedDb(checked === true)}
|
||||
data-slot="storage-state-indexed-db"
|
||||
/>
|
||||
<span>
|
||||
<span className="block">
|
||||
{t("profileInfo.localStorage.includeIndexedDb")}
|
||||
</span>
|
||||
<span className="block text-muted-foreground">
|
||||
{t("profileInfo.localStorage.indexedDbHint")}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
|
||||
@@ -1617,7 +1617,10 @@
|
||||
"stats": "Websites: {{origins}} · Einträge: {{items}}",
|
||||
"hint": "Eine Playwright-storageState-Datei mit den Cookies und dem Local Storage. Zum Importieren muss der Browser geschlossen sein.",
|
||||
"exported": "Speicherzustand exportiert",
|
||||
"imported": "Importiert. Local-Storage-Einträge: {{items}} · Websites: {{origins}} · Cookies: {{cookies}}"
|
||||
"imported": "Importiert. Local-Storage-Einträge: {{items}} · Websites: {{origins}} · Cookies: {{cookies}}",
|
||||
"includeIndexedDb": "IndexedDB einschließen",
|
||||
"indexedDbHint": "Der Export öffnet das Profil kurz im Hintergrund; es wird keine Seite geladen.",
|
||||
"importedWithIndexedDb": "Importiert. Local-Storage-Einträge: {{items}} · Websites: {{origins}} · Cookies: {{cookies}} · IndexedDB-Datensätze: {{records}}"
|
||||
}
|
||||
},
|
||||
"profileTransfer": {
|
||||
@@ -2653,7 +2656,15 @@
|
||||
"teamMemberNotFound": "Dieses Mitglied ist nicht mehr im Team.",
|
||||
"teamGroupAccessDenied": "Du hast keinen Zugriff auf die Gruppe dieses Profils.",
|
||||
"teamAccessNotEntitled": "Zugriff nach Gruppe braucht einen aktiven Team- oder Enterprise-Plan.",
|
||||
"replayRecipeNotFound": "Dieses Rezept gibt es nicht mehr."
|
||||
"replayRecipeNotFound": "Dieses Rezept gibt es nicht mehr.",
|
||||
"indexedDbUnavailable": "Der Browser konnte IndexedDB nicht lesen oder schreiben. Schließe das Profil und versuche es erneut.",
|
||||
"indexedDbTooLarge": "Die IndexedDB ist größer als 24 MiB und passt nicht in eine Storage-State-Datei.",
|
||||
"snapshotInvalid": "Die Anfrage für den Wiederherstellungspunkt war ungültig.",
|
||||
"snapshotNothingToKeep": "Dieses Profil hat noch keine synchronisierten Daten. Synchronisiere es zuerst.",
|
||||
"snapshotNotFound": "Diesen Wiederherstellungspunkt gibt es nicht mehr.",
|
||||
"restorePointsUnsupported": "Der Sync-Server ist zu alt für Wiederherstellungspunkte. Aktualisiere ihn.",
|
||||
"restorePointFailed": "Der Wiederherstellungspunkt ist fehlgeschlagen: {{detail}}",
|
||||
"restorePointsNeedSync": "Wiederherstellungspunkte brauchen eingeschaltete Synchronisierung für dieses Profil."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profile",
|
||||
@@ -4084,5 +4095,21 @@
|
||||
"saved": "Zugriff gespeichert.",
|
||||
"someGroups_one": "{{count}} Gruppe",
|
||||
"someGroups_other": "{{count}} Gruppen"
|
||||
},
|
||||
"restorePoints": {
|
||||
"title": "Wiederherstellungspunkte",
|
||||
"hint": "Kopien der Profildaten auf dem Sync-Server: einer pro Tag nach einer Änderung und die, die du erstellst. Von jeder Art bleiben die neuesten 3.",
|
||||
"create": "Erstellen",
|
||||
"created": "Wiederherstellungspunkt erstellt.",
|
||||
"empty": "Noch keine Wiederherstellungspunkte.",
|
||||
"restore": "Wiederherstellen",
|
||||
"restored": "Wiederherstellungspunkt wiederhergestellt.",
|
||||
"deleted": "Wiederherstellungspunkt gelöscht.",
|
||||
"restoreTitle": "Dieses Profil wiederherstellen?",
|
||||
"restoreDescription": "Die Profildaten werden auf die Kopie vom {{when}} zurückgesetzt. Die aktuellen Daten werden vorher als neuer Wiederherstellungspunkt gesichert.",
|
||||
"kind": {
|
||||
"auto": "Täglich",
|
||||
"manual": "Manuell"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1617,7 +1617,10 @@
|
||||
"stats": "Sites: {{origins}} · Items: {{items}}",
|
||||
"hint": "One Playwright storageState file with the cookies and the Local Storage. Import needs the browser closed.",
|
||||
"exported": "Storage state exported",
|
||||
"imported": "Imported. Local Storage items: {{items}} · Sites: {{origins}} · Cookies: {{cookies}}"
|
||||
"imported": "Imported. Local Storage items: {{items}} · Sites: {{origins}} · Cookies: {{cookies}}",
|
||||
"includeIndexedDb": "Include IndexedDB",
|
||||
"indexedDbHint": "Export opens the profile in the background for a moment; no page loads.",
|
||||
"importedWithIndexedDb": "Imported. Local Storage items: {{items}} · Sites: {{origins}} · Cookies: {{cookies}} · IndexedDB records: {{records}}"
|
||||
}
|
||||
},
|
||||
"profileTransfer": {
|
||||
@@ -2653,7 +2656,15 @@
|
||||
"teamMemberNotFound": "This member is no longer on the team.",
|
||||
"teamGroupAccessDenied": "You have no access to the group of this profile.",
|
||||
"teamAccessNotEntitled": "Access by group needs an active Team or Enterprise plan.",
|
||||
"replayRecipeNotFound": "This recipe no longer exists."
|
||||
"replayRecipeNotFound": "This recipe no longer exists.",
|
||||
"indexedDbUnavailable": "The browser could not read or write IndexedDB. Close the profile and try again.",
|
||||
"indexedDbTooLarge": "The IndexedDB is over 24 MiB, too large for one storage state file.",
|
||||
"snapshotInvalid": "The restore point request was not valid.",
|
||||
"snapshotNothingToKeep": "This profile has no synced data to keep yet. Sync it first.",
|
||||
"snapshotNotFound": "This restore point no longer exists.",
|
||||
"restorePointsUnsupported": "The sync server is too old for restore points. Update it.",
|
||||
"restorePointFailed": "The restore point failed: {{detail}}",
|
||||
"restorePointsNeedSync": "Restore points need sync on for this profile."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profiles",
|
||||
@@ -4084,5 +4095,21 @@
|
||||
"saved": "Access saved.",
|
||||
"someGroups_one": "{{count}} group",
|
||||
"someGroups_other": "{{count}} groups"
|
||||
},
|
||||
"restorePoints": {
|
||||
"title": "Restore points",
|
||||
"hint": "Copies of this profile's data on the sync server: one a day after a change, and the ones you make. The newest 3 of each are kept.",
|
||||
"create": "Create",
|
||||
"created": "Restore point created.",
|
||||
"empty": "No restore points yet.",
|
||||
"restore": "Restore",
|
||||
"restored": "Restore point restored.",
|
||||
"deleted": "Restore point deleted.",
|
||||
"restoreTitle": "Restore this profile?",
|
||||
"restoreDescription": "The profile's data goes back to the copy from {{when}}. The current data is kept as a new restore point first.",
|
||||
"kind": {
|
||||
"auto": "Daily",
|
||||
"manual": "Manual"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1628,7 +1628,10 @@
|
||||
"stats": "Sitios: {{origins}} · Elementos: {{items}}",
|
||||
"hint": "Un archivo storageState de Playwright con las cookies y el Local Storage. Para importar, el navegador debe estar cerrado.",
|
||||
"exported": "Estado de almacenamiento exportado",
|
||||
"imported": "Importado. Elementos de Local Storage: {{items}} · Sitios: {{origins}} · Cookies: {{cookies}}"
|
||||
"imported": "Importado. Elementos de Local Storage: {{items}} · Sitios: {{origins}} · Cookies: {{cookies}}",
|
||||
"includeIndexedDb": "Incluir IndexedDB",
|
||||
"indexedDbHint": "La exportación abre el perfil en segundo plano un momento; no se carga ninguna página.",
|
||||
"importedWithIndexedDb": "Importado. Elementos de Local Storage: {{items}} · Sitios: {{origins}} · Cookies: {{cookies}} · Registros de IndexedDB: {{records}}"
|
||||
}
|
||||
},
|
||||
"profileTransfer": {
|
||||
@@ -2673,7 +2676,15 @@
|
||||
"teamMemberNotFound": "Este miembro ya no está en el equipo.",
|
||||
"teamGroupAccessDenied": "No tienes acceso al grupo de este perfil.",
|
||||
"teamAccessNotEntitled": "El acceso por grupo necesita un plan Team o Enterprise activo.",
|
||||
"replayRecipeNotFound": "Esta receta ya no existe."
|
||||
"replayRecipeNotFound": "Esta receta ya no existe.",
|
||||
"indexedDbUnavailable": "El navegador no pudo leer ni escribir IndexedDB. Cierra el perfil e inténtalo de nuevo.",
|
||||
"indexedDbTooLarge": "La IndexedDB supera los 24 MiB, demasiado para un archivo de storage state.",
|
||||
"snapshotInvalid": "La solicitud del punto de restauración no era válida.",
|
||||
"snapshotNothingToKeep": "Este perfil aún no tiene datos sincronizados que guardar. Sincronízalo primero.",
|
||||
"snapshotNotFound": "Este punto de restauración ya no existe.",
|
||||
"restorePointsUnsupported": "El servidor de sincronización es demasiado antiguo para los puntos de restauración. Actualízalo.",
|
||||
"restorePointFailed": "El punto de restauración falló: {{detail}}",
|
||||
"restorePointsNeedSync": "Los puntos de restauración necesitan la sincronización activada en este perfil."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Perfiles",
|
||||
@@ -4129,5 +4140,21 @@
|
||||
"someGroups_one": "{{count}} grupo",
|
||||
"someGroups_many": "{{count}} grupos",
|
||||
"someGroups_other": "{{count}} grupos"
|
||||
},
|
||||
"restorePoints": {
|
||||
"title": "Puntos de restauración",
|
||||
"hint": "Copias de los datos de este perfil en el servidor de sincronización: una al día tras un cambio, y las que crees. Se guardan las 3 más recientes de cada tipo.",
|
||||
"create": "Crear",
|
||||
"created": "Punto de restauración creado.",
|
||||
"empty": "Aún no hay puntos de restauración.",
|
||||
"restore": "Restaurar",
|
||||
"restored": "Punto de restauración restaurado.",
|
||||
"deleted": "Punto de restauración eliminado.",
|
||||
"restoreTitle": "¿Restaurar este perfil?",
|
||||
"restoreDescription": "Los datos del perfil vuelven a la copia del {{when}}. Antes, los datos actuales se guardan como un nuevo punto de restauración.",
|
||||
"kind": {
|
||||
"auto": "Diario",
|
||||
"manual": "Manual"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1628,7 +1628,10 @@
|
||||
"stats": "Sites : {{origins}} · Éléments : {{items}}",
|
||||
"hint": "Un fichier storageState de Playwright avec les cookies et le Local Storage. L'import nécessite que le navigateur soit fermé.",
|
||||
"exported": "État de stockage exporté",
|
||||
"imported": "Importé. Éléments Local Storage : {{items}} · Sites : {{origins}} · Cookies : {{cookies}}"
|
||||
"imported": "Importé. Éléments Local Storage : {{items}} · Sites : {{origins}} · Cookies : {{cookies}}",
|
||||
"includeIndexedDb": "Inclure IndexedDB",
|
||||
"indexedDbHint": "L’export ouvre le profil en arrière-plan un instant ; aucune page n’est chargée.",
|
||||
"importedWithIndexedDb": "Importé. Éléments Local Storage : {{items}} · Sites : {{origins}} · Cookies : {{cookies}} · Enregistrements IndexedDB : {{records}}"
|
||||
}
|
||||
},
|
||||
"profileTransfer": {
|
||||
@@ -2673,7 +2676,15 @@
|
||||
"teamMemberNotFound": "Ce membre ne fait plus partie de l’équipe.",
|
||||
"teamGroupAccessDenied": "Vous n’avez pas accès au groupe de ce profil.",
|
||||
"teamAccessNotEntitled": "L’accès par groupe nécessite un forfait Team ou Enterprise actif.",
|
||||
"replayRecipeNotFound": "Cette recette n’existe plus."
|
||||
"replayRecipeNotFound": "Cette recette n’existe plus.",
|
||||
"indexedDbUnavailable": "Le navigateur n’a pas pu lire ou écrire IndexedDB. Fermez le profil et réessayez.",
|
||||
"indexedDbTooLarge": "L’IndexedDB dépasse 24 Mio, trop pour un seul fichier storage state.",
|
||||
"snapshotInvalid": "La demande de point de restauration n’était pas valide.",
|
||||
"snapshotNothingToKeep": "Ce profil n’a pas encore de données synchronisées à garder. Synchronisez-le d’abord.",
|
||||
"snapshotNotFound": "Ce point de restauration n’existe plus.",
|
||||
"restorePointsUnsupported": "Le serveur de synchronisation est trop ancien pour les points de restauration. Mettez-le à jour.",
|
||||
"restorePointFailed": "Le point de restauration a échoué : {{detail}}",
|
||||
"restorePointsNeedSync": "Les points de restauration nécessitent la synchronisation activée pour ce profil."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profils",
|
||||
@@ -4129,5 +4140,21 @@
|
||||
"someGroups_one": "{{count}} groupe",
|
||||
"someGroups_many": "{{count}} groupes",
|
||||
"someGroups_other": "{{count}} groupes"
|
||||
},
|
||||
"restorePoints": {
|
||||
"title": "Points de restauration",
|
||||
"hint": "Copies des données de ce profil sur le serveur de synchronisation : une par jour après un changement, et celles que vous créez. Les 3 plus récentes de chaque type sont gardées.",
|
||||
"create": "Créer",
|
||||
"created": "Point de restauration créé.",
|
||||
"empty": "Aucun point de restauration pour l’instant.",
|
||||
"restore": "Restaurer",
|
||||
"restored": "Point de restauration restauré.",
|
||||
"deleted": "Point de restauration supprimé.",
|
||||
"restoreTitle": "Restaurer ce profil ?",
|
||||
"restoreDescription": "Les données du profil reviennent à la copie du {{when}}. Les données actuelles sont d’abord gardées comme nouveau point de restauration.",
|
||||
"kind": {
|
||||
"auto": "Quotidien",
|
||||
"manual": "Manuel"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1617,7 +1617,10 @@
|
||||
"stats": "サイト: {{origins}} · 項目: {{items}}",
|
||||
"hint": "Cookie とローカルストレージを 1 つの Playwright storageState ファイルにまとめます。インポートするにはブラウザーを閉じてください。",
|
||||
"exported": "ストレージ状態をエクスポートしました",
|
||||
"imported": "インポートしました。ローカルストレージの項目: {{items}} · サイト: {{origins}} · Cookie: {{cookies}}"
|
||||
"imported": "インポートしました。ローカルストレージの項目: {{items}} · サイト: {{origins}} · Cookie: {{cookies}}",
|
||||
"includeIndexedDb": "IndexedDB を含める",
|
||||
"indexedDbHint": "エクスポートでは、プロファイルを一瞬バックグラウンドで開きます。ページは読み込みません。",
|
||||
"importedWithIndexedDb": "インポートしました。Local Storage の項目: {{items}} · サイト: {{origins}} · Cookie: {{cookies}} · IndexedDB のレコード: {{records}}"
|
||||
}
|
||||
},
|
||||
"profileTransfer": {
|
||||
@@ -2653,7 +2656,15 @@
|
||||
"teamMemberNotFound": "このメンバーはもうチームにいません。",
|
||||
"teamGroupAccessDenied": "このプロファイルのグループにアクセスできません。",
|
||||
"teamAccessNotEntitled": "グループ別のアクセスには、有効な Team または Enterprise プランが必要です。",
|
||||
"replayRecipeNotFound": "このレシピはもう存在しません。"
|
||||
"replayRecipeNotFound": "このレシピはもう存在しません。",
|
||||
"indexedDbUnavailable": "ブラウザーが IndexedDB を読み書きできませんでした。プロファイルを閉じてもう一度お試しください。",
|
||||
"indexedDbTooLarge": "IndexedDB が 24 MiB を超えていて、1 つの storage state ファイルに収まりません。",
|
||||
"snapshotInvalid": "復元ポイントのリクエストが無効でした。",
|
||||
"snapshotNothingToKeep": "このプロファイルにはまだ同期されたデータがありません。先に同期してください。",
|
||||
"snapshotNotFound": "この復元ポイントはもう存在しません。",
|
||||
"restorePointsUnsupported": "同期サーバーが古いため、復元ポイントを使えません。更新してください。",
|
||||
"restorePointFailed": "復元ポイントの処理に失敗しました: {{detail}}",
|
||||
"restorePointsNeedSync": "復元ポイントには、このプロファイルの同期をオンにする必要があります。"
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "プロファイル",
|
||||
@@ -4084,5 +4095,21 @@
|
||||
"saved": "アクセスを保存しました。",
|
||||
"someGroups_one": "{{count}} グループ",
|
||||
"someGroups_other": "{{count}} グループ"
|
||||
},
|
||||
"restorePoints": {
|
||||
"title": "復元ポイント",
|
||||
"hint": "同期サーバー上にあるこのプロファイルのデータのコピーです。変更があった日に 1 つ自動で作られ、手動でも作れます。種類ごとに最新の 3 つが残ります。",
|
||||
"create": "作成",
|
||||
"created": "復元ポイントを作成しました。",
|
||||
"empty": "まだ復元ポイントはありません。",
|
||||
"restore": "復元",
|
||||
"restored": "復元ポイントから復元しました。",
|
||||
"deleted": "復元ポイントを削除しました。",
|
||||
"restoreTitle": "このプロファイルを復元しますか?",
|
||||
"restoreDescription": "プロファイルのデータを {{when}} のコピーに戻します。現在のデータは先に新しい復元ポイントとして保存されます。",
|
||||
"kind": {
|
||||
"auto": "毎日",
|
||||
"manual": "手動"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1617,7 +1617,10 @@
|
||||
"stats": "사이트: {{origins}} · 항목: {{items}}",
|
||||
"hint": "쿠키와 로컬 스토리지를 하나의 Playwright storageState 파일로 담습니다. 가져오려면 브라우저를 닫아야 합니다.",
|
||||
"exported": "스토리지 상태를 내보냈습니다",
|
||||
"imported": "가져왔습니다. 로컬 스토리지 항목: {{items}} · 사이트: {{origins}} · 쿠키: {{cookies}}"
|
||||
"imported": "가져왔습니다. 로컬 스토리지 항목: {{items}} · 사이트: {{origins}} · 쿠키: {{cookies}}",
|
||||
"includeIndexedDb": "IndexedDB 포함",
|
||||
"indexedDbHint": "내보내기는 프로필을 잠시 백그라운드에서 엽니다. 페이지는 불러오지 않습니다.",
|
||||
"importedWithIndexedDb": "가져왔습니다. Local Storage 항목: {{items}} · 사이트: {{origins}} · 쿠키: {{cookies}} · IndexedDB 레코드: {{records}}"
|
||||
}
|
||||
},
|
||||
"profileTransfer": {
|
||||
@@ -2653,7 +2656,15 @@
|
||||
"teamMemberNotFound": "이 멤버는 더 이상 팀에 없습니다.",
|
||||
"teamGroupAccessDenied": "이 프로필의 그룹에 접근할 수 없습니다.",
|
||||
"teamAccessNotEntitled": "그룹별 접근 권한에는 활성 Team 또는 Enterprise 요금제가 필요합니다.",
|
||||
"replayRecipeNotFound": "이 레시피는 더 이상 없습니다."
|
||||
"replayRecipeNotFound": "이 레시피는 더 이상 없습니다.",
|
||||
"indexedDbUnavailable": "브라우저가 IndexedDB를 읽거나 쓰지 못했습니다. 프로필을 닫고 다시 시도하세요.",
|
||||
"indexedDbTooLarge": "IndexedDB가 24MiB를 넘어 하나의 storage state 파일에 담을 수 없습니다.",
|
||||
"snapshotInvalid": "복원 지점 요청이 올바르지 않습니다.",
|
||||
"snapshotNothingToKeep": "이 프로필에는 아직 보관할 동기화 데이터가 없습니다. 먼저 동기화하세요.",
|
||||
"snapshotNotFound": "이 복원 지점은 더 이상 없습니다.",
|
||||
"restorePointsUnsupported": "동기화 서버가 너무 오래되어 복원 지점을 쓸 수 없습니다. 업데이트하세요.",
|
||||
"restorePointFailed": "복원 지점 작업이 실패했습니다: {{detail}}",
|
||||
"restorePointsNeedSync": "복원 지점을 쓰려면 이 프로필의 동기화를 켜야 합니다."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "프로필",
|
||||
@@ -4084,5 +4095,21 @@
|
||||
"saved": "접근 권한을 저장했습니다.",
|
||||
"someGroups_one": "{{count}}개 그룹",
|
||||
"someGroups_other": "{{count}}개 그룹"
|
||||
},
|
||||
"restorePoints": {
|
||||
"title": "복원 지점",
|
||||
"hint": "동기화 서버에 있는 이 프로필 데이터의 사본입니다. 변경이 있으면 하루에 하나 자동으로 만들고, 직접 만들 수도 있습니다. 종류마다 최신 3개를 보관합니다.",
|
||||
"create": "만들기",
|
||||
"created": "복원 지점을 만들었습니다.",
|
||||
"empty": "아직 복원 지점이 없습니다.",
|
||||
"restore": "복원",
|
||||
"restored": "복원 지점에서 복원했습니다.",
|
||||
"deleted": "복원 지점을 삭제했습니다.",
|
||||
"restoreTitle": "이 프로필을 복원할까요?",
|
||||
"restoreDescription": "프로필 데이터가 {{when}}의 사본으로 돌아갑니다. 현재 데이터는 먼저 새 복원 지점으로 보관됩니다.",
|
||||
"kind": {
|
||||
"auto": "매일",
|
||||
"manual": "수동"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1628,7 +1628,10 @@
|
||||
"stats": "Sites: {{origins}} · Itens: {{items}}",
|
||||
"hint": "Um arquivo storageState do Playwright com os cookies e o Local Storage. Para importar, o navegador precisa estar fechado.",
|
||||
"exported": "Estado de armazenamento exportado",
|
||||
"imported": "Importado. Itens de Local Storage: {{items}} · Sites: {{origins}} · Cookies: {{cookies}}"
|
||||
"imported": "Importado. Itens de Local Storage: {{items}} · Sites: {{origins}} · Cookies: {{cookies}}",
|
||||
"includeIndexedDb": "Incluir IndexedDB",
|
||||
"indexedDbHint": "A exportação abre o perfil em segundo plano por um momento; nenhuma página é carregada.",
|
||||
"importedWithIndexedDb": "Importado. Itens do Local Storage: {{items}} · Sites: {{origins}} · Cookies: {{cookies}} · Registros do IndexedDB: {{records}}"
|
||||
}
|
||||
},
|
||||
"profileTransfer": {
|
||||
@@ -2673,7 +2676,15 @@
|
||||
"teamMemberNotFound": "Este membro não está mais na equipe.",
|
||||
"teamGroupAccessDenied": "Você não tem acesso ao grupo deste perfil.",
|
||||
"teamAccessNotEntitled": "O acesso por grupo precisa de um plano Team ou Enterprise ativo.",
|
||||
"replayRecipeNotFound": "Esta receita não existe mais."
|
||||
"replayRecipeNotFound": "Esta receita não existe mais.",
|
||||
"indexedDbUnavailable": "O navegador não conseguiu ler ou gravar o IndexedDB. Feche o perfil e tente de novo.",
|
||||
"indexedDbTooLarge": "O IndexedDB tem mais de 24 MiB, grande demais para um arquivo de storage state.",
|
||||
"snapshotInvalid": "A solicitação do ponto de restauração não era válida.",
|
||||
"snapshotNothingToKeep": "Este perfil ainda não tem dados sincronizados para guardar. Sincronize-o primeiro.",
|
||||
"snapshotNotFound": "Este ponto de restauração não existe mais.",
|
||||
"restorePointsUnsupported": "O servidor de sincronização é antigo demais para pontos de restauração. Atualize-o.",
|
||||
"restorePointFailed": "O ponto de restauração falhou: {{detail}}",
|
||||
"restorePointsNeedSync": "Pontos de restauração precisam da sincronização ativada neste perfil."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Perfis",
|
||||
@@ -4129,5 +4140,21 @@
|
||||
"someGroups_one": "{{count}} grupo",
|
||||
"someGroups_many": "{{count}} grupos",
|
||||
"someGroups_other": "{{count}} grupos"
|
||||
},
|
||||
"restorePoints": {
|
||||
"title": "Pontos de restauração",
|
||||
"hint": "Cópias dos dados deste perfil no servidor de sincronização: uma por dia após uma mudança, e as que você criar. As 3 mais recentes de cada tipo são mantidas.",
|
||||
"create": "Criar",
|
||||
"created": "Ponto de restauração criado.",
|
||||
"empty": "Ainda não há pontos de restauração.",
|
||||
"restore": "Restaurar",
|
||||
"restored": "Ponto de restauração restaurado.",
|
||||
"deleted": "Ponto de restauração excluído.",
|
||||
"restoreTitle": "Restaurar este perfil?",
|
||||
"restoreDescription": "Os dados do perfil voltam à cópia de {{when}}. Antes, os dados atuais são guardados como um novo ponto de restauração.",
|
||||
"kind": {
|
||||
"auto": "Diário",
|
||||
"manual": "Manual"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1639,7 +1639,10 @@
|
||||
"stats": "Сайты: {{origins}} · Записи: {{items}}",
|
||||
"hint": "Один файл storageState для Playwright с cookie и Local Storage. Для импорта браузер должен быть закрыт.",
|
||||
"exported": "Состояние хранилища экспортировано",
|
||||
"imported": "Импортировано. Записи Local Storage: {{items}} · Сайты: {{origins}} · Cookie: {{cookies}}"
|
||||
"imported": "Импортировано. Записи Local Storage: {{items}} · Сайты: {{origins}} · Cookie: {{cookies}}",
|
||||
"includeIndexedDb": "Включить IndexedDB",
|
||||
"indexedDbHint": "Экспорт на миг открывает профиль в фоне; страницы не загружаются.",
|
||||
"importedWithIndexedDb": "Импортировано. Записей Local Storage: {{items}} · Сайтов: {{origins}} · Cookie: {{cookies}} · Записей IndexedDB: {{records}}"
|
||||
}
|
||||
},
|
||||
"profileTransfer": {
|
||||
@@ -2693,7 +2696,15 @@
|
||||
"teamMemberNotFound": "Этого участника больше нет в команде.",
|
||||
"teamGroupAccessDenied": "У вас нет доступа к группе этого профиля.",
|
||||
"teamAccessNotEntitled": "Для доступа по группам нужен активный план Team или Enterprise.",
|
||||
"replayRecipeNotFound": "Этого рецепта больше нет."
|
||||
"replayRecipeNotFound": "Этого рецепта больше нет.",
|
||||
"indexedDbUnavailable": "Браузер не смог прочитать или записать IndexedDB. Закройте профиль и попробуйте снова.",
|
||||
"indexedDbTooLarge": "IndexedDB больше 24 МиБ — слишком много для одного файла storage state.",
|
||||
"snapshotInvalid": "Запрос точки восстановления недействителен.",
|
||||
"snapshotNothingToKeep": "У этого профиля пока нет синхронизированных данных. Сначала синхронизируйте его.",
|
||||
"snapshotNotFound": "Этой точки восстановления больше нет.",
|
||||
"restorePointsUnsupported": "Сервер синхронизации слишком старый для точек восстановления. Обновите его.",
|
||||
"restorePointFailed": "Ошибка точки восстановления: {{detail}}",
|
||||
"restorePointsNeedSync": "Для точек восстановления нужна включённая синхронизация этого профиля."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Профили",
|
||||
@@ -4174,5 +4185,21 @@
|
||||
"someGroups_few": "{{count}} группы",
|
||||
"someGroups_many": "{{count}} групп",
|
||||
"someGroups_other": "{{count}} группы"
|
||||
},
|
||||
"restorePoints": {
|
||||
"title": "Точки восстановления",
|
||||
"hint": "Копии данных этого профиля на сервере синхронизации: одна в день после изменений и те, что вы создаёте. Хранятся 3 последние каждого вида.",
|
||||
"create": "Создать",
|
||||
"created": "Точка восстановления создана.",
|
||||
"empty": "Точек восстановления пока нет.",
|
||||
"restore": "Восстановить",
|
||||
"restored": "Профиль восстановлен из точки.",
|
||||
"deleted": "Точка восстановления удалена.",
|
||||
"restoreTitle": "Восстановить этот профиль?",
|
||||
"restoreDescription": "Данные профиля вернутся к копии от {{when}}. Текущие данные сначала сохранятся как новая точка восстановления.",
|
||||
"kind": {
|
||||
"auto": "Ежедневная",
|
||||
"manual": "Ручная"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1617,7 +1617,10 @@
|
||||
"stats": "Siteler: {{origins}} · Öğeler: {{items}}",
|
||||
"hint": "Çerezleri ve Local Storage'ı tek bir Playwright storageState dosyasında toplar. İçe aktarmak için tarayıcı kapalı olmalıdır.",
|
||||
"exported": "Depolama durumu dışa aktarıldı",
|
||||
"imported": "İçe aktarıldı. Local Storage öğeleri: {{items}} · Siteler: {{origins}} · Çerezler: {{cookies}}"
|
||||
"imported": "İçe aktarıldı. Local Storage öğeleri: {{items}} · Siteler: {{origins}} · Çerezler: {{cookies}}",
|
||||
"includeIndexedDb": "IndexedDB'yi dahil et",
|
||||
"indexedDbHint": "Dışa aktarma profili bir anlığına arka planda açar; hiçbir sayfa yüklenmez.",
|
||||
"importedWithIndexedDb": "İçe aktarıldı. Local Storage öğeleri: {{items}} · Siteler: {{origins}} · Çerezler: {{cookies}} · IndexedDB kayıtları: {{records}}"
|
||||
}
|
||||
},
|
||||
"profileTransfer": {
|
||||
@@ -2653,7 +2656,15 @@
|
||||
"teamMemberNotFound": "Bu üye artık ekipte değil.",
|
||||
"teamGroupAccessDenied": "Bu profilin grubuna erişimin yok.",
|
||||
"teamAccessNotEntitled": "Gruba göre erişim için etkin bir Team veya Enterprise planı gerekir.",
|
||||
"replayRecipeNotFound": "Bu tarif artık yok."
|
||||
"replayRecipeNotFound": "Bu tarif artık yok.",
|
||||
"indexedDbUnavailable": "Tarayıcı IndexedDB'yi okuyamadı veya yazamadı. Profili kapatıp yeniden dene.",
|
||||
"indexedDbTooLarge": "IndexedDB 24 MiB'den büyük, tek bir storage state dosyası için fazla.",
|
||||
"snapshotInvalid": "Geri yükleme noktası isteği geçerli değildi.",
|
||||
"snapshotNothingToKeep": "Bu profilin henüz saklanacak eşitlenmiş verisi yok. Önce eşitle.",
|
||||
"snapshotNotFound": "Bu geri yükleme noktası artık yok.",
|
||||
"restorePointsUnsupported": "Eşitleme sunucusu geri yükleme noktaları için çok eski. Güncelle.",
|
||||
"restorePointFailed": "Geri yükleme noktası başarısız oldu: {{detail}}",
|
||||
"restorePointsNeedSync": "Geri yükleme noktaları için bu profilde eşitleme açık olmalı."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profiller",
|
||||
@@ -4084,5 +4095,21 @@
|
||||
"saved": "Erişim kaydedildi.",
|
||||
"someGroups_one": "{{count}} grup",
|
||||
"someGroups_other": "{{count}} grup"
|
||||
},
|
||||
"restorePoints": {
|
||||
"title": "Geri yükleme noktaları",
|
||||
"hint": "Bu profilin verilerinin eşitleme sunucusundaki kopyaları: bir değişiklikten sonra günde bir tane ve senin oluşturdukların. Her türün en yeni 3 tanesi tutulur.",
|
||||
"create": "Oluştur",
|
||||
"created": "Geri yükleme noktası oluşturuldu.",
|
||||
"empty": "Henüz geri yükleme noktası yok.",
|
||||
"restore": "Geri yükle",
|
||||
"restored": "Geri yükleme noktası geri yüklendi.",
|
||||
"deleted": "Geri yükleme noktası silindi.",
|
||||
"restoreTitle": "Bu profil geri yüklensin mi?",
|
||||
"restoreDescription": "Profilin verileri {{when}} tarihli kopyaya döner. Önce mevcut veriler yeni bir geri yükleme noktası olarak saklanır.",
|
||||
"kind": {
|
||||
"auto": "Günlük",
|
||||
"manual": "Elle"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1617,7 +1617,10 @@
|
||||
"stats": "Trang web: {{origins}} · Mục: {{items}}",
|
||||
"hint": "Một tệp storageState của Playwright gồm cookie và Local Storage. Để nhập, trình duyệt phải đóng.",
|
||||
"exported": "Đã xuất trạng thái lưu trữ",
|
||||
"imported": "Đã nhập. Mục Local Storage: {{items}} · Trang web: {{origins}} · Cookie: {{cookies}}"
|
||||
"imported": "Đã nhập. Mục Local Storage: {{items}} · Trang web: {{origins}} · Cookie: {{cookies}}",
|
||||
"includeIndexedDb": "Bao gồm IndexedDB",
|
||||
"indexedDbHint": "Khi xuất, hồ sơ được mở ngầm trong giây lát; không trang nào được tải.",
|
||||
"importedWithIndexedDb": "Đã nhập. Mục Local Storage: {{items}} · Trang: {{origins}} · Cookie: {{cookies}} · Bản ghi IndexedDB: {{records}}"
|
||||
}
|
||||
},
|
||||
"profileTransfer": {
|
||||
@@ -2653,7 +2656,15 @@
|
||||
"teamMemberNotFound": "Thành viên này không còn trong nhóm.",
|
||||
"teamGroupAccessDenied": "Bạn không có quyền truy cập nhóm của hồ sơ này.",
|
||||
"teamAccessNotEntitled": "Quyền truy cập theo nhóm cần gói Team hoặc Enterprise đang hoạt động.",
|
||||
"replayRecipeNotFound": "Công thức này không còn nữa."
|
||||
"replayRecipeNotFound": "Công thức này không còn nữa.",
|
||||
"indexedDbUnavailable": "Trình duyệt không thể đọc hoặc ghi IndexedDB. Hãy đóng hồ sơ rồi thử lại.",
|
||||
"indexedDbTooLarge": "IndexedDB lớn hơn 24 MiB, quá lớn cho một tệp storage state.",
|
||||
"snapshotInvalid": "Yêu cầu điểm khôi phục không hợp lệ.",
|
||||
"snapshotNothingToKeep": "Hồ sơ này chưa có dữ liệu đồng bộ để giữ. Hãy đồng bộ trước.",
|
||||
"snapshotNotFound": "Điểm khôi phục này không còn nữa.",
|
||||
"restorePointsUnsupported": "Máy chủ đồng bộ quá cũ để dùng điểm khôi phục. Hãy cập nhật.",
|
||||
"restorePointFailed": "Điểm khôi phục thất bại: {{detail}}",
|
||||
"restorePointsNeedSync": "Điểm khôi phục cần bật đồng bộ cho hồ sơ này."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profile",
|
||||
@@ -4084,5 +4095,21 @@
|
||||
"saved": "Đã lưu quyền truy cập.",
|
||||
"someGroups_one": "{{count}} nhóm",
|
||||
"someGroups_other": "{{count}} nhóm"
|
||||
},
|
||||
"restorePoints": {
|
||||
"title": "Điểm khôi phục",
|
||||
"hint": "Các bản sao dữ liệu của hồ sơ này trên máy chủ đồng bộ: mỗi ngày một bản sau khi có thay đổi, và những bản bạn tạo. Giữ 3 bản mới nhất của mỗi loại.",
|
||||
"create": "Tạo",
|
||||
"created": "Đã tạo điểm khôi phục.",
|
||||
"empty": "Chưa có điểm khôi phục nào.",
|
||||
"restore": "Khôi phục",
|
||||
"restored": "Đã khôi phục từ điểm khôi phục.",
|
||||
"deleted": "Đã xóa điểm khôi phục.",
|
||||
"restoreTitle": "Khôi phục hồ sơ này?",
|
||||
"restoreDescription": "Dữ liệu hồ sơ sẽ trở về bản sao lúc {{when}}. Dữ liệu hiện tại được giữ trước thành một điểm khôi phục mới.",
|
||||
"kind": {
|
||||
"auto": "Hằng ngày",
|
||||
"manual": "Thủ công"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1617,7 +1617,10 @@
|
||||
"stats": "网站:{{origins}} · 条目:{{items}}",
|
||||
"hint": "一个包含 Cookie 和本地存储的 Playwright storageState 文件。导入时浏览器必须关闭。",
|
||||
"exported": "已导出存储状态",
|
||||
"imported": "已导入。本地存储条目:{{items}} · 网站:{{origins}} · Cookie:{{cookies}}"
|
||||
"imported": "已导入。本地存储条目:{{items}} · 网站:{{origins}} · Cookie:{{cookies}}",
|
||||
"includeIndexedDb": "包含 IndexedDB",
|
||||
"indexedDbHint": "导出时会在后台短暂打开该配置文件,不会加载任何页面。",
|
||||
"importedWithIndexedDb": "已导入。Local Storage 项:{{items}} · 网站:{{origins}} · Cookie:{{cookies}} · IndexedDB 记录:{{records}}"
|
||||
}
|
||||
},
|
||||
"profileTransfer": {
|
||||
@@ -2653,7 +2656,15 @@
|
||||
"teamMemberNotFound": "此成员已不在团队中。",
|
||||
"teamGroupAccessDenied": "你无权访问此配置文件所在的分组。",
|
||||
"teamAccessNotEntitled": "按分组的权限需要有效的 Team 或 Enterprise 套餐。",
|
||||
"replayRecipeNotFound": "此配方已不存在。"
|
||||
"replayRecipeNotFound": "此配方已不存在。",
|
||||
"indexedDbUnavailable": "浏览器无法读取或写入 IndexedDB。请关闭配置文件后重试。",
|
||||
"indexedDbTooLarge": "IndexedDB 超过 24 MiB,无法放入一个 storage state 文件。",
|
||||
"snapshotInvalid": "还原点请求无效。",
|
||||
"snapshotNothingToKeep": "此配置文件还没有可保留的同步数据。请先同步。",
|
||||
"snapshotNotFound": "此还原点已不存在。",
|
||||
"restorePointsUnsupported": "同步服务器版本太旧,无法使用还原点。请更新它。",
|
||||
"restorePointFailed": "还原点操作失败:{{detail}}",
|
||||
"restorePointsNeedSync": "使用还原点需要为此配置文件开启同步。"
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "配置文件",
|
||||
@@ -4084,5 +4095,21 @@
|
||||
"saved": "权限已保存。",
|
||||
"someGroups_one": "{{count}} 个分组",
|
||||
"someGroups_other": "{{count}} 个分组"
|
||||
},
|
||||
"restorePoints": {
|
||||
"title": "还原点",
|
||||
"hint": "同步服务器上此配置文件数据的副本:有变化的日子自动保留一份,也可以手动创建。每种保留最新的 3 份。",
|
||||
"create": "创建",
|
||||
"created": "已创建还原点。",
|
||||
"empty": "还没有还原点。",
|
||||
"restore": "还原",
|
||||
"restored": "已从还原点还原。",
|
||||
"deleted": "已删除还原点。",
|
||||
"restoreTitle": "要还原此配置文件吗?",
|
||||
"restoreDescription": "配置文件的数据将回到 {{when}} 的副本。当前数据会先保存为新的还原点。",
|
||||
"kind": {
|
||||
"auto": "每日",
|
||||
"manual": "手动"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,6 +136,14 @@ export type BackendErrorCode =
|
||||
| "TEAM_GROUP_ACCESS_DENIED"
|
||||
| "TEAM_ACCESS_NOT_ENTITLED"
|
||||
| "REPLAY_RECIPE_NOT_FOUND"
|
||||
| "INDEXED_DB_UNAVAILABLE"
|
||||
| "INDEXED_DB_TOO_LARGE"
|
||||
| "SNAPSHOT_INVALID"
|
||||
| "SNAPSHOT_NOTHING_TO_KEEP"
|
||||
| "SNAPSHOT_NOT_FOUND"
|
||||
| "RESTORE_POINTS_UNSUPPORTED"
|
||||
| "RESTORE_POINT_FAILED"
|
||||
| "RESTORE_POINTS_NEED_SYNC"
|
||||
| "LOCAL_STORAGE_EPHEMERAL"
|
||||
| "LOCAL_STORAGE_PROFILE_PROTECTED"
|
||||
| "LOCAL_STORAGE_UNREADABLE"
|
||||
@@ -715,6 +723,24 @@ export function translateBackendError(t: TFunction, err: unknown): string {
|
||||
return t("backendErrors.teamAccessNotEntitled");
|
||||
case "REPLAY_RECIPE_NOT_FOUND":
|
||||
return t("backendErrors.replayRecipeNotFound");
|
||||
case "INDEXED_DB_UNAVAILABLE":
|
||||
return t("backendErrors.indexedDbUnavailable");
|
||||
case "INDEXED_DB_TOO_LARGE":
|
||||
return t("backendErrors.indexedDbTooLarge");
|
||||
case "SNAPSHOT_INVALID":
|
||||
return t("backendErrors.snapshotInvalid");
|
||||
case "SNAPSHOT_NOTHING_TO_KEEP":
|
||||
return t("backendErrors.snapshotNothingToKeep");
|
||||
case "SNAPSHOT_NOT_FOUND":
|
||||
return t("backendErrors.snapshotNotFound");
|
||||
case "RESTORE_POINTS_UNSUPPORTED":
|
||||
return t("backendErrors.restorePointsUnsupported");
|
||||
case "RESTORE_POINT_FAILED":
|
||||
return t("backendErrors.restorePointFailed", {
|
||||
detail: parsed.params?.detail ?? "",
|
||||
});
|
||||
case "RESTORE_POINTS_NEED_SYNC":
|
||||
return t("backendErrors.restorePointsNeedSync");
|
||||
case "MCP_TOOLSET_UNKNOWN":
|
||||
return t("backendErrors.mcpToolsetUnknown");
|
||||
case "MCP_AGENT_UNKNOWN":
|
||||
|
||||
Reference in new issue
Block a user