feat: extension export via api

This commit is contained in:
zhom
2026-08-16 19:50:12 +04:00
parent 2be0d4df0b
commit d7f002d8ac
26 changed files with 4489 additions and 443 deletions
+2
View File
@@ -87,7 +87,9 @@ export const commandCoverage = {
"list_extensions",
"get_extension_icon",
"add_extension",
"add_unpacked_extension",
"update_extension",
"update_extension_from_path",
"delete_extension",
"list_extension_groups",
"create_extension_group",
+207
View File
@@ -1,5 +1,6 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { randomBytes } from "node:crypto";
import { existsSync } from "node:fs";
import {
chmod,
@@ -13,6 +14,7 @@ import {
import os from "node:os";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { crc32 } from "node:zlib";
export const TEST_BROWSER_VERSION = "150.0.7871.100";
@@ -214,6 +216,211 @@ export function extensionZipBase64() {
return "UEsDBBQAAAAAAE8K9Fxo1IfNawAAAGsAAAANAAAAbWFuaWZlc3QuanNvbnsibWFuaWZlc3RfdmVyc2lvbiI6MywibmFtZSI6IkRvbnV0IEUyRSBGaXh0dXJlIiwidmVyc2lvbiI6IjEuMC4wIiwiZGVzY3JpcHRpb24iOiJJc29sYXRlZCB0ZXN0IGV4dGVuc2lvbiJ9UEsBAhQDFAAAAAAATwr0XGjUh81rAAAAawAAAA0AAAAAAAAAAAAAAIABAAAAAG1hbmlmZXN0Lmpzb25QSwUGAAAAAAEAAQA7AAAAlgAAAAAA";
}
// 1980-01-01 00:00, the earliest timestamp the ZIP format can carry. Fixed so
// two calls with the same entries produce byte-identical archives.
const DOS_TIME = 0;
const DOS_DATE = 0x0021;
/**
* Build a ZIP archive from `entries` (`{ name, data }`) with every member
* stored, not deflated.
*
* Stored is what the inline fixture above already is, and it is load-bearing
* for the oversized fixture below: the assertion is about a request body that
* has to stay over the limit under test, so nothing in the archive may shrink
* the padding back under it.
*/
export function buildStoredZip(entries) {
const locals = [];
const central = [];
let offset = 0;
for (const { name, data } of entries) {
const nameBytes = Buffer.from(name, "utf8");
const body = Buffer.isBuffer(data) ? data : Buffer.from(data);
const checksum = crc32(body);
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt16LE(20, 4);
local.writeUInt16LE(DOS_TIME, 10);
local.writeUInt16LE(DOS_DATE, 12);
local.writeUInt32LE(checksum, 14);
local.writeUInt32LE(body.length, 18);
local.writeUInt32LE(body.length, 22);
local.writeUInt16LE(nameBytes.length, 26);
locals.push(local, nameBytes, body);
const entry = Buffer.alloc(46);
entry.writeUInt32LE(0x02014b50, 0);
entry.writeUInt16LE(20, 4);
entry.writeUInt16LE(20, 6);
entry.writeUInt16LE(DOS_TIME, 12);
entry.writeUInt16LE(DOS_DATE, 14);
entry.writeUInt32LE(checksum, 16);
entry.writeUInt32LE(body.length, 20);
entry.writeUInt32LE(body.length, 24);
entry.writeUInt16LE(nameBytes.length, 28);
entry.writeUInt32LE(offset, 42);
central.push(entry, nameBytes);
offset += local.length + nameBytes.length + body.length;
}
const directory = Buffer.concat(central);
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0);
end.writeUInt16LE(entries.length, 8);
end.writeUInt16LE(entries.length, 10);
end.writeUInt32LE(directory.length, 12);
end.writeUInt32LE(offset, 16);
return Buffer.concat([...locals, directory, end]);
}
export const OVERSIZED_EXTENSION_NAME = "Donut E2E Oversized Fixture";
/**
* A valid Manifest V3 ZIP padded past the 2 MiB body limit axum applies by
* default, so the raised limit on the extension routes is the only reason a
* request carrying it can succeed.
*
* The padding is random bytes, and the archive stores rather than deflates
* them, so neither the fixture nor the transport can quietly shrink the body
* back under the limit and turn the assertion into a tautology.
*/
export function oversizedExtensionZipBase64(paddingBytes = 3 * 1024 * 1024) {
return buildStoredZip([
{
name: "manifest.json",
data: `${JSON.stringify(
{
manifest_version: 3,
name: OVERSIZED_EXTENSION_NAME,
version: "1.0.0",
description: "Isolated oversized test extension",
},
null,
2,
)}\n`,
},
{ name: "payload.bin", data: randomBytes(paddingBytes) },
]).toString("base64");
}
// What `_locales/<default_locale>/messages.json` resolves the manifest's
// placeholders to. Deliberately free of the `__MSG_` marker so a test can
// assert the stored record carries no placeholder anywhere.
export const LOCALIZED_EXTENSION_MESSAGES = {
extName: "Donut E2E Localized Blocker",
extDescription: "Resolved from the default locale, not the manifest",
extAuthor: "Donut E2E Localization",
};
/**
* A Manifest V3 ZIP shaped the way Chrome Web Store extensions actually ship:
* `name`, `description` and `author` are `__MSG_key__` placeholders and the
* real strings live in `_locales/<default_locale>/messages.json`. uBlock Origin
* Lite is exactly this, which is why an importer that stores the manifest
* verbatim shows users `__MSG_extName__`.
*
* Pass `messages: {}` for a locale file that resolves none of the placeholders,
* or `messages: null` to omit the locale file entirely.
*/
export function localizedExtensionZipBase64({
defaultLocale = "en",
messages = LOCALIZED_EXTENSION_MESSAGES,
} = {}) {
const entries = [
{
name: "manifest.json",
data: `${JSON.stringify(
{
manifest_version: 3,
name: "__MSG_extName__",
version: "2.4.0",
description: "__MSG_extDescription__",
author: "__MSG_extAuthor__",
default_locale: defaultLocale,
},
null,
2,
)}\n`,
},
];
if (messages) {
entries.push({
name: `_locales/${defaultLocale}/messages.json`,
data: `${JSON.stringify(
Object.fromEntries(
Object.entries(messages).map(([key, message]) => [key, { message }]),
),
null,
2,
)}\n`,
});
}
return buildStoredZip(entries).toString("base64");
}
// A 1x1 PNG, inline for the same reason the ZIP above is: no encoder
// dependency, and the exact bytes are what the icon assertions compare.
const EXTENSION_ICON_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
export function extensionIconPngBase64() {
return EXTENSION_ICON_PNG_BASE64;
}
/**
* Write a real unpacked Manifest V3 extension at `directory` and return its
* absolute path.
*
* Unlike the ZIP fixture this one declares `icons` and ships the file they
* point at, so importing the folder exercises icon extraction for both import
* modes: linking reads the icon straight out of the folder, copying reads it
* back out of the ZIP the importer builds. The background service worker is
* what makes a loaded copy observable over CDP, which registers a
* `chrome-extension://<id>/background.js` target.
*/
export async function writeUnpackedExtension(
directory,
{ name = "Donut E2E Unpacked", version = "1.0.0" } = {},
) {
const absolute = path.resolve(directory);
await mkdir(path.join(absolute, "icons"), { recursive: true });
await writeFile(
path.join(absolute, "manifest.json"),
`${JSON.stringify(
{
manifest_version: 3,
name,
version,
description: "Isolated unpacked test extension",
icons: { 16: "icons/icon-16.png", 48: "icons/icon-48.png" },
background: { service_worker: "background.js" },
},
null,
2,
)}\n`,
);
await writeFile(
path.join(absolute, "background.js"),
[
"globalThis.__donutE2eExtension = chrome.runtime.id;",
"chrome.runtime.onInstalled.addListener(() => {",
" console.log('donut e2e extension installed');",
"});",
"",
].join("\n"),
);
const icon = Buffer.from(EXTENSION_ICON_PNG_BASE64, "base64");
for (const size of [16, 48]) {
await writeFile(path.join(absolute, "icons", `icon-${size}.png`), icon);
}
return absolute;
}
export function currentHostOs() {
return os.platform() === "darwin"
? "macos"
+180
View File
@@ -11,6 +11,7 @@ import {
defaultWayfernPath,
inspectWayfern,
prepareWayfern,
writeUnpackedExtension,
} from "../lib/fixtures.mjs";
const fixtureUrl = process.env.DONUT_E2E_FIXTURE_URL;
@@ -709,3 +710,182 @@ test("a proxy worker dies with its browser, with and without the app running", a
await app.close();
}
});
// Two things nothing else covers. First, that an assigned extension group
// actually reaches Wayfern: a loaded MV3 extension registers a
// `chrome-extension://<id>/background.js` service-worker target, so CDP can see
// it from outside. Second, that staging is per profile. It used to be one
// shared `extensions/unpacked` directory wiped on every launch, and because
// Chromium records the absolute staging path and reads those files lazily for
// the life of the process instead of copying them into the profile, launching a
// second profile broke the extension in every browser already running.
test("an assigned extension group reaches Wayfern and each profile stages its own copy", async () => {
assert.ok(process.env.WAYFERN_TEST_TOKEN, "WAYFERN_TEST_TOKEN is required");
const localWayfernPath = defaultWayfernPath(
process.env.DONUT_E2E_PROJECT_ROOT,
);
const localWayfernVersion = existsSync(localWayfernPath)
? inspectWayfern(localWayfernPath).version
: null;
const app = appFromEnvironment("browser-extensions", {
seedVersionCache: localWayfernVersion ?? false,
wayfernTermsAccepted: false,
});
const launched = [];
try {
const prepared = await prepareWayfern(
app,
process.env.DONUT_E2E_PROJECT_ROOT,
);
if (!app.session) await app.start();
if (!(await app.invoke("check_wayfern_terms_accepted"))) {
await app.invoke("accept_wayfern_terms");
}
const extension = await app.invoke("add_unpacked_extension", {
name: "Donut Launch Fixture",
path: await writeUnpackedExtension(
path.join(app.root, "fixtures", "loaded-extension"),
{ name: "Donut Launch Fixture", version: "1.0.0" },
),
link: false,
});
const group = await app.invoke("create_extension_group", {
name: "Launch Extensions",
});
await app.invoke("add_extension_to_group", {
groupId: group.id,
extensionId: extension.id,
});
const settings = await app.invoke("get_app_settings");
const saved = await app.invoke("save_app_settings", {
settings: {
...settings,
api_enabled: true,
api_port: 0,
api_token: null,
onboarding_completed: true,
},
});
const base = `http://127.0.0.1:${await app.invoke("start_api_server", { port: 0 })}`;
const stagedManifest = (profileId) =>
path.join(
app.dataRoot,
"data",
"extensions",
"unpacked",
profileId,
extension.id,
"manifest.json",
);
const extensionWorkers = async (debuggingPort) => {
const targets = await fetch(
`http://127.0.0.1:${debuggingPort}/json`,
).then((response) => response.json());
return targets.filter(
(target) =>
target.type === "service_worker" &&
String(target.url).startsWith("chrome-extension://"),
);
};
const launchWithExtension = async (name) => {
const profile = await createRealProfile(app, prepared.version, name);
assert.equal(
(
await app.invoke("assign_extension_group_to_profile", {
profileId: profile.id,
extensionGroupId: group.id,
})
).extension_group_id,
group.id,
);
const run = await request(`${base}/v1/profiles/${profile.id}/run`, {
method: "POST",
token: saved.api_token,
body: { url: `${fixtureUrl}/extension-launch`, headless: true },
});
assert.equal(run.response.status, 200, JSON.stringify(run.value));
const record = {
profile,
debuggingPort: run.value.remote_debugging_port,
};
launched.push(record);
const workers = await app.waitFor(
async () => {
const found = await extensionWorkers(record.debuggingPort);
return found.length > 0 ? found : null;
},
{
timeoutMs: 60_000,
description: `the extension's service worker in ${name}`,
},
);
assert.match(
workers[0].url,
/^chrome-extension:\/\/\w+\/background\.js$/,
);
return record;
};
const first = await launchWithExtension("Extension Launch One");
assert.ok(
existsSync(stagedManifest(first.profile.id)),
"the first profile must stage the extension under its own id",
);
if (process.platform !== "win32") {
// The staged path is what Chromium was handed, and it is per profile.
const running = (await app.invoke("list_browser_profiles")).find(
(item) => item.id === first.profile.id,
);
const command = execFileSync(
"ps",
["-ww", "-o", "command=", "-p", String(running.process_id)],
{ encoding: "utf8" },
);
assert.ok(
command.includes(
`--load-extension=${path.dirname(stagedManifest(first.profile.id))}`,
),
"Wayfern must be pointed at this profile's own staged copy",
);
}
await launchWithExtension("Extension Launch Two");
// The regression itself: the second launch must not have taken the first
// profile's files with it. The staged manifest is what its running browser
// is still reading from.
for (const { profile } of launched) {
assert.ok(
existsSync(stagedManifest(profile.id)),
`${profile.name} lost its staged extension to another profile's launch`,
);
}
for (const { profile } of launched) {
const running = (await app.invoke("list_browser_profiles")).find(
(item) => item.id === profile.id,
);
await app.invoke("kill_browser_profile", { profile: running });
await waitForProcessExit(app, running.process_id);
}
await app.invoke("stop_api_server");
} catch (error) {
await app.capture("failure");
throw error;
} finally {
if (app.session) {
const running = await app.invoke("list_browser_profiles").catch(() => []);
for (const { profile } of launched) {
const record = running.find((item) => item.id === profile.id);
if (record?.process_id && processExists(record.process_id)) {
await app
.invoke("kill_browser_profile", { profile: record })
.catch(() => {});
}
}
}
await app.close();
}
});
+107 -1
View File
@@ -1,15 +1,23 @@
import assert from "node:assert/strict";
import { existsSync } from "node:fs";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import {
mkdir,
readdir,
readFile,
realpath,
writeFile,
} from "node:fs/promises";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import test from "node:test";
import { withApp } from "../lib/app.mjs";
import {
extensionIconPngBase64,
extensionZipBase64,
wireGuardFixture,
writeChromiumCookies,
writeChromiumHistory,
writeUnpackedExtension,
} from "../lib/fixtures.mjs";
async function createProfile(app, name = "Entity Profile") {
@@ -555,6 +563,104 @@ test("extensions, extension groups, VPN storage, DNS rules, and event-backed ass
await app.invoke("delete_extension_group", { groupId: extensionGroup.id });
await app.invoke("delete_extension", { extensionId: extension.id });
// Folder imports, the "Load unpacked" flow. Copying packs the folder into
// the store; linking loads it from where the user keeps it, which only
// exists on this machine and therefore never syncs.
const unpackedDir = await writeUnpackedExtension(
path.join(app.root, "fixtures", "unpacked-extension"),
);
const copied = await app.invoke("add_unpacked_extension", {
name: "Overridden By The Manifest",
path: unpackedDir,
link: false,
});
assert.equal(copied.source_kind, "unpacked");
assert.equal(copied.linked_path, null);
assert.equal(copied.file_type, "zip");
assert.equal(copied.file_name, "unpacked-extension.zip");
assert.equal(copied.name, "Donut E2E Unpacked");
assert.equal(copied.version, "1.0.0");
// The folder declares icons, so packing it must carry one through into the
// store rather than dropping it the way the icon-less ZIP fixture does.
assert.equal(
await app.invoke("get_extension_icon", { extensionId: copied.id }),
`data:image/png;base64,${extensionIconPngBase64()}`,
);
const linked = await app.invoke("add_unpacked_extension", {
name: "Linked Fixture",
path: unpackedDir,
link: true,
});
assert.equal(linked.source_kind, "unpacked");
assert.equal(linked.file_type, "unpacked");
assert.equal(linked.linked_path, await realpath(unpackedDir));
assert.equal(
linked.sync_enabled,
false,
"a linked extension has no payload to upload, so it must never be synced",
);
const repackedDir = await writeUnpackedExtension(
path.join(app.root, "fixtures", "unpacked-extension-v2"),
{ name: "Donut E2E Unpacked v2", version: "2.0.0" },
);
const repacked = await app.invoke("update_extension_from_path", {
extensionId: copied.id,
name: "Repacked Fixture Extension",
path: repackedDir,
link: false,
});
assert.equal(repacked.name, "Repacked Fixture Extension");
assert.equal(repacked.version, "2.0.0");
assert.equal(repacked.file_name, "unpacked-extension-v2.zip");
assert.deepEqual(
await readdir(
path.join(app.dataRoot, "data", "extensions", copied.id, "file"),
),
["unpacked-extension-v2.zip"],
"re-importing replaces the stored payload instead of stacking a second one",
);
// Re-importing a linked extension as a copy ends the link, which is what
// makes it portable again. With no explicit name the manifest names it.
const unlinked = await app.invoke("update_extension_from_path", {
extensionId: linked.id,
name: null,
path: repackedDir,
link: false,
});
assert.equal(unlinked.linked_path, null);
assert.equal(unlinked.source_kind, "unpacked");
assert.equal(unlinked.name, "Donut E2E Unpacked v2");
assert.match(
await app.invokeError("add_unpacked_extension", {
name: "Not An Extension",
path: app.root,
link: false,
}),
/EXTENSION_MANIFEST_MISSING/,
);
assert.match(
await app.invokeError("update_extension_from_path", {
extensionId: unlinked.id,
name: null,
path: path.join(app.root, "fixtures", "absent"),
link: false,
}),
/EXTENSION_DIR_NOT_FOUND/,
);
for (const id of [copied.id, unlinked.id]) {
await app.invoke("delete_extension", { extensionId: id });
}
assert.deepEqual(await app.invoke("list_extensions"), []);
assert.ok(
existsSync(path.join(unpackedDir, "manifest.json")),
"importing a folder must never move or consume the user's copy of it",
);
const vpn = await app.invoke("create_vpn_config_manual", {
name: "E2E WireGuard",
vpnType: "WireGuard",
+553
View File
@@ -3,6 +3,14 @@ import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import test from "node:test";
import { withApp } from "../lib/app.mjs";
import {
extensionZipBase64,
LOCALIZED_EXTENSION_MESSAGES,
localizedExtensionZipBase64,
OVERSIZED_EXTENSION_NAME,
oversizedExtensionZipBase64,
writeUnpackedExtension,
} from "../lib/fixtures.mjs";
const VLESS_URI =
"vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@127.0.0.1:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=www.example.com&fp=chrome&pbk=BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc&sid=0123456789abcdef&spx=%2F&type=tcp&headerType=none#MCP";
@@ -93,6 +101,27 @@ test("authenticated REST API serves its complete OpenAPI contract and CRUD lifec
]) {
assert.ok(paths.includes(required), `OpenAPI is missing ${required}`);
}
// The served spec comes from the hand-maintained ApiDoc derive, not from
// the router, so an extension route can answer requests while being absent
// from the contract an agent generates its client from.
for (const [route, methods] of [
["/v1/extensions", ["get", "post"]],
["/v1/extensions/{id}", ["get", "put", "delete"]],
["/v1/extension-groups", ["get", "post"]],
["/v1/extension-groups/{id}", ["get", "put", "delete"]],
[
"/v1/extension-groups/{id}/extensions/{extension_id}",
["post", "delete"],
],
]) {
assert.ok(paths.includes(route), `OpenAPI is missing ${route}`);
for (const method of methods) {
assert.ok(
openapi.value.paths[route][method],
`OpenAPI is missing ${method.toUpperCase()} ${route}`,
);
}
}
const unauthorized = await jsonRequest(`${base}/v1/profiles`);
assert.equal(unauthorized.response.status, 401);
@@ -197,6 +226,428 @@ test("authenticated REST API serves its complete OpenAPI contract and CRUD lifec
assert.equal(imported.response.status, 200);
assert.equal(imported.value.imported_count, 1);
// Extensions arrive either as an inline payload or as a path the app can
// read, and the folder form is the whole point: it is how an agent reaches
// the "load unpacked" flow that the desktop offers through a file picker.
const archiveExtension = await jsonRequest(`${base}/v1/extensions`, {
method: "POST",
token: saved.api_token,
body: {
name: "REST Archive Extension",
file_name: "fixture.zip",
file_data_base64: extensionZipBase64(),
},
});
assert.equal(
archiveExtension.response.status,
201,
JSON.stringify(archiveExtension.value),
);
assert.equal(archiveExtension.value.name, "Donut E2E Fixture");
assert.equal(archiveExtension.value.source_kind, "archive");
assert.equal(archiveExtension.value.linked_path, null);
const unpackedDir = await writeUnpackedExtension(
path.join(app.root, "fixtures", "rest-unpacked-extension"),
{ name: "Donut REST Unpacked", version: "1.2.0" },
);
const folderExtension = await jsonRequest(`${base}/v1/extensions`, {
method: "POST",
token: saved.api_token,
body: { name: "REST Folder Extension", source_path: unpackedDir },
});
assert.equal(
folderExtension.response.status,
201,
JSON.stringify(folderExtension.value),
);
assert.equal(folderExtension.value.name, "Donut REST Unpacked");
assert.equal(folderExtension.value.version, "1.2.0");
assert.equal(folderExtension.value.source_kind, "unpacked");
assert.equal(folderExtension.value.linked_path, null);
// Two sources in one request have no defined winner, so the request is
// refused rather than silently resolved.
const ambiguousSource = await jsonRequest(`${base}/v1/extensions`, {
method: "POST",
token: saved.api_token,
body: {
name: "REST Ambiguous Extension",
file_name: "fixture.zip",
file_data_base64: extensionZipBase64(),
source_path: unpackedDir,
},
});
assert.equal(ambiguousSource.response.status, 400);
assert.match(
JSON.stringify(ambiguousSource.value),
/EXTENSION_SOURCE_REQUIRED/,
);
const sourcelessExtension = await jsonRequest(`${base}/v1/extensions`, {
method: "POST",
token: saved.api_token,
body: { name: "REST Sourceless Extension" },
});
assert.equal(sourcelessExtension.response.status, 400);
assert.match(
JSON.stringify(sourcelessExtension.value),
/EXTENSION_SOURCE_REQUIRED/,
);
// An archive has no folder to keep loading from, so linking one is refused
// rather than quietly stored as a copy.
const linkedArchive = await jsonRequest(`${base}/v1/extensions`, {
method: "POST",
token: saved.api_token,
body: {
name: "REST Linked Archive",
file_name: "fixture.zip",
file_data_base64: extensionZipBase64(),
link: true,
},
});
assert.equal(linkedArchive.response.status, 400);
assert.match(
JSON.stringify(linkedArchive.value),
/EXTENSION_LINK_REQUIRES_DIRECTORY/,
);
const extensionId = folderExtension.value.id;
assert.equal(
(
await jsonRequest(`${base}/v1/extensions/${extensionId}`, {
token: saved.api_token,
})
).value.id,
extensionId,
);
const renamedExtension = await jsonRequest(
`${base}/v1/extensions/${extensionId}`,
{
method: "PUT",
token: saved.api_token,
body: { name: "REST Renamed Extension" },
},
);
assert.equal(
renamedExtension.response.status,
200,
JSON.stringify(renamedExtension.value),
);
assert.equal(renamedExtension.value.name, "REST Renamed Extension");
assert.equal(
(await jsonRequest(`${base}/v1/extensions`, { token: saved.api_token }))
.value.length,
2,
);
// Axum's default body limit is 2 MiB, which plenty of real `.crx` files
// exceed: every one of them was refused before the handler ran until the
// extension payload routes got a limit of their own. The fixture below is
// stored rather than deflated, so the body genuinely stays over the
// default and a 201 can only come from the raised limit.
const oversizedBody = {
name: "REST Oversized Extension",
file_name: "oversized.zip",
file_data_base64: oversizedExtensionZipBase64(),
};
assert.ok(
Buffer.byteLength(JSON.stringify(oversizedBody)) > 2 * 1024 * 1024,
"the oversized fixture must exceed the default body limit it tests",
);
const oversized = await jsonRequest(`${base}/v1/extensions`, {
method: "POST",
token: saved.api_token,
body: oversizedBody,
});
assert.equal(
oversized.response.status,
201,
JSON.stringify(oversized.value),
);
// Read out of the archive that arrived, so the payload landed whole rather
// than merely being accepted.
assert.equal(oversized.value.name, OVERSIZED_EXTENSION_NAME);
assert.equal(oversized.value.version, "1.0.0");
assert.equal(oversized.value.file_type, "zip");
// The raised limit is scoped to the two paths that carry a payload. A
// group name is never megabytes long, so a route that accepted one would
// mean the layer had been attached to the whole router.
const oversizedGroupBody = { name: "G".repeat(3 * 1024 * 1024) };
assert.ok(
Buffer.byteLength(JSON.stringify(oversizedGroupBody)) > 2 * 1024 * 1024,
);
const oversizedGroup = await jsonRequest(`${base}/v1/extension-groups`, {
method: "POST",
token: saved.api_token,
body: oversizedGroupBody,
});
assert.equal(
oversizedGroup.response.status,
413,
JSON.stringify(oversizedGroup.value),
);
assert.deepEqual(
(
await jsonRequest(`${base}/v1/extension-groups`, {
token: saved.api_token,
})
).value,
[],
"the refused group request must not have stored anything",
);
// Chrome Web Store extensions overwhelmingly localize their manifest: the
// name a user recognizes sits in `_locales/<default_locale>/messages.json`
// and the manifest holds `__MSG_extName__`. Storing the manifest verbatim
// is what puts a raw placeholder in the extension list.
const localized = await jsonRequest(`${base}/v1/extensions`, {
method: "POST",
token: saved.api_token,
body: {
name: "REST Localized Extension",
file_name: "localized.zip",
file_data_base64: localizedExtensionZipBase64(),
},
});
assert.equal(
localized.response.status,
201,
JSON.stringify(localized.value),
);
assert.equal(localized.value.name, LOCALIZED_EXTENSION_MESSAGES.extName);
assert.equal(
localized.value.description,
LOCALIZED_EXTENSION_MESSAGES.extDescription,
);
assert.equal(
localized.value.author,
LOCALIZED_EXTENSION_MESSAGES.extAuthor,
);
assert.doesNotMatch(JSON.stringify(localized.value), /__MSG_/);
// The resolved strings have to be what was persisted, not something the
// create response computed on its way out.
assert.equal(
(
await jsonRequest(`${base}/v1/extensions/${localized.value.id}`, {
token: saved.api_token,
})
).value.name,
LOCALIZED_EXTENSION_MESSAGES.extName,
);
// A placeholder the locale file cannot resolve falls back to the name the
// caller sent. What it must never do is store `__MSG_extName__` itself.
const unresolved = await jsonRequest(`${base}/v1/extensions`, {
method: "POST",
token: saved.api_token,
body: {
name: "REST Unresolved Placeholder",
file_name: "unresolved.zip",
file_data_base64: localizedExtensionZipBase64({ messages: {} }),
},
});
assert.equal(
unresolved.response.status,
201,
JSON.stringify(unresolved.value),
);
assert.equal(unresolved.value.name, "REST Unresolved Placeholder");
assert.equal(unresolved.value.description, null);
assert.equal(unresolved.value.author, null);
assert.doesNotMatch(JSON.stringify(unresolved.value), /__MSG_/);
const extensionGroup = await jsonRequest(`${base}/v1/extension-groups`, {
method: "POST",
token: saved.api_token,
body: { name: "REST Extension Group" },
});
assert.equal(
extensionGroup.response.status,
201,
JSON.stringify(extensionGroup.value),
);
assert.equal(extensionGroup.value.name, "REST Extension Group");
assert.deepEqual(extensionGroup.value.extension_ids, []);
const extensionGroupId = extensionGroup.value.id;
const renamedExtensionGroup = await jsonRequest(
`${base}/v1/extension-groups/${extensionGroupId}`,
{
method: "PUT",
token: saved.api_token,
body: { name: "REST Extension Group Updated" },
},
);
assert.equal(
renamedExtensionGroup.response.status,
200,
JSON.stringify(renamedExtensionGroup.value),
);
assert.equal(
renamedExtensionGroup.value.name,
"REST Extension Group Updated",
);
const membershipUrl = `${base}/v1/extension-groups/${extensionGroupId}/extensions/${extensionId}`;
const joined = await jsonRequest(membershipUrl, {
method: "POST",
token: saved.api_token,
});
assert.equal(joined.response.status, 200, JSON.stringify(joined.value));
assert.deepEqual(joined.value.extension_ids, [extensionId]);
assert.deepEqual(
(
await jsonRequest(`${base}/v1/extension-groups/${extensionGroupId}`, {
token: saved.api_token,
})
).value.extension_ids,
[extensionId],
);
const left = await jsonRequest(membershipUrl, {
method: "DELETE",
token: saved.api_token,
});
assert.equal(left.response.status, 200, JSON.stringify(left.value));
assert.deepEqual(left.value.extension_ids, []);
assert.deepEqual(
(
await jsonRequest(`${base}/v1/extension-groups/${extensionGroupId}`, {
token: saved.api_token,
})
).value.extension_ids,
[],
);
// The whole path an automation client takes: an extension, a group holding
// it, and a profile that will load that group the next time it launches.
// Each piece already had coverage; the sequence did not, and it is the
// sequence that has to work for extensions to be usable over REST at all.
const launchProfile = await app.invoke("create_browser_profile_new", {
name: "REST Extension Profile",
browserStr: "wayfern",
version: "150.0.7871.100",
releaseType: "stable",
proxyId: null,
vpnId: null,
// A stored fingerprint keeps this suite off the real browser; the
// browser suite covers generation.
wayfernConfig: { fingerprint: "{}" },
groupId: null,
ephemeral: false,
dnsBlocklist: null,
launchHook: null,
});
const launchGroup = await jsonRequest(`${base}/v1/extension-groups`, {
method: "POST",
token: saved.api_token,
body: { name: "REST Launch Extension Group" },
});
assert.equal(
launchGroup.response.status,
201,
JSON.stringify(launchGroup.value),
);
const launchGroupId = launchGroup.value.id;
assert.deepEqual(
(
await jsonRequest(
`${base}/v1/extension-groups/${launchGroupId}/extensions/${archiveExtension.value.id}`,
{ method: "POST", token: saved.api_token },
)
).value.extension_ids,
[archiveExtension.value.id],
);
const assigned = await jsonRequest(
`${base}/v1/profiles/${launchProfile.id}`,
{
method: "PUT",
token: saved.api_token,
body: { extension_group_id: launchGroupId },
},
);
assert.equal(assigned.response.status, 200, JSON.stringify(assigned.value));
assert.equal(assigned.value.profile.id, launchProfile.id);
// `ApiProfile` carries no `extension_group_id`, so the assignment can only
// be read back through the surface the launcher itself resolves.
const assignedGroup = () =>
app.invoke("get_extension_group_for_profile", {
profileId: launchProfile.id,
});
assert.equal((await assignedGroup()).id, launchGroupId);
assert.deepEqual((await assignedGroup()).extension_ids, [
archiveExtension.value.id,
]);
// A group that does not exist used to be stored anyway and fail at launch,
// far from the request that caused it.
const missingExtensionGroup = await jsonRequest(
`${base}/v1/profiles/${launchProfile.id}`,
{
method: "PUT",
token: saved.api_token,
body: { extension_group_id: "00000000-0000-0000-0000-0000000000ee" },
},
);
assert.equal(
missingExtensionGroup.response.status,
404,
JSON.stringify(missingExtensionGroup.value),
);
assert.equal(
(await assignedGroup()).id,
launchGroupId,
"a refused assignment must leave the previous one in place",
);
assert.equal(
(
await jsonRequest(`${base}/v1/profiles/${launchProfile.id}`, {
method: "PUT",
token: saved.api_token,
body: { extension_group_id: "" },
})
).response.status,
200,
);
assert.equal(await assignedGroup(), null);
assert.equal(
(
await jsonRequest(`${base}/v1/extension-groups/${launchGroupId}`, {
method: "DELETE",
token: saved.api_token,
})
).response.status,
204,
);
await app.invoke("delete_profile", { profileId: launchProfile.id });
for (const id of [
extensionId,
archiveExtension.value.id,
oversized.value.id,
localized.value.id,
unresolved.value.id,
]) {
assert.equal(
(
await jsonRequest(`${base}/v1/extensions/${id}`, {
method: "DELETE",
token: saved.api_token,
})
).response.status,
204,
);
}
assert.equal(
(
await jsonRequest(`${base}/v1/extension-groups/${extensionGroupId}`, {
method: "DELETE",
token: saved.api_token,
})
).response.status,
204,
);
const missing = await jsonRequest(`${base}/v1/groups/missing`, {
token: saved.api_token,
});
@@ -323,6 +774,13 @@ test("MCP Streamable HTTP initialization, auth, discovery, calls, and isolated a
"run_profile_remote",
"get_remote_session",
"stop_remote_session",
// Extension management is only usable from an agent if importing and
// grouping are reachable, not just listing and deleting.
"add_extension",
"update_extension",
"add_extension_to_group",
"remove_extension_from_group",
"update_extension_group",
]) {
assert.ok(names.includes(name), `MCP is missing ${name}`);
}
@@ -414,6 +872,101 @@ test("MCP Streamable HTTP initialization, auth, discovery, calls, and isolated a
);
await app.invoke("delete_stored_proxy", { proxyId: vlessProxy.id });
let toolCallId = 7;
const callTool = (name, args) =>
jsonRequest(`${base}/mcp/${config.token}`, {
method: "POST",
headers: mcpHeaders,
body: {
jsonrpc: "2.0",
id: toolCallId++,
method: "tools/call",
params: { name, arguments: args },
},
});
const unpackedDir = await writeUnpackedExtension(
path.join(app.root, "fixtures", "mcp-unpacked-extension"),
{ name: "Donut MCP Unpacked", version: "1.0.0" },
);
const addedExtension = await callTool("add_extension", {
path: unpackedDir,
name: "MCP Folder Extension",
});
assert.equal(addedExtension.response.status, 200);
const subscriptionGated = /subscription/i.test(
addedExtension.value.error?.message ?? "",
);
// The e2e build overrides the paid-plan gate whenever a Wayfern test token
// is present, so with one in the environment a gated answer means the
// override stopped working and everything below it silently stopped
// running.
assert.ok(
!subscriptionGated || !process.env.WAYFERN_TEST_TOKEN,
`the e2e paid-plan override did not apply: ${addedExtension.value.error?.message}`,
);
if (subscriptionGated) {
// Every extension tool is gated on an active paid plan and this session
// is signed out, so the call path is unreachable here. The tool list
// above still proves the tools are published.
console.warn(
"Skipping the MCP extension tool calls: this session has no paid entitlement",
);
} else {
assert.equal(addedExtension.value.error, undefined);
const stored = (await app.invoke("list_extensions")).find(
(item) => item.name === "Donut MCP Unpacked",
);
assert.ok(stored, "the MCP import must produce a stored extension");
assert.equal(stored.source_kind, "unpacked");
assert.equal(stored.linked_path, null);
const renamedExtension = await callTool("update_extension", {
extension_id: stored.id,
name: "MCP Renamed Extension",
});
assert.equal(renamedExtension.value.error, undefined);
assert.equal(
(await app.invoke("list_extensions")).find(
(item) => item.id === stored.id,
).name,
"MCP Renamed Extension",
);
const extensionGroup = await app.invoke("create_extension_group", {
name: "MCP Extension Group",
});
const joined = await callTool("add_extension_to_group", {
group_id: extensionGroup.id,
extension_id: stored.id,
});
assert.equal(joined.value.error, undefined);
const readGroup = async () =>
(await app.invoke("list_extension_groups")).find(
(item) => item.id === extensionGroup.id,
);
assert.deepEqual((await readGroup()).extension_ids, [stored.id]);
const renamedGroup = await callTool("update_extension_group", {
group_id: extensionGroup.id,
name: "MCP Extension Group Updated",
});
assert.equal(renamedGroup.value.error, undefined);
assert.equal((await readGroup()).name, "MCP Extension Group Updated");
const removed = await callTool("remove_extension_from_group", {
group_id: extensionGroup.id,
extension_id: stored.id,
});
assert.equal(removed.value.error, undefined);
assert.deepEqual((await readGroup()).extension_ids, []);
await app.invoke("delete_extension", { extensionId: stored.id });
await app.invoke("delete_extension_group", {
groupId: extensionGroup.id,
});
}
const agents = await app.invoke("list_mcp_agents");
assert.ok(agents.some((agent) => agent.id === "cursor"));
await assertCommandErrorCode(app, "add_mcp_to_agent", "MCP_AGENT_UNKNOWN", {
+461 -1
View File
@@ -1,8 +1,15 @@
import assert from "node:assert/strict";
import { mkdir, realpath, writeFile } from "node:fs/promises";
import path from "node:path";
import test from "node:test";
import Color from "color";
import en from "../../src/i18n/locales/en.json" with { type: "json" };
import { getDerivedThemeColors, THEMES } from "../../src/lib/themes.ts";
import { withApp } from "../lib/app.mjs";
import {
extensionZipBase64,
writeUnpackedExtension,
} from "../lib/fixtures.mjs";
const THEME_VARIABLES = [
"--background",
@@ -87,6 +94,21 @@ function themeVariablesEqual(actual, expected) {
async function applyThemeForContrastAudit(app, theme) {
await app.execute(
`
// This audit reads settled colour tokens, not the animation between
// them. Tab triggers carry "transition-colors duration-150" and start
// from --muted-foreground, so a computed style sampled mid-transition
// returns an intermediate colour and the assertion fails on whichever
// theme the machine happened to be slow on. Kill transitions for the
// duration of the audit rather than racing them with a fixed sleep.
let freeze = document.getElementById("donut-e2e-freeze-transitions");
if (!freeze) {
freeze = document.createElement("style");
freeze.id = "donut-e2e-freeze-transitions";
freeze.textContent =
"*, *::before, *::after { transition: none !important; animation: none !important; }";
document.head.appendChild(freeze);
}
const [colors, derived, mode] = arguments;
const root = document.documentElement;
root.classList.remove("light", "dark");
@@ -97,7 +119,11 @@ async function applyThemeForContrastAudit(app, theme) {
`,
[theme.colors, getDerivedThemeColors(theme.colors), theme.mode],
);
await new Promise((resolve) => setTimeout(resolve, 200));
// One frame is enough once transitions are off; the value cannot drift after
// style recalculation.
await app.execute(
`return new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve(true))));`,
);
}
async function animatedTabContrastSnapshot(app) {
@@ -1013,3 +1039,437 @@ test("a light custom preset keeps light component behavior after restart", async
);
});
});
const EXTENSION_STRINGS = en.extensions;
/**
* Answer the native directory picker from inside the webview.
*
* "Load unpacked" calls `open({ directory: true })` from
* `@tauri-apps/plugin-dialog`, which puts an OS window on screen that no
* WebDriver can reach. The call leaves the page as the `plugin:dialog|open` IPC
* command, but Tauri locks its own entry points down: `invoke`, `ipc` and
* `postMessage` are all installed with
* `Object.defineProperty(window.__TAURI_INTERNALS__, name, { value })`, so they
* are non-writable and cannot be wrapped. The seam underneath them is the
* transport, which POSTs the command through `fetch` to
* `ipc://localhost/<command>`. Answering that one request with the shape Tauri
* expects (`Tauri-Response: ok` plus a JSON body) resolves the picker with a
* folder and needs no test-only hook in the production component. Every other
* command still reaches the real backend.
*/
async function stubFolderPicker(app, folder) {
await app.execute(
`const folder = arguments[0];
if (!window.__donutOriginalFetch) {
window.__donutOriginalFetch = window.fetch;
}
window.__donutFolderPickerCalls = [];
window.fetch = function (input, init) {
const url = String(
typeof input === "string" ? input : (input && input.url) || "",
);
let command = "";
try {
command = decodeURIComponent(url.split("/").pop() || "");
} catch (_error) {
command = "";
}
if (command === "plugin:dialog|open") {
let payload = null;
try {
payload = JSON.parse((init && init.body) || "null");
} catch (_error) {
payload = null;
}
window.__donutFolderPickerCalls.push(payload);
return Promise.resolve(
new Response(JSON.stringify(folder), {
status: 200,
headers: {
"content-type": "application/json",
"Tauri-Response": "ok",
},
}),
);
}
return window.__donutOriginalFetch.apply(window, arguments);
};
return true;`,
[folder],
);
}
/** Restores the real transport and returns what the picker was asked for. */
async function restoreFolderPicker(app) {
return app.execute(
`const calls = window.__donutFolderPickerCalls ?? [];
if (window.__donutOriginalFetch) {
window.fetch = window.__donutOriginalFetch;
delete window.__donutOriginalFetch;
}
delete window.__donutFolderPickerCalls;
return calls;`,
);
}
async function openExtensionsPage(app) {
await app.clickSelector('[aria-label="Extensions"]');
await app.waitFor(
() =>
app.execute(`return Boolean(document.querySelector(arguments[0]));`, [
`[aria-label="${EXTENSION_STRINGS.loadUnpacked}"]`,
]),
{ description: "extension management page" },
);
}
async function stageUnpackedFolder(app, folder) {
await stubFolderPicker(app, folder);
await app.clickSelector(`[aria-label="${EXTENSION_STRINGS.loadUnpacked}"]`);
await app.waitFor(
() =>
app.execute(
`return Boolean(document.querySelector("#ext-link-folder"));`,
),
{
description:
"staged folder import form (the intercepted directory picker has to resolve)",
},
);
}
async function uploadArchiveThroughUi(app, archivePath, typedName) {
// The real control is a hidden file input a button clicks for the user;
// WebDriver can only type a path into an input it can see.
await app.execute(`
const input = document.querySelector("#ext-file-input");
input.classList.remove("hidden");
input.style.position = "fixed";
input.style.left = "12px";
input.style.bottom = "12px";
`);
const input = await app.session.findCss("#ext-file-input");
await app.session.sendKeys(input, archivePath);
await app.waitForText(path.basename(archivePath));
await app.execute(`
const input = document.querySelector("#ext-file-input");
input.classList.add("hidden");
input.removeAttribute("style");
`);
await app.fillSelector(
`input[placeholder="${EXTENSION_STRINGS.namePlaceholder}"]`,
typedName,
);
await app.clickText(en.common.buttons.add, { roles: ["button"] });
}
/** The link checkbox plus the copy that is supposed to explain it. */
async function linkCheckboxState(app, id) {
return app.execute(
`const checkbox = document.querySelector("#" + arguments[0]);
const label = document.querySelector('label[for="' + arguments[0] + '"]');
const help = label?.parentElement?.querySelector("p");
return checkbox
? {
checked: checkbox.getAttribute("data-state") === "checked",
label: (label?.innerText ?? "").trim(),
help: (help?.innerText ?? "").trim(),
}
: null;`,
[id],
);
}
function extensionRowScript(body) {
return `const wanted = arguments[0];
const row = [...document.querySelectorAll("tbody tr")].find((candidate) => {
const cells = [...candidate.querySelectorAll("td")];
return cells.length >= 7 && (cells[2].innerText || "").trim() === wanted;
});
${body}`;
}
async function extensionRow(app, name) {
return app.execute(
extensionRowScript(`if (!row) return null;
const cells = [...row.querySelectorAll("td")];
const sync = row.querySelector('[data-slot="animated-switch"]');
return {
name: (cells[2].innerText || "").trim(),
source: (cells[4].innerText || "").trim(),
syncChecked: sync ? sync.getAttribute("data-state") === "checked" : null,
syncDisabled: sync ? sync.disabled === true : null,
};`),
[name],
);
}
async function extensionEditButton(app, name) {
return app.execute(
extensionRowScript(
`return row ? row.querySelector("td:last-child button") : null;`,
),
[name],
);
}
async function dialogText(app, title) {
return app.execute(
`const wanted = arguments[0];
const dialog = [...document.querySelectorAll("[role='dialog']")]
.reverse()
.find((node) =>
[...node.querySelectorAll("[data-slot='dialog-title']")].some(
(heading) => (heading.textContent || "").trim() === wanted,
),
);
return dialog ? (dialog.innerText || "").trim() : null;`,
[title],
);
}
async function toastTexts(app) {
return app.execute(
`return [...document.querySelectorAll("[data-sonner-toast]")]
.map((toast) => (toast.innerText || "").trim())
.filter(Boolean);`,
);
}
test("an uploaded archive and a loaded folder both import, each under its own source", async () => {
await withApp("ui-extension-import-sources", async (app) => {
const archivePath = path.join(app.root, "ui-archive-extension.zip");
await writeFile(archivePath, Buffer.from(extensionZipBase64(), "base64"));
const folder = await writeUnpackedExtension(
path.join(app.root, "fixtures", "ui-copied-extension"),
{ name: "Donut UI Copied Folder" },
);
await openExtensionsPage(app);
await uploadArchiveThroughUi(
app,
archivePath,
"Overridden By The Manifest",
);
await app.waitForText("Donut E2E Fixture");
await stageUnpackedFolder(app, folder);
assert.ok(await app.visibleTextIncludes(EXTENSION_STRINGS.selectedFolder));
assert.ok(
await app.visibleTextIncludes(folder),
"the staged import has to name the folder it is about to read",
);
const staged = await linkCheckboxState(app, "ext-link-folder");
assert.equal(staged?.checked, false, "linking a folder has to be opt-in");
assert.equal(staged.help, EXTENSION_STRINGS.linkFolderOff);
await app.clickText(en.common.buttons.add, { roles: ["button"] });
await app.waitForText("Donut UI Copied Folder");
const pickerCalls = await restoreFolderPicker(app);
assert.equal(pickerCalls.length, 1, "Load unpacked has to open the picker");
assert.equal(pickerCalls[0].options.directory, true);
assert.equal(pickerCalls[0].options.multiple, false);
assert.equal(
pickerCalls[0].options.title,
EXTENSION_STRINGS.selectFolderTitle,
);
assert.notEqual(
EXTENSION_STRINGS.source.archive,
EXTENSION_STRINGS.source.unpacked,
);
assert.equal(
(await extensionRow(app, "Donut E2E Fixture"))?.source,
EXTENSION_STRINGS.source.archive,
);
assert.equal(
(await extensionRow(app, "Donut UI Copied Folder"))?.source,
EXTENSION_STRINGS.source.unpacked,
);
const extensions = await app.invoke("list_extensions");
assert.equal(extensions.length, 2);
const copied = extensions.find(
(extension) => extension.name === "Donut UI Copied Folder",
);
assert.equal(copied.source_kind, "unpacked");
assert.equal(
copied.linked_path,
null,
"an unlinked folder import is copied into the store, not pointed at",
);
assert.equal(copied.file_type, "zip");
const archive = extensions.find(
(extension) => extension.name === "Donut E2E Fixture",
);
assert.equal(archive.source_kind, "archive");
assert.equal(archive.linked_path, null);
});
});
test("linking a folder says what it costs, records the path, and locks that row's sync off", async () => {
await withApp("ui-extension-linked-folder", async (app) => {
await app.invoke("add_extension", {
name: "Copied Neighbour",
fileName: "ui-neighbour-extension.zip",
fileData: [...Buffer.from(extensionZipBase64(), "base64")],
});
const folder = await writeUnpackedExtension(
path.join(app.root, "fixtures", "ui-linked-extension"),
{ name: "Donut UI Linked Folder" },
);
await openExtensionsPage(app);
await app.waitForText("Donut E2E Fixture");
await stageUnpackedFolder(app, folder);
const off = await linkCheckboxState(app, "ext-link-folder");
assert.equal(off?.checked, false);
assert.equal(off.label, EXTENSION_STRINGS.linkFolder);
assert.equal(off.help, EXTENSION_STRINGS.linkFolderOff);
await app.clickSelector("#ext-link-folder");
const on = await app.waitFor(
async () => {
const state = await linkCheckboxState(app, "ext-link-folder");
return state?.checked ? state : false;
},
{ description: "link checkbox to turn on" },
);
assert.equal(on.help, EXTENSION_STRINGS.linkFolderOn);
assert.notEqual(
on.help,
off.help,
"the checkbox has to say what turning it on changes",
);
await app.clickText(en.common.buttons.add, { roles: ["button"] });
await app.waitForText("Donut UI Linked Folder");
assert.equal((await restoreFolderPicker(app)).length, 1);
const linkedRow = await extensionRow(app, "Donut UI Linked Folder");
assert.equal(linkedRow?.source, EXTENSION_STRINGS.source.linked);
assert.equal(linkedRow.syncChecked, false);
assert.equal(linkedRow.syncDisabled, true);
assert.equal(
(await extensionRow(app, "Donut E2E Fixture"))?.syncDisabled,
false,
"only the linked row loses its sync control",
);
const linked = (await app.invoke("list_extensions")).find(
(extension) => extension.name === "Donut UI Linked Folder",
);
assert.equal(linked.linked_path, await realpath(folder));
assert.equal(linked.file_type, "unpacked");
assert.equal(linked.sync_enabled, false);
});
});
test("the edit dialog replaces an extension's payload from a folder", async () => {
await withApp("ui-extension-replace-from-folder", async (app) => {
const original = await app.invoke("add_extension", {
name: "Replaced Later",
fileName: "ui-original-extension.zip",
fileData: [...Buffer.from(extensionZipBase64(), "base64")],
});
assert.equal(original.version, "1.0.0");
const folder = await writeUnpackedExtension(
path.join(app.root, "fixtures", "ui-replacement-extension"),
{ name: "Donut UI Replacement", version: "3.1.4" },
);
await openExtensionsPage(app);
await app.waitForText("Donut E2E Fixture");
const editButton = await extensionEditButton(app, "Donut E2E Fixture");
assert.ok(editButton, "the extension row's edit control was not visible");
await app.clickElement(editButton, "extension edit button");
const beforeReplace = await app.waitFor(
() => dialogText(app, EXTENSION_STRINGS.editExtension),
{ description: "extension edit dialog" },
);
assert.ok(beforeReplace.includes(EXTENSION_STRINGS.source.label));
assert.ok(beforeReplace.includes(EXTENSION_STRINGS.source.archive));
await stubFolderPicker(app, folder);
await app.clickTextIn('[role="dialog"]', EXTENSION_STRINGS.selectFolder, {
roles: ["button"],
});
await app.waitFor(
async () =>
(await dialogText(app, EXTENSION_STRINGS.editExtension))?.includes(
folder,
),
{ description: "chosen replacement folder" },
);
const replaceLink = await linkCheckboxState(app, "ext-edit-link-folder");
assert.equal(replaceLink?.checked, false);
assert.equal(replaceLink.help, EXTENSION_STRINGS.linkFolderOff);
await app.clickTextIn('[role="dialog"]', en.common.buttons.save, {
roles: ["button"],
});
await app.waitFor(
async () =>
(await toastTexts(app)).some((text) =>
text.includes(EXTENSION_STRINGS.updateSuccess),
),
{ description: "extension update confirmation" },
);
assert.equal((await restoreFolderPicker(app)).length, 1);
const extensions = await app.invoke("list_extensions");
assert.equal(
extensions.length,
1,
"replacing a payload must not add a second extension",
);
const [updated] = extensions;
assert.equal(updated.id, original.id);
assert.equal(updated.source_kind, "unpacked");
assert.equal(updated.linked_path, null);
assert.equal(updated.file_name, "ui-replacement-extension.zip");
assert.equal(updated.version, "3.1.4");
// The dialog's own name field stays authoritative, so the row keeps its
// name while the payload underneath it is swapped.
assert.equal(updated.name, "Donut E2E Fixture");
await app.waitFor(
async () =>
(await extensionRow(app, "Donut E2E Fixture"))?.source ===
EXTENSION_STRINGS.source.unpacked,
{ description: "replaced row to report its new source" },
);
});
});
test("a folder with no manifest fails with the translated reason, not a raw code", async () => {
await withApp("ui-extension-manifest-missing", async (app) => {
const folder = path.join(app.root, "fixtures", "ui-not-an-extension");
await mkdir(folder, { recursive: true });
await writeFile(path.join(folder, "readme.txt"), "no manifest here\n");
await openExtensionsPage(app);
await stageUnpackedFolder(app, folder);
await app.clickText(en.common.buttons.add, { roles: ["button"] });
const expected = en.backendErrors.extensionManifestMissing;
await app.waitFor(
async () =>
(await toastTexts(app)).some((text) => text.includes(expected)),
{ description: "translated manifest-missing toast" },
);
const toasts = await toastTexts(app);
assert.ok(
toasts.every((text) => !text.includes("EXTENSION_MANIFEST_MISSING")),
`a raw backend code reached the user: ${JSON.stringify(toasts)}`,
);
assert.ok(
toasts.every((text) => !text.includes(EXTENSION_STRINGS.uploadFailed)),
"the generic fallback would hide which folder problem this was",
);
assert.deepEqual(await app.invoke("list_extensions"), []);
assert.equal((await restoreFolderPicker(app)).length, 1);
});
});
+743 -33
View File
@@ -7,7 +7,7 @@ use crate::tag_manager::TAG_MANAGER;
use axum::{
extract::{
ws::{Message as WsMessage, WebSocket, WebSocketUpgrade},
Path, Query, State,
DefaultBodyLimit, Path, Query, State,
},
http::{header, HeaderMap, Method, StatusCode},
middleware::{self, Next},
@@ -41,6 +41,10 @@ pub struct ApiProfile {
pub is_running: bool,
pub proxy_bypass_rules: Vec<String>,
pub vpn_id: Option<String>,
/// Extension group loaded into the browser at launch. Settable via
/// `PUT /v1/profiles/{id}`; exposed here so a caller can read back what it
/// set instead of having to go through the desktop app.
pub extension_group_id: Option<String>,
pub clear_on_close: bool,
/// Cloud sync mode: `"Disabled"`, `"Regular"` or `"Encrypted"`.
/// Settable via `PUT /v1/profiles/{id}`; exposed here so a caller can read
@@ -84,6 +88,7 @@ impl From<&crate::profile::types::BrowserProfile> for ApiProfile {
is_running: profile.process_id.is_some(),
proxy_bypass_rules: profile.proxy_bypass_rules.clone(),
vpn_id: profile.vpn_id.clone(),
extension_group_id: profile.extension_group_id.clone(),
clear_on_close: profile.clear_on_close,
sync_mode: format!("{:?}", profile.sync_mode),
cloud_sync_enabled: profile.is_sync_enabled(),
@@ -416,6 +421,59 @@ struct ImportCookiesResponse {
errors: Vec<String>,
}
/// Add an extension from exactly one source: an uploaded payload
/// (`file_name` together with `file_data_base64`), or `source_path` on the
/// machine running Donut. Supplying both, or neither, is a 400.
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateExtensionRequest {
/// Display name. Optional — the manifest's own name wins when it has one,
/// and a blank name is only rejected when the manifest has none either.
pub name: Option<String>,
/// Name of the uploaded file. Its suffix picks the type: `.crx` or `.zip`.
pub file_name: Option<String>,
/// Payload bytes, standard base64. Only meaningful with `file_name`.
pub file_data_base64: Option<String>,
/// Path on this machine: a `.crx`/`.zip`, or an unpacked extension
/// directory holding a top-level `manifest.json`.
pub source_path: Option<String>,
/// Load a `source_path` directory in place instead of copying it into the
/// store, so edits to the folder apply on the next browser start. Directory
/// sources only, and a linked extension never syncs.
pub link: Option<bool>,
}
/// Replace an extension's payload, rename it, or both. Every field is
/// optional, but a request that carries neither a name nor a source has
/// nothing to do and is a 400.
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateExtensionRequest {
/// New display name.
pub name: Option<String>,
/// Name of the replacement upload. Its suffix picks the type: `.crx` or `.zip`.
pub file_name: Option<String>,
/// Replacement payload bytes, standard base64. Only meaningful with `file_name`.
pub file_data_base64: Option<String>,
/// Path on this machine to re-import from: a `.crx`/`.zip`, or an unpacked
/// extension directory.
pub source_path: Option<String>,
/// Load a `source_path` directory in place instead of copying it in.
pub link: Option<bool>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateExtensionGroupRequest {
pub name: String,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateExtensionGroupRequest {
/// New group name.
pub name: Option<String>,
/// Replaces the whole membership list. Omit to leave it untouched; use the
/// `/extensions/{extension_id}` sub-routes to add or remove one member.
pub extension_ids: Option<Vec<String>>,
}
#[derive(Debug, Deserialize, ToSchema)]
struct BatchRunRequest {
/// Profile IDs to launch.
@@ -561,9 +619,17 @@ struct ImportProxiesResponse {
update_vpn,
delete_vpn,
get_extensions,
get_extension_groups,
create_extension_api,
get_extension_api,
update_extension_api,
delete_extension_api,
get_extension_groups,
create_extension_group_api,
get_extension_group_api,
update_extension_group_api,
delete_extension_group_api,
add_extension_to_group_api,
remove_extension_from_group_api,
download_browser_api,
get_browser_versions,
check_browser_downloaded,
@@ -624,6 +690,12 @@ struct ImportProxiesResponse {
OpenUrlRequest,
ImportCookiesRequest,
ImportCookiesResponse,
CreateExtensionRequest,
UpdateExtensionRequest,
CreateExtensionGroupRequest,
UpdateExtensionGroupRequest,
crate::extension_manager::Extension,
crate::extension_manager::ExtensionGroup,
ProxySettings,
DetectedProfilesResponse,
ImportProfilesRequest,
@@ -838,15 +910,37 @@ fn build_v1_router() -> Router<ApiServerState> {
.routes(routes!(import_vpn))
.routes(routes!(export_vpn))
.routes(routes!(get_vpn, update_vpn, delete_vpn))
.routes(routes!(get_extensions))
.routes(routes!(delete_extension_api))
.routes(routes!(get_extension_groups))
.routes(routes!(delete_extension_group_api))
.routes(routes!(get_extension_groups, create_extension_group_api))
.routes(routes!(
get_extension_group_api,
update_extension_group_api,
delete_extension_group_api
))
.routes(routes!(
add_extension_to_group_api,
remove_extension_from_group_api
))
.routes(routes!(download_browser_api))
.routes(routes!(get_browser_versions))
.routes(routes!(check_browser_downloaded))
.split_for_parts();
routes
// The two paths that carry an extension payload, kept apart so the raised
// body limit reaches them and nothing else. Axum's 2 MiB default is smaller
// than plenty of real `.crx` files, and a create that 413s before the
// handler runs is indistinguishable from a broken endpoint. The GET and
// DELETE on these paths ride along because a limit has to be attached per
// path, and neither reads a body.
let (extension_payload_routes, _) = OpenApiRouter::new()
.routes(routes!(get_extensions, create_extension_api))
.routes(routes!(
get_extension_api,
update_extension_api,
delete_extension_api
))
.split_for_parts();
routes.merge(extension_payload_routes.layer(DefaultBodyLimit::max(64 * 1024 * 1024)))
}
// Terms and Conditions check middleware
@@ -1140,6 +1234,11 @@ fn manager_error_response(err: impl std::fmt::Display) -> (StatusCode, String) {
|| lower.contains("not supported on your platform")
|| lower.contains("is not downloaded")
|| lower.contains("terms and conditions")
// Extension-group compatibility: a group holding a Firefox-only add-on,
// or a browser that takes no extensions at all. Both are a caller pairing
// two things that don't go together, not a fault of this machine.
|| lower.contains("is not compatible with")
|| lower.contains("not supported for browser")
{
StatusCode::BAD_REQUEST
} else {
@@ -1509,6 +1608,27 @@ async fn update_profile(
} else {
Some(extension_group_id)
};
// Assigning a group that does not exist, or one holding an extension this
// profile's browser cannot load, fails at launch instead of here unless it
// is checked now — which is what the Tauri and MCP paths already do.
if let Some(group_id) = ext_group.as_deref() {
let browser = {
let profiles = profile_manager
.list_profiles()
.map_err(manager_error_response)?;
profiles
.iter()
.find(|p| p.id.to_string() == id)
.map(|p| p.browser.clone())
.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
format!("Profile with ID '{id}' not found"),
)
})?
};
with_extension_manager(|mgr| mgr.validate_group_compatibility(group_id, &browser))?;
}
if let Err(e) = profile_manager.update_profile_extension_group(&id, ext_group) {
return Err(manager_error_response(e));
}
@@ -2255,44 +2375,214 @@ async fn delete_vpn(
// Extension API endpoints
/// Take the extension store once, in one place, so a poisoned lock answers 500
/// instead of panicking the request thread, and every handler classifies the
/// manager's `{"code": ...}` errors identically.
fn with_extension_manager<T>(
action: impl FnOnce(
&crate::extension_manager::ExtensionManager,
) -> Result<T, Box<dyn std::error::Error>>,
) -> Result<T, (StatusCode, String)> {
let manager = crate::extension_manager::EXTENSION_MANAGER
.lock()
.map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
"extension manager unavailable".to_string(),
)
})?;
action(&manager).map_err(manager_error_response)
}
/// The payload an extension write carries, once the request body has been
/// reduced to the single source it is allowed to name.
#[derive(Debug)]
enum ExtensionSource {
Upload {
file_name: String,
data: Vec<u8>,
},
LocalPath {
path: std::path::PathBuf,
link: bool,
},
}
fn extension_request_error(code: &str) -> (StatusCode, String) {
(
StatusCode::BAD_REQUEST,
serde_json::json!({ "code": code }).to_string(),
)
}
/// Reduce a create/update body to the one source it names. `Ok(None)` means it
/// named none, which only an update (a plain rename) may do.
fn resolve_extension_source(
file_name: Option<String>,
file_data_base64: Option<String>,
source_path: Option<String>,
link: Option<bool>,
) -> Result<Option<ExtensionSource>, (StatusCode, String)> {
use base64::Engine as _;
let upload = match (file_name, file_data_base64) {
(Some(name), Some(encoded)) => Some((name, encoded)),
(None, None) => None,
// Half an upload is not a source: honouring it would mean storing an empty
// payload, or inventing a file name and with it a file type.
_ => return Err(extension_request_error("EXTENSION_SOURCE_REQUIRED")),
};
let path = source_path.filter(|p| !p.trim().is_empty());
let link = link.unwrap_or(false);
match (upload, path) {
// Two sources is as unanswerable as none: there is no rule for which one
// the caller meant, so neither is guessed at.
(Some(_), Some(_)) => Err(extension_request_error("EXTENSION_SOURCE_REQUIRED")),
(Some((file_name, encoded)), None) => {
if link {
// Linking means "keep loading the folder where it already is". An
// uploaded archive has no folder on this machine to point at.
return Err(extension_request_error("EXTENSION_LINK_REQUIRES_DIRECTORY"));
}
let data = base64::engine::general_purpose::STANDARD
.decode(encoded)
.map_err(|_| extension_request_error("EXTENSION_INVALID_BASE64"))?;
Ok(Some(ExtensionSource::Upload { file_name, data }))
}
(None, Some(path)) => Ok(Some(ExtensionSource::LocalPath {
path: std::path::PathBuf::from(path),
link,
})),
(None, None) => Ok(None),
}
}
#[utoipa::path(
get,
path = "/v1/extensions",
responses(
(status = 200, description = "List of extensions"),
(status = 200, description = "List of extensions", body = Vec<crate::extension_manager::Extension>),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = [])),
tag = "extensions"
)]
async fn get_extensions(
State(_state): State<ApiServerState>,
) -> Result<Json<Vec<crate::extension_manager::Extension>>, StatusCode> {
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
mgr
.list_extensions()
.map(Json)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
) -> Result<Json<Vec<crate::extension_manager::Extension>>, (StatusCode, String)> {
with_extension_manager(|mgr| mgr.list_extensions()).map(Json)
}
/// Add an extension.
///
/// The body names exactly one source:
/// - `file_name` plus `file_data_base64` uploads a `.crx`/`.zip`.
/// - `source_path` reads a `.crx`/`.zip`, or packs an unpacked extension
/// directory, from the machine running Donut. `link: true` loads that
/// directory in place instead, so edits apply on the next browser start;
/// a linked extension is machine-local and never syncs.
///
/// `name` may be omitted: the manifest's own name is preferred anyway, and it
/// is only an error when the manifest has none either.
#[utoipa::path(
get,
path = "/v1/extension-groups",
post,
path = "/v1/extensions",
request_body = CreateExtensionRequest,
responses(
(status = 200, description = "List of extension groups"),
(status = 201, description = "Extension added", body = crate::extension_manager::Extension),
(status = 400, description = "No source, two sources, undecodable payload, unsupported file type, or an unreadable unpacked directory"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "source_path does not exist"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = [])),
tag = "extensions"
)]
async fn get_extension_groups(
State(_state): State<ApiServerState>,
) -> Result<Json<Vec<crate::extension_manager::ExtensionGroup>>, StatusCode> {
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
mgr
.list_groups()
.map(Json)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
async fn create_extension_api(
Json(request): Json<CreateExtensionRequest>,
) -> Result<(StatusCode, Json<crate::extension_manager::Extension>), (StatusCode, String)> {
let source = resolve_extension_source(
request.file_name,
request.file_data_base64,
request.source_path,
request.link,
)?
.ok_or_else(|| extension_request_error("EXTENSION_SOURCE_REQUIRED"))?;
let name = request.name.unwrap_or_default();
let extension = with_extension_manager(|mgr| match source {
ExtensionSource::Upload { file_name, data } => mgr.add_extension(name, file_name, data),
ExtensionSource::LocalPath { path, link } => mgr.add_extension_from_path(name, &path, link),
})?;
Ok((StatusCode::CREATED, Json(extension)))
}
#[utoipa::path(
get,
path = "/v1/extensions/{id}",
params(("id" = String, Path, description = "Extension ID")),
responses(
(status = 200, description = "Extension", body = crate::extension_manager::Extension),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Extension not found"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = [])),
tag = "extensions"
)]
async fn get_extension_api(
Path(id): Path<String>,
) -> Result<Json<crate::extension_manager::Extension>, (StatusCode, String)> {
with_extension_manager(|mgr| mgr.get_extension(&id)).map(Json)
}
/// Replace an extension's payload, rename it, or both.
///
/// Sources are the same as on create, and every field is optional — but a body
/// carrying neither a name nor a source asks for nothing and is refused rather
/// than answered with an unchanged extension.
#[utoipa::path(
put,
path = "/v1/extensions/{id}",
params(("id" = String, Path, description = "Extension ID")),
request_body = UpdateExtensionRequest,
responses(
(status = 200, description = "Extension updated", body = crate::extension_manager::Extension),
(status = 400, description = "Nothing to change, two sources, undecodable payload, unsupported file type, or an unreadable unpacked directory"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Extension not found, or source_path does not exist"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = [])),
tag = "extensions"
)]
async fn update_extension_api(
Path(id): Path<String>,
Json(request): Json<UpdateExtensionRequest>,
) -> Result<Json<crate::extension_manager::Extension>, (StatusCode, String)> {
let name = request.name;
let source = resolve_extension_source(
request.file_name,
request.file_data_base64,
request.source_path,
request.link,
)?;
if name.is_none() && source.is_none() {
return Err(extension_request_error("EXTENSION_SOURCE_REQUIRED"));
}
with_extension_manager(|mgr| match source {
Some(ExtensionSource::Upload { file_name, data }) => {
mgr.update_extension(&id, name, Some(file_name), Some(data))
}
Some(ExtensionSource::LocalPath { path, link }) => {
mgr.update_extension_from_path(&id, name, &path, link)
}
None => mgr.update_extension(&id, name, None, None),
})
.map(Json)
}
#[utoipa::path(
@@ -2312,11 +2602,88 @@ async fn delete_extension_api(
Path(id): Path<String>,
State(state): State<ApiServerState>,
) -> Result<StatusCode, (StatusCode, String)> {
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
mgr
.delete_extension(&state.app_handle, &id)
with_extension_manager(|mgr| mgr.delete_extension(&state.app_handle, &id))
.map(|_| StatusCode::NO_CONTENT)
.map_err(manager_error_response)
}
#[utoipa::path(
get,
path = "/v1/extension-groups",
responses(
(status = 200, description = "List of extension groups", body = Vec<crate::extension_manager::ExtensionGroup>),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = [])),
tag = "extensions"
)]
async fn get_extension_groups(
State(_state): State<ApiServerState>,
) -> Result<Json<Vec<crate::extension_manager::ExtensionGroup>>, (StatusCode, String)> {
with_extension_manager(|mgr| mgr.list_groups()).map(Json)
}
#[utoipa::path(
post,
path = "/v1/extension-groups",
request_body = CreateExtensionGroupRequest,
responses(
(status = 201, description = "Extension group created", body = crate::extension_manager::ExtensionGroup),
(status = 400, description = "Empty or duplicate name"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = [])),
tag = "extensions"
)]
async fn create_extension_group_api(
Json(request): Json<CreateExtensionGroupRequest>,
) -> Result<(StatusCode, Json<crate::extension_manager::ExtensionGroup>), (StatusCode, String)> {
let group = with_extension_manager(|mgr| mgr.create_group(request.name))?;
Ok((StatusCode::CREATED, Json(group)))
}
#[utoipa::path(
get,
path = "/v1/extension-groups/{id}",
params(("id" = String, Path, description = "Extension Group ID")),
responses(
(status = 200, description = "Extension group", body = crate::extension_manager::ExtensionGroup),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Extension group not found"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = [])),
tag = "extensions"
)]
async fn get_extension_group_api(
Path(id): Path<String>,
) -> Result<Json<crate::extension_manager::ExtensionGroup>, (StatusCode, String)> {
with_extension_manager(|mgr| mgr.get_group(&id)).map(Json)
}
/// Rename a group, replace its whole membership list, or both. `extension_ids`
/// is a replacement, not an addition — omit it to leave membership alone.
#[utoipa::path(
put,
path = "/v1/extension-groups/{id}",
params(("id" = String, Path, description = "Extension Group ID")),
request_body = UpdateExtensionGroupRequest,
responses(
(status = 200, description = "Extension group updated", body = crate::extension_manager::ExtensionGroup),
(status = 400, description = "Empty or duplicate name"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Extension group not found"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = [])),
tag = "extensions"
)]
async fn update_extension_group_api(
Path(id): Path<String>,
Json(request): Json<UpdateExtensionGroupRequest>,
) -> Result<Json<crate::extension_manager::ExtensionGroup>, (StatusCode, String)> {
with_extension_manager(|mgr| mgr.update_group(&id, request.name, request.extension_ids)).map(Json)
}
#[utoipa::path(
@@ -2336,11 +2703,55 @@ async fn delete_extension_group_api(
Path(id): Path<String>,
State(state): State<ApiServerState>,
) -> Result<StatusCode, (StatusCode, String)> {
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
mgr
.delete_group(&state.app_handle, &id)
with_extension_manager(|mgr| mgr.delete_group(&state.app_handle, &id))
.map(|_| StatusCode::NO_CONTENT)
.map_err(manager_error_response)
}
/// Add one extension to a group. Adding a member it already has is a no-op,
/// not an error, so a client re-running its setup converges.
#[utoipa::path(
post,
path = "/v1/extension-groups/{id}/extensions/{extension_id}",
params(
("id" = String, Path, description = "Extension Group ID"),
("extension_id" = String, Path, description = "Extension ID to add"),
),
responses(
(status = 200, description = "Extension group with the extension added", body = crate::extension_manager::ExtensionGroup),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Extension or extension group not found"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = [])),
tag = "extensions"
)]
async fn add_extension_to_group_api(
Path((id, extension_id)): Path<(String, String)>,
) -> Result<Json<crate::extension_manager::ExtensionGroup>, (StatusCode, String)> {
with_extension_manager(|mgr| mgr.add_extension_to_group(&id, &extension_id)).map(Json)
}
/// Remove one extension from a group. The extension itself is untouched.
#[utoipa::path(
delete,
path = "/v1/extension-groups/{id}/extensions/{extension_id}",
params(
("id" = String, Path, description = "Extension Group ID"),
("extension_id" = String, Path, description = "Extension ID to remove"),
),
responses(
(status = 200, description = "Extension group with the extension removed", body = crate::extension_manager::ExtensionGroup),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Extension group not found"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = [])),
tag = "extensions"
)]
async fn remove_extension_from_group_api(
Path((id, extension_id)): Path<(String, String)>,
) -> Result<Json<crate::extension_manager::ExtensionGroup>, (StatusCode, String)> {
with_extension_manager(|mgr| mgr.remove_extension_from_group(&id, &extension_id)).map(Json)
}
// API Handler - Run Profile with Remote Debugging
@@ -4304,6 +4715,21 @@ mod tests {
(Method::GET, "/v1/remote-hours"),
// A run id is required; the collection DELETE is not a route.
(Method::DELETE, "/v1/cookie-bot/runs/"),
// Extension writes touch this machine's own store. They start no
// browser, so metering them would spend an automation client's quota on
// uploading a `.crx`.
(Method::POST, "/v1/extensions"),
(Method::PUT, "/v1/extensions/extension-id"),
(Method::DELETE, "/v1/extensions/extension-id"),
(Method::POST, "/v1/extension-groups"),
(
Method::POST,
"/v1/extension-groups/group-id/extensions/extension-id",
),
(
Method::DELETE,
"/v1/extension-groups/group-id/extensions/extension-id",
),
] {
assert!(
!is_automation_request(&method, path),
@@ -4642,6 +5068,44 @@ mod tests {
"{field} must be optional on the quota, required list: {quota:?}"
);
}
// An extension arrives either as an upload or as a path, and its name is
// usually read from the manifest. Marking any one of these required would
// make a generated client send a field that contradicts the source it
// actually has.
for request in ["CreateExtensionRequest", "UpdateExtensionRequest"] {
let fields = schema_required(&spec, request);
assert!(
fields.is_empty(),
"every field of {request} must be optional, required list: {fields:?}"
);
}
let update_extension_group = schema_required(&spec, "UpdateExtensionGroupRequest");
for field in ["name", "extension_ids"] {
assert!(
!update_extension_group.iter().any(|f| f == field),
"{field} must be optional on a group update, required list: {update_extension_group:?}"
);
}
// A group cannot be created without one.
let create_extension_group = schema_required(&spec, "CreateExtensionGroupRequest");
assert!(
create_extension_group.iter().any(|f| f == "name"),
"name is required to create a group, required list: {create_extension_group:?}"
);
// `linked_path` is only set for an extension loaded in place, and every
// extension stored before unpacked support has neither it nor a
// `source_kind`, so a required marking would break reading them back.
let extension = schema_required(&spec, "Extension");
for field in ["linked_path", "version", "description", "author"] {
assert!(
!extension.iter().any(|f| f == field),
"{field} must be optional on an extension, required list: {extension:?}"
);
}
}
#[test]
@@ -4686,6 +5150,174 @@ mod tests {
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
}
#[test]
fn a_rejected_extension_upload_is_the_callers_problem_not_a_server_fault() {
// Every one of these is something about the request: the wrong file type, a
// folder with no readable manifest, a link asked for on a file. Answering
// 500 would tell a client to retry a body that can never be accepted.
for code in [
"EXTENSION_UNSUPPORTED_FILE_TYPE",
"EXTENSION_NOT_A_DIRECTORY",
"EXTENSION_MANIFEST_MISSING",
"EXTENSION_MANIFEST_INVALID",
"EXTENSION_DIR_TOO_LARGE",
"EXTENSION_PATH_HAS_COMMA",
"EXTENSION_LINK_REQUIRES_DIRECTORY",
"NAME_CANNOT_BE_EMPTY",
] {
let (status, body) = manager_error_response(serde_json::json!({ "code": code }).to_string());
assert_eq!(status, StatusCode::BAD_REQUEST, "{code} must be a 400");
assert!(body.contains(code), "{code} must reach the caller");
}
// A path that is not there is the one refusal that names a missing thing.
let (status, _) =
manager_error_response(serde_json::json!({ "code": "EXTENSION_DIR_NOT_FOUND" }).to_string());
assert_eq!(status, StatusCode::NOT_FOUND);
}
#[test]
fn an_incompatible_extension_group_is_refused_rather_than_reported_as_broken() {
// `validate_group_compatibility` answers in prose, so both of its refusals
// fell through to 500 — which reads as "this server is broken" for what is
// really a group the caller cannot put on that profile.
for message in [
"Extension 'uBlock' (crx) is not compatible with gecko browsers",
"Extensions are not supported for browser 'firefox'",
] {
let (status, body) = manager_error_response(message);
assert_eq!(status, StatusCode::BAD_REQUEST, "{message} must be a 400");
assert_eq!(body, message, "the diagnostic must reach the caller");
}
// A group that is simply absent stays a 404.
let (status, _) = manager_error_response("Extension group with id 'gone' not found");
assert_eq!(status, StatusCode::NOT_FOUND);
}
#[test]
fn an_extension_write_names_exactly_one_source() {
use base64::Engine as _;
let encoded = base64::engine::general_purpose::STANDARD.encode(b"PK\x03\x04");
// An upload decodes to the bytes the caller sent.
match resolve_extension_source(
Some("ublock.crx".to_string()),
Some(encoded.clone()),
None,
None,
) {
Ok(Some(ExtensionSource::Upload { file_name, data })) => {
assert_eq!(file_name, "ublock.crx");
assert_eq!(data, b"PK\x03\x04");
}
other => panic!("an upload must resolve to its bytes: {other:?}"),
}
// A path is taken as-is, and `link` rides with it.
match resolve_extension_source(None, None, Some("/srv/ext".to_string()), Some(true)) {
Ok(Some(ExtensionSource::LocalPath { path, link })) => {
assert_eq!(path, std::path::PathBuf::from("/srv/ext"));
assert!(link);
}
other => panic!("a path must resolve to a path: {other:?}"),
}
// Naming both sources has no answer: neither one is guessed at.
let both = resolve_extension_source(
Some("ublock.crx".to_string()),
Some(encoded.clone()),
Some("/srv/ext".to_string()),
None,
);
assert_eq!(
both.expect_err("two sources must be refused"),
extension_request_error("EXTENSION_SOURCE_REQUIRED")
);
// Half an upload is not a source. Storing an empty payload, or inventing a
// file name, would both produce an extension that never loads.
for half in [
(Some("ublock.crx".to_string()), None),
(None, Some(encoded.clone())),
] {
let (file_name, data) = half;
assert_eq!(
resolve_extension_source(file_name, data, None, None)
.expect_err("half an upload must be refused"),
extension_request_error("EXTENSION_SOURCE_REQUIRED")
);
}
// Linking means "load the folder where it is"; an upload has no folder.
assert_eq!(
resolve_extension_source(
Some("ublock.crx".to_string()),
Some(encoded),
None,
Some(true)
)
.expect_err("a linked upload must be refused"),
extension_request_error("EXTENSION_LINK_REQUIRES_DIRECTORY")
);
// Undecodable base64 is named as such rather than reaching the store as an
// empty or truncated archive.
assert_eq!(
resolve_extension_source(
Some("ublock.crx".to_string()),
Some("not base64!!".to_string()),
None,
None
)
.expect_err("undecodable base64 must be refused"),
extension_request_error("EXTENSION_INVALID_BASE64")
);
// No source at all is legal on the wire — it is a rename, and only the
// update handler accepts it.
assert!(resolve_extension_source(None, None, None, None)
.expect("naming no source is not an error here")
.is_none());
// An empty string is not a path.
assert!(
resolve_extension_source(None, None, Some(" ".to_string()), None)
.expect("a blank path is not an error here")
.is_none()
);
}
#[test]
fn an_extension_update_that_asks_for_nothing_is_refused() {
// Every field is optional, so an empty body parses. Answering 200 with an
// untouched extension would tell a client its rename landed.
let empty: UpdateExtensionRequest =
serde_json::from_str("{}").expect("an empty update body must deserialize");
assert!(empty.name.is_none());
assert!(resolve_extension_source(
empty.file_name,
empty.file_data_base64,
empty.source_path,
empty.link
)
.expect("no source is not an error on update")
.is_none());
}
#[test]
fn creating_an_extension_needs_neither_a_name_nor_a_link() {
// The manifest's own name is preferred, so a caller uploading a `.crx`
// sends two fields and nothing else.
let minimal: CreateExtensionRequest =
serde_json::from_str(r#"{"file_name": "ublock.crx", "file_data_base64": "UEsDBA=="}"#)
.expect("a minimal create body must deserialize");
assert!(minimal.name.is_none());
assert!(minimal.link.is_none());
assert_eq!(minimal.file_name.as_deref(), Some("ublock.crx"));
}
#[test]
fn a_remote_session_exposes_a_cdp_endpoint_an_external_client_can_attach_to() {
// Without this route `run-remote` hands back a session id that nothing
@@ -4763,6 +5395,7 @@ mod tests {
"/v1/extension-groups",
"/v1/extensions/{id}",
"/v1/extension-groups/{id}",
"/v1/extension-groups/{id}/extensions/{extension_id}",
"/v1/profiles/import",
"/v1/profiles/import/detect",
"/v1/proxies/import",
@@ -4801,6 +5434,26 @@ mod tests {
("/v1/cookie-bot/runs", "get"),
("/v1/cookie-bot/runs", "post"),
("/v1/cookie-bot/runs/{run_id}", "delete"),
// The extension surface is five paths carrying eleven methods, so it is
// the densest place in the router for one `routes!` to swallow another.
("/v1/extensions", "get"),
("/v1/extensions", "post"),
("/v1/extensions/{id}", "get"),
("/v1/extensions/{id}", "put"),
("/v1/extensions/{id}", "delete"),
("/v1/extension-groups", "get"),
("/v1/extension-groups", "post"),
("/v1/extension-groups/{id}", "get"),
("/v1/extension-groups/{id}", "put"),
("/v1/extension-groups/{id}", "delete"),
(
"/v1/extension-groups/{id}/extensions/{extension_id}",
"post",
),
(
"/v1/extension-groups/{id}/extensions/{extension_id}",
"delete",
),
] {
assert!(
paths[path].get(method).is_some(),
@@ -4865,6 +5518,14 @@ mod tests {
"RemoteHoursQuota",
"RemoteHoursMember",
"RemoteHoursBreakdown",
// Neither extension type was registered while only the list and delete
// routes existed, so every extension response resolved to nothing.
"Extension",
"ExtensionGroup",
"CreateExtensionRequest",
"UpdateExtensionRequest",
"CreateExtensionGroupRequest",
"UpdateExtensionGroupRequest",
] {
assert!(
spec["components"]["schemas"][schema]["properties"].is_object(),
@@ -4895,6 +5556,15 @@ mod tests {
"RemoteSessionState",
),
("/v1/remote-hours", "get", "200", "RemoteHoursQuota"),
("/v1/extensions", "post", "201", "Extension"),
("/v1/extensions/{id}", "put", "200", "Extension"),
("/v1/extension-groups", "post", "201", "ExtensionGroup"),
(
"/v1/extension-groups/{id}/extensions/{extension_id}",
"post",
"200",
"ExtensionGroup",
),
] {
let reference =
&paths[path][method]["responses"][status]["content"]["application/json"]["schema"]["$ref"];
@@ -4905,6 +5575,23 @@ mod tests {
);
}
// Both extension lists declared a 200 with no body at all, so a generated
// client got a call that returns nothing from a route that returns
// everything. Each must be an array of the same component its single-item
// route resolves to.
for (path, schema) in [
("/v1/extensions", "Extension"),
("/v1/extension-groups", "ExtensionGroup"),
] {
let item = &paths[path]["get"]["responses"]["200"]["content"]["application/json"]["schema"]
["items"]["$ref"];
assert_eq!(
item.as_str(),
Some(format!("#/components/schemas/{schema}").as_str()),
"get {path} 200 is not a list of {schema}: {item:?}"
);
}
// The presets a client may choose from must never carry the behaviour they
// expand to. A site list, a dwell range or a step programme appearing here
// would mean the browsing model had leaked out of the server.
@@ -4967,5 +5654,28 @@ mod tests {
"a schedule write must not declare a 429: {method}"
);
}
// Extension writes launch nothing and lease nothing, so the limiter never
// sees them. A declared 429 would be a status the server cannot produce.
for (path, method) in [
("/v1/extensions", "post"),
("/v1/extensions/{id}", "put"),
("/v1/extensions/{id}", "delete"),
("/v1/extension-groups", "post"),
("/v1/extension-groups/{id}", "put"),
(
"/v1/extension-groups/{id}/extensions/{extension_id}",
"post",
),
(
"/v1/extension-groups/{id}/extensions/{extension_id}",
"delete",
),
] {
assert!(
paths[path][method]["responses"].get("429").is_none(),
"an extension write must not declare a 429: {method} {path}"
);
}
}
}
+7
View File
@@ -1328,6 +1328,13 @@ impl BrowserRunner {
crate::profile::clear_on_close::clear_profile_browsing_data(profile).await;
}
// The browser held these open for the life of the process; nothing reads
// them once it has exited, and they are plaintext extension code sitting
// on real disk even for an ephemeral profile.
crate::extension_manager::ExtensionManager::cleanup_unpacked_for_profile(
&profile.id.to_string(),
);
log::info!(
"Wayfern process cleanup completed for profile: {} (ID: {})",
profile.name,
+7
View File
@@ -804,6 +804,13 @@ impl CloudAuthManager {
/// Account is in a paid/active state. Used for the "any active plan" gates
/// (sync token); per-feature access uses the capability helpers.
pub async fn has_active_paid_subscription(&self) -> bool {
#[cfg(feature = "e2e")]
if crate::e2e_automation_enabled()
&& std::env::var_os("WAYFERN_TEST_TOKEN").is_some_and(|token| !token.is_empty())
{
return true;
}
self.entitlements().await.map(|e| e.active).unwrap_or(false)
}
File diff suppressed because it is too large Load Diff
+6 -3
View File
@@ -188,9 +188,10 @@ use profile_importer::{
};
use extension_manager::{
add_extension, add_extension_to_group, assign_extension_group_to_profile, create_extension_group,
delete_extension, delete_extension_group, get_extension_group_for_profile, get_extension_icon,
list_extension_groups, list_extensions, remove_extension_from_group, update_extension,
add_extension, add_extension_to_group, add_unpacked_extension, assign_extension_group_to_profile,
create_extension_group, delete_extension, delete_extension_group,
get_extension_group_for_profile, get_extension_icon, list_extension_groups, list_extensions,
remove_extension_from_group, update_extension, update_extension_from_path,
update_extension_group,
};
@@ -2750,7 +2751,9 @@ pub fn run_with_builder(
list_extensions,
get_extension_icon,
add_extension,
add_unpacked_extension,
update_extension,
update_extension_from_path,
delete_extension,
list_extension_groups,
create_extension_group,
+262 -2
View File
@@ -1307,6 +1307,33 @@ impl McpServer {
"required": []
}),
},
McpTool {
name: "add_extension".to_string(),
description: "Add a managed browser extension from a path on the machine running Donut: a .crx or .zip archive file, or an unpacked extension folder holding a top-level manifest.json. With link set to true, which only applies to a folder, the folder is loaded in place instead of being copied into Donut, so edits to it apply on the next browser start and the extension is machine-local and never synced. Requires Pro subscription.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "Path on the machine running Donut to a .crx/.zip file or to an unpacked extension folder" },
"name": { "type": "string", "description": "Display name, used only when the manifest carries no name of its own" },
"link": { "type": "boolean", "description": "Folders only: load the folder in place instead of copying it into Donut. Linked extensions never sync. Defaults to false." }
},
"required": ["path"]
}),
},
McpTool {
name: "update_extension".to_string(),
description: "Rename a managed extension and/or replace its payload from a path on the machine running Donut: a .crx or .zip archive file, or an unpacked extension folder holding a top-level manifest.json. With link set to true, which only applies to a folder, the folder is loaded in place instead of being copied into Donut, so the extension becomes machine-local and never syncs. At least one of name or path must be given. Requires Pro subscription.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"extension_id": { "type": "string", "description": "The extension ID to update" },
"name": { "type": "string", "description": "New display name" },
"path": { "type": "string", "description": "Path on the machine running Donut to the .crx/.zip file or unpacked extension folder to replace the payload with" },
"link": { "type": "boolean", "description": "Folders only: load the folder in place instead of copying it into Donut. Linked extensions never sync. Defaults to false." }
},
"required": ["extension_id"]
}),
},
McpTool {
name: "create_extension_group".to_string(),
description: "Create a new extension group. Requires Pro subscription.".to_string(),
@@ -1318,6 +1345,47 @@ impl McpServer {
"required": ["name"]
}),
},
McpTool {
name: "update_extension_group".to_string(),
description: "Rename an extension group and/or replace its membership with an exact list of extension IDs. Requires Pro subscription.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"group_id": { "type": "string", "description": "The extension group ID to update" },
"name": { "type": "string", "description": "New name for the extension group" },
"extension_ids": {
"type": "array",
"items": { "type": "string" },
"description": "The complete set of extension IDs the group should contain, replacing the current membership"
}
},
"required": ["group_id"]
}),
},
McpTool {
name: "add_extension_to_group".to_string(),
description: "Add an extension to an extension group. Requires Pro subscription.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"group_id": { "type": "string", "description": "The extension group ID" },
"extension_id": { "type": "string", "description": "The extension ID to add to the group" }
},
"required": ["group_id", "extension_id"]
}),
},
McpTool {
name: "remove_extension_from_group".to_string(),
description: "Remove an extension from an extension group. Requires Pro subscription.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"group_id": { "type": "string", "description": "The extension group ID" },
"extension_id": { "type": "string", "description": "The extension ID to remove from the group" }
},
"required": ["group_id", "extension_id"]
}),
},
McpTool {
name: "delete_extension".to_string(),
description: "Delete a managed extension. Requires Pro subscription.".to_string(),
@@ -2215,7 +2283,12 @@ impl McpServer {
// Extension management
"list_extensions" => self.handle_list_extensions().await,
"list_extension_groups" => self.handle_list_extension_groups().await,
"add_extension" => self.handle_add_extension(arguments).await,
"update_extension" => self.handle_update_extension(arguments).await,
"create_extension_group" => self.handle_create_extension_group(arguments).await,
"update_extension_group" => self.handle_update_extension_group(arguments).await,
"add_extension_to_group" => self.handle_add_extension_to_group(arguments).await,
"remove_extension_from_group" => self.handle_remove_extension_from_group(arguments).await,
"delete_extension" => self.handle_delete_extension_mcp(arguments).await,
"delete_extension_group" => self.handle_delete_extension_group_mcp(arguments).await,
"assign_extension_group_to_profile" => {
@@ -4402,6 +4475,88 @@ impl McpServer {
Ok(serde_json::to_value(groups).unwrap())
}
async fn handle_add_extension(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
if !CLOUD_AUTH.has_active_paid_subscription().await {
return Err(McpError {
code: -32000,
message: "Extension management requires an active Pro subscription".to_string(),
});
}
let path = arguments
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing required parameter: path".to_string(),
})?;
let name = arguments
.get("name")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let link = arguments
.get("link")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
let extension = mgr
.add_extension_from_path(name, std::path::Path::new(path), link)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to add extension: {e}"),
})?;
Ok(serde_json::to_value(extension).unwrap())
}
async fn handle_update_extension(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
if !CLOUD_AUTH.has_active_paid_subscription().await {
return Err(McpError {
code: -32000,
message: "Extension management requires an active Pro subscription".to_string(),
});
}
let extension_id = arguments
.get("extension_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing required parameter: extension_id".to_string(),
})?;
let name = arguments
.get("name")
.and_then(|v| v.as_str())
.map(str::to_string);
let path = arguments.get("path").and_then(|v| v.as_str());
if name.is_none() && path.is_none() {
return Err(McpError {
code: -32602,
message: "Provide at least one of: name, path".to_string(),
});
}
let link = arguments
.get("link")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
let extension = match path {
Some(path) => {
mgr.update_extension_from_path(extension_id, name, std::path::Path::new(path), link)
}
None => mgr.update_extension(extension_id, name, None, None),
}
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to update extension: {e}"),
})?;
Ok(serde_json::to_value(extension).unwrap())
}
async fn handle_create_extension_group(
&self,
arguments: &serde_json::Value,
@@ -4427,6 +4582,106 @@ impl McpServer {
Ok(serde_json::to_value(group).unwrap())
}
async fn handle_update_extension_group(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
if !CLOUD_AUTH.has_active_paid_subscription().await {
return Err(McpError {
code: -32000,
message: "Extension management requires an active Pro subscription".to_string(),
});
}
let group_id = arguments
.get("group_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing required parameter: group_id".to_string(),
})?;
let name = arguments
.get("name")
.and_then(|v| v.as_str())
.map(str::to_string);
let extension_ids = arguments
.get("extension_ids")
.and_then(|v| v.as_array())
.map(|ids| {
ids
.iter()
.filter_map(|id| id.as_str().map(str::to_string))
.collect::<Vec<String>>()
});
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
let group = mgr
.update_group(group_id, name, extension_ids)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to update extension group: {e}"),
})?;
Ok(serde_json::to_value(group).unwrap())
}
async fn handle_add_extension_to_group(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
if !CLOUD_AUTH.has_active_paid_subscription().await {
return Err(McpError {
code: -32000,
message: "Extension management requires an active Pro subscription".to_string(),
});
}
let (group_id, extension_id) = Self::group_and_extension_ids(arguments)?;
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
let group = mgr
.add_extension_to_group(group_id, extension_id)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to add extension to group: {e}"),
})?;
Ok(serde_json::to_value(group).unwrap())
}
async fn handle_remove_extension_from_group(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
if !CLOUD_AUTH.has_active_paid_subscription().await {
return Err(McpError {
code: -32000,
message: "Extension management requires an active Pro subscription".to_string(),
});
}
let (group_id, extension_id) = Self::group_and_extension_ids(arguments)?;
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
let group = mgr
.remove_extension_from_group(group_id, extension_id)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to remove extension from group: {e}"),
})?;
Ok(serde_json::to_value(group).unwrap())
}
fn group_and_extension_ids(arguments: &serde_json::Value) -> Result<(&str, &str), McpError> {
let group_id = arguments
.get("group_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing required parameter: group_id".to_string(),
})?;
let extension_id = arguments
.get("extension_id")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing required parameter: extension_id".to_string(),
})?;
Ok((group_id, extension_id))
}
async fn handle_delete_extension_mcp(
&self,
arguments: &serde_json::Value,
@@ -6035,9 +6290,9 @@ mod tests {
let server = McpServer::new();
let tools = server.get_tools();
// Should have at least 54 tools (34 + 7 browser interaction + 13 remote
// Should have at least 59 tools (39 + 7 browser interaction + 13 remote
// fleet and cookie-bot tools)
assert!(tools.len() >= 54);
assert!(tools.len() >= 59);
// Names are the contract an MCP client is written against, so a duplicate
// silently shadows one of the two in dispatch and the tool that loses is
@@ -6092,7 +6347,12 @@ mod tests {
// Extension tools
assert!(tool_names.contains(&"list_extensions"));
assert!(tool_names.contains(&"list_extension_groups"));
assert!(tool_names.contains(&"add_extension"));
assert!(tool_names.contains(&"update_extension"));
assert!(tool_names.contains(&"create_extension_group"));
assert!(tool_names.contains(&"update_extension_group"));
assert!(tool_names.contains(&"add_extension_to_group"));
assert!(tool_names.contains(&"remove_extension_from_group"));
assert!(tool_names.contains(&"delete_extension"));
assert!(tool_names.contains(&"delete_extension_group"));
assert!(tool_names.contains(&"assign_extension_group_to_profile"));
+18 -2
View File
@@ -2084,6 +2084,13 @@ impl SyncEngine {
manager.get_extension(ext_id).ok()
};
// A linked extension is an absolute path on this machine with no payload in
// the store. Uploading it would publish metadata another device could never
// resolve, so it stays local whatever queued this run.
if local_ext.as_ref().is_some_and(|e| e.is_linked()) {
return Ok(());
}
let remote_key = format!("extensions/{}.json", ext_id);
let stat = self.client.stat(&remote_key).await?;
@@ -3251,7 +3258,9 @@ pub async fn enable_extension_group_sync_if_needed(extension_group_id: &str) ->
manager
.get_extension(ext_id)
.ok()
.map(|e| e.sync_enabled)
// A linked extension has no binary to hand the other device, only a
// path that means nothing there, so the cascade must not pick it up.
.map(|e| e.sync_enabled || e.is_linked())
.unwrap_or(true)
};
if !already_synced {
@@ -3983,7 +3992,9 @@ pub async fn enable_sync_for_all_entities(app_handle: tauri::AppHandle) -> Resul
.map_err(|e| format!("Failed to list extensions: {e}"))?
};
for ext in &exts {
if !ext.sync_enabled {
// Linked extensions are machine-local by definition and are skipped
// rather than reported as a failure on every sync setup.
if !ext.sync_enabled && !ext.is_linked() {
if let Err(e) = set_extension_sync_enabled(app_handle.clone(), ext.id.clone(), true).await {
log::warn!("Failed to enable sync for extension {}: {e}", ext.id);
}
@@ -4029,6 +4040,11 @@ pub async fn set_extension_sync_enabled(
};
if enabled {
// A linked extension is a path on this machine and nothing else; there is
// no payload to upload and the path would be meaningless on another device.
if ext.is_linked() {
return Err(serde_json::json!({ "code": "EXTENSION_LINKED_CANNOT_SYNC" }).to_string());
}
ensure_sync_configured(&app_handle).await?;
}
+420 -167
View File
@@ -11,6 +11,7 @@ import {
} from "@tanstack/react-table";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { open } from "@tauri-apps/plugin-dialog";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { FaChrome } from "react-icons/fa";
@@ -19,6 +20,8 @@ import {
LuChevronDown,
LuChevronUp,
LuExternalLink,
LuFolderOpen,
LuLink,
LuPencil,
LuPuzzle,
LuRefreshCw,
@@ -82,6 +85,18 @@ import { RippleButton } from "./ui/ripple";
type SyncStatus = "disabled" | "syncing" | "synced" | "error" | "waiting";
/** A payload staged in the UI, before it is handed to the backend. */
type PendingSource =
| { kind: "archive"; fileName: string; data: number[] }
| { kind: "folder"; path: string };
const ARCHIVE_EXTENSIONS = [".crx", ".zip"];
function pathBaseName(path: string): string {
const segments = path.split(/[/\\]/).filter(Boolean);
return segments[segments.length - 1] ?? path;
}
function getSyncStatusDot(
item: { sync_enabled?: boolean; last_sync?: number },
liveStatus: SyncStatus | undefined,
@@ -148,14 +163,13 @@ export function ExtensionManagementDialog({
const [extensionGroups, setExtensionGroups] = useState<ExtensionGroup[]>([]);
const [isLoading, setIsLoading] = useState(false);
// Extension upload state
// Extension import state
const [isUploading, setIsUploading] = useState(false);
const [extensionName, setExtensionName] = useState("");
const [showUploadForm, setShowUploadForm] = useState(false);
const [pendingFile, setPendingFile] = useState<{
name: string;
data: number[];
} | null>(null);
const [pendingSource, setPendingSource] = useState<PendingSource | null>(
null,
);
const [linkFolder, setLinkFolder] = useState(false);
// Group state
const [showCreateGroup, setShowCreateGroup] = useState(false);
@@ -192,10 +206,9 @@ export function ExtensionManagementDialog({
null,
);
const [editExtensionName, setEditExtensionName] = useState("");
const [pendingUpdateFile, setPendingUpdateFile] = useState<{
name: string;
data: number[];
} | null>(null);
const [pendingUpdateSource, setPendingUpdateSource] =
useState<PendingSource | null>(null);
const [editLinkFolder, setEditLinkFolder] = useState(false);
// Extension icons
const [extensionIcons, setExtensionIcons] = useState<Record<string, string>>(
@@ -295,6 +308,30 @@ export function ExtensionManagementDialog({
};
}, []);
/** Structured backend codes win; anything else falls back to a local message
* so the user never sees a raw Rust string. */
const showActionError = useCallback(
(err: unknown, fallback: string) => {
showErrorToast(
parseBackendError(err) ? translateBackendError(t, err) : fallback,
);
},
[t],
);
const resetImportForm = useCallback(() => {
setPendingSource(null);
setExtensionName("");
setLinkFolder(false);
}, []);
const closeEditExtension = useCallback(() => {
setEditingExtension(null);
setEditExtensionName("");
setPendingUpdateSource(null);
setEditLinkFolder(false);
}, []);
const handleToggleExtSync = useCallback(
async (ext: Extension) => {
setIsTogglingExtSync((prev) => ({ ...prev, [ext.id]: true }));
@@ -310,16 +347,12 @@ export function ExtensionManagementDialog({
);
void loadData();
} catch (err) {
showErrorToast(
parseBackendError(err)
? translateBackendError(t, err)
: t("proxies.management.updateSyncFailed"),
);
showActionError(err, t("proxies.management.updateSyncFailed"));
} finally {
setIsTogglingExtSync((prev) => ({ ...prev, [ext.id]: false }));
}
},
[loadData, t],
[loadData, showActionError, t],
);
const handleToggleGroupSync = useCallback(
@@ -337,119 +370,178 @@ export function ExtensionManagementDialog({
);
void loadData();
} catch (err) {
showErrorToast(
parseBackendError(err)
? translateBackendError(t, err)
: t("proxies.management.updateSyncFailed"),
);
showActionError(err, t("proxies.management.updateSyncFailed"));
} finally {
setIsTogglingGroupSync((prev) => ({ ...prev, [group.id]: false }));
}
},
[loadData, t],
[loadData, showActionError, t],
);
const handleUpdateExtension = useCallback(async () => {
if (!editingExtension || !editExtensionName.trim()) return;
try {
await invoke("update_extension", {
extensionId: editingExtension.id,
name: editExtensionName.trim(),
fileName: pendingUpdateFile?.name ?? null,
fileData: pendingUpdateFile?.data ?? null,
});
if (pendingUpdateSource?.kind === "folder") {
await invoke("update_extension_from_path", {
extensionId: editingExtension.id,
name: editExtensionName.trim(),
path: pendingUpdateSource.path,
link: editLinkFolder,
});
} else {
await invoke("update_extension", {
extensionId: editingExtension.id,
name: editExtensionName.trim(),
fileName: pendingUpdateSource?.fileName ?? null,
fileData: pendingUpdateSource?.data ?? null,
});
}
showSuccessToast(t("extensions.updateSuccess"));
setEditingExtension(null);
setEditExtensionName("");
setPendingUpdateFile(null);
closeEditExtension();
void loadData();
} catch (err) {
showErrorToast(err instanceof Error ? err.message : String(err));
showActionError(err, t("extensions.updateFailed"));
}
}, [editingExtension, editExtensionName, pendingUpdateFile, loadData, t]);
}, [
editingExtension,
editExtensionName,
pendingUpdateSource,
editLinkFolder,
closeEditExtension,
loadData,
showActionError,
t,
]);
const handleEditFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const validExtensions = [".xpi", ".crx", ".zip"];
const isValid = validExtensions.some((ext) =>
/** Reads a picked archive into memory, shared by the import and the replace
* flows. Resolves to null when the file is rejected or unreadable. */
const readArchiveFile = useCallback(
(file: File): Promise<PendingSource | null> => {
const isValid = ARCHIVE_EXTENSIONS.some((ext) =>
file.name.toLowerCase().endsWith(ext),
);
if (!isValid) {
showErrorToast(t("extensions.invalidFileType"));
return;
return Promise.resolve(null);
}
const reader = new FileReader();
reader.onload = (event) => {
const arrayBuffer = event.target?.result as ArrayBuffer;
const data = Array.from(new Uint8Array(arrayBuffer));
setPendingUpdateFile({ name: file.name, data });
};
reader.readAsArrayBuffer(file);
e.target.value = "";
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (event) => {
const arrayBuffer = event.target?.result as ArrayBuffer;
resolve({
kind: "archive",
fileName: file.name,
data: Array.from(new Uint8Array(arrayBuffer)),
});
};
reader.onerror = () => {
showErrorToast(t("extensions.readError"));
resolve(null);
};
reader.readAsArrayBuffer(file);
});
},
[t],
);
const handleEditFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
void readArchiveFile(file).then((source) => {
if (!source) return;
setPendingUpdateSource(source);
setEditLinkFolder(false);
});
},
[readArchiveFile],
);
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
const validExtensions = [".xpi", ".crx", ".zip"];
const isValid = validExtensions.some((ext) =>
file.name.toLowerCase().endsWith(ext),
);
if (!isValid) {
showErrorToast(t("extensions.invalidFileType"));
return;
}
const reader = new FileReader();
reader.onload = (event) => {
const arrayBuffer = event.target?.result as ArrayBuffer;
const data = Array.from(new Uint8Array(arrayBuffer));
const baseName = file.name
.replace(/\.(xpi|crx|zip)$/i, "")
.replace(/[-_]/g, " ");
setExtensionName(baseName);
setPendingFile({ name: file.name, data });
setShowUploadForm(true);
};
reader.onerror = () => {
showErrorToast(t("extensions.readError"));
};
reader.readAsArrayBuffer(file);
// Reset input
e.target.value = "";
void readArchiveFile(file).then((source) => {
if (!source) return;
setExtensionName(
file.name.replace(/\.(crx|zip)$/i, "").replace(/[-_]/g, " "),
);
setLinkFolder(false);
setPendingSource(source);
});
},
[t],
[readArchiveFile],
);
/** Native directory picker, the "Load unpacked" entry point. */
const pickExtensionFolder = useCallback(async (): Promise<string | null> => {
try {
const selected = await open({
directory: true,
multiple: false,
title: t("extensions.selectFolderTitle"),
});
return typeof selected === "string" ? selected : null;
} catch (err) {
console.error("Failed to open folder dialog:", err);
showErrorToast(t("importProfile.folderDialogFailed"));
return null;
}
}, [t]);
const handleLoadUnpacked = useCallback(async () => {
const folder = await pickExtensionFolder();
if (!folder) return;
setExtensionName(pathBaseName(folder).replace(/[-_]/g, " "));
setLinkFolder(false);
setPendingSource({ kind: "folder", path: folder });
}, [pickExtensionFolder]);
const handleEditFolderSelect = useCallback(async () => {
const folder = await pickExtensionFolder();
if (!folder) return;
setPendingUpdateSource({ kind: "folder", path: folder });
}, [pickExtensionFolder]);
const handleUpload = useCallback(async () => {
if (!pendingFile || !extensionName.trim()) return;
if (!pendingSource || !extensionName.trim()) return;
setIsUploading(true);
try {
await invoke("add_extension", {
name: extensionName.trim(),
fileName: pendingFile.name,
fileData: pendingFile.data,
});
if (pendingSource.kind === "folder") {
await invoke("add_unpacked_extension", {
name: extensionName.trim(),
path: pendingSource.path,
link: linkFolder,
});
} else {
await invoke("add_extension", {
name: extensionName.trim(),
fileName: pendingSource.fileName,
fileData: pendingSource.data,
});
}
showSuccessToast(t("extensions.uploadSuccess"));
setShowUploadForm(false);
setPendingFile(null);
setExtensionName("");
resetImportForm();
void loadData();
} catch (err) {
showErrorToast(err instanceof Error ? err.message : String(err));
showActionError(err, t("extensions.uploadFailed"));
} finally {
setIsUploading(false);
}
}, [pendingFile, extensionName, loadData, t]);
}, [
pendingSource,
extensionName,
linkFolder,
resetImportForm,
loadData,
showActionError,
t,
]);
const handleDeleteExtension = useCallback(async () => {
if (!extensionToDelete) return;
@@ -460,11 +552,11 @@ export function ExtensionManagementDialog({
setExtensionToDelete(null);
void loadData();
} catch (err) {
showErrorToast(err instanceof Error ? err.message : String(err));
showActionError(err, t("extensions.deleteFailed"));
} finally {
setIsDeleting(false);
}
}, [extensionToDelete, loadData, t]);
}, [extensionToDelete, loadData, showActionError, t]);
const handleCreateGroup = useCallback(async () => {
if (!newGroupName.trim()) return;
@@ -475,9 +567,9 @@ export function ExtensionManagementDialog({
setNewGroupName("");
void loadData();
} catch (err) {
showErrorToast(err instanceof Error ? err.message : String(err));
showActionError(err, t("extensions.groupCreateFailed"));
}
}, [newGroupName, loadData, t]);
}, [newGroupName, loadData, showActionError, t]);
const handleSaveGroupEdits = useCallback(async () => {
if (!editingGroup || !editGroupName.trim()) return;
@@ -518,9 +610,16 @@ export function ExtensionManagementDialog({
setEditGroupExtensionIds([]);
void loadData();
} catch (err) {
showErrorToast(err instanceof Error ? err.message : String(err));
showActionError(err, t("extensions.groupUpdateFailed"));
}
}, [editingGroup, editGroupName, editGroupExtensionIds, loadData, t]);
}, [
editingGroup,
editGroupName,
editGroupExtensionIds,
loadData,
showActionError,
t,
]);
const handleDeleteGroup = useCallback(async () => {
if (!groupToDelete) return;
@@ -531,11 +630,11 @@ export function ExtensionManagementDialog({
setGroupToDelete(null);
void loadData();
} catch (err) {
showErrorToast(err instanceof Error ? err.message : String(err));
showActionError(err, t("extensions.groupDeleteFailed"));
} finally {
setIsDeleting(false);
}
}, [groupToDelete, loadData, t]);
}, [groupToDelete, loadData, showActionError, t]);
const selectedExtensions = useMemo(
() => extensions.filter((ext) => extRowSelection[ext.id]),
@@ -561,11 +660,11 @@ export function ExtensionManagementDialog({
setExtRowSelection({});
void loadData();
} catch (err) {
showErrorToast(err instanceof Error ? err.message : String(err));
showActionError(err, t("extensions.deleteFailed"));
} finally {
setIsDeleting(false);
}
}, [selectedExtensions, loadData, t]);
}, [selectedExtensions, loadData, showActionError, t]);
const handleBulkDeleteGroups = useCallback(async () => {
if (selectedGroups.length === 0) return;
@@ -581,18 +680,27 @@ export function ExtensionManagementDialog({
setGroupRowSelection({});
void loadData();
} catch (err) {
showErrorToast(err instanceof Error ? err.message : String(err));
showActionError(err, t("extensions.groupDeleteFailed"));
} finally {
setIsDeleting(false);
}
}, [selectedGroups, loadData, t]);
}, [selectedGroups, loadData, showActionError, t]);
const handleBulkToggleExtSync = useCallback(async () => {
if (selectedExtensions.length === 0) return;
const allOn = selectedExtensions.every((e) => e.sync_enabled);
const targetEnabled = !allOn;
// A linked extension has no payload to upload, so enabling sync on one is
// refused by the backend. Skip them instead of failing the whole batch.
const targets = targetEnabled
? selectedExtensions.filter((ext) => !ext.linked_path)
: selectedExtensions;
if (targets.length === 0) {
showErrorToast(t("extensions.linkedNoSync"));
return;
}
const results = await Promise.allSettled(
selectedExtensions.map((ext) =>
targets.map((ext) =>
invoke("set_extension_sync_enabled", {
extensionId: ext.id,
enabled: targetEnabled,
@@ -603,10 +711,9 @@ export function ExtensionManagementDialog({
| PromiseRejectedResult
| undefined;
if (firstRejection) {
showErrorToast(
parseBackendError(firstRejection.reason)
? translateBackendError(t, firstRejection.reason)
: t("proxies.management.updateSyncFailed"),
showActionError(
firstRejection.reason,
t("proxies.management.updateSyncFailed"),
);
} else {
showSuccessToast(
@@ -616,7 +723,7 @@ export function ExtensionManagementDialog({
);
}
void loadData();
}, [selectedExtensions, loadData, t]);
}, [selectedExtensions, loadData, showActionError, t]);
const handleBulkToggleGroupSync = useCallback(async () => {
if (selectedGroups.length === 0) return;
@@ -634,10 +741,9 @@ export function ExtensionManagementDialog({
| PromiseRejectedResult
| undefined;
if (firstRejection) {
showErrorToast(
parseBackendError(firstRejection.reason)
? translateBackendError(t, firstRejection.reason)
: t("proxies.management.updateSyncFailed"),
showActionError(
firstRejection.reason,
t("proxies.management.updateSyncFailed"),
);
} else {
showSuccessToast(
@@ -647,7 +753,7 @@ export function ExtensionManagementDialog({
);
}
void loadData();
}, [selectedGroups, loadData, t]);
}, [selectedGroups, loadData, showActionError, t]);
const renderCompatIcons = useCallback(
(compat: string[]) => {
@@ -691,6 +797,42 @@ export function ExtensionManagementDialog({
[extensionIcons],
);
/** What the extension actually is: a stored archive, a folder packed into
* the store, or a folder loaded in place from the user's disk. */
const renderSource = useCallback(
(ext: Extension) => {
if (ext.linked_path) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex min-w-0 items-center gap-1 text-xs text-muted-foreground">
<LuLink className="size-3 shrink-0" />
<span className="truncate">
{t("extensions.source.linked")}
</span>
</span>
</TooltipTrigger>
<TooltipContent>
<p className="max-w-xs break-all">
{t("extensions.source.linkedTooltip", {
path: ext.linked_path,
})}
</p>
</TooltipContent>
</Tooltip>
);
}
return (
<span className="block min-w-0 truncate text-xs text-muted-foreground">
{ext.source_kind === "unpacked"
? t("extensions.source.unpacked")
: t("extensions.source.archive")}
</span>
);
},
[t],
);
const MAX_VISIBLE_ICONS = 3;
const extensionColumns = useMemo<ColumnDef<Extension>[]>(
@@ -762,6 +904,13 @@ export function ExtensionManagementDialog({
cell: ({ row }) =>
renderCompatIcons(row.original.browser_compatibility),
},
{
id: "source",
size: 128,
enableSorting: false,
header: () => null,
cell: ({ row }) => renderSource(row.original),
},
{
id: "sync",
size: 88,
@@ -770,6 +919,7 @@ export function ExtensionManagementDialog({
cell: ({ row }) => {
const ext = row.original;
const syncDot = getSyncStatusDot(ext, extSyncStatus[ext.id], t);
const isLinked = Boolean(ext.linked_path);
return (
<div className="flex shrink-0 items-center gap-2">
<Tooltip>
@@ -790,15 +940,17 @@ export function ExtensionManagementDialog({
<AnimatedSwitch
checked={ext.sync_enabled}
onCheckedChange={() => void handleToggleExtSync(ext)}
disabled={isTogglingExtSync[ext.id]}
disabled={isLinked || isTogglingExtSync[ext.id]}
/>
</span>
</TooltipTrigger>
<TooltipContent>
<p>
{ext.sync_enabled
? t("syncTooltips.disable")
: t("syncTooltips.enable")}
{isLinked
? t("extensions.linkedNoSync")
: ext.sync_enabled
? t("syncTooltips.disable")
: t("syncTooltips.enable")}
</p>
</TooltipContent>
</Tooltip>
@@ -824,7 +976,8 @@ export function ExtensionManagementDialog({
onClick={() => {
setEditingExtension(ext);
setEditExtensionName(ext.name);
setPendingUpdateFile(null);
setPendingUpdateSource(null);
setEditLinkFolder(Boolean(ext.linked_path));
}}
>
<LuPencil className="size-3.5" />
@@ -859,6 +1012,7 @@ export function ExtensionManagementDialog({
handleToggleExtSync,
renderExtensionIcon,
renderCompatIcons,
renderSource,
],
);
@@ -1160,25 +1314,48 @@ export function ExtensionManagementDialog({
</AnimatedTabsList>
<div className="flex items-center gap-2">
{activeTab === "extensions" && (
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
size="sm"
variant="outline"
disabled={limitedMode}
onClick={() =>
document.getElementById("ext-file-input")?.click()
}
aria-label={t("extensions.upload")}
>
<LuUpload className="size-4" />
<span className="hidden @2xl:inline">
{t("extensions.upload")}
</span>
</RippleButton>
</TooltipTrigger>
<TooltipContent>{t("extensions.upload")}</TooltipContent>
</Tooltip>
<>
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
size="sm"
variant="outline"
disabled={limitedMode}
onClick={() =>
document.getElementById("ext-file-input")?.click()
}
aria-label={t("extensions.upload")}
>
<LuUpload className="size-4" />
<span className="hidden @2xl:inline">
{t("extensions.upload")}
</span>
</RippleButton>
</TooltipTrigger>
<TooltipContent>
{t("extensions.upload")}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
size="sm"
variant="outline"
disabled={limitedMode}
onClick={() => void handleLoadUnpacked()}
aria-label={t("extensions.loadUnpacked")}
>
<LuFolderOpen className="size-4" />
<span className="hidden @2xl:inline">
{t("extensions.loadUnpacked")}
</span>
</RippleButton>
</TooltipTrigger>
<TooltipContent>
{t("extensions.loadUnpackedTooltip")}
</TooltipContent>
</Tooltip>
</>
)}
{activeTab === "groups" && (
<Tooltip>
@@ -1216,21 +1393,51 @@ export function ExtensionManagementDialog({
<Input
id="ext-file-input"
type="file"
accept=".xpi,.crx,.zip"
accept=".crx,.zip"
className="hidden"
onChange={handleFileSelect}
disabled={limitedMode}
/>
{/* Upload form */}
{showUploadForm && pendingFile && (
{/* Import form */}
{pendingSource && (
<div className="space-y-3 rounded-md border p-3">
<div className="text-sm text-muted-foreground">
{t("extensions.selectedFile")}:{" "}
<span className="font-medium text-foreground">
{pendingFile.name}
{pendingSource.kind === "folder"
? t("extensions.selectedFolder")
: t("extensions.selectedFile")}
:{" "}
<span className="font-medium break-all text-foreground">
{pendingSource.kind === "folder"
? pendingSource.path
: pendingSource.fileName}
</span>
</div>
{pendingSource.kind === "folder" && (
<div className="flex items-start gap-2">
<Checkbox
id="ext-link-folder"
checked={linkFolder}
onCheckedChange={(value) => {
setLinkFolder(value === true);
}}
className="mt-0.5"
/>
<div className="space-y-0.5">
<Label
htmlFor="ext-link-folder"
className="text-sm font-normal"
>
{t("extensions.linkFolder")}
</Label>
<p className="text-xs text-muted-foreground">
{linkFolder
? t("extensions.linkFolderOn")
: t("extensions.linkFolderOff")}
</p>
</div>
</div>
)}
<div className="flex gap-2">
<Input
value={extensionName}
@@ -1252,11 +1459,7 @@ export function ExtensionManagementDialog({
<Button
size="sm"
variant="outline"
onClick={() => {
setShowUploadForm(false);
setPendingFile(null);
setExtensionName("");
}}
onClick={resetImportForm}
>
{t("common.buttons.cancel")}
</Button>
@@ -1611,11 +1814,7 @@ export function ExtensionManagementDialog({
<Dialog
open={editingExtension !== null}
onOpenChange={(open) => {
if (!open) {
setEditingExtension(null);
setEditExtensionName("");
setPendingUpdateFile(null);
}
if (!open) closeEditExtension();
}}
>
<DialogContent className="flex max-h-[90vh] max-w-lg flex-col">
@@ -1684,9 +1883,35 @@ export function ExtensionManagementDialog({
)}
</div>
<span className="text-muted-foreground">
{t("common.labels.type")}
{t("extensions.source.label")}
</span>
<span>.{editingExtension.file_type}</span>
<span>
{editingExtension.linked_path
? t("extensions.source.linked")
: editingExtension.source_kind === "unpacked"
? t("extensions.source.unpacked")
: t("extensions.source.archive")}
</span>
{editingExtension.linked_path ? (
<>
<span className="text-muted-foreground">
{t("extensions.source.folderLabel")}
</span>
<span className="break-all">
{editingExtension.linked_path}
</span>
<p className="col-span-2 text-xs text-muted-foreground">
{t("extensions.linkFolderOn")}
</p>
</>
) : (
<>
<span className="text-muted-foreground">
{t("common.labels.type")}
</span>
<span>.{editingExtension.file_type}</span>
</>
)}
{editingExtension.homepage_url && (
<>
<span className="text-muted-foreground">
@@ -1716,10 +1941,10 @@ export function ExtensionManagementDialog({
</div>
</div>
{/* Re-upload */}
{/* Replace the payload with another archive or folder */}
<div className="space-y-2">
<Label>{t("extensions.reupload")}</Label>
<div className="flex items-center gap-2">
<Label>{t("extensions.replaceSource")}</Label>
<div className="flex flex-wrap items-center gap-2">
<RippleButton
size="sm"
variant="outline"
@@ -1733,30 +1958,58 @@ export function ExtensionManagementDialog({
<input
id="ext-edit-file-input"
type="file"
accept=".xpi,.crx,.zip"
accept=".crx,.zip"
className="hidden"
onChange={handleEditFileSelect}
/>
{pendingUpdateFile && (
<RippleButton
size="sm"
variant="outline"
onClick={() => void handleEditFolderSelect()}
>
<LuFolderOpen className="mr-1 size-3" />
{t("extensions.selectFolder")}
</RippleButton>
{pendingUpdateSource && (
<span className="max-w-[200px] truncate text-xs text-muted-foreground">
{pendingUpdateFile.name}
{pendingUpdateSource.kind === "folder"
? pendingUpdateSource.path
: pendingUpdateSource.fileName}
</span>
)}
</div>
{pendingUpdateSource?.kind === "folder" && (
<div className="flex items-start gap-2 pt-1">
<Checkbox
id="ext-edit-link-folder"
checked={editLinkFolder}
onCheckedChange={(value) => {
setEditLinkFolder(value === true);
}}
className="mt-0.5"
/>
<div className="space-y-0.5">
<Label
htmlFor="ext-edit-link-folder"
className="text-sm font-normal"
>
{t("extensions.linkFolder")}
</Label>
<p className="text-xs text-muted-foreground">
{editLinkFolder
? t("extensions.linkFolderOn")
: t("extensions.linkFolderOff")}
</p>
</div>
</div>
)}
</div>
</div>
)}
</ScrollArea>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setEditingExtension(null);
setEditExtensionName("");
setPendingUpdateFile(null);
}}
>
<Button variant="outline" onClick={closeEditExtension}>
{t("common.buttons.cancel")}
</Button>
<RippleButton
+35 -3
View File
@@ -1271,7 +1271,7 @@
"deleteConfirmDescription": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
"deleteGroupConfirmTitle": "Delete Extension Group",
"deleteGroupConfirmDescription": "Are you sure you want to delete the group \"{{name}}\"? This action cannot be undone.",
"invalidFileType": "Invalid file type. Please upload a .crx, .xpi, or .zip file.",
"invalidFileType": "Invalid file type. Please choose a .crx or .zip file.",
"readError": "Failed to read the extension file.",
"assignTitle": "Assign Extension Group",
"assignDescription": "Assign {{count}} selected profile(s) to an extension group.",
@@ -1279,7 +1279,6 @@
"assignSuccess": "Extension group assigned successfully",
"editExtension": "Edit extension",
"updateSuccess": "Extension updated successfully",
"reupload": "Re-upload",
"version": "Version",
"author": "Author",
"homepage": "Homepage",
@@ -1287,10 +1286,26 @@
"editGroupDescription": "Update the group name and manage which extensions are included.",
"groupExtensions": "Extensions in this group",
"noExtensionsInGroup": "No extensions added yet",
"editExtensionDescription": "Update extension name, view metadata, or re-upload the extension file.",
"editExtensionDescription": "Update the extension name, view its metadata, or replace it with another archive or folder.",
"metadata": "Metadata",
"noMetadata": "No metadata available from manifest.",
"selectFile": "Choose File",
"loadUnpacked": "Load unpacked",
"loadUnpackedTooltip": "Load an extension from a folder containing manifest.json",
"selectFolderTitle": "Select Extension Folder",
"selectedFolder": "Selected folder",
"selectFolder": "Choose Folder",
"linkFolder": "Load in place from this folder",
"linkFolderOff": "The folder is copied into Donut. The extension is portable and syncs to your other devices.",
"linkFolderOn": "Donut loads the extension straight from this folder on every launch. Your edits apply on the next browser start, but the extension stays on this machine and never syncs.",
"replaceSource": "Replace source",
"linkedNoSync": "Linked extensions stay on this machine and can't sync.",
"uploadFailed": "Failed to add extension",
"updateFailed": "Failed to update extension",
"deleteFailed": "Failed to delete extension",
"groupCreateFailed": "Failed to create extension group",
"groupUpdateFailed": "Failed to update extension group",
"groupDeleteFailed": "Failed to delete extension group",
"syncEnabled": "Sync enabled",
"syncDisabled": "Sync disabled",
"syncEnableTooltip": "Enable sync",
@@ -1303,6 +1318,14 @@
"groupsTitle": "Delete extension groups",
"groupsDescription": "Delete {{count}} extension groups? {{names}}",
"confirmButton": "Delete"
},
"source": {
"label": "Source",
"archive": "Archive",
"unpacked": "Unpacked folder",
"linked": "Linked folder",
"folderLabel": "Folder",
"linkedTooltip": "Loaded in place from {{path}}"
}
},
"pro": {
@@ -1842,6 +1865,15 @@
"vpnNotFound": "VPN not found",
"extensionNotFound": "Extension not found",
"extensionGroupNotFound": "Extension group not found",
"extensionUnsupportedFileType": "That file type isn't supported. An extension has to be a .crx or .zip archive, or a folder.",
"extensionDirNotFound": "That folder no longer exists.",
"extensionNotADirectory": "That path is not a folder.",
"extensionManifestMissing": "There is no manifest.json in that folder. Choose the folder that holds the extension's manifest.json.",
"extensionManifestInvalid": "The manifest.json in that folder could not be read.",
"extensionDirTooLarge": "That folder is too large to copy into Donut (the limit is 256 MB and 20,000 files). Link it in place instead.",
"extensionPathHasComma": "That folder's path contains a comma, which Chromium cannot load. Rename or move the folder.",
"extensionLinkRequiresDirectory": "Only a folder can be loaded in place. Turn linking off to add an archive.",
"extensionLinkedCannotSync": "This extension is loaded from a folder on this machine, so there is nothing to sync.",
"cannotModifyCloudManagedProxy": "Cannot modify sync for a cloud-managed proxy",
"syncLockedByProfile": "Sync cannot be disabled while this is used by synced profiles",
"syncNotConfigured": "Sync is not configured. Sign in or configure a self-hosted server first.",
+35 -3
View File
@@ -1274,7 +1274,7 @@
"deleteConfirmDescription": "¿Estás seguro de que deseas eliminar \"{{name}}\"? Esta acción no se puede deshacer.",
"deleteGroupConfirmTitle": "Eliminar Grupo de Extensiones",
"deleteGroupConfirmDescription": "¿Estás seguro de que deseas eliminar el grupo \"{{name}}\"? Esta acción no se puede deshacer.",
"invalidFileType": "Tipo de archivo no válido. Suba un archivo .crx, .xpi o .zip.",
"invalidFileType": "Tipo de archivo no válido. Elige un archivo .crx o .zip.",
"readError": "No se pudo leer el archivo de extensión.",
"assignTitle": "Asignar Grupo de Extensiones",
"assignDescription": "Asignar {{count}} perfil(es) seleccionado(s) a un grupo de extensiones.",
@@ -1282,7 +1282,6 @@
"assignSuccess": "Grupo de extensiones asignado exitosamente",
"editExtension": "Editar extensión",
"updateSuccess": "Extensión actualizada exitosamente",
"reupload": "Re-subir",
"version": "Versión",
"author": "Autor",
"homepage": "Página de inicio",
@@ -1290,10 +1289,26 @@
"editGroupDescription": "Actualiza el nombre del grupo y gestiona qué extensiones están incluidas.",
"groupExtensions": "Extensiones en este grupo",
"noExtensionsInGroup": "Aún no se han añadido extensiones",
"editExtensionDescription": "Actualizar el nombre de la extensión, ver metadatos o volver a cargar el archivo de extensión.",
"editExtensionDescription": "Actualiza el nombre de la extensión, consulta sus metadatos o reemplázala por otro archivo comprimido o carpeta.",
"metadata": "Metadatos",
"noMetadata": "No hay metadatos disponibles del manifiesto.",
"selectFile": "Elegir archivo",
"loadUnpacked": "Cargar sin empaquetar",
"loadUnpackedTooltip": "Carga una extensión desde una carpeta que contenga manifest.json",
"selectFolderTitle": "Seleccionar carpeta de la extensión",
"selectedFolder": "Carpeta seleccionada",
"selectFolder": "Elegir carpeta",
"linkFolder": "Cargar directamente desde esta carpeta",
"linkFolderOff": "La carpeta se copia en Donut. La extensión es portátil y se sincroniza con tus otros dispositivos.",
"linkFolderOn": "Donut carga la extensión directamente desde esta carpeta en cada inicio. Tus cambios se aplican al abrir el navegador de nuevo, pero la extensión permanece en este equipo y nunca se sincroniza.",
"replaceSource": "Reemplazar origen",
"linkedNoSync": "Las extensiones enlazadas permanecen en este equipo y no se pueden sincronizar.",
"uploadFailed": "No se pudo añadir la extensión",
"updateFailed": "No se pudo actualizar la extensión",
"deleteFailed": "No se pudo eliminar la extensión",
"groupCreateFailed": "No se pudo crear el grupo de extensiones",
"groupUpdateFailed": "No se pudo actualizar el grupo de extensiones",
"groupDeleteFailed": "No se pudo eliminar el grupo de extensiones",
"syncEnabled": "Sincronización habilitada",
"syncDisabled": "Sincronización deshabilitada",
"syncEnableTooltip": "Habilitar sincronización",
@@ -1306,6 +1321,14 @@
"groupsTitle": "Eliminar grupos de extensiones",
"groupsDescription": "¿Eliminar {{count}} grupos de extensiones? {{names}}",
"confirmButton": "Eliminar"
},
"source": {
"label": "Origen",
"archive": "Archivo comprimido",
"unpacked": "Carpeta sin empaquetar",
"linked": "Carpeta enlazada",
"folderLabel": "Carpeta",
"linkedTooltip": "Se carga directamente desde {{path}}"
}
},
"pro": {
@@ -1849,6 +1872,15 @@
"vpnNotFound": "VPN no encontrada",
"extensionNotFound": "Extensión no encontrada",
"extensionGroupNotFound": "Grupo de extensiones no encontrado",
"extensionUnsupportedFileType": "Ese tipo de archivo no es compatible. Una extensión debe ser un archivo .crx o .zip, o una carpeta.",
"extensionDirNotFound": "Esa carpeta ya no existe.",
"extensionNotADirectory": "Esa ruta no es una carpeta.",
"extensionManifestMissing": "No hay ningún manifest.json en esa carpeta. Elige la carpeta que contiene el manifest.json de la extensión.",
"extensionManifestInvalid": "No se pudo leer el manifest.json de esa carpeta.",
"extensionDirTooLarge": "Esa carpeta es demasiado grande para copiarla en Donut (el límite es 256 MB y 20 000 archivos). Enlázala en su ubicación.",
"extensionPathHasComma": "La ruta de esa carpeta contiene una coma, que Chromium no puede cargar. Cambia el nombre de la carpeta o muévela.",
"extensionLinkRequiresDirectory": "Solo se puede cargar en su ubicación una carpeta. Desactiva el enlace para añadir un archivo comprimido.",
"extensionLinkedCannotSync": "Esta extensión se carga desde una carpeta de este equipo, así que no hay nada que sincronizar.",
"cannotModifyCloudManagedProxy": "No se puede modificar la sincronización de un proxy gestionado en la nube",
"syncLockedByProfile": "No se puede desactivar la sincronización mientras se usa en perfiles sincronizados",
"syncNotConfigured": "La sincronización no está configurada. Inicia sesión o configura un servidor propio.",
+35 -3
View File
@@ -1274,7 +1274,7 @@
"deleteConfirmDescription": "Êtes-vous sûr de vouloir supprimer \"{{name}}\" ? Cette action est irréversible.",
"deleteGroupConfirmTitle": "Supprimer le Groupe d'Extensions",
"deleteGroupConfirmDescription": "Êtes-vous sûr de vouloir supprimer le groupe \"{{name}}\" ? Cette action est irréversible.",
"invalidFileType": "Type de fichier non valide. Veuillez télécharger un fichier .crx, .xpi ou .zip.",
"invalidFileType": "Type de fichier non valide. Choisissez un fichier .crx ou .zip.",
"readError": "Impossible de lire le fichier d'extension.",
"assignTitle": "Assigner un Groupe d'Extensions",
"assignDescription": "Assigner {{count}} profil(s) sélectionné(s) à un groupe d'extensions.",
@@ -1282,7 +1282,6 @@
"assignSuccess": "Groupe d'extensions assigné avec succès",
"editExtension": "Modifier l'extension",
"updateSuccess": "Extension mise à jour avec succès",
"reupload": "Re-télécharger",
"version": "Version",
"author": "Auteur",
"homepage": "Page d'accueil",
@@ -1290,10 +1289,26 @@
"editGroupDescription": "Mettez à jour le nom du groupe et gérez les extensions incluses.",
"groupExtensions": "Extensions dans ce groupe",
"noExtensionsInGroup": "Aucune extension ajoutée",
"editExtensionDescription": "Modifier le nom de l'extension, voir les métadonnées ou re-télécharger le fichier d'extension.",
"editExtensionDescription": "Modifiez le nom de l'extension, consultez ses métadonnées ou remplacez-la par une autre archive ou un autre dossier.",
"metadata": "Métadonnées",
"noMetadata": "Aucune métadonnée disponible depuis le manifeste.",
"selectFile": "Choisir un fichier",
"loadUnpacked": "Charger non empaquetée",
"loadUnpackedTooltip": "Chargez une extension depuis un dossier contenant manifest.json",
"selectFolderTitle": "Sélectionner le dossier de l'extension",
"selectedFolder": "Dossier sélectionné",
"selectFolder": "Choisir un dossier",
"linkFolder": "Charger directement depuis ce dossier",
"linkFolderOff": "Le dossier est copié dans Donut. L'extension est portable et se synchronise avec vos autres appareils.",
"linkFolderOn": "Donut charge l'extension directement depuis ce dossier à chaque lancement. Vos modifications s'appliquent au prochain démarrage du navigateur, mais l'extension reste sur cet ordinateur et ne se synchronise jamais.",
"replaceSource": "Remplacer la source",
"linkedNoSync": "Les extensions liées restent sur cet ordinateur et ne peuvent pas être synchronisées.",
"uploadFailed": "Échec de l'ajout de l'extension",
"updateFailed": "Échec de la mise à jour de l'extension",
"deleteFailed": "Échec de la suppression de l'extension",
"groupCreateFailed": "Échec de la création du groupe d'extensions",
"groupUpdateFailed": "Échec de la mise à jour du groupe d'extensions",
"groupDeleteFailed": "Échec de la suppression du groupe d'extensions",
"syncEnabled": "Synchronisation activée",
"syncDisabled": "Synchronisation désactivée",
"syncEnableTooltip": "Activer la synchronisation",
@@ -1306,6 +1321,14 @@
"groupsTitle": "Supprimer les groupes d'extensions",
"groupsDescription": "Supprimer {{count}} groupes d'extensions ? {{names}}",
"confirmButton": "Supprimer"
},
"source": {
"label": "Source",
"archive": "Archive",
"unpacked": "Dossier non empaqueté",
"linked": "Dossier lié",
"folderLabel": "Dossier",
"linkedTooltip": "Chargée directement depuis {{path}}"
}
},
"pro": {
@@ -1849,6 +1872,15 @@
"vpnNotFound": "VPN introuvable",
"extensionNotFound": "Extension introuvable",
"extensionGroupNotFound": "Groupe d'extensions introuvable",
"extensionUnsupportedFileType": "Ce type de fichier n'est pas pris en charge. Une extension doit être une archive .crx ou .zip, ou un dossier.",
"extensionDirNotFound": "Ce dossier n'existe plus.",
"extensionNotADirectory": "Ce chemin n'est pas un dossier.",
"extensionManifestMissing": "Il n'y a pas de manifest.json dans ce dossier. Choisissez le dossier qui contient le manifest.json de l'extension.",
"extensionManifestInvalid": "Le fichier manifest.json de ce dossier n'a pas pu être lu.",
"extensionDirTooLarge": "Ce dossier est trop volumineux pour être copié dans Donut (la limite est de 256 Mo et 20 000 fichiers). Liez-le sur place à la place.",
"extensionPathHasComma": "Le chemin de ce dossier contient une virgule, que Chromium ne peut pas charger. Renommez ou déplacez le dossier.",
"extensionLinkRequiresDirectory": "Seul un dossier peut être chargé sur place. Désactivez la liaison pour ajouter une archive.",
"extensionLinkedCannotSync": "Cette extension est chargée depuis un dossier de cet ordinateur : il n'y a rien à synchroniser.",
"cannotModifyCloudManagedProxy": "Impossible de modifier la synchronisation d'un proxy géré dans le cloud",
"syncLockedByProfile": "La synchronisation ne peut pas être désactivée tant qu'elle est utilisée par des profils synchronisés",
"syncNotConfigured": "La synchronisation n'est pas configurée. Connectez-vous ou configurez un serveur auto-hébergé.",
+35 -3
View File
@@ -1271,7 +1271,7 @@
"deleteConfirmDescription": "「{{name}}」を削除してもよろしいですか?この操作は元に戻せません。",
"deleteGroupConfirmTitle": "拡張機能グループを削除",
"deleteGroupConfirmDescription": "グループ「{{name}}」を削除してもよろしいですか?この操作は元に戻せません。",
"invalidFileType": "無効なファイルタイプです。.crx、.xpi、または .zip ファイルをアップロードしてください。",
"invalidFileType": "ファイル形式が正しくありません。.crx または .zip ファイルを選択してください。",
"readError": "拡張機能ファイルの読み取りに失敗しました。",
"assignTitle": "拡張機能グループの割り当て",
"assignDescription": "選択した{{count}}件のプロファイルを拡張機能グループに割り当てます。",
@@ -1279,7 +1279,6 @@
"assignSuccess": "拡張機能グループが正常に割り当てられました",
"editExtension": "拡張機能を編集",
"updateSuccess": "拡張機能が正常に更新されました",
"reupload": "再アップロード",
"version": "バージョン",
"author": "作者",
"homepage": "ホームページ",
@@ -1287,10 +1286,26 @@
"editGroupDescription": "グループ名を更新し、含まれる拡張機能を管理します。",
"groupExtensions": "このグループの拡張機能",
"noExtensionsInGroup": "拡張機能がまだ追加されていません",
"editExtensionDescription": "拡張機能の名前を更新、メタデータを表示、またはファイルを再アップロードします。",
"editExtensionDescription": "拡張機能の名前を変更したり、メタデータを確認したり、別のアーカイブやフォルダに置き換えたりできます。",
"metadata": "メタデータ",
"noMetadata": "マニフェストからのメタデータはありません。",
"selectFile": "ファイルを選択",
"loadUnpacked": "フォルダから読み込む",
"loadUnpackedTooltip": "manifest.json を含むフォルダから拡張機能を読み込みます",
"selectFolderTitle": "拡張機能のフォルダを選択",
"selectedFolder": "選択したフォルダ",
"selectFolder": "フォルダを選択",
"linkFolder": "このフォルダから直接読み込む",
"linkFolderOff": "フォルダは Donut にコピーされます。拡張機能は持ち運べるようになり、他のデバイスにも同期されます。",
"linkFolderOn": "Donut は起動のたびにこのフォルダから直接拡張機能を読み込みます。編集内容は次回のブラウザ起動時に反映されますが、拡張機能はこの端末にのみ残り、同期されません。",
"replaceSource": "ソースを置き換える",
"linkedNoSync": "リンクした拡張機能はこの端末にのみ残るため、同期できません。",
"uploadFailed": "拡張機能を追加できませんでした",
"updateFailed": "拡張機能を更新できませんでした",
"deleteFailed": "拡張機能を削除できませんでした",
"groupCreateFailed": "拡張機能グループを作成できませんでした",
"groupUpdateFailed": "拡張機能グループを更新できませんでした",
"groupDeleteFailed": "拡張機能グループを削除できませんでした",
"syncEnabled": "同期が有効",
"syncDisabled": "同期が無効",
"syncEnableTooltip": "同期を有効にする",
@@ -1303,6 +1318,14 @@
"groupsTitle": "拡張機能グループを削除",
"groupsDescription": "{{count}}件の拡張機能グループを削除しますか? {{names}}",
"confirmButton": "削除"
},
"source": {
"label": "ソース",
"archive": "アーカイブ",
"unpacked": "展開済みフォルダ",
"linked": "リンクされたフォルダ",
"folderLabel": "フォルダ",
"linkedTooltip": "{{path}} から直接読み込みます"
}
},
"pro": {
@@ -1842,6 +1865,15 @@
"vpnNotFound": "VPNが見つかりません",
"extensionNotFound": "拡張機能が見つかりません",
"extensionGroupNotFound": "拡張機能グループが見つかりません",
"extensionUnsupportedFileType": "この形式には対応していません。拡張機能は .crx または .zip アーカイブ、あるいはフォルダである必要があります。",
"extensionDirNotFound": "そのフォルダは存在しません。",
"extensionNotADirectory": "そのパスはフォルダではありません。",
"extensionManifestMissing": "そのフォルダに manifest.json がありません。拡張機能の manifest.json があるフォルダを選択してください。",
"extensionManifestInvalid": "そのフォルダの manifest.json を読み取れませんでした。",
"extensionDirTooLarge": "そのフォルダは大きすぎて Donut にコピーできません(上限は 256 MB・20,000 ファイル)。代わりにリンクして読み込んでください。",
"extensionPathHasComma": "そのフォルダのパスにカンマが含まれており、Chromium が読み込めません。フォルダの名前を変更するか、移動してください。",
"extensionLinkRequiresDirectory": "その場で読み込めるのはフォルダのみです。アーカイブを追加するにはリンクをオフにしてください。",
"extensionLinkedCannotSync": "この拡張機能はこの端末のフォルダから読み込まれているため、同期する対象がありません。",
"cannotModifyCloudManagedProxy": "クラウド管理のプロキシの同期は変更できません",
"syncLockedByProfile": "同期済みプロファイルで使用中のため、同期を無効にできません",
"syncNotConfigured": "同期が設定されていません。サインインするか、セルフホストサーバーを設定してください。",
+35 -3
View File
@@ -1271,7 +1271,7 @@
"deleteConfirmDescription": "\"{{name}}\"을(를) 정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
"deleteGroupConfirmTitle": "확장 프로그램 그룹 삭제",
"deleteGroupConfirmDescription": "그룹 \"{{name}}\"을(를) 정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
"invalidFileType": "잘못된 파일 형입니다. .crx, .xpi 또는 .zip 파일을 업로드하세요.",
"invalidFileType": "지원하지 않는 파일 형입니다. .crx 또는 .zip 파일을 선택하세요.",
"readError": "확장 프로그램 파일 읽기 실패.",
"assignTitle": "확장 프로그램 그룹 할당",
"assignDescription": "선택한 {{count}}개 프로필을 확장 프로그램 그룹에 할당합니다.",
@@ -1279,7 +1279,6 @@
"assignSuccess": "확장 프로그램 그룹이 할당되었습니다",
"editExtension": "확장 프로그램 편집",
"updateSuccess": "확장 프로그램이 업데이트되었습니다",
"reupload": "다시 업로드",
"version": "버전",
"author": "작성자",
"homepage": "홈페이지",
@@ -1287,10 +1286,26 @@
"editGroupDescription": "그룹 이름을 업데이트하고 포함된 확장 프로그램을 관리합니다.",
"groupExtensions": "이 그룹의 확장 프로그램",
"noExtensionsInGroup": "아직 추가된 확장 프로그램이 없습니다",
"editExtensionDescription": "확장 프로그램 이름을 업데이트하거나, 메타데이터를 보거나, 확장 프로그램 파일을 다시 업로드합니다.",
"editExtensionDescription": "확장 프로그램 이름을 변경하고, 메타데이터를 확인하고, 다른 압축 파일이나 폴더로 교체할 수 있습니다.",
"metadata": "메타데이터",
"noMetadata": "manifest에서 사용할 수 있는 메타데이터가 없습니다.",
"selectFile": "파일 선택",
"loadUnpacked": "폴더에서 불러오기",
"loadUnpackedTooltip": "manifest.json이 있는 폴더에서 확장 프로그램을 불러옵니다",
"selectFolderTitle": "확장 프로그램 폴더 선택",
"selectedFolder": "선택한 폴더",
"selectFolder": "폴더 선택",
"linkFolder": "이 폴더에서 바로 불러오기",
"linkFolderOff": "폴더가 Donut으로 복사됩니다. 확장 프로그램을 옮길 수 있고 다른 기기와 동기화됩니다.",
"linkFolderOn": "Donut이 실행할 때마다 이 폴더에서 바로 확장 프로그램을 불러옵니다. 수정한 내용은 브라우저를 다시 시작할 때 적용되지만, 확장 프로그램은 이 컴퓨터에만 남고 동기화되지 않습니다.",
"replaceSource": "소스 교체",
"linkedNoSync": "연결된 확장 프로그램은 이 컴퓨터에만 있어 동기화할 수 없습니다.",
"uploadFailed": "확장 프로그램을 추가하지 못했습니다",
"updateFailed": "확장 프로그램을 업데이트하지 못했습니다",
"deleteFailed": "확장 프로그램을 삭제하지 못했습니다",
"groupCreateFailed": "확장 프로그램 그룹을 만들지 못했습니다",
"groupUpdateFailed": "확장 프로그램 그룹을 업데이트하지 못했습니다",
"groupDeleteFailed": "확장 프로그램 그룹을 삭제하지 못했습니다",
"syncEnabled": "동기화 사용됨",
"syncDisabled": "동기화 사용 안 함",
"syncEnableTooltip": "동기화 사용",
@@ -1303,6 +1318,14 @@
"groupsTitle": "확장 프로그램 그룹 삭제",
"groupsDescription": "{{count}}개의 확장 프로그램 그룹을 삭제하시겠습니까? {{names}}",
"confirmButton": "삭제"
},
"source": {
"label": "소스",
"archive": "압축 파일",
"unpacked": "압축 해제된 폴더",
"linked": "연결된 폴더",
"folderLabel": "폴더",
"linkedTooltip": "{{path}}에서 바로 불러옵니다"
}
},
"pro": {
@@ -1842,6 +1865,15 @@
"vpnNotFound": "VPN을 찾을 수 없습니다",
"extensionNotFound": "확장 프로그램을 찾을 수 없습니다",
"extensionGroupNotFound": "확장 프로그램 그룹을 찾을 수 없습니다",
"extensionUnsupportedFileType": "지원하지 않는 파일 형식입니다. 확장 프로그램은 .crx 또는 .zip 압축 파일이거나 폴더여야 합니다.",
"extensionDirNotFound": "해당 폴더가 더 이상 존재하지 않습니다.",
"extensionNotADirectory": "해당 경로는 폴더가 아닙니다.",
"extensionManifestMissing": "해당 폴더에 manifest.json이 없습니다. 확장 프로그램의 manifest.json이 있는 폴더를 선택하세요.",
"extensionManifestInvalid": "해당 폴더의 manifest.json을 읽을 수 없습니다.",
"extensionDirTooLarge": "폴더가 너무 커서 Donut으로 복사할 수 없습니다(최대 256MB, 20,000개 파일). 대신 폴더를 연결해 사용하세요.",
"extensionPathHasComma": "폴더 경로에 쉼표가 있어 Chromium이 불러올 수 없습니다. 폴더 이름을 바꾸거나 옮기세요.",
"extensionLinkRequiresDirectory": "폴더만 그 자리에서 불러올 수 있습니다. 압축 파일을 추가하려면 연결을 끄세요.",
"extensionLinkedCannotSync": "이 확장 프로그램은 이 컴퓨터의 폴더에서 불러오므로 동기화할 항목이 없습니다.",
"cannotModifyCloudManagedProxy": "클라우드 관리 프록시의 동기화는 수정할 수 없습니다",
"syncLockedByProfile": "동기화된 프로필에서 사용 중인 동안에는 동기화를 비활성화할 수 없습니다",
"syncNotConfigured": "동기화가 구성되지 않았습니다. 먼저 로그인하거나 자체 호스팅 서버를 구성하세요.",
+35 -3
View File
@@ -1274,7 +1274,7 @@
"deleteConfirmDescription": "Tem certeza de que deseja excluir \"{{name}}\"? Esta ação não pode ser desfeita.",
"deleteGroupConfirmTitle": "Excluir Grupo de Extensões",
"deleteGroupConfirmDescription": "Tem certeza de que deseja excluir o grupo \"{{name}}\"? Esta ação não pode ser desfeita.",
"invalidFileType": "Tipo de arquivo inválido. Envie um arquivo .crx, .xpi ou .zip.",
"invalidFileType": "Tipo de arquivo inválido. Escolha um arquivo .crx ou .zip.",
"readError": "Falha ao ler o arquivo de extensão.",
"assignTitle": "Atribuir Grupo de Extensões",
"assignDescription": "Atribuir {{count}} perfil(is) selecionado(s) a um grupo de extensões.",
@@ -1282,7 +1282,6 @@
"assignSuccess": "Grupo de extensões atribuído com sucesso",
"editExtension": "Editar extensão",
"updateSuccess": "Extensão atualizada com sucesso",
"reupload": "Re-enviar",
"version": "Versão",
"author": "Autor",
"homepage": "Página inicial",
@@ -1290,10 +1289,26 @@
"editGroupDescription": "Atualize o nome do grupo e gerencie quais extensões estão incluídas.",
"groupExtensions": "Extensões neste grupo",
"noExtensionsInGroup": "Nenhuma extensão adicionada ainda",
"editExtensionDescription": "Atualizar o nome da extensão, ver metadados ou reenviar o arquivo da extensão.",
"editExtensionDescription": "Atualize o nome da extensão, veja seus metadados ou substitua-a por outro arquivo compactado ou pasta.",
"metadata": "Metadados",
"noMetadata": "Nenhum metadado disponível do manifesto.",
"selectFile": "Escolher arquivo",
"loadUnpacked": "Carregar descompactada",
"loadUnpackedTooltip": "Carregue uma extensão a partir de uma pasta com manifest.json",
"selectFolderTitle": "Selecionar pasta da extensão",
"selectedFolder": "Pasta selecionada",
"selectFolder": "Escolher pasta",
"linkFolder": "Carregar direto desta pasta",
"linkFolderOff": "A pasta é copiada para o Donut. A extensão fica portátil e sincroniza com seus outros dispositivos.",
"linkFolderOn": "O Donut carrega a extensão direto desta pasta a cada inicialização. Suas edições valem na próxima abertura do navegador, mas a extensão fica só neste computador e nunca sincroniza.",
"replaceSource": "Substituir origem",
"linkedNoSync": "Extensões vinculadas ficam só neste computador e não sincronizam.",
"uploadFailed": "Falha ao adicionar a extensão",
"updateFailed": "Falha ao atualizar a extensão",
"deleteFailed": "Falha ao excluir a extensão",
"groupCreateFailed": "Falha ao criar o grupo de extensões",
"groupUpdateFailed": "Falha ao atualizar o grupo de extensões",
"groupDeleteFailed": "Falha ao excluir o grupo de extensões",
"syncEnabled": "Sincronização ativada",
"syncDisabled": "Sincronização desativada",
"syncEnableTooltip": "Ativar sincronização",
@@ -1306,6 +1321,14 @@
"groupsTitle": "Excluir grupos de extensões",
"groupsDescription": "Excluir {{count}} grupos de extensões? {{names}}",
"confirmButton": "Excluir"
},
"source": {
"label": "Origem",
"archive": "Arquivo compactado",
"unpacked": "Pasta descompactada",
"linked": "Pasta vinculada",
"folderLabel": "Pasta",
"linkedTooltip": "Carregada direto de {{path}}"
}
},
"pro": {
@@ -1849,6 +1872,15 @@
"vpnNotFound": "VPN não encontrada",
"extensionNotFound": "Extensão não encontrada",
"extensionGroupNotFound": "Grupo de extensões não encontrado",
"extensionUnsupportedFileType": "Esse tipo de arquivo não é compatível. Uma extensão precisa ser um arquivo .crx ou .zip, ou uma pasta.",
"extensionDirNotFound": "Essa pasta não existe mais.",
"extensionNotADirectory": "Esse caminho não é uma pasta.",
"extensionManifestMissing": "Não há manifest.json nessa pasta. Escolha a pasta que contém o manifest.json da extensão.",
"extensionManifestInvalid": "Não foi possível ler o manifest.json dessa pasta.",
"extensionDirTooLarge": "Essa pasta é grande demais para copiar para o Donut (o limite é 256 MB e 20.000 arquivos). Vincule-a no lugar.",
"extensionPathHasComma": "O caminho dessa pasta tem uma vírgula, que o Chromium não consegue carregar. Renomeie ou mova a pasta.",
"extensionLinkRequiresDirectory": "Só uma pasta pode ser carregada no lugar. Desative o vínculo para adicionar um arquivo compactado.",
"extensionLinkedCannotSync": "Esta extensão é carregada de uma pasta deste computador, então não há nada para sincronizar.",
"cannotModifyCloudManagedProxy": "Não é possível modificar a sincronização de um proxy gerenciado na nuvem",
"syncLockedByProfile": "A sincronização não pode ser desativada enquanto estiver em uso por perfis sincronizados",
"syncNotConfigured": "A sincronização não está configurada. Faça login ou configure um servidor auto-hospedado.",
+35 -3
View File
@@ -1277,7 +1277,7 @@
"deleteConfirmDescription": "Вы уверены, что хотите удалить «{{name}}»? Это действие нельзя отменить.",
"deleteGroupConfirmTitle": "Удалить группу расширений",
"deleteGroupConfirmDescription": "Вы уверены, что хотите удалить группу «{{name}}»? Это действие нельзя отменить.",
"invalidFileType": "Недопустимый тип файла. Загрузите файл .crx, .xpi или .zip.",
"invalidFileType": "Неподдерживаемый тип файла. Выберите файл .crx или .zip.",
"readError": "Не удалось прочитать файл расширения.",
"assignTitle": "Назначить группу расширений",
"assignDescription": "Назначить {{count}} выбранных профилей в группу расширений.",
@@ -1285,7 +1285,6 @@
"assignSuccess": "Группа расширений успешно назначена",
"editExtension": "Редактировать расширение",
"updateSuccess": "Расширение успешно обновлено",
"reupload": "Загрузить заново",
"version": "Версия",
"author": "Автор",
"homepage": "Домашняя страница",
@@ -1293,10 +1292,26 @@
"editGroupDescription": "Обновите название группы и управляйте включёнными расширениями.",
"groupExtensions": "Расширения в этой группе",
"noExtensionsInGroup": "Расширения ещё не добавлены",
"editExtensionDescription": "Обновите имя расширения, просмотрите метаданные или загрузите файл расширения повторно.",
"editExtensionDescription": "Измените имя расширения, посмотрите его метаданные или замените его другим архивом либо папкой.",
"metadata": "Метаданные",
"noMetadata": "Метаданные из манифеста недоступны.",
"selectFile": "Выбрать файл",
"loadUnpacked": "Загрузить из папки",
"loadUnpackedTooltip": "Загрузить расширение из папки с файлом manifest.json",
"selectFolderTitle": "Выберите папку расширения",
"selectedFolder": "Выбранная папка",
"selectFolder": "Выбрать папку",
"linkFolder": "Загружать прямо из этой папки",
"linkFolderOff": "Папка копируется в Donut. Расширение можно переносить, и оно синхронизируется с другими устройствами.",
"linkFolderOn": "Donut загружает расширение прямо из этой папки при каждом запуске. Изменения применяются при следующем старте браузера, но расширение остаётся только на этом компьютере и не синхронизируется.",
"replaceSource": "Заменить источник",
"linkedNoSync": "Связанные расширения остаются только на этом компьютере и не синхронизируются.",
"uploadFailed": "Не удалось добавить расширение",
"updateFailed": "Не удалось обновить расширение",
"deleteFailed": "Не удалось удалить расширение",
"groupCreateFailed": "Не удалось создать группу расширений",
"groupUpdateFailed": "Не удалось обновить группу расширений",
"groupDeleteFailed": "Не удалось удалить группу расширений",
"syncEnabled": "Синхронизация включена",
"syncDisabled": "Синхронизация отключена",
"syncEnableTooltip": "Включить синхронизацию",
@@ -1309,6 +1324,14 @@
"groupsTitle": "Удалить группы расширений",
"groupsDescription": "Удалить {{count}} групп расширений? {{names}}",
"confirmButton": "Удалить"
},
"source": {
"label": "Источник",
"archive": "Архив",
"unpacked": "Распакованная папка",
"linked": "Связанная папка",
"folderLabel": "Папка",
"linkedTooltip": "Загружается прямо из {{path}}"
}
},
"pro": {
@@ -1856,6 +1879,15 @@
"vpnNotFound": "VPN не найден",
"extensionNotFound": "Расширение не найдено",
"extensionGroupNotFound": "Группа расширений не найдена",
"extensionUnsupportedFileType": "Этот тип файла не поддерживается. Расширение должно быть архивом .crx или .zip либо папкой.",
"extensionDirNotFound": "Эта папка больше не существует.",
"extensionNotADirectory": "Указанный путь не является папкой.",
"extensionManifestMissing": "В этой папке нет файла manifest.json. Выберите папку, в которой лежит manifest.json расширения.",
"extensionManifestInvalid": "Не удалось прочитать manifest.json в этой папке.",
"extensionDirTooLarge": "Папка слишком большая, чтобы скопировать её в Donut (не более 256 МБ и 20 000 файлов). Вместо этого свяжите её.",
"extensionPathHasComma": "В пути к папке есть запятая, которую Chromium не может обработать. Переименуйте или переместите папку.",
"extensionLinkRequiresDirectory": "Загружать на месте можно только папку. Отключите связывание, чтобы добавить архив.",
"extensionLinkedCannotSync": "Это расширение загружается из папки на этом компьютере, поэтому синхронизировать нечего.",
"cannotModifyCloudManagedProxy": "Невозможно изменить синхронизацию для облачного прокси",
"syncLockedByProfile": "Невозможно отключить синхронизацию, пока используется синхронизированными профилями",
"syncNotConfigured": "Синхронизация не настроена. Войдите или настройте собственный сервер.",
+35 -3
View File
@@ -1271,7 +1271,7 @@
"deleteConfirmDescription": "\"{{name}}\" uzantısını silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.",
"deleteGroupConfirmTitle": "Uzantı Grubunu Sil",
"deleteGroupConfirmDescription": "\"{{name}}\" grubunu silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.",
"invalidFileType": "Geçersiz dosya türü. Lütfen bir .crx, .xpi veya .zip dosyası yükleyin.",
"invalidFileType": "Geçersiz dosya türü. Lütfen bir .crx veya .zip dosyası seçin.",
"readError": "Uzantı dosyası okunamadı.",
"assignTitle": "Uzantı Grubu Ata",
"assignDescription": "Seçili {{count}} profili bir uzantı grubuna atayın.",
@@ -1279,7 +1279,6 @@
"assignSuccess": "Uzantı grubu başarıyla atandı",
"editExtension": "Uzantıyı düzenle",
"updateSuccess": "Uzantı başarıyla güncellendi",
"reupload": "Yeniden yükle",
"version": "Sürüm",
"author": "Yazar",
"homepage": "Ana sayfa",
@@ -1287,10 +1286,26 @@
"editGroupDescription": "Grup adını güncelleyin ve gruba dahil uzantıları yönetin.",
"groupExtensions": "Bu gruptaki uzantılar",
"noExtensionsInGroup": "Henüz uzantı eklenmedi",
"editExtensionDescription": "Uzantı adını güncelleyin, üst verileri görüntüleyin veya uzantı dosyasını yeniden yükleyin.",
"editExtensionDescription": "Uzantının adını güncelleyin, meta verilerini görüntüleyin veya başka bir arşiv ya da klasörle değiştirin.",
"metadata": "Üst Veriler",
"noMetadata": "Manifest'te üst veri yok.",
"selectFile": "Dosya Seç",
"loadUnpacked": "Klasörden yükle",
"loadUnpackedTooltip": "manifest.json içeren bir klasörden uzantı yükleyin",
"selectFolderTitle": "Uzantı klasörünü seçin",
"selectedFolder": "Seçilen klasör",
"selectFolder": "Klasör seç",
"linkFolder": "Doğrudan bu klasörden yükle",
"linkFolderOff": "Klasör Donut'a kopyalanır. Uzantı taşınabilir olur ve diğer cihazlarınızla eşitlenir.",
"linkFolderOn": "Donut her başlatmada uzantıyı doğrudan bu klasörden yükler. Değişiklikleriniz tarayıcının bir sonraki açılışında geçerli olur, ancak uzantı yalnızca bu bilgisayarda kalır ve hiçbir zaman eşitlenmez.",
"replaceSource": "Kaynağı değiştir",
"linkedNoSync": "Bağlı uzantılar yalnızca bu bilgisayarda kalır ve eşitlenemez.",
"uploadFailed": "Uzantı eklenemedi",
"updateFailed": "Uzantı güncellenemedi",
"deleteFailed": "Uzantı silinemedi",
"groupCreateFailed": "Uzantı grubu oluşturulamadı",
"groupUpdateFailed": "Uzantı grubu güncellenemedi",
"groupDeleteFailed": "Uzantı grubu silinemedi",
"syncEnabled": "Eşitleme etkinleştirildi",
"syncDisabled": "Eşitleme devre dışı bırakıldı",
"syncEnableTooltip": "Eşitlemeyi etkinleştir",
@@ -1303,6 +1318,14 @@
"groupsTitle": "Uzantı gruplarını sil",
"groupsDescription": "{{count}} uzantı grubu silinsin mi? {{names}}",
"confirmButton": "Sil"
},
"source": {
"label": "Kaynak",
"archive": "Arşiv",
"unpacked": "Paketlenmemiş klasör",
"linked": "Bağlı klasör",
"folderLabel": "Klasör",
"linkedTooltip": "Doğrudan {{path}} konumundan yükleniyor"
}
},
"pro": {
@@ -1842,6 +1865,15 @@
"vpnNotFound": "VPN bulunamadı",
"extensionNotFound": "Uzantı bulunamadı",
"extensionGroupNotFound": "Uzantı grubu bulunamadı",
"extensionUnsupportedFileType": "Bu dosya türü desteklenmiyor. Bir uzantı .crx ya da .zip arşivi veya bir klasör olmalıdır.",
"extensionDirNotFound": "Bu klasör artık mevcut değil.",
"extensionNotADirectory": "Bu yol bir klasör değil.",
"extensionManifestMissing": "Bu klasörde manifest.json yok. Uzantının manifest.json dosyasını içeren klasörü seçin.",
"extensionManifestInvalid": "Bu klasördeki manifest.json okunamadı.",
"extensionDirTooLarge": "Bu klasör Donut'a kopyalanamayacak kadar büyük (sınır: 256 MB ve 20.000 dosya). Bunun yerine klasörü bağlayın.",
"extensionPathHasComma": "Bu klasörün yolunda virgül var ve Chromium bunu yükleyemez. Klasörü yeniden adlandırın veya taşıyın.",
"extensionLinkRequiresDirectory": "Yerinde yalnızca bir klasör yüklenebilir. Arşiv eklemek için bağlamayı kapatın.",
"extensionLinkedCannotSync": "Bu uzantı, bu bilgisayardaki bir klasörden yükleniyor; eşitlenecek bir şey yok.",
"cannotModifyCloudManagedProxy": "Bulut tarafından yönetilen bir proxy'nin eşitlemesi değiştirilemez",
"syncLockedByProfile": "Eşitlenen profiller tarafından kullanılırken eşitleme devre dışı bırakılamaz",
"syncNotConfigured": "Eşitleme yapılandırılmadı. Önce oturum açın veya kendi sunucunuzu yapılandırın.",
+35 -3
View File
@@ -1271,7 +1271,7 @@
"deleteConfirmDescription": "Bạn có chắc muốn xóa \"{{name}}\"? Hành động này không thể hoàn tác.",
"deleteGroupConfirmTitle": "Xóa nhóm tiện ích",
"deleteGroupConfirmDescription": "Bạn có chắc muốn xóa nhóm \"{{name}}\"? Hành động này không thể hoàn tác.",
"invalidFileType": "Loại tệp không hợp lệ. Vui lòng tải lên tệp .crx, .xpi hoặc .zip.",
"invalidFileType": "Loại tệp không hợp lệ. Vui lòng chọn tệp .crx hoặc .zip.",
"readError": "Đọc tệp tiện ích thất bại.",
"assignTitle": "Gán nhóm tiện ích",
"assignDescription": "Gán {{count}} profile đã chọn vào nhóm tiện ích.",
@@ -1279,7 +1279,6 @@
"assignSuccess": "Gán nhóm tiện ích thành công",
"editExtension": "Chỉnh sửa tiện ích",
"updateSuccess": "Cập nhật tiện ích thành công",
"reupload": "Tải lên lại",
"version": "Phiên bản",
"author": "Tác giả",
"homepage": "Trang chủ",
@@ -1287,10 +1286,26 @@
"editGroupDescription": "Cập nhật tên nhóm và quản lý tiện ích trong nhóm.",
"groupExtensions": "Tiện ích trong nhóm này",
"noExtensionsInGroup": "Chưa thêm tiện ích nào",
"editExtensionDescription": "Cập nhật tên tiện ích, xem metadata hoặc tải lên lại tệp tiện ích.",
"editExtensionDescription": "Cập nhật tên tiện ích, xem siêu dữ liệu hoặc thay bằng tệp nén hay thư mục khác.",
"metadata": "Metadata",
"noMetadata": "Không có metadata từ manifest.",
"selectFile": "Chọn tệp",
"loadUnpacked": "Tải từ thư mục",
"loadUnpackedTooltip": "Tải tiện ích từ thư mục có chứa manifest.json",
"selectFolderTitle": "Chọn thư mục tiện ích",
"selectedFolder": "Thư mục đã chọn",
"selectFolder": "Chọn thư mục",
"linkFolder": "Tải trực tiếp từ thư mục này",
"linkFolderOff": "Thư mục được sao chép vào Donut. Tiện ích có thể mang đi và đồng bộ sang các thiết bị khác của bạn.",
"linkFolderOn": "Donut tải tiện ích trực tiếp từ thư mục này mỗi lần khởi chạy. Các thay đổi của bạn có hiệu lực ở lần mở trình duyệt tiếp theo, nhưng tiện ích chỉ nằm trên máy này và không bao giờ đồng bộ.",
"replaceSource": "Thay nguồn",
"linkedNoSync": "Tiện ích được liên kết chỉ nằm trên máy này nên không thể đồng bộ.",
"uploadFailed": "Không thêm được tiện ích",
"updateFailed": "Không cập nhật được tiện ích",
"deleteFailed": "Không xóa được tiện ích",
"groupCreateFailed": "Không tạo được nhóm tiện ích",
"groupUpdateFailed": "Không cập nhật được nhóm tiện ích",
"groupDeleteFailed": "Không xóa được nhóm tiện ích",
"syncEnabled": "Đã bật đồng bộ",
"syncDisabled": "Đã tắt đồng bộ",
"syncEnableTooltip": "Bật đồng bộ",
@@ -1303,6 +1318,14 @@
"groupsTitle": "Xóa nhóm tiện ích",
"groupsDescription": "Xóa {{count}} nhóm tiện ích? {{names}}",
"confirmButton": "Xóa"
},
"source": {
"label": "Nguồn",
"archive": "Tệp nén",
"unpacked": "Thư mục đã giải nén",
"linked": "Thư mục liên kết",
"folderLabel": "Thư mục",
"linkedTooltip": "Tải trực tiếp từ {{path}}"
}
},
"pro": {
@@ -1842,6 +1865,15 @@
"vpnNotFound": "Không tìm thấy VPN",
"extensionNotFound": "Không tìm thấy tiện ích",
"extensionGroupNotFound": "Không tìm thấy nhóm tiện ích",
"extensionUnsupportedFileType": "Loại tệp này không được hỗ trợ. Tiện ích phải là tệp nén .crx hoặc .zip, hoặc một thư mục.",
"extensionDirNotFound": "Thư mục đó không còn tồn tại.",
"extensionNotADirectory": "Đường dẫn đó không phải là thư mục.",
"extensionManifestMissing": "Thư mục đó không có manifest.json. Hãy chọn thư mục chứa manifest.json của tiện ích.",
"extensionManifestInvalid": "Không đọc được manifest.json trong thư mục đó.",
"extensionDirTooLarge": "Thư mục đó quá lớn để sao chép vào Donut (giới hạn là 256 MB và 20.000 tệp). Hãy liên kết thư mục thay vì sao chép.",
"extensionPathHasComma": "Đường dẫn thư mục có dấu phẩy nên Chromium không tải được. Hãy đổi tên hoặc di chuyển thư mục.",
"extensionLinkRequiresDirectory": "Chỉ có thể tải tại chỗ một thư mục. Hãy tắt liên kết để thêm tệp nén.",
"extensionLinkedCannotSync": "Tiện ích này được tải từ một thư mục trên máy này nên không có gì để đồng bộ.",
"cannotModifyCloudManagedProxy": "Không thể chỉnh sửa đồng bộ cho proxy được quản lý bởi đám mây",
"syncLockedByProfile": "Không thể tắt đồng bộ khi đang được sử dụng bởi profile đã đồng bộ",
"syncNotConfigured": "Chưa cấu hình đồng bộ. Đăng nhập hoặc cấu hình máy chủ tự lưu trữ trước.",
+35 -3
View File
@@ -1271,7 +1271,7 @@
"deleteConfirmDescription": "确定要删除「{{name}}」吗?此操作无法撤消。",
"deleteGroupConfirmTitle": "删除扩展程序组",
"deleteGroupConfirmDescription": "确定要删除分组「{{name}}」吗?此操作无法撤消。",
"invalidFileType": "无效的文件类型。请上传 .crx、.xpi 或 .zip 文件。",
"invalidFileType": "文件类型无效。请选择 .crx 或 .zip 文件。",
"readError": "读取扩展程序文件失败。",
"assignTitle": "分配扩展程序组",
"assignDescription": "将 {{count}} 个选定的配置文件分配到扩展程序组。",
@@ -1279,7 +1279,6 @@
"assignSuccess": "扩展程序组分配成功",
"editExtension": "编辑扩展",
"updateSuccess": "扩展更新成功",
"reupload": "重新上传",
"version": "版本",
"author": "作者",
"homepage": "主页",
@@ -1287,10 +1286,26 @@
"editGroupDescription": "更新分组名称并管理包含的扩展。",
"groupExtensions": "此分组中的扩展",
"noExtensionsInGroup": "尚未添加扩展",
"editExtensionDescription": "更新扩展名称、查看元数据或重新上传扩展文件。",
"editExtensionDescription": "修改扩展名称、查看元数据,或用其他压缩包或文件夹替换它。",
"metadata": "元数据",
"noMetadata": "清单中没有可用的元数据。",
"selectFile": "选择文件",
"loadUnpacked": "加载文件夹",
"loadUnpackedTooltip": "从包含 manifest.json 的文件夹加载扩展",
"selectFolderTitle": "选择扩展文件夹",
"selectedFolder": "已选文件夹",
"selectFolder": "选择文件夹",
"linkFolder": "直接从该文件夹加载",
"linkFolderOff": "文件夹会复制到 Donut,扩展可随身携带并同步到你的其他设备。",
"linkFolderOn": "Donut 每次启动都直接从该文件夹加载扩展。你的修改会在下次启动浏览器时生效,但扩展只保留在本机,不会同步。",
"replaceSource": "替换来源",
"linkedNoSync": "已链接的扩展只保留在本机,无法同步。",
"uploadFailed": "添加扩展失败",
"updateFailed": "更新扩展失败",
"deleteFailed": "删除扩展失败",
"groupCreateFailed": "创建扩展组失败",
"groupUpdateFailed": "更新扩展组失败",
"groupDeleteFailed": "删除扩展组失败",
"syncEnabled": "同步已启用",
"syncDisabled": "同步已禁用",
"syncEnableTooltip": "启用同步",
@@ -1303,6 +1318,14 @@
"groupsTitle": "删除扩展组",
"groupsDescription": "删除 {{count}} 个扩展组?{{names}}",
"confirmButton": "删除"
},
"source": {
"label": "来源",
"archive": "压缩包",
"unpacked": "解压文件夹",
"linked": "链接文件夹",
"folderLabel": "文件夹",
"linkedTooltip": "直接从 {{path}} 加载"
}
},
"pro": {
@@ -1842,6 +1865,15 @@
"vpnNotFound": "未找到 VPN",
"extensionNotFound": "未找到扩展",
"extensionGroupNotFound": "未找到扩展分组",
"extensionUnsupportedFileType": "不支持该文件类型。扩展必须是 .crx 或 .zip 压缩包,或者一个文件夹。",
"extensionDirNotFound": "该文件夹已不存在。",
"extensionNotADirectory": "该路径不是文件夹。",
"extensionManifestMissing": "该文件夹中没有 manifest.json。请选择包含扩展 manifest.json 的文件夹。",
"extensionManifestInvalid": "无法读取该文件夹中的 manifest.json。",
"extensionDirTooLarge": "该文件夹过大,无法复制到 Donut(上限为 256 MB、20,000 个文件)。请改用链接方式加载。",
"extensionPathHasComma": "该文件夹路径中包含逗号,Chromium 无法加载。请重命名或移动该文件夹。",
"extensionLinkRequiresDirectory": "只有文件夹才能就地加载。要添加压缩包,请关闭链接选项。",
"extensionLinkedCannotSync": "该扩展是从本机文件夹加载的,没有需要同步的内容。",
"cannotModifyCloudManagedProxy": "无法修改云管理代理的同步",
"syncLockedByProfile": "在被已同步的配置文件使用时无法禁用同步",
"syncNotConfigured": "同步未配置。请先登录或配置自托管服务器。",
+27
View File
@@ -28,6 +28,15 @@ export type BackendErrorCode =
| "VPN_NOT_FOUND"
| "EXTENSION_NOT_FOUND"
| "EXTENSION_GROUP_NOT_FOUND"
| "EXTENSION_UNSUPPORTED_FILE_TYPE"
| "EXTENSION_DIR_NOT_FOUND"
| "EXTENSION_NOT_A_DIRECTORY"
| "EXTENSION_MANIFEST_MISSING"
| "EXTENSION_MANIFEST_INVALID"
| "EXTENSION_DIR_TOO_LARGE"
| "EXTENSION_PATH_HAS_COMMA"
| "EXTENSION_LINK_REQUIRES_DIRECTORY"
| "EXTENSION_LINKED_CANNOT_SYNC"
| "CANNOT_MODIFY_CLOUD_MANAGED_PROXY"
| "SYNC_LOCKED_BY_PROFILE"
| "SYNC_NOT_CONFIGURED"
@@ -219,6 +228,24 @@ export function translateBackendError(t: TFunction, err: unknown): string {
return t("backendErrors.extensionNotFound");
case "EXTENSION_GROUP_NOT_FOUND":
return t("backendErrors.extensionGroupNotFound");
case "EXTENSION_UNSUPPORTED_FILE_TYPE":
return t("backendErrors.extensionUnsupportedFileType");
case "EXTENSION_DIR_NOT_FOUND":
return t("backendErrors.extensionDirNotFound");
case "EXTENSION_NOT_A_DIRECTORY":
return t("backendErrors.extensionNotADirectory");
case "EXTENSION_MANIFEST_MISSING":
return t("backendErrors.extensionManifestMissing");
case "EXTENSION_MANIFEST_INVALID":
return t("backendErrors.extensionManifestInvalid");
case "EXTENSION_DIR_TOO_LARGE":
return t("backendErrors.extensionDirTooLarge");
case "EXTENSION_PATH_HAS_COMMA":
return t("backendErrors.extensionPathHasComma");
case "EXTENSION_LINK_REQUIRES_DIRECTORY":
return t("backendErrors.extensionLinkRequiresDirectory");
case "EXTENSION_LINKED_CANNOT_SYNC":
return t("backendErrors.extensionLinkedCannotSync");
case "CANNOT_MODIFY_CLOUD_MANAGED_PROXY":
return t("backendErrors.cannotModifyCloudManagedProxy");
case "SYNC_LOCKED_BY_PROFILE":
+5
View File
@@ -62,6 +62,11 @@ export interface Extension {
description?: string;
author?: string;
homepage_url?: string;
/** How the payload was imported: a `.crx`/`.zip` archive, or a folder. */
source_kind: "archive" | "unpacked";
/** Absolute folder the extension is loaded from in place. Set means nothing
* was copied into Donut, so the extension is machine-local and never syncs. */
linked_path?: string;
}
export interface ExtensionGroup {