feat: Local Storage transfer

This commit is contained in:
zhom committed 2026-10-12 07:43:51 +04:00
1 parent d594fe4bfd
commit 74c507bd9b
23 files changed
+1552 -38

No files matched your search

+3
View File
@@ -179,6 +179,9 @@ export const commandCoverage = {
"analyze_pasted_cookies",
"import_pasted_cookies",
"export_profile_cookies",
"local_storage::get_profile_local_storage_stats",
"local_storage::export_profile_storage_state",
"local_storage::import_profile_storage_state",
"set_profile_password",
"change_profile_password",
"remove_profile_password",
+113 -1
View File
@@ -705,7 +705,10 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
const inPlace = await agentClick({ role: "button", name: "Clicked" });
assert.equal(inPlace.navigated, false);
assert.ok(inPlace.ms < 5000, `an in-place click took ${inPlace.ms} ms`);
const link = await agentClick({ role: "link", name: "Open the linked page" });
const link = await agentClick({
role: "link",
name: "Open the linked page",
});
assert.equal(link.navigated, true);
assert.equal(
await cdp.evaluate("document.querySelector('#path').textContent"),
@@ -1943,6 +1946,115 @@ test("a batch from a template makes numbered profiles, each with its own device"
}
});
test("Local Storage that Donut writes, Wayfern reads, and what Wayfern writes exports", async () => {
assert.ok(process.env.WAYFERN_TEST_TOKEN, "WAYFERN_TEST_TOKEN is required");
const app = appFromEnvironment("browser-local-storage", {
seedVersionCache:
cachedFixtureVersion(process.env.DONUT_E2E_PROJECT_ROOT) ?? false,
wayfernTermsAccepted: false,
});
let browserPid;
try {
const prepared = await prepareWayfern(
app,
process.env.DONUT_E2E_PROJECT_ROOT,
);
if (!app.session) await app.start();
await app.invoke("accept_wayfern_terms");
const profile = await createRealProfile(
app,
prepared.version,
"Local Storage",
);
const origin = new URL(fixtureUrl).origin;
const imported = await app.invoke("import_profile_storage_state", {
profileId: profile.id,
content: JSON.stringify({
origins: [
{
origin,
localStorage: [
{ name: "imported", value: "from Donut" },
{ name: "unicode", value: "日本語 🍩" },
],
},
],
}),
});
assert.equal(imported.items, 2);
const launched = await app.invoke("launch_browser_profile", {
profile: (await app.invoke("list_browser_profiles")).find(
(p) => p.id === profile.id,
),
url: `${fixtureUrl}/local-storage`,
});
browserPid = launched.process_id;
const port = processCommandLine(browserPid).match(
/--remote-debugging-port=(\d+)/,
)?.[1];
assert.ok(port, "the launch exposes a debugging port");
const cdp = await CdpClient.connect(Number(port));
await cdp.waitFor(`location.pathname === "/local-storage"`, {
description: "the fixture page",
});
assert.equal(
await cdp.evaluate("localStorage.getItem('imported')"),
"from Donut",
);
assert.equal(
await cdp.evaluate("localStorage.getItem('unicode')"),
"日本語 🍩",
);
await cdp.evaluate("localStorage.setItem('written', 'by Wayfern'); true");
// Chromium commits Local Storage to disk a few seconds after a change.
await new Promise((resolve) => setTimeout(resolve, 8_000));
cdp.close?.();
const running = (await app.invoke("list_browser_profiles")).find(
(p) => p.id === profile.id,
);
await app.invoke("kill_browser_profile", { profile: running });
await waitForProcessExit(app, browserPid);
browserPid = undefined;
const exported = JSON.parse(
await app.invoke("export_profile_storage_state", {
profileId: profile.id,
includeCookies: false,
}),
);
const site = exported.origins.find((o) => o.origin === origin);
assert.ok(
site,
`the fixture origin is exported: ${JSON.stringify(exported.origins)}`,
);
const items = Object.fromEntries(
site.localStorage.map((i) => [i.name, i.value]),
);
assert.equal(items.imported, "from Donut");
assert.equal(items.written, "by Wayfern");
await app.invoke("delete_profile", {
profileId: profile.id,
permanent: true,
});
} catch (error) {
await app.capture("failure");
throw error;
} finally {
if (app.session && browserPid && processExists(browserPid)) {
const running = (
await app.invoke("list_browser_profiles").catch(() => [])
).find((item) => item.process_id === browserPid);
if (running)
await app
.invoke("kill_browser_profile", { profile: running })
.catch(() => {});
}
await app.close();
}
});
test("custom launch arguments reach only their Wayfern profile", async () => {
assert.ok(process.env.WAYFERN_TEST_TOKEN, "WAYFERN_TEST_TOKEN is required");
const app = appFromEnvironment("browser-launch-args", {
+149
View File
@@ -1354,6 +1354,155 @@ test("cookie import/copy/export, profile encryption, and traffic-stat read/clear
});
});
test("a storage state file moves cookies and Local Storage into a closed profile and back out", async () => {
await withApp("entities-storage-state", async (app) => {
const profile = await createProfile(app, "Storage State");
assert.deepEqual(
await app.invoke("get_profile_local_storage_stats", {
profileId: profile.id,
}),
{ origins: 0, items: 0 },
"a new profile has no Local Storage yet",
);
const state = {
cookies: [
{
name: "sid",
value: "storage-state-cookie",
domain: ".shop.example",
path: "/",
expires: 2_000_000_000,
httpOnly: true,
secure: true,
sameSite: "Lax",
},
],
origins: [
{
origin: "https://shop.example",
localStorage: [
{ name: "cart", value: "3 items" },
{ name: "lang", value: "日本語" },
],
},
// The same site spelled with its default port and a path.
{
origin: "https://SHOP.example:443/checkout",
localStorage: [{ name: "step", value: "2" }],
},
{
origin: "https://mail.example",
localStorage: [{ name: "token", value: "t" }],
},
],
};
const imported = await app.invoke("import_profile_storage_state", {
profileId: profile.id,
content: JSON.stringify(state),
});
assert.equal(imported.origins, 2);
assert.equal(imported.items, 4);
assert.equal(imported.cookies_imported, 1);
assert.deepEqual(
await app.invoke("get_profile_local_storage_stats", {
profileId: profile.id,
}),
{ origins: 2, items: 4 },
);
const exported = JSON.parse(
await app.invoke("export_profile_storage_state", {
profileId: profile.id,
includeCookies: true,
}),
);
const shop = exported.origins.find(
(o) => o.origin === "https://shop.example",
);
assert.deepEqual(
Object.fromEntries(shop.localStorage.map((i) => [i.name, i.value])),
{ cart: "3 items", lang: "日本語", step: "2" },
);
const cookie = exported.cookies.find((c) => c.name === "sid");
assert.equal(cookie.value, "storage-state-cookie");
assert.equal(cookie.sameSite, "Lax");
assert.equal(cookie.httpOnly, true);
const withoutCookies = JSON.parse(
await app.invoke("export_profile_storage_state", {
profileId: profile.id,
includeCookies: false,
}),
);
assert.deepEqual(withoutCookies.cookies, []);
// A second import replaces an item by name and keeps the others.
await app.invoke("import_profile_storage_state", {
profileId: profile.id,
content: JSON.stringify({
origins: [
{
origin: "https://shop.example",
localStorage: [{ name: "cart", value: "empty" }],
},
],
}),
});
const again = JSON.parse(
await app.invoke("export_profile_storage_state", {
profileId: profile.id,
includeCookies: false,
}),
);
const shopAgain = again.origins.find(
(o) => o.origin === "https://shop.example",
);
assert.equal(
shopAgain.localStorage.find((i) => i.name === "cart").value,
"empty",
);
assert.equal(shopAgain.localStorage.length, 3);
for (const [content, code] of [
["not json", /STORAGE_STATE_INVALID/],
[
JSON.stringify({
origins: [{ origin: "file:///etc", localStorage: [] }],
}),
/LOCAL_STORAGE_ORIGIN_INVALID/,
],
]) {
assert.match(
await app.invokeError("import_profile_storage_state", {
profileId: profile.id,
content,
}),
code,
);
}
const ephemeral = await app.invoke("create_browser_profile_new", {
name: "Storage Ephemeral",
browserStr: "wayfern",
version: "150.0.7871.100",
releaseType: "stable",
proxyId: null,
vpnId: null,
wayfernConfig: { fingerprint: "{}" },
groupId: null,
ephemeral: true,
dnsBlocklist: null,
launchHook: null,
});
assert.match(
await app.invokeError("get_profile_local_storage_stats", {
profileId: ephemeral.id,
}),
/LOCAL_STORAGE_EPHEMERAL/,
);
});
});
test("2FA keys stay sealed on this device, hand out codes, and go with their profile", async () => {
await withApp("entities-two-factor", async (app) => {
const secret = "JBSWY3DPEHPK3PXP";
+108 -19
View File
@@ -182,7 +182,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -193,7 +193,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -1300,6 +1300,15 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
[[package]]
name = "crc32c"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a47af21622d091a8f0fb295b88bc886ac74efcc613efc19f5d0b21de5c89e47"
dependencies = [
"rustc_version",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -1649,7 +1658,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -1773,6 +1782,7 @@ dependencies = [
"resvg",
"ring",
"rusqlite",
"rusty-leveldb",
"secret-service",
"security-framework",
"serde",
@@ -2027,6 +2037,17 @@ dependencies = [
"typeid",
]
[[package]]
name = "errno"
version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f639046355ee4f37944e44f60642c6f3a7efa3cf6b78c78a0d989a8ce6c396a1"
dependencies = [
"errno-dragonfly",
"libc",
"winapi",
]
[[package]]
name = "errno"
version = "0.3.14"
@@ -2034,7 +2055,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
name = "errno-dragonfly"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf"
dependencies = [
"cc",
"libc",
]
[[package]]
@@ -2286,6 +2317,16 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "futures"
version = "0.3.34"
@@ -3291,6 +3332,12 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "integer-encoding"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bb03732005da905c88227371639bf1ad885cc712789c011c31c5fb3ab3ccf02"
[[package]]
name = "interpolate_name"
version = "0.2.4"
@@ -3914,7 +3961,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.21",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -4496,7 +4543,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.45.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5045,13 +5092,24 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
dependencies = [
"libc",
"rand_chacha 0.3.1",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha",
"rand_chacha 0.9.0",
"rand_core 0.9.5",
]
@@ -5066,6 +5124,16 @@ dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
@@ -5128,7 +5196,7 @@ dependencies = [
"paste",
"profiling",
"rand 0.9.5",
"rand_chacha",
"rand_chacha 0.9.0",
"simd_helpers",
"thiserror 2.0.21",
"v_frame",
@@ -5468,10 +5536,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
dependencies = [
"bitflags 2.13.2",
"errno",
"errno 0.3.14",
"libc",
"linux-raw-sys",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -5513,6 +5581,21 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "rusty-leveldb"
version = "4.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43ddda8f34100fca4bf1b0bbf743d12d27b5d84c35c74a52f392aac26f54e82d"
dependencies = [
"bytes",
"crc32c",
"errno 0.2.8",
"fs2",
"integer-encoding",
"rand 0.8.8",
"snap",
]
[[package]]
name = "ryu"
version = "1.0.23"
@@ -6043,7 +6126,7 @@ version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"errno 0.3.14",
"libc",
]
@@ -6134,6 +6217,12 @@ dependencies = [
"managed",
]
[[package]]
name = "snap"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "199905e6153d6405f9728fe44daace35f8f837bbf830bb6e85fbd5828709a886"
[[package]]
name = "socket2"
version = "0.6.5"
@@ -6141,7 +6230,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -6898,10 +6987,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -7387,7 +7476,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.21",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -7466,7 +7555,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -8017,7 +8106,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -8038,7 +8127,7 @@ dependencies = [
"objc2-foundation",
"objc2-quartz-core",
"raw-window-handle",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
"windows-version",
]
@@ -8419,7 +8508,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
dependencies = [
"cfg-if",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
+1
View File
@@ -132,6 +132,7 @@ crossbeam-channel = "0.5"
sys-locale = "0.3"
jsonc-parser = { version = "0.34", features = ["cst", "serde_json"] }
toml_edit = "0.25"
rusty-leveldb = "4.0.1"
[target.'cfg(unix)'.dependencies]
nix = { version = "0.31", features = ["signal", "process"] }
+116 -1
View File
@@ -274,6 +274,24 @@ impl From<crate::profile_statuses::ProfileStatus> for ApiStatusResponse {
}
}
/// Playwright's `storageState`: cookies and each origin's Local Storage.
#[derive(Debug, Serialize, Deserialize, ToSchema)]
struct ApiStorageState {
/// Playwright cookies (`name`, `value`, `domain`, `path`, `expires`,
/// `httpOnly`, `secure`, `sameSite`).
#[serde(default)]
#[schema(value_type = Vec<Object>)]
cookies: Vec<serde_json::Value>,
#[serde(default)]
origins: Vec<crate::local_storage::OriginStorage>,
}
#[derive(Debug, Deserialize)]
struct StorageStateQuery {
#[serde(default)]
include_cookies: bool,
}
#[derive(Debug, Deserialize, ToSchema)]
struct TemplateRequest {
/// 1 to 60 characters, unique (case-insensitive).
@@ -762,6 +780,8 @@ struct ImportProxiesResponse {
detect_import_profiles,
import_profiles_api,
import_profile_cookies,
export_storage_state,
import_storage_state,
get_groups,
get_group,
create_group,
@@ -884,6 +904,10 @@ struct ImportProxiesResponse {
crate::proxy_distribution::ProxyAssignmentResult,
OpenUrlRequest,
ImportCookiesRequest,
ApiStorageState,
crate::local_storage::OriginStorage,
crate::local_storage::StorageItem,
crate::local_storage::StorageStateImport,
ImportCookiesResponse,
CreateExtensionRequest,
UpdateExtensionRequest,
@@ -1120,6 +1144,7 @@ fn build_v1_router() -> Router<ApiServerState> {
.routes(routes!(detect_import_profiles))
.routes(routes!(import_profiles_api))
.routes(routes!(import_profile_cookies))
.routes(routes!(export_storage_state, import_storage_state))
.routes(routes!(get_groups, create_group))
.routes(routes!(get_group, update_group, delete_group))
.routes(routes!(get_statuses, create_status))
@@ -1440,7 +1465,12 @@ fn manager_error_response(err: impl std::fmt::Display) -> (StatusCode, String) {
if let Some(code) = value.get("code").and_then(|c| c.as_str()) {
let status = if code.ends_with("_NOT_FOUND") {
StatusCode::NOT_FOUND
} else if LAUNCH_CONFLICT_CODES.contains(&code) || code == "BULK_CREATE_BUSY" {
} else if LAUNCH_CONFLICT_CODES.contains(&code)
|| code == "BULK_CREATE_BUSY"
|| code == "LOCAL_STORAGE_BROWSER_RUNNING"
|| code == "LOCAL_STORAGE_REMOTE_SESSION"
|| code == "LOCAL_STORAGE_PROFILE_PROTECTED"
{
// Someone or something else holds this profile: another team member, a
// browser already open, or a remote session whose work has not been
// pulled back yet. All of them are "try again later", not "your request
@@ -4953,6 +4983,74 @@ async fn import_profiles_api(
.map_err(manager_error_response)
}
#[utoipa::path(
get,
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")
),
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 = 401, description = "Unauthorized"),
(status = 404, description = "Profile not found"),
(status = 409, description = "The profile is password-protected"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "cookies"
)]
async fn export_storage_state(
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)
})
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.map_err(manager_error_response)?;
serde_json::from_str(&text)
.map(Json)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
}
#[utoipa::path(
post,
path = "/v1/profiles/{id}/storage-state",
params(
("id" = String, Path, description = "Profile ID")
),
request_body = ApiStorageState,
responses(
(status = 200, description = "Imported. An item replaces the one of the same name; the origin's other items stay", body = crate::local_storage::StorageStateImport),
(status = 400, description = "Not a storageState, an origin that is not http(s), more than 10 MiB for one origin, or an ephemeral profile"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Profile not found"),
(status = 409, description = "The browser is running, the profile is password-protected, or a remote session owns it"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "cookies"
)]
async fn import_storage_state(
State(state): State<ApiServerState>,
Path(id): Path<String>,
Json(body): Json<ApiStorageState>,
) -> Result<Json<crate::local_storage::StorageStateImport>, (StatusCode, String)> {
let content =
serde_json::to_string(&body).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
crate::local_storage::import_storage_state(&state.app_handle, &id, &content)
.await
.map(Json)
.map_err(manager_error_response)
}
#[utoipa::path(
post,
path = "/v1/profiles/{id}/cookies/import",
@@ -6195,6 +6293,23 @@ mod tests {
assert!(spec["paths"]["/v1/templates/{id}/profiles"]["post"]["responses"]["429"].is_object());
}
#[test]
fn openapi_documents_the_storage_state() {
let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes");
let path = &spec["paths"]["/v1/profiles/{id}/storage-state"];
assert!(path["get"].is_object() && path["post"].is_object());
assert!(path["post"]["responses"]["409"].is_object());
let origin = &spec["components"]["schemas"]["OriginStorage"];
assert!(
origin["properties"]["localStorage"].is_object(),
"Playwright's spelling"
);
assert!(
schema_required(&spec, "ApiStorageState").is_empty(),
"cookies and origins may each be left out"
);
}
#[test]
fn openapi_documents_the_profile_dns_server() {
let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes");
+4
View File
@@ -148,6 +148,7 @@ mod cookie_bot;
mod cookie_manager;
mod cookie_paste;
pub mod events;
mod local_storage;
mod mcp_integrations;
mod mcp_remote;
mod mcp_server;
@@ -3539,6 +3540,9 @@ pub fn run_with_builder(
add_mcp_to_agent,
remove_mcp_from_agent,
set_mcp_toolsets,
local_storage::get_profile_local_storage_stats,
local_storage::export_profile_storage_state,
local_storage::import_profile_storage_state,
start_mcp_remote_bridge,
stop_mcp_remote_bridge,
get_mcp_remote_status,
+627
View File
@@ -0,0 +1,627 @@
//! A profile's Local Storage, read and written in the shape of Playwright's
//! `storageState`, so a signed-in session moves between Donut and automation
//! tools: `{ "cookies": [...], "origins": [{ "origin", "localStorage": [...] }] }`.
//!
//! Chromium keeps Local Storage in a LevelDB under
//! `Default/Local Storage/leveldb`:
//!
//! - `VERSION` is `1`;
//! - `META:<origin>` holds when the origin's data last changed and its size,
//! `METAACCESS:<origin>` when it was last read (small protobufs);
//! - `_<origin>\0<key>` holds a value. A key and a value each start with a
//! format byte: `0` for UTF-16LE, `1` for Latin-1.
//!
//! A read opens a copy of the folder, so it never changes the profile. A write
//! needs the browser closed: a running browser holds the database and would
//! lose the write or corrupt the files.
use crate::profile::{BrowserProfile, ProfileManager};
use rusty_leveldb::{CompressorId, LdbIterator, Options, WriteBatch, DB};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
/// The most an import may carry, cookies included.
pub const MAX_IMPORT_BYTES: usize = 32 * 1024 * 1024;
/// Chromium's own limit per origin: five million UTF-16 code units.
const MAX_ORIGIN_BYTES: u64 = 10 * 1024 * 1024;
const MAX_ORIGINS: usize = 10_000;
/// Seconds from 1601-01-01 (Chromium's time origin) to the Unix epoch.
const CHROMIUM_EPOCH_OFFSET_SECS: i64 = 11_644_473_600;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
pub struct StorageItem {
pub name: String,
pub value: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
pub struct OriginStorage {
/// `https://example.com`, the way a page's `origin` reads.
pub origin: String,
#[serde(rename = "localStorage", default)]
pub local_storage: Vec<StorageItem>,
}
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
pub struct LocalStorageStats {
pub origins: usize,
pub items: usize,
}
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
pub struct StorageStateImport {
pub origins: usize,
pub items: usize,
pub cookies_imported: usize,
pub cookies_replaced: usize,
/// Codes of the cookies that could not be imported.
pub cookie_errors: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct StorageState {
#[serde(default)]
origins: Vec<OriginStorage>,
#[serde(default)]
cookies: Vec<serde_json::Value>,
}
fn code(code: &str) -> String {
crate::backend_error(code)
}
fn database_dir(profile: &BrowserProfile) -> PathBuf {
profile
.get_profile_data_path(&ProfileManager::instance().get_profiles_dir())
.join("Default")
.join("Local Storage")
.join("leveldb")
}
/// The profile, when its Local Storage can be read at all: an ephemeral
/// profile keeps none on disk, and a password-protected one keeps it sealed.
fn readable_profile(profile_id: &str) -> Result<BrowserProfile, String> {
let profile = ProfileManager::instance()
.list_profiles()
.map_err(|e| crate::wrap_backend_error(e, "Failed to list profiles"))?
.into_iter()
.find(|profile| profile.id.to_string() == profile_id)
.ok_or_else(|| code("PROFILE_NOT_FOUND"))?;
if profile.ephemeral {
return Err(code("LOCAL_STORAGE_EPHEMERAL"));
}
if profile.password_protected {
return Err(code("LOCAL_STORAGE_PROFILE_PROTECTED"));
}
Ok(profile)
}
// --- The database format ----------------------------------------------------
fn decode_string(bytes: &[u8]) -> Option<String> {
let (format, rest) = bytes.split_first()?;
match format {
0 => {
if rest.len() % 2 != 0 {
return None;
}
let units: Vec<u16> = rest
.chunks_exact(2)
.map(|pair| u16::from_le_bytes([pair[0], pair[1]]))
.collect();
String::from_utf16(&units).ok()
}
1 => Some(rest.iter().map(|&b| char::from(b)).collect()),
_ => None,
}
}
/// Latin-1 when every character fits, as Chromium writes it; else UTF-16LE.
fn encode_string(text: &str) -> Vec<u8> {
if text.chars().all(|c| (c as u32) <= 0xFF) {
let mut out = Vec::with_capacity(text.len() + 1);
out.push(1);
out.extend(text.chars().map(|c| c as u8));
out
} else {
let mut out = Vec::with_capacity(text.len() * 2 + 1);
out.push(0);
for unit in text.encode_utf16() {
out.extend_from_slice(&unit.to_le_bytes());
}
out
}
}
fn data_prefix(origin: &str) -> Vec<u8> {
let mut key = Vec::with_capacity(origin.len() + 2);
key.push(b'_');
key.extend_from_slice(origin.as_bytes());
key.push(0);
key
}
fn data_key(origin: &str, name: &str) -> Vec<u8> {
let mut key = data_prefix(origin);
key.extend(encode_string(name));
key
}
/// `(origin, name)` from a data key, or `None` for any other entry.
fn parse_data_key(key: &[u8]) -> Option<(String, String)> {
let rest = key.strip_prefix(b"_")?;
let split = rest.iter().position(|&b| b == 0)?;
let origin = std::str::from_utf8(&rest[..split]).ok()?.to_string();
let name = decode_string(&rest[split + 1..])?;
Some((origin, name))
}
fn varint(mut value: u64, out: &mut Vec<u8>) {
while value >= 0x80 {
out.push((value as u8) | 0x80);
value >>= 7;
}
out.push(value as u8);
}
/// Microseconds since 1601-01-01, Chromium's `Time` internal value.
fn chromium_now() -> i64 {
let unix_micros = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_micros() as i64);
unix_micros + CHROMIUM_EPOCH_OFFSET_SECS * 1_000_000
}
/// `LocalStorageAreaWriteMetaData { last_modified = 1; size_bytes = 2; }`.
fn write_meta(last_modified: i64, size_bytes: u64) -> Vec<u8> {
let mut out = vec![0x08];
varint(last_modified as u64, &mut out);
out.push(0x10);
varint(size_bytes, &mut out);
out
}
/// `LocalStorageAreaAccessMetaData { last_accessed = 1; }`.
fn access_meta(last_accessed: i64) -> Vec<u8> {
let mut out = vec![0x08];
varint(last_accessed as u64, &mut out);
out
}
/// What an item weighs against the per-origin limit: UTF-16 code units, two
/// bytes each, the way Chromium counts.
fn item_bytes(name: &str, value: &str) -> u64 {
((name.encode_utf16().count() + value.encode_utf16().count()) * 2) as u64
}
fn open(dir: &Path) -> Result<DB, String> {
let options = Options {
compressor: rusty_leveldb::compressor::SnappyCompressor::ID,
..Options::default()
};
DB::open(dir, options).map_err(|e| e.to_string())
}
fn read_database(dir: &Path) -> Result<Vec<OriginStorage>, String> {
let mut db = open(dir)?;
let mut iter = db.new_iter().map_err(|e| e.to_string())?;
let mut origins: Vec<OriginStorage> = Vec::new();
iter.seek(b"_");
while iter.valid() {
let Some((key, value)) = iter.current() else {
break;
};
if !key.starts_with(b"_") {
break;
}
if let (Some((origin, name)), Some(value)) = (parse_data_key(&key), decode_string(&value)) {
match origins.last_mut() {
Some(last) if last.origin == origin => last.local_storage.push(StorageItem { name, value }),
_ => origins.push(OriginStorage {
origin,
local_storage: vec![StorageItem { name, value }],
}),
}
}
if !iter.advance() {
break;
}
}
drop(iter);
let _ = db.close();
Ok(origins)
}
/// Write `origins` into the database at `dir`, creating it when the profile
/// has none yet. An item replaces the one of the same name; the origin's
/// other items stay.
fn write_database(dir: &Path, origins: &[OriginStorage]) -> Result<(), String> {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
let mut db = open(dir)?;
let now = chromium_now();
if db.get(b"VERSION").is_none() {
db.put(b"VERSION", b"1").map_err(|e| e.to_string())?;
}
for origin in origins {
let mut batch = WriteBatch::default();
for item in &origin.local_storage {
batch.put(
&data_key(&origin.origin, &item.name),
&encode_string(&item.value),
);
}
db.write(batch, false).map_err(|e| e.to_string())?;
let prefix = data_prefix(&origin.origin);
let mut size = 0u64;
let mut iter = db.new_iter().map_err(|e| e.to_string())?;
iter.seek(&prefix);
while iter.valid() {
let Some((key, value)) = iter.current() else {
break;
};
if !key.starts_with(&prefix) {
break;
}
if let (Some((_, name)), Some(value)) = (parse_data_key(&key), decode_string(&value)) {
size += item_bytes(&name, &value);
}
if !iter.advance() {
break;
}
}
drop(iter);
if size > MAX_ORIGIN_BYTES {
return Err(
serde_json::json!({
"code": "LOCAL_STORAGE_ORIGIN_TOO_LARGE",
"params": { "origin": origin.origin }
})
.to_string(),
);
}
let meta_key = format!("META:{}", origin.origin);
let access_key = format!("METAACCESS:{}", origin.origin);
db.put(meta_key.as_bytes(), &write_meta(now, size))
.map_err(|e| e.to_string())?;
db.put(access_key.as_bytes(), &access_meta(now))
.map_err(|e| e.to_string())?;
}
db.flush().map_err(|e| e.to_string())?;
db.close().map_err(|e| e.to_string())
}
/// A copy of the folder to read from, without the browser's lock file.
fn copy_database(from: &Path) -> Result<Option<tempfile::TempDir>, String> {
if !from.exists() {
return Ok(None);
}
let copy = tempfile::tempdir().map_err(|e| e.to_string())?;
for entry in std::fs::read_dir(from).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
let name = entry.file_name();
if name == "LOCK" || !entry.path().is_file() {
continue;
}
std::fs::copy(entry.path(), copy.path().join(&name)).map_err(|e| e.to_string())?;
}
Ok(Some(copy))
}
/// `https://example.com`, from whatever spelling of an origin was imported.
fn normalized_origin(raw: &str) -> Result<String, String> {
let invalid = || {
serde_json::json!({
"code": "LOCAL_STORAGE_ORIGIN_INVALID",
"params": { "origin": raw.chars().take(200).collect::<String>() }
})
.to_string()
};
let url = url::Url::parse(raw.trim()).map_err(|_| invalid())?;
if !matches!(url.scheme(), "http" | "https") {
return Err(invalid());
}
let origin = url.origin();
if !origin.is_tuple() {
return Err(invalid());
}
Ok(origin.ascii_serialization())
}
// --- What the app and the API call -----------------------------------------
pub fn read(profile_id: &str) -> Result<Vec<OriginStorage>, String> {
let profile = readable_profile(profile_id)?;
let Some(copy) = copy_database(&database_dir(&profile))? else {
return Ok(Vec::new());
};
read_database(copy.path())
.map_err(|e| crate::backend_error_with_detail("LOCAL_STORAGE_UNREADABLE", e))
}
pub fn stats(profile_id: &str) -> Result<LocalStorageStats, String> {
let origins = read(profile_id)?;
Ok(LocalStorageStats {
origins: origins.len(),
items: origins.iter().map(|o| o.local_storage.len()).sum(),
})
}
/// Playwright's cookie shape from a stored cookie.
fn playwright_cookie(cookie: &crate::cookie_manager::UnifiedCookie) -> serde_json::Value {
let same_site = match cookie.same_site {
0 => "None",
2 => "Strict",
// Unspecified behaves as Lax in Chromium, so Lax is what it travels as.
_ => "Lax",
};
serde_json::json!({
"name": cookie.name,
"value": cookie.value,
"domain": cookie.domain,
"path": cookie.path,
"expires": if cookie.expires == 0 { -1 } else { cookie.expires },
"httpOnly": cookie.is_http_only,
"secure": cookie.is_secure,
"sameSite": same_site,
})
}
pub fn export_storage_state(profile_id: &str, include_cookies: bool) -> Result<String, String> {
let origins = read(profile_id)?;
let cookies: Vec<serde_json::Value> = if include_cookies {
crate::cookie_manager::CookieManager::read_cookies(profile_id)
.map_err(|e| crate::backend_error_with_detail("COOKIE_DB_UNAVAILABLE", e))?
.domains
.iter()
.flat_map(|domain| domain.cookies.iter().map(playwright_cookie))
.collect()
} else {
Vec::new()
};
let count: usize = origins.iter().map(|o| o.local_storage.len()).sum();
log::info!(
"Storage state exported profile={profile_id} origins={} items={count} cookies={}",
origins.len(),
cookies.len()
);
serde_json::to_string_pretty(&serde_json::json!({ "cookies": cookies, "origins": origins }))
.map_err(|e| e.to_string())
}
/// Check and tidy what an import carries, before anything is written.
fn checked_origins(origins: Vec<OriginStorage>) -> Result<Vec<OriginStorage>, String> {
if origins.len() > MAX_ORIGINS {
return Err(code("LOCAL_STORAGE_TOO_MANY_ORIGINS"));
}
let mut merged: Vec<OriginStorage> = Vec::new();
for origin in origins {
let name = normalized_origin(&origin.origin)?;
let size: u64 = origin
.local_storage
.iter()
.map(|item| item_bytes(&item.name, &item.value))
.sum();
if size > MAX_ORIGIN_BYTES {
return Err(
serde_json::json!({
"code": "LOCAL_STORAGE_ORIGIN_TOO_LARGE",
"params": { "origin": name }
})
.to_string(),
);
}
match merged.iter_mut().find(|o| o.origin == name) {
Some(existing) => existing.local_storage.extend(origin.local_storage),
None => merged.push(OriginStorage {
origin: name,
local_storage: origin.local_storage,
}),
}
}
Ok(merged)
}
pub async fn import_storage_state(
app_handle: &tauri::AppHandle,
profile_id: &str,
content: &str,
) -> Result<StorageStateImport, String> {
if content.len() > MAX_IMPORT_BYTES {
return Err(code("LOCAL_STORAGE_IMPORT_TOO_LARGE"));
}
let state: StorageState =
serde_json::from_str(content).map_err(|_| code("STORAGE_STATE_INVALID"))?;
let profile = readable_profile(profile_id)?;
let running = ProfileManager::instance()
.check_browser_status(app_handle.clone(), &profile)
.await
.unwrap_or(false);
if running {
return Err(code("LOCAL_STORAGE_BROWSER_RUNNING"));
}
if crate::remote_handoff::state_for(profile_id).is_some() {
return Err(code("LOCAL_STORAGE_REMOTE_SESSION"));
}
let origins = checked_origins(state.origins)?;
let items: usize = origins.iter().map(|o| o.local_storage.len()).sum();
let (mut cookies_imported, mut cookies_replaced, mut cookie_errors) = (0, 0, Vec::new());
if !state.cookies.is_empty() {
let cookies = serde_json::json!({ "cookies": state.cookies }).to_string();
let result =
crate::cookie_manager::CookieManager::import_cookies(app_handle, profile_id, &cookies)
.await?;
cookies_imported = result.cookies_imported;
cookies_replaced = result.cookies_replaced;
cookie_errors = result.errors;
}
if !origins.is_empty() {
let dir = database_dir(&profile);
let to_write = origins.clone();
tauri::async_runtime::spawn_blocking(move || write_database(&dir, &to_write))
.await
.map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?
.map_err(|e| {
if e.starts_with('{') {
e
} else {
crate::backend_error_with_detail("LOCAL_STORAGE_UNWRITABLE", e)
}
})?;
}
log::info!(
"Storage state imported profile={profile_id} origins={} items={items} cookies={cookies_imported}",
origins.len()
);
Ok(StorageStateImport {
origins: origins.len(),
items,
cookies_imported,
cookies_replaced,
cookie_errors,
})
}
#[tauri::command]
pub async fn get_profile_local_storage_stats(
profile_id: String,
) -> Result<LocalStorageStats, String> {
tauri::async_runtime::spawn_blocking(move || stats(&profile_id))
.await
.map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?
}
#[tauri::command]
pub async fn export_profile_storage_state(
profile_id: String,
include_cookies: bool,
) -> Result<String, String> {
tauri::async_runtime::spawn_blocking(move || export_storage_state(&profile_id, include_cookies))
.await
.map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?
}
#[tauri::command]
pub async fn import_profile_storage_state(
app_handle: tauri::AppHandle,
profile_id: String,
content: String,
) -> Result<StorageStateImport, String> {
import_storage_state(&app_handle, &profile_id, &content).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strings_round_trip_in_both_formats() {
for text in ["plain", "", "ümlaut ÿ", "日本語 🍩"] {
let encoded = encode_string(text);
assert_eq!(decode_string(&encoded).as_deref(), Some(text));
}
assert_eq!(encode_string("ab")[0], 1, "Latin-1 when it fits");
assert_eq!(encode_string("日")[0], 0, "UTF-16 when it does not");
assert_eq!(decode_string(&[0, 1]), None, "an odd UTF-16 length");
assert_eq!(decode_string(&[9, 1]), None);
}
#[test]
fn a_data_key_names_its_origin_and_item() {
let key = data_key("https://example.com", "token");
assert!(key.starts_with(b"_https://example.com\0\x01"));
assert_eq!(
parse_data_key(&key),
Some(("https://example.com".to_string(), "token".to_string()))
);
assert_eq!(parse_data_key(b"META:https://example.com"), None);
}
#[test]
fn meta_is_the_protobuf_chromium_reads() {
assert_eq!(write_meta(1, 300), vec![0x08, 0x01, 0x10, 0xAC, 0x02]);
assert_eq!(access_meta(150), vec![0x08, 0x96, 0x01]);
}
#[test]
fn a_written_database_reads_back_and_keeps_other_items() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("leveldb");
let item = |name: &str, value: &str| StorageItem {
name: name.to_string(),
value: value.to_string(),
};
write_database(
&path,
&[OriginStorage {
origin: "https://shop.example".to_string(),
local_storage: vec![item("cart", "3 items"), item("lang", "日本語")],
}],
)
.unwrap();
write_database(
&path,
&[
OriginStorage {
origin: "https://shop.example".to_string(),
local_storage: vec![item("cart", "empty")],
},
OriginStorage {
origin: "https://mail.example".to_string(),
local_storage: vec![item("session", "abc")],
},
],
)
.unwrap();
let copy = copy_database(&path).unwrap().unwrap();
let read = read_database(copy.path()).unwrap();
assert_eq!(read.len(), 2);
let shop = read
.iter()
.find(|o| o.origin == "https://shop.example")
.unwrap();
assert_eq!(
shop.local_storage,
vec![item("cart", "empty"), item("lang", "日本語")]
);
let mut db = open(&path).unwrap();
assert_eq!(db.get(b"VERSION").as_deref(), Some(&b"1"[..]));
assert!(db.get(b"META:https://mail.example").is_some());
}
#[test]
fn imported_origins_are_checked_and_spelled_as_a_page_reads_them() {
let origin = |name: &str| OriginStorage {
origin: name.to_string(),
local_storage: vec![StorageItem {
name: "k".to_string(),
value: "v".to_string(),
}],
};
let tidy = checked_origins(vec![
origin("https://Example.com:443/path"),
origin("https://example.com"),
])
.unwrap();
assert_eq!(tidy.len(), 1);
assert_eq!(tidy[0].origin, "https://example.com");
assert_eq!(tidy[0].local_storage.len(), 2);
for bad in ["file:///etc", "javascript:alert(1)", "not a url"] {
assert!(checked_origins(vec![origin(bad)])
.unwrap_err()
.contains("LOCAL_STORAGE_ORIGIN_INVALID"));
}
let huge = OriginStorage {
origin: "https://big.example".to_string(),
local_storage: vec![StorageItem {
name: "k".to_string(),
value: "x".repeat(6_000_000),
}],
};
assert!(checked_origins(vec![huge])
.unwrap_err()
.contains("LOCAL_STORAGE_ORIGIN_TOO_LARGE"));
}
}
+21 -4
View File
@@ -3209,6 +3209,7 @@ mod tests {
proxy_path: &std::path::Path,
) -> Result<(std::process::ExitStatus, String, Duration), Box<dyn std::error::Error>> {
let stdout = tempfile::NamedTempFile::new()?;
let stderr = tempfile::NamedTempFile::new()?;
let mut cmd = Command::new(proxy_path);
cmd
.args([
@@ -3221,13 +3222,29 @@ mod tests {
"--type",
"http",
])
.env("RUST_LOG", "debug")
.stdin(Stdio::null())
.stdout(Stdio::from(stdout.reopen()?))
.stderr(Stdio::null());
.stderr(Stdio::from(stderr.reopen()?))
.kill_on_drop(true);
let started = std::time::Instant::now();
let status = tokio::time::timeout(CLI_EXIT_LIMIT, cmd.status())
.await
.map_err(|_| "CLI command timed out - this indicates improper detachment")??;
let mut child = cmd.spawn()?;
let status = match tokio::time::timeout(CLI_EXIT_LIMIT, child.wait()).await {
Ok(status) => status?,
Err(_) => {
let _ = child.kill().await;
let log = std::fs::read_to_string(stderr.path()).unwrap_or_default();
let tail: Vec<&str> = log.lines().rev().take(25).collect();
let tail: Vec<&str> = tail.into_iter().rev().collect();
return Err(
format!(
"CLI command timed out - this indicates improper detachment. Its log:\n{}",
tail.join("\n")
)
.into(),
);
}
};
Ok((
status,
std::fs::read_to_string(stdout.path())?,
+144 -2
View File
@@ -2,8 +2,8 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { save } from "@tauri-apps/plugin-dialog";
import { writeTextFile } from "@tauri-apps/plugin-fs";
import { open as openDialog, save } from "@tauri-apps/plugin-dialog";
import { readTextFile, writeTextFile } from "@tauri-apps/plugin-fs";
import { hideOthers } from "aria-hidden";
import Color from "color";
import { motion, useReducedMotion } from "motion/react";
@@ -2403,6 +2403,13 @@ function CookiesSectionInline({
: "—"}
</p>
</div>
{profile.ephemeral !== true && !profile.password_protected && (
<StorageStateBlock
profile={profile}
isDisabled={isDisabled}
t={t}
/>
)}
{domains.length > 0 && (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-muted/40">
<p className="shrink-0 border-b border-border px-3 py-2 text-[10px] tracking-wide text-muted-foreground uppercase">
@@ -2432,6 +2439,141 @@ function CookiesSectionInline({
);
}
/**
* The profile's Local Storage with its cookies, as one Playwright
* `storageState` file: export it to an automation tool, or import one.
*/
function StorageStateBlock({
profile,
isDisabled,
t,
}: {
profile: BrowserProfile;
isDisabled: boolean;
t: (key: string, options?: Record<string, unknown>) => string;
}) {
const [stats, setStats] = React.useState<{
origins: number;
items: number;
} | null>(null);
const [busy, setBusy] = React.useState(false);
const loadStats = React.useCallback(async () => {
try {
setStats(
await invoke<{ origins: number; items: number }>(
"get_profile_local_storage_stats",
{ profileId: profile.id },
),
);
} catch {
setStats(null);
}
}, [profile.id]);
React.useEffect(() => {
void loadStats();
}, [loadStats]);
const handleExport = async () => {
setBusy(true);
try {
const content = await invoke<string>("export_profile_storage_state", {
profileId: profile.id,
includeCookies: true,
});
const filePath = await save({
defaultPath: `${profile.name}_storage_state.json`,
filters: [{ name: "JSON", extensions: ["json"] }],
});
if (!filePath) return;
await writeTextFile(filePath, content);
showSuccessToast(t("profileInfo.localStorage.exported"));
} catch (e) {
showErrorToast(translateBackendError(t as never, e));
} finally {
setBusy(false);
}
};
const handleImport = async () => {
setBusy(true);
try {
const selected = await openDialog({
multiple: false,
filters: [{ name: "JSON", extensions: ["json"] }],
});
if (!selected || typeof selected !== "string") return;
const content = await readTextFile(selected);
const result = await invoke<{
origins: number;
items: number;
cookies_imported: number;
}>("import_profile_storage_state", { profileId: profile.id, content });
showSuccessToast(
t("profileInfo.localStorage.imported", {
items: result.items,
origins: result.origins,
cookies: result.cookies_imported,
}),
);
void loadStats();
} catch (e) {
showErrorToast(translateBackendError(t as never, e));
} finally {
setBusy(false);
}
};
return (
<div
data-slot="profile-storage-state"
className="flex items-center justify-between gap-3 rounded-md border border-border bg-muted/40 px-3 py-2"
>
<div className="min-w-0">
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
{t("profileInfo.localStorage.title")}
</p>
<p className="mt-0.5 text-sm" data-slot="profile-local-storage-stats">
{stats
? t("profileInfo.localStorage.stats", {
origins: stats.origins,
items: stats.items,
})
: "—"}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{t("profileInfo.localStorage.hint")}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-7 gap-1.5"
data-slot="storage-state-export"
disabled={isDisabled || busy}
onClick={() => void handleExport()}
>
<LuDownload className="size-3.5" />
{t("common.buttons.export")}
</Button>
<Button
variant="outline"
size="sm"
className="h-7 gap-1.5"
data-slot="storage-state-import"
disabled={isDisabled || busy}
onClick={() => void handleImport()}
>
<LuUpload className="size-3.5" />
{t("common.buttons.import")}
</Button>
</div>
</div>
);
}
// Inline password set / change / remove form. Replaces three separate
// nested modal dialogs with one in-page form that branches on the current
// `password_protected` state of the profile.
+20
View File
@@ -835,6 +835,10 @@
"name": "crc-catalog",
"license": "Apache-2.0 OR MIT"
},
{
"name": "crc32c",
"license": "Apache-2.0 OR MIT"
},
{
"name": "crc32fast",
"license": "Apache-2.0 OR MIT"
@@ -1247,6 +1251,10 @@
"name": "framer-motion",
"license": "MIT"
},
{
"name": "fs2",
"license": "Apache-2.0 OR MIT"
},
{
"name": "futures",
"license": "Apache-2.0 OR MIT"
@@ -1563,6 +1571,10 @@
"name": "inout",
"license": "Apache-2.0 OR MIT"
},
{
"name": "integer-encoding",
"license": "MIT"
},
{
"name": "internmap",
"license": "ISC"
@@ -2391,6 +2403,10 @@
"name": "rustls-webpki",
"license": "ISC"
},
{
"name": "rusty-leveldb",
"license": "MIT"
},
{
"name": "ryu",
"license": "Apache-2.0 OR BSL-1.0"
@@ -2591,6 +2607,10 @@
"name": "smoltcp",
"license": "0BSD"
},
{
"name": "snap",
"license": "BSD-3-Clause"
},
{
"name": "socket2",
"license": "Apache-2.0 OR MIT"
+19 -1
View File
@@ -1610,6 +1610,13 @@
"extensionsUnavailable": "Flüchtige Profile werden beim Schließen des Browsers verworfen, daher können ihnen keine Erweiterungsgruppen zugewiesen werden.",
"syncUnavailable": "Flüchtige Profile werden beim Schließen des Browsers verworfen, daher gibt es nichts, was in die Cloud synchronisiert werden könnte.",
"hint": "Erstellen Sie ein normales Profil, wenn die Daten erhalten bleiben sollen."
},
"localStorage": {
"title": "Local Storage",
"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}}"
}
},
"profileTransfer": {
@@ -2620,7 +2627,18 @@
"templateLimitReached": "Sie können höchstens 100 Vorlagen behalten. Löschen Sie zuerst eine.",
"bulkCreateCountInvalid": "Sie können 1 bis {{max}} Profile auf einmal erstellen.",
"bulkCreateBusy": "Es werden bereits Profile erstellt. Warten Sie, bis das abgeschlossen ist.",
"mcpToolsetUnknown": "Diese Werkzeuggruppe existiert nicht."
"mcpToolsetUnknown": "Diese Werkzeuggruppe existiert nicht.",
"localStorageEphemeral": "Ein flüchtiges Profil speichert keinen Local Storage auf der Festplatte.",
"localStorageProfileProtected": "Dieses Profil ist passwortgeschützt. Sein Local Storage bleibt versiegelt.",
"localStorageUnreadable": "Der Local Storage des Profils konnte nicht gelesen werden.",
"localStorageUnwritable": "Der Local Storage des Profils konnte nicht geschrieben werden.",
"localStorageOriginInvalid": "„{{origin}}“ ist kein Web-Ursprung wie https://example.com.",
"localStorageOriginTooLarge": "Der Local Storage für {{origin}} ist größer als die 10 MiB, die eine Website behalten darf.",
"localStorageTooManyOrigins": "Die Datei enthält mehr als 10.000 Websites.",
"localStorageImportTooLarge": "Die Datei ist größer als 32 MiB.",
"storageStateInvalid": "Dies ist keine storageState-Datei.",
"localStorageBrowserRunning": "Schließen Sie den Browser, bevor Sie einen Speicherzustand importieren.",
"localStorageRemoteSession": "Eine Remote-Sitzung verwendet dieses Profil. Warten Sie, bis ihre Arbeit wieder hier ist."
},
"rail": {
"profiles": "Profile",
+19 -1
View File
@@ -1610,6 +1610,13 @@
"extensionsUnavailable": "Ephemeral profiles are discarded when the browser closes, so extension groups cannot be assigned to them.",
"syncUnavailable": "Ephemeral profiles are discarded when the browser closes, so there is nothing to sync to the cloud.",
"hint": "Create a regular profile if you need this to persist."
},
"localStorage": {
"title": "Local Storage",
"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}}"
}
},
"profileTransfer": {
@@ -2620,7 +2627,18 @@
"templateLimitReached": "You can keep at most 100 templates. Delete one first.",
"bulkCreateCountInvalid": "You can create from 1 to {{max}} profiles at a time.",
"bulkCreateBusy": "Profiles are already being created. Wait until that is done.",
"mcpToolsetUnknown": "This tool group does not exist."
"mcpToolsetUnknown": "This tool group does not exist.",
"localStorageEphemeral": "An ephemeral profile keeps no Local Storage on disk.",
"localStorageProfileProtected": "This profile is password-protected. Its Local Storage stays sealed.",
"localStorageUnreadable": "The profile's Local Storage could not be read.",
"localStorageUnwritable": "The profile's Local Storage could not be written.",
"localStorageOriginInvalid": "\"{{origin}}\" is not a web origin like https://example.com.",
"localStorageOriginTooLarge": "Local Storage for {{origin}} is larger than the 10 MiB a site may keep.",
"localStorageTooManyOrigins": "The file has more than 10,000 sites.",
"localStorageImportTooLarge": "The file is larger than 32 MiB.",
"storageStateInvalid": "This is not a storageState file.",
"localStorageBrowserRunning": "Close the browser before you import a storage state.",
"localStorageRemoteSession": "A remote session owns this profile. Wait until its work is back here."
},
"rail": {
"profiles": "Profiles",
+19 -1
View File
@@ -1621,6 +1621,13 @@
"extensionsUnavailable": "Los perfiles efímeros se descartan al cerrar el navegador, así que no se les pueden asignar grupos de extensiones.",
"syncUnavailable": "Los perfiles efímeros se descartan al cerrar el navegador, así que no hay nada que sincronizar con la nube.",
"hint": "Crea un perfil normal si necesitas que esto se conserve."
},
"localStorage": {
"title": "Local Storage",
"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}}"
}
},
"profileTransfer": {
@@ -2640,7 +2647,18 @@
"templateLimitReached": "Puedes guardar como máximo 100 plantillas. Elimina una primero.",
"bulkCreateCountInvalid": "Puedes crear de 1 a {{max}} perfiles a la vez.",
"bulkCreateBusy": "Ya se están creando perfiles. Espera a que termine.",
"mcpToolsetUnknown": "Este grupo de herramientas no existe."
"mcpToolsetUnknown": "Este grupo de herramientas no existe.",
"localStorageEphemeral": "Un perfil efímero no guarda Local Storage en el disco.",
"localStorageProfileProtected": "Este perfil está protegido con contraseña. Su Local Storage sigue sellado.",
"localStorageUnreadable": "No se pudo leer el Local Storage del perfil.",
"localStorageUnwritable": "No se pudo escribir el Local Storage del perfil.",
"localStorageOriginInvalid": "\"{{origin}}\" no es un origen web como https://example.com.",
"localStorageOriginTooLarge": "El Local Storage de {{origin}} supera los 10 MiB que puede guardar un sitio.",
"localStorageTooManyOrigins": "El archivo tiene más de 10.000 sitios.",
"localStorageImportTooLarge": "El archivo supera los 32 MiB.",
"storageStateInvalid": "Este no es un archivo storageState.",
"localStorageBrowserRunning": "Cierra el navegador antes de importar un estado de almacenamiento.",
"localStorageRemoteSession": "Una sesión remota usa este perfil. Espera a que su trabajo vuelva aquí."
},
"rail": {
"profiles": "Perfiles",
+19 -1
View File
@@ -1621,6 +1621,13 @@
"extensionsUnavailable": "Les profils éphémères sont supprimés à la fermeture du navigateur : aucun groupe d'extensions ne peut leur être attribué.",
"syncUnavailable": "Les profils éphémères sont supprimés à la fermeture du navigateur : il n'y a rien à synchroniser vers le cloud.",
"hint": "Créez un profil normal si vous avez besoin de conserver ces données."
},
"localStorage": {
"title": "Local Storage",
"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}}"
}
},
"profileTransfer": {
@@ -2640,7 +2647,18 @@
"templateLimitReached": "Vous pouvez garder au plus 100 modèles. Supprimez-en un d'abord.",
"bulkCreateCountInvalid": "Vous pouvez créer de 1 à {{max}} profils à la fois.",
"bulkCreateBusy": "Des profils sont déjà en cours de création. Attendez la fin.",
"mcpToolsetUnknown": "Ce groupe d'outils n'existe pas."
"mcpToolsetUnknown": "Ce groupe d'outils n'existe pas.",
"localStorageEphemeral": "Un profil éphémère ne garde pas de Local Storage sur le disque.",
"localStorageProfileProtected": "Ce profil est protégé par un mot de passe. Son Local Storage reste scellé.",
"localStorageUnreadable": "Impossible de lire le Local Storage du profil.",
"localStorageUnwritable": "Impossible d'écrire le Local Storage du profil.",
"localStorageOriginInvalid": "« {{origin}} » n'est pas une origine web comme https://example.com.",
"localStorageOriginTooLarge": "Le Local Storage de {{origin}} dépasse les 10 Mio qu'un site peut garder.",
"localStorageTooManyOrigins": "Le fichier contient plus de 10 000 sites.",
"localStorageImportTooLarge": "Le fichier dépasse 32 Mio.",
"storageStateInvalid": "Ce n'est pas un fichier storageState.",
"localStorageBrowserRunning": "Fermez le navigateur avant d'importer un état de stockage.",
"localStorageRemoteSession": "Une session distante utilise ce profil. Attendez que son travail soit revenu ici."
},
"rail": {
"profiles": "Profils",
+19 -1
View File
@@ -1610,6 +1610,13 @@
"extensionsUnavailable": "一時プロファイルはブラウザーを閉じると破棄されるため、拡張機能グループを割り当てられません。",
"syncUnavailable": "一時プロファイルはブラウザーを閉じると破棄されるため、クラウドに同期するものはありません。",
"hint": "データを保持したい場合は通常のプロファイルを作成してください。"
},
"localStorage": {
"title": "ローカルストレージ",
"stats": "サイト: {{origins}} · 項目: {{items}}",
"hint": "Cookie とローカルストレージを 1 つの Playwright storageState ファイルにまとめます。インポートするにはブラウザーを閉じてください。",
"exported": "ストレージ状態をエクスポートしました",
"imported": "インポートしました。ローカルストレージの項目: {{items}} · サイト: {{origins}} · Cookie: {{cookies}}"
}
},
"profileTransfer": {
@@ -2620,7 +2627,18 @@
"templateLimitReached": "保存できるテンプレートは最大100件です。先に1件削除してください。",
"bulkCreateCountInvalid": "一度に作成できるプロファイルは1〜{{max}}件です。",
"bulkCreateBusy": "既にプロファイルを作成中です。完了するまでお待ちください。",
"mcpToolsetUnknown": "このツールグループは存在しません。"
"mcpToolsetUnknown": "このツールグループは存在しません。",
"localStorageEphemeral": "エフェメラルプロファイルはローカルストレージをディスクに保存しません。",
"localStorageProfileProtected": "このプロファイルはパスワードで保護されています。ローカルストレージは封印されたままです。",
"localStorageUnreadable": "プロファイルのローカルストレージを読み取れませんでした。",
"localStorageUnwritable": "プロファイルのローカルストレージに書き込めませんでした。",
"localStorageOriginInvalid": "「{{origin}}」は https://example.com のような Web オリジンではありません。",
"localStorageOriginTooLarge": "{{origin}} のローカルストレージが、サイトが保存できる 10 MiB を超えています。",
"localStorageTooManyOrigins": "ファイルに 10,000 を超えるサイトが含まれています。",
"localStorageImportTooLarge": "ファイルが 32 MiB を超えています。",
"storageStateInvalid": "これは storageState ファイルではありません。",
"localStorageBrowserRunning": "ストレージ状態をインポートする前にブラウザーを閉じてください。",
"localStorageRemoteSession": "リモートセッションがこのプロファイルを使用しています。作業がここに戻るまでお待ちください。"
},
"rail": {
"profiles": "プロファイル",
+19 -1
View File
@@ -1610,6 +1610,13 @@
"extensionsUnavailable": "임시 프로필은 브라우저를 닫으면 삭제되므로 확장 프로그램 그룹을 지정할 수 없습니다.",
"syncUnavailable": "임시 프로필은 브라우저를 닫으면 삭제되므로 클라우드에 동기화할 항목이 없습니다.",
"hint": "데이터를 유지하려면 일반 프로필을 만드세요."
},
"localStorage": {
"title": "로컬 스토리지",
"stats": "사이트: {{origins}} · 항목: {{items}}",
"hint": "쿠키와 로컬 스토리지를 하나의 Playwright storageState 파일로 담습니다. 가져오려면 브라우저를 닫아야 합니다.",
"exported": "스토리지 상태를 내보냈습니다",
"imported": "가져왔습니다. 로컬 스토리지 항목: {{items}} · 사이트: {{origins}} · 쿠키: {{cookies}}"
}
},
"profileTransfer": {
@@ -2620,7 +2627,18 @@
"templateLimitReached": "템플릿은 최대 100개까지 보관할 수 있습니다. 먼저 하나를 삭제하세요.",
"bulkCreateCountInvalid": "한 번에 1개부터 {{max}}개까지 프로필을 만들 수 있습니다.",
"bulkCreateBusy": "이미 프로필을 만드는 중입니다. 끝날 때까지 기다리세요.",
"mcpToolsetUnknown": "이 도구 그룹은 없습니다."
"mcpToolsetUnknown": "이 도구 그룹은 없습니다.",
"localStorageEphemeral": "임시 프로필은 로컬 스토리지를 디스크에 저장하지 않습니다.",
"localStorageProfileProtected": "이 프로필은 비밀번호로 보호되어 있습니다. 로컬 스토리지는 봉인된 상태로 유지됩니다.",
"localStorageUnreadable": "프로필의 로컬 스토리지를 읽지 못했습니다.",
"localStorageUnwritable": "프로필의 로컬 스토리지에 쓰지 못했습니다.",
"localStorageOriginInvalid": "\"{{origin}}\"은(는) https://example.com 같은 웹 출처가 아닙니다.",
"localStorageOriginTooLarge": "{{origin}}의 로컬 스토리지가 사이트가 보관할 수 있는 10 MiB를 넘습니다.",
"localStorageTooManyOrigins": "파일에 사이트가 10,000개 넘게 있습니다.",
"localStorageImportTooLarge": "파일이 32 MiB보다 큽니다.",
"storageStateInvalid": "storageState 파일이 아닙니다.",
"localStorageBrowserRunning": "스토리지 상태를 가져오기 전에 브라우저를 닫으세요.",
"localStorageRemoteSession": "원격 세션이 이 프로필을 사용 중입니다. 작업이 이곳으로 돌아올 때까지 기다리세요."
},
"rail": {
"profiles": "프로필",
+19 -1
View File
@@ -1621,6 +1621,13 @@
"extensionsUnavailable": "Perfis efêmeros são descartados quando o navegador fecha, portanto não é possível atribuir grupos de extensões a eles.",
"syncUnavailable": "Perfis efêmeros são descartados quando o navegador fecha, portanto não há nada para sincronizar com a nuvem.",
"hint": "Crie um perfil normal se precisar que isso seja mantido."
},
"localStorage": {
"title": "Local Storage",
"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}}"
}
},
"profileTransfer": {
@@ -2640,7 +2647,18 @@
"templateLimitReached": "Você pode manter no máximo 100 modelos. Exclua um primeiro.",
"bulkCreateCountInvalid": "Você pode criar de 1 a {{max}} perfis de cada vez.",
"bulkCreateBusy": "Já há perfis sendo criados. Aguarde até terminar.",
"mcpToolsetUnknown": "Este grupo de ferramentas não existe."
"mcpToolsetUnknown": "Este grupo de ferramentas não existe.",
"localStorageEphemeral": "Um perfil efêmero não guarda Local Storage no disco.",
"localStorageProfileProtected": "Este perfil é protegido por senha. O Local Storage dele continua selado.",
"localStorageUnreadable": "Não foi possível ler o Local Storage do perfil.",
"localStorageUnwritable": "Não foi possível gravar o Local Storage do perfil.",
"localStorageOriginInvalid": "\"{{origin}}\" não é uma origem web como https://example.com.",
"localStorageOriginTooLarge": "O Local Storage de {{origin}} passa dos 10 MiB que um site pode guardar.",
"localStorageTooManyOrigins": "O arquivo tem mais de 10.000 sites.",
"localStorageImportTooLarge": "O arquivo tem mais de 32 MiB.",
"storageStateInvalid": "Este não é um arquivo storageState.",
"localStorageBrowserRunning": "Feche o navegador antes de importar um estado de armazenamento.",
"localStorageRemoteSession": "Uma sessão remota está usando este perfil. Aguarde até o trabalho dela voltar para cá."
},
"rail": {
"profiles": "Perfis",
+19 -1
View File
@@ -1632,6 +1632,13 @@
"extensionsUnavailable": "Временные профили удаляются при закрытии браузера, поэтому назначить им группы расширений нельзя.",
"syncUnavailable": "Временные профили удаляются при закрытии браузера, поэтому синхронизировать с облаком нечего.",
"hint": "Создайте обычный профиль, если эти данные должны сохраняться."
},
"localStorage": {
"title": "Local Storage",
"stats": "Сайты: {{origins}} · Записи: {{items}}",
"hint": "Один файл storageState для Playwright с cookie и Local Storage. Для импорта браузер должен быть закрыт.",
"exported": "Состояние хранилища экспортировано",
"imported": "Импортировано. Записи Local Storage: {{items}} · Сайты: {{origins}} · Cookie: {{cookies}}"
}
},
"profileTransfer": {
@@ -2660,7 +2667,18 @@
"templateLimitReached": "Можно хранить не более 100 шаблонов. Сначала удалите один.",
"bulkCreateCountInvalid": "За один раз можно создать от 1 до {{max}} профилей.",
"bulkCreateBusy": "Профили уже создаются. Дождитесь окончания.",
"mcpToolsetUnknown": "Такой группы инструментов нет."
"mcpToolsetUnknown": "Такой группы инструментов нет.",
"localStorageEphemeral": "Временный профиль не хранит Local Storage на диске.",
"localStorageProfileProtected": "Профиль защищён паролем. Его Local Storage остаётся запечатанным.",
"localStorageUnreadable": "Не удалось прочитать Local Storage профиля.",
"localStorageUnwritable": "Не удалось записать Local Storage профиля.",
"localStorageOriginInvalid": "«{{origin}}» — не веб-источник вида https://example.com.",
"localStorageOriginTooLarge": "Local Storage для {{origin}} больше 10 МиБ, которые может хранить сайт.",
"localStorageTooManyOrigins": "В файле больше 10 000 сайтов.",
"localStorageImportTooLarge": "Файл больше 32 МиБ.",
"storageStateInvalid": "Это не файл storageState.",
"localStorageBrowserRunning": "Закройте браузер перед импортом состояния хранилища.",
"localStorageRemoteSession": "Этот профиль использует удалённая сессия. Дождитесь, пока её работа вернётся сюда."
},
"rail": {
"profiles": "Профили",
+19 -1
View File
@@ -1610,6 +1610,13 @@
"extensionsUnavailable": "Geçici profiller tarayıcı kapandığında silinir, bu yüzden onlara uzantı grubu atanamaz.",
"syncUnavailable": "Geçici profiller tarayıcı kapandığında silinir, bu yüzden buluta eşitlenecek bir şey yoktur.",
"hint": "Bunun kalıcı olmasını istiyorsanız normal bir profil oluşturun."
},
"localStorage": {
"title": "Local Storage",
"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}}"
}
},
"profileTransfer": {
@@ -2620,7 +2627,18 @@
"templateLimitReached": "En fazla 100 şablon tutabilirsiniz. Önce birini silin.",
"bulkCreateCountInvalid": "Bir seferde 1 ile {{max}} arasında profil oluşturabilirsiniz.",
"bulkCreateBusy": "Profiller zaten oluşturuluyor. Bitmesini bekleyin.",
"mcpToolsetUnknown": "Bu araç grubu yok."
"mcpToolsetUnknown": "Bu araç grubu yok.",
"localStorageEphemeral": "Geçici bir profil Local Storage'ı diskte tutmaz.",
"localStorageProfileProtected": "Bu profil parolayla korunuyor. Local Storage'ı mühürlü kalır.",
"localStorageUnreadable": "Profilin Local Storage'ı okunamadı.",
"localStorageUnwritable": "Profilin Local Storage'ına yazılamadı.",
"localStorageOriginInvalid": "\"{{origin}}\", https://example.com gibi bir web kaynağı değil.",
"localStorageOriginTooLarge": "{{origin}} için Local Storage, bir sitenin tutabileceği 10 MiB'tan büyük.",
"localStorageTooManyOrigins": "Dosyada 10.000'den fazla site var.",
"localStorageImportTooLarge": "Dosya 32 MiB'tan büyük.",
"storageStateInvalid": "Bu bir storageState dosyası değil.",
"localStorageBrowserRunning": "Bir depolama durumunu içe aktarmadan önce tarayıcıyı kapatın.",
"localStorageRemoteSession": "Bu profili uzak bir oturum kullanıyor. İşi buraya dönene kadar bekleyin."
},
"rail": {
"profiles": "Profiller",
+19 -1
View File
@@ -1610,6 +1610,13 @@
"extensionsUnavailable": "Hồ sơ tạm thời bị xoá khi đóng trình duyệt, nên không thể gán nhóm tiện ích mở rộng cho chúng.",
"syncUnavailable": "Hồ sơ tạm thời bị xoá khi đóng trình duyệt, nên không có gì để đồng bộ lên đám mây.",
"hint": "Hãy tạo hồ sơ thường nếu bạn cần giữ lại dữ liệu này."
},
"localStorage": {
"title": "Local Storage",
"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}}"
}
},
"profileTransfer": {
@@ -2620,7 +2627,18 @@
"templateLimitReached": "Bạn chỉ có thể giữ tối đa 100 mẫu. Hãy xóa một mẫu trước.",
"bulkCreateCountInvalid": "Mỗi lần bạn có thể tạo từ 1 đến {{max}} profile.",
"bulkCreateBusy": "Đang có profile được tạo. Hãy chờ đến khi xong.",
"mcpToolsetUnknown": "Nhóm công cụ này không tồn tại."
"mcpToolsetUnknown": "Nhóm công cụ này không tồn tại.",
"localStorageEphemeral": "Profile tạm thời không lưu Local Storage trên đĩa.",
"localStorageProfileProtected": "Profile này được bảo vệ bằng mật khẩu. Local Storage của nó vẫn được niêm phong.",
"localStorageUnreadable": "Không thể đọc Local Storage của profile.",
"localStorageUnwritable": "Không thể ghi Local Storage của profile.",
"localStorageOriginInvalid": "\"{{origin}}\" không phải nguồn web như https://example.com.",
"localStorageOriginTooLarge": "Local Storage của {{origin}} lớn hơn 10 MiB mà một trang web được giữ.",
"localStorageTooManyOrigins": "Tệp có hơn 10.000 trang web.",
"localStorageImportTooLarge": "Tệp lớn hơn 32 MiB.",
"storageStateInvalid": "Đây không phải tệp storageState.",
"localStorageBrowserRunning": "Hãy đóng trình duyệt trước khi nhập trạng thái lưu trữ.",
"localStorageRemoteSession": "Một phiên từ xa đang dùng profile này. Hãy đợi đến khi công việc của nó trở lại đây."
},
"rail": {
"profiles": "Profile",
+19 -1
View File
@@ -1610,6 +1610,13 @@
"extensionsUnavailable": "临时配置在浏览器关闭时会被丢弃,因此无法为其分配扩展分组。",
"syncUnavailable": "临时配置在浏览器关闭时会被丢弃,因此没有可同步到云端的内容。",
"hint": "如果需要保留这些数据,请创建普通配置。"
},
"localStorage": {
"title": "本地存储",
"stats": "网站:{{origins}} · 条目:{{items}}",
"hint": "一个包含 Cookie 和本地存储的 Playwright storageState 文件。导入时浏览器必须关闭。",
"exported": "已导出存储状态",
"imported": "已导入。本地存储条目:{{items}} · 网站:{{origins}} · Cookie:{{cookies}}"
}
},
"profileTransfer": {
@@ -2620,7 +2627,18 @@
"templateLimitReached": "最多可保留 100 个模板。请先删除一个。",
"bulkCreateCountInvalid": "每次可以创建 1 到 {{max}} 个配置文件。",
"bulkCreateBusy": "已在创建配置文件。请等待完成。",
"mcpToolsetUnknown": "该工具分组不存在。"
"mcpToolsetUnknown": "该工具分组不存在。",
"localStorageEphemeral": "临时配置文件不会在磁盘上保存本地存储。",
"localStorageProfileProtected": "此配置文件受密码保护,其本地存储保持封存。",
"localStorageUnreadable": "无法读取配置文件的本地存储。",
"localStorageUnwritable": "无法写入配置文件的本地存储。",
"localStorageOriginInvalid": "“{{origin}}”不是 https://example.com 这样的网页来源。",
"localStorageOriginTooLarge": "{{origin}} 的本地存储超过了网站可保留的 10 MiB。",
"localStorageTooManyOrigins": "文件中的网站超过 10,000 个。",
"localStorageImportTooLarge": "文件大于 32 MiB。",
"storageStateInvalid": "这不是 storageState 文件。",
"localStorageBrowserRunning": "导入存储状态前请关闭浏览器。",
"localStorageRemoteSession": "远程会话正在使用此配置文件。请等待其工作回到这里。"
},
"rail": {
"profiles": "配置文件",
+37
View File
@@ -122,6 +122,17 @@ export type BackendErrorCode =
| "API_PORT_UNAVAILABLE"
| "MCP_AGENT_UNKNOWN"
| "MCP_TOOLSET_UNKNOWN"
| "LOCAL_STORAGE_EPHEMERAL"
| "LOCAL_STORAGE_PROFILE_PROTECTED"
| "LOCAL_STORAGE_UNREADABLE"
| "LOCAL_STORAGE_UNWRITABLE"
| "LOCAL_STORAGE_ORIGIN_INVALID"
| "LOCAL_STORAGE_ORIGIN_TOO_LARGE"
| "LOCAL_STORAGE_TOO_MANY_ORIGINS"
| "LOCAL_STORAGE_IMPORT_TOO_LARGE"
| "STORAGE_STATE_INVALID"
| "LOCAL_STORAGE_BROWSER_RUNNING"
| "LOCAL_STORAGE_REMOTE_SESSION"
| "MCP_AGENT_INSTALL_FAILED"
| "MCP_AGENT_REMOVE_FAILED"
| "MCP_REMOTE_REQUIRES_SIGN_IN"
@@ -636,6 +647,32 @@ export function translateBackendError(t: TFunction, err: unknown): string {
return t("backendErrors.mcpRemoteKeyLimit");
case "MCP_REMOTE_KEY_UNAVAILABLE":
return t("backendErrors.mcpRemoteKeyUnavailable");
case "LOCAL_STORAGE_EPHEMERAL":
return t("backendErrors.localStorageEphemeral");
case "LOCAL_STORAGE_PROFILE_PROTECTED":
return t("backendErrors.localStorageProfileProtected");
case "LOCAL_STORAGE_UNREADABLE":
return t("backendErrors.localStorageUnreadable");
case "LOCAL_STORAGE_UNWRITABLE":
return t("backendErrors.localStorageUnwritable");
case "LOCAL_STORAGE_ORIGIN_INVALID":
return t("backendErrors.localStorageOriginInvalid", {
origin: parsed.params?.origin ?? "",
});
case "LOCAL_STORAGE_ORIGIN_TOO_LARGE":
return t("backendErrors.localStorageOriginTooLarge", {
origin: parsed.params?.origin ?? "",
});
case "LOCAL_STORAGE_TOO_MANY_ORIGINS":
return t("backendErrors.localStorageTooManyOrigins");
case "LOCAL_STORAGE_IMPORT_TOO_LARGE":
return t("backendErrors.localStorageImportTooLarge");
case "STORAGE_STATE_INVALID":
return t("backendErrors.storageStateInvalid");
case "LOCAL_STORAGE_BROWSER_RUNNING":
return t("backendErrors.localStorageBrowserRunning");
case "LOCAL_STORAGE_REMOTE_SESSION":
return t("backendErrors.localStorageRemoteSession");
case "MCP_TOOLSET_UNKNOWN":
return t("backendErrors.mcpToolsetUnknown");
case "MCP_AGENT_UNKNOWN":