diff --git a/e2e/coverage-map.mjs b/e2e/coverage-map.mjs index fd28e92a..f35c115b 100644 --- a/e2e/coverage-map.mjs +++ b/e2e/coverage-map.mjs @@ -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", diff --git a/e2e/tests/browser.test.mjs b/e2e/tests/browser.test.mjs index 0c1bae16..892257af 100644 --- a/e2e/tests/browser.test.mjs +++ b/e2e/tests/browser.test.mjs @@ -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", { diff --git a/e2e/tests/entities.test.mjs b/e2e/tests/entities.test.mjs index 45b0d7e3..5041073b 100644 --- a/e2e/tests/entities.test.mjs +++ b/e2e/tests/entities.test.mjs @@ -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"; diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1fcd1f3a..91171023 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b6d6244c..a3252b56 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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"] } diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index c5d2f4dd..9b17c09c 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -274,6 +274,24 @@ impl From 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)] + cookies: Vec, + #[serde(default)] + origins: Vec, +} + +#[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 { .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, 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, + Query(query): Query, +) -> Result, (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, + Path(id): Path, + Json(body): Json, +) -> Result, (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"); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7d1da967..fcb9fb28 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src-tauri/src/local_storage.rs b/src-tauri/src/local_storage.rs new file mode 100644 index 00000000..653eb2c4 --- /dev/null +++ b/src-tauri/src/local_storage.rs @@ -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:` holds when the origin's data last changed and its size, +//! `METAACCESS:` when it was last read (small protobufs); +//! - `_\0` 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, +} + +#[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, +} + +#[derive(Debug, Deserialize)] +struct StorageState { + #[serde(default)] + origins: Vec, + #[serde(default)] + cookies: Vec, +} + +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 { + 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 { + let (format, rest) = bytes.split_first()?; + match format { + 0 => { + if rest.len() % 2 != 0 { + return None; + } + let units: Vec = 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 { + 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 { + 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 { + 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) { + 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 { + 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 { + 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 { + 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, String> { + let mut db = open(dir)?; + let mut iter = db.new_iter().map_err(|e| e.to_string())?; + let mut origins: Vec = 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, 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 { + let invalid = || { + serde_json::json!({ + "code": "LOCAL_STORAGE_ORIGIN_INVALID", + "params": { "origin": raw.chars().take(200).collect::() } + }) + .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, 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 { + 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 { + let origins = read(profile_id)?; + let cookies: Vec = 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) -> Result, String> { + if origins.len() > MAX_ORIGINS { + return Err(code("LOCAL_STORAGE_TOO_MANY_ORIGINS")); + } + let mut merged: Vec = 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 { + 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 { + 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 { + 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 { + 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")); + } +} diff --git a/src-tauri/src/proxy_manager.rs b/src-tauri/src/proxy_manager.rs index 25223549..ae124807 100644 --- a/src-tauri/src/proxy_manager.rs +++ b/src-tauri/src/proxy_manager.rs @@ -3209,6 +3209,7 @@ mod tests { proxy_path: &std::path::Path, ) -> Result<(std::process::ExitStatus, String, Duration), Box> { 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())?, diff --git a/src/components/profile-info-dialog.tsx b/src/components/profile-info-dialog.tsx index 3b4bc232..8f8509ae 100644 --- a/src/components/profile-info-dialog.tsx +++ b/src/components/profile-info-dialog.tsx @@ -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({ : "—"}

+ {profile.ephemeral !== true && !profile.password_protected && ( + + )} {domains.length > 0 && (

@@ -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; +}) { + 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("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 ( +

+
+

+ {t("profileInfo.localStorage.title")} +

+

+ {stats + ? t("profileInfo.localStorage.stats", { + origins: stats.origins, + items: stats.items, + }) + : "—"} +

+

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

+
+
+ + +
+
+ ); +} + // 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. diff --git a/src/generated/licenses.json b/src/generated/licenses.json index e0535671..ad4ef8e2 100644 --- a/src/generated/licenses.json +++ b/src/generated/licenses.json @@ -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" diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 95811c1c..3a075cde 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -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", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 744dfb46..de0e6932 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index d963a5b9..c8942a8e 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -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", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 9fb50c53..150fc962 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -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", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index d3e23905..f24088fb 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -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": "プロファイル", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index da7302e6..f9920c90 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -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": "프로필", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index e61e7ee7..13c181b2 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -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", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 7402cdfe..f78196f4 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -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": "Профили", diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index 23156134..48330f36 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -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", diff --git a/src/i18n/locales/vi.json b/src/i18n/locales/vi.json index b0c8b035..c140b73f 100644 --- a/src/i18n/locales/vi.json +++ b/src/i18n/locales/vi.json @@ -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", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 05be1463..054c0af8 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -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": "配置文件", diff --git a/src/lib/backend-errors.ts b/src/lib/backend-errors.ts index 11c34600..41556d99 100644 --- a/src/lib/backend-errors.ts +++ b/src/lib/backend-errors.ts @@ -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":