mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-17 16:37:20 +02:00
feat: extension export via api
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+1134
-204
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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"));
|
||||
|
||||
@@ -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?;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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é.",
|
||||
|
||||
@@ -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": "同期が設定されていません。サインインするか、セルフホストサーバーを設定してください。",
|
||||
|
||||
@@ -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": "동기화가 구성되지 않았습니다. 먼저 로그인하거나 자체 호스팅 서버를 구성하세요.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "Синхронизация не настроена. Войдите или настройте собственный сервер.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "同步未配置。请先登录或配置自托管服务器。",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user