mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-18 21:47:25 +02:00
feat: Local Storage transfer
This commit is contained in:
1 parent
d594fe4bfd
commit
74c507bd9b
23 files changed
+1552
-38
No files matched your search
@@ -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
@@ -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", {
|
||||
|
||||
@@ -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";
|
||||
|
||||
Generated
+108
-19
@@ -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]]
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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())?,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "プロファイル",
|
||||
|
||||
@@ -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": "프로필",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Профили",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "配置文件",
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in new issue
Block a user