mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-19 17:37:16 +02:00
feat: extension export via api
This commit is contained in:
@@ -87,7 +87,9 @@ export const commandCoverage = {
|
|||||||
"list_extensions",
|
"list_extensions",
|
||||||
"get_extension_icon",
|
"get_extension_icon",
|
||||||
"add_extension",
|
"add_extension",
|
||||||
|
"add_unpacked_extension",
|
||||||
"update_extension",
|
"update_extension",
|
||||||
|
"update_extension_from_path",
|
||||||
"delete_extension",
|
"delete_extension",
|
||||||
"list_extension_groups",
|
"list_extension_groups",
|
||||||
"create_extension_group",
|
"create_extension_group",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import {
|
import {
|
||||||
chmod,
|
chmod,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { DatabaseSync } from "node:sqlite";
|
import { DatabaseSync } from "node:sqlite";
|
||||||
|
import { crc32 } from "node:zlib";
|
||||||
|
|
||||||
export const TEST_BROWSER_VERSION = "150.0.7871.100";
|
export const TEST_BROWSER_VERSION = "150.0.7871.100";
|
||||||
|
|
||||||
@@ -214,6 +216,211 @@ export function extensionZipBase64() {
|
|||||||
return "UEsDBBQAAAAAAE8K9Fxo1IfNawAAAGsAAAANAAAAbWFuaWZlc3QuanNvbnsibWFuaWZlc3RfdmVyc2lvbiI6MywibmFtZSI6IkRvbnV0IEUyRSBGaXh0dXJlIiwidmVyc2lvbiI6IjEuMC4wIiwiZGVzY3JpcHRpb24iOiJJc29sYXRlZCB0ZXN0IGV4dGVuc2lvbiJ9UEsBAhQDFAAAAAAATwr0XGjUh81rAAAAawAAAA0AAAAAAAAAAAAAAIABAAAAAG1hbmlmZXN0Lmpzb25QSwUGAAAAAAEAAQA7AAAAlgAAAAAA";
|
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() {
|
export function currentHostOs() {
|
||||||
return os.platform() === "darwin"
|
return os.platform() === "darwin"
|
||||||
? "macos"
|
? "macos"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
defaultWayfernPath,
|
defaultWayfernPath,
|
||||||
inspectWayfern,
|
inspectWayfern,
|
||||||
prepareWayfern,
|
prepareWayfern,
|
||||||
|
writeUnpackedExtension,
|
||||||
} from "../lib/fixtures.mjs";
|
} from "../lib/fixtures.mjs";
|
||||||
|
|
||||||
const fixtureUrl = process.env.DONUT_E2E_FIXTURE_URL;
|
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();
|
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 assert from "node:assert/strict";
|
||||||
import { existsSync } from "node:fs";
|
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 path from "node:path";
|
||||||
import { DatabaseSync } from "node:sqlite";
|
import { DatabaseSync } from "node:sqlite";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import { withApp } from "../lib/app.mjs";
|
import { withApp } from "../lib/app.mjs";
|
||||||
import {
|
import {
|
||||||
|
extensionIconPngBase64,
|
||||||
extensionZipBase64,
|
extensionZipBase64,
|
||||||
wireGuardFixture,
|
wireGuardFixture,
|
||||||
writeChromiumCookies,
|
writeChromiumCookies,
|
||||||
writeChromiumHistory,
|
writeChromiumHistory,
|
||||||
|
writeUnpackedExtension,
|
||||||
} from "../lib/fixtures.mjs";
|
} from "../lib/fixtures.mjs";
|
||||||
|
|
||||||
async function createProfile(app, name = "Entity Profile") {
|
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_group", { groupId: extensionGroup.id });
|
||||||
await app.invoke("delete_extension", { extensionId: extension.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", {
|
const vpn = await app.invoke("create_vpn_config_manual", {
|
||||||
name: "E2E WireGuard",
|
name: "E2E WireGuard",
|
||||||
vpnType: "WireGuard",
|
vpnType: "WireGuard",
|
||||||
|
|||||||
@@ -3,6 +3,14 @@ import { mkdir, writeFile } from "node:fs/promises";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import { withApp } from "../lib/app.mjs";
|
import { withApp } from "../lib/app.mjs";
|
||||||
|
import {
|
||||||
|
extensionZipBase64,
|
||||||
|
LOCALIZED_EXTENSION_MESSAGES,
|
||||||
|
localizedExtensionZipBase64,
|
||||||
|
OVERSIZED_EXTENSION_NAME,
|
||||||
|
oversizedExtensionZipBase64,
|
||||||
|
writeUnpackedExtension,
|
||||||
|
} from "../lib/fixtures.mjs";
|
||||||
|
|
||||||
const VLESS_URI =
|
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";
|
"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}`);
|
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`);
|
const unauthorized = await jsonRequest(`${base}/v1/profiles`);
|
||||||
assert.equal(unauthorized.response.status, 401);
|
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.response.status, 200);
|
||||||
assert.equal(imported.value.imported_count, 1);
|
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`, {
|
const missing = await jsonRequest(`${base}/v1/groups/missing`, {
|
||||||
token: saved.api_token,
|
token: saved.api_token,
|
||||||
});
|
});
|
||||||
@@ -323,6 +774,13 @@ test("MCP Streamable HTTP initialization, auth, discovery, calls, and isolated a
|
|||||||
"run_profile_remote",
|
"run_profile_remote",
|
||||||
"get_remote_session",
|
"get_remote_session",
|
||||||
"stop_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}`);
|
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 });
|
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");
|
const agents = await app.invoke("list_mcp_agents");
|
||||||
assert.ok(agents.some((agent) => agent.id === "cursor"));
|
assert.ok(agents.some((agent) => agent.id === "cursor"));
|
||||||
await assertCommandErrorCode(app, "add_mcp_to_agent", "MCP_AGENT_UNKNOWN", {
|
await assertCommandErrorCode(app, "add_mcp_to_agent", "MCP_AGENT_UNKNOWN", {
|
||||||
|
|||||||
+461
-1
@@ -1,8 +1,15 @@
|
|||||||
import assert from "node:assert/strict";
|
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 test from "node:test";
|
||||||
import Color from "color";
|
import Color from "color";
|
||||||
|
import en from "../../src/i18n/locales/en.json" with { type: "json" };
|
||||||
import { getDerivedThemeColors, THEMES } from "../../src/lib/themes.ts";
|
import { getDerivedThemeColors, THEMES } from "../../src/lib/themes.ts";
|
||||||
import { withApp } from "../lib/app.mjs";
|
import { withApp } from "../lib/app.mjs";
|
||||||
|
import {
|
||||||
|
extensionZipBase64,
|
||||||
|
writeUnpackedExtension,
|
||||||
|
} from "../lib/fixtures.mjs";
|
||||||
|
|
||||||
const THEME_VARIABLES = [
|
const THEME_VARIABLES = [
|
||||||
"--background",
|
"--background",
|
||||||
@@ -87,6 +94,21 @@ function themeVariablesEqual(actual, expected) {
|
|||||||
async function applyThemeForContrastAudit(app, theme) {
|
async function applyThemeForContrastAudit(app, theme) {
|
||||||
await app.execute(
|
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 [colors, derived, mode] = arguments;
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
root.classList.remove("light", "dark");
|
root.classList.remove("light", "dark");
|
||||||
@@ -97,7 +119,11 @@ async function applyThemeForContrastAudit(app, theme) {
|
|||||||
`,
|
`,
|
||||||
[theme.colors, getDerivedThemeColors(theme.colors), theme.mode],
|
[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) {
|
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::{
|
use axum::{
|
||||||
extract::{
|
extract::{
|
||||||
ws::{Message as WsMessage, WebSocket, WebSocketUpgrade},
|
ws::{Message as WsMessage, WebSocket, WebSocketUpgrade},
|
||||||
Path, Query, State,
|
DefaultBodyLimit, Path, Query, State,
|
||||||
},
|
},
|
||||||
http::{header, HeaderMap, Method, StatusCode},
|
http::{header, HeaderMap, Method, StatusCode},
|
||||||
middleware::{self, Next},
|
middleware::{self, Next},
|
||||||
@@ -41,6 +41,10 @@ pub struct ApiProfile {
|
|||||||
pub is_running: bool,
|
pub is_running: bool,
|
||||||
pub proxy_bypass_rules: Vec<String>,
|
pub proxy_bypass_rules: Vec<String>,
|
||||||
pub vpn_id: Option<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,
|
pub clear_on_close: bool,
|
||||||
/// Cloud sync mode: `"Disabled"`, `"Regular"` or `"Encrypted"`.
|
/// Cloud sync mode: `"Disabled"`, `"Regular"` or `"Encrypted"`.
|
||||||
/// Settable via `PUT /v1/profiles/{id}`; exposed here so a caller can read
|
/// 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(),
|
is_running: profile.process_id.is_some(),
|
||||||
proxy_bypass_rules: profile.proxy_bypass_rules.clone(),
|
proxy_bypass_rules: profile.proxy_bypass_rules.clone(),
|
||||||
vpn_id: profile.vpn_id.clone(),
|
vpn_id: profile.vpn_id.clone(),
|
||||||
|
extension_group_id: profile.extension_group_id.clone(),
|
||||||
clear_on_close: profile.clear_on_close,
|
clear_on_close: profile.clear_on_close,
|
||||||
sync_mode: format!("{:?}", profile.sync_mode),
|
sync_mode: format!("{:?}", profile.sync_mode),
|
||||||
cloud_sync_enabled: profile.is_sync_enabled(),
|
cloud_sync_enabled: profile.is_sync_enabled(),
|
||||||
@@ -416,6 +421,59 @@ struct ImportCookiesResponse {
|
|||||||
errors: Vec<String>,
|
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)]
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
struct BatchRunRequest {
|
struct BatchRunRequest {
|
||||||
/// Profile IDs to launch.
|
/// Profile IDs to launch.
|
||||||
@@ -561,9 +619,17 @@ struct ImportProxiesResponse {
|
|||||||
update_vpn,
|
update_vpn,
|
||||||
delete_vpn,
|
delete_vpn,
|
||||||
get_extensions,
|
get_extensions,
|
||||||
get_extension_groups,
|
create_extension_api,
|
||||||
|
get_extension_api,
|
||||||
|
update_extension_api,
|
||||||
delete_extension_api,
|
delete_extension_api,
|
||||||
|
get_extension_groups,
|
||||||
|
create_extension_group_api,
|
||||||
|
get_extension_group_api,
|
||||||
|
update_extension_group_api,
|
||||||
delete_extension_group_api,
|
delete_extension_group_api,
|
||||||
|
add_extension_to_group_api,
|
||||||
|
remove_extension_from_group_api,
|
||||||
download_browser_api,
|
download_browser_api,
|
||||||
get_browser_versions,
|
get_browser_versions,
|
||||||
check_browser_downloaded,
|
check_browser_downloaded,
|
||||||
@@ -624,6 +690,12 @@ struct ImportProxiesResponse {
|
|||||||
OpenUrlRequest,
|
OpenUrlRequest,
|
||||||
ImportCookiesRequest,
|
ImportCookiesRequest,
|
||||||
ImportCookiesResponse,
|
ImportCookiesResponse,
|
||||||
|
CreateExtensionRequest,
|
||||||
|
UpdateExtensionRequest,
|
||||||
|
CreateExtensionGroupRequest,
|
||||||
|
UpdateExtensionGroupRequest,
|
||||||
|
crate::extension_manager::Extension,
|
||||||
|
crate::extension_manager::ExtensionGroup,
|
||||||
ProxySettings,
|
ProxySettings,
|
||||||
DetectedProfilesResponse,
|
DetectedProfilesResponse,
|
||||||
ImportProfilesRequest,
|
ImportProfilesRequest,
|
||||||
@@ -838,15 +910,37 @@ fn build_v1_router() -> Router<ApiServerState> {
|
|||||||
.routes(routes!(import_vpn))
|
.routes(routes!(import_vpn))
|
||||||
.routes(routes!(export_vpn))
|
.routes(routes!(export_vpn))
|
||||||
.routes(routes!(get_vpn, update_vpn, delete_vpn))
|
.routes(routes!(get_vpn, update_vpn, delete_vpn))
|
||||||
.routes(routes!(get_extensions))
|
.routes(routes!(get_extension_groups, create_extension_group_api))
|
||||||
.routes(routes!(delete_extension_api))
|
.routes(routes!(
|
||||||
.routes(routes!(get_extension_groups))
|
get_extension_group_api,
|
||||||
.routes(routes!(delete_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!(download_browser_api))
|
||||||
.routes(routes!(get_browser_versions))
|
.routes(routes!(get_browser_versions))
|
||||||
.routes(routes!(check_browser_downloaded))
|
.routes(routes!(check_browser_downloaded))
|
||||||
.split_for_parts();
|
.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
|
// 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("not supported on your platform")
|
||||||
|| lower.contains("is not downloaded")
|
|| lower.contains("is not downloaded")
|
||||||
|| lower.contains("terms and conditions")
|
|| 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
|
StatusCode::BAD_REQUEST
|
||||||
} else {
|
} else {
|
||||||
@@ -1509,6 +1608,27 @@ async fn update_profile(
|
|||||||
} else {
|
} else {
|
||||||
Some(extension_group_id)
|
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) {
|
if let Err(e) = profile_manager.update_profile_extension_group(&id, ext_group) {
|
||||||
return Err(manager_error_response(e));
|
return Err(manager_error_response(e));
|
||||||
}
|
}
|
||||||
@@ -2255,44 +2375,214 @@ async fn delete_vpn(
|
|||||||
|
|
||||||
// Extension API endpoints
|
// 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(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/v1/extensions",
|
path = "/v1/extensions",
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "List of extensions"),
|
(status = 200, description = "List of extensions", body = Vec<crate::extension_manager::Extension>),
|
||||||
(status = 401, description = "Unauthorized"),
|
(status = 401, description = "Unauthorized"),
|
||||||
|
(status = 500, description = "Internal server error"),
|
||||||
),
|
),
|
||||||
security(("bearer_auth" = [])),
|
security(("bearer_auth" = [])),
|
||||||
tag = "extensions"
|
tag = "extensions"
|
||||||
)]
|
)]
|
||||||
async fn get_extensions(
|
async fn get_extensions(
|
||||||
State(_state): State<ApiServerState>,
|
State(_state): State<ApiServerState>,
|
||||||
) -> Result<Json<Vec<crate::extension_manager::Extension>>, StatusCode> {
|
) -> Result<Json<Vec<crate::extension_manager::Extension>>, (StatusCode, String)> {
|
||||||
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
with_extension_manager(|mgr| mgr.list_extensions()).map(Json)
|
||||||
mgr
|
|
||||||
.list_extensions()
|
|
||||||
.map(Json)
|
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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(
|
#[utoipa::path(
|
||||||
get,
|
post,
|
||||||
path = "/v1/extension-groups",
|
path = "/v1/extensions",
|
||||||
|
request_body = CreateExtensionRequest,
|
||||||
responses(
|
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 = 401, description = "Unauthorized"),
|
||||||
|
(status = 404, description = "source_path does not exist"),
|
||||||
|
(status = 500, description = "Internal server error"),
|
||||||
),
|
),
|
||||||
security(("bearer_auth" = [])),
|
security(("bearer_auth" = [])),
|
||||||
tag = "extensions"
|
tag = "extensions"
|
||||||
)]
|
)]
|
||||||
async fn get_extension_groups(
|
async fn create_extension_api(
|
||||||
State(_state): State<ApiServerState>,
|
Json(request): Json<CreateExtensionRequest>,
|
||||||
) -> Result<Json<Vec<crate::extension_manager::ExtensionGroup>>, StatusCode> {
|
) -> Result<(StatusCode, Json<crate::extension_manager::Extension>), (StatusCode, String)> {
|
||||||
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
let source = resolve_extension_source(
|
||||||
mgr
|
request.file_name,
|
||||||
.list_groups()
|
request.file_data_base64,
|
||||||
.map(Json)
|
request.source_path,
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
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(
|
#[utoipa::path(
|
||||||
@@ -2312,11 +2602,88 @@ async fn delete_extension_api(
|
|||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
State(state): State<ApiServerState>,
|
State(state): State<ApiServerState>,
|
||||||
) -> Result<StatusCode, (StatusCode, String)> {
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
with_extension_manager(|mgr| mgr.delete_extension(&state.app_handle, &id))
|
||||||
mgr
|
|
||||||
.delete_extension(&state.app_handle, &id)
|
|
||||||
.map(|_| StatusCode::NO_CONTENT)
|
.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(
|
#[utoipa::path(
|
||||||
@@ -2336,11 +2703,55 @@ async fn delete_extension_group_api(
|
|||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
State(state): State<ApiServerState>,
|
State(state): State<ApiServerState>,
|
||||||
) -> Result<StatusCode, (StatusCode, String)> {
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
with_extension_manager(|mgr| mgr.delete_group(&state.app_handle, &id))
|
||||||
mgr
|
|
||||||
.delete_group(&state.app_handle, &id)
|
|
||||||
.map(|_| StatusCode::NO_CONTENT)
|
.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
|
// API Handler - Run Profile with Remote Debugging
|
||||||
@@ -4304,6 +4715,21 @@ mod tests {
|
|||||||
(Method::GET, "/v1/remote-hours"),
|
(Method::GET, "/v1/remote-hours"),
|
||||||
// A run id is required; the collection DELETE is not a route.
|
// A run id is required; the collection DELETE is not a route.
|
||||||
(Method::DELETE, "/v1/cookie-bot/runs/"),
|
(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!(
|
assert!(
|
||||||
!is_automation_request(&method, path),
|
!is_automation_request(&method, path),
|
||||||
@@ -4642,6 +5068,44 @@ mod tests {
|
|||||||
"{field} must be optional on the quota, required list: {quota:?}"
|
"{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]
|
#[test]
|
||||||
@@ -4686,6 +5150,174 @@ mod tests {
|
|||||||
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
|
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]
|
#[test]
|
||||||
fn a_remote_session_exposes_a_cdp_endpoint_an_external_client_can_attach_to() {
|
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
|
// Without this route `run-remote` hands back a session id that nothing
|
||||||
@@ -4763,6 +5395,7 @@ mod tests {
|
|||||||
"/v1/extension-groups",
|
"/v1/extension-groups",
|
||||||
"/v1/extensions/{id}",
|
"/v1/extensions/{id}",
|
||||||
"/v1/extension-groups/{id}",
|
"/v1/extension-groups/{id}",
|
||||||
|
"/v1/extension-groups/{id}/extensions/{extension_id}",
|
||||||
"/v1/profiles/import",
|
"/v1/profiles/import",
|
||||||
"/v1/profiles/import/detect",
|
"/v1/profiles/import/detect",
|
||||||
"/v1/proxies/import",
|
"/v1/proxies/import",
|
||||||
@@ -4801,6 +5434,26 @@ mod tests {
|
|||||||
("/v1/cookie-bot/runs", "get"),
|
("/v1/cookie-bot/runs", "get"),
|
||||||
("/v1/cookie-bot/runs", "post"),
|
("/v1/cookie-bot/runs", "post"),
|
||||||
("/v1/cookie-bot/runs/{run_id}", "delete"),
|
("/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!(
|
assert!(
|
||||||
paths[path].get(method).is_some(),
|
paths[path].get(method).is_some(),
|
||||||
@@ -4865,6 +5518,14 @@ mod tests {
|
|||||||
"RemoteHoursQuota",
|
"RemoteHoursQuota",
|
||||||
"RemoteHoursMember",
|
"RemoteHoursMember",
|
||||||
"RemoteHoursBreakdown",
|
"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!(
|
assert!(
|
||||||
spec["components"]["schemas"][schema]["properties"].is_object(),
|
spec["components"]["schemas"][schema]["properties"].is_object(),
|
||||||
@@ -4895,6 +5556,15 @@ mod tests {
|
|||||||
"RemoteSessionState",
|
"RemoteSessionState",
|
||||||
),
|
),
|
||||||
("/v1/remote-hours", "get", "200", "RemoteHoursQuota"),
|
("/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 =
|
let reference =
|
||||||
&paths[path][method]["responses"][status]["content"]["application/json"]["schema"]["$ref"];
|
&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
|
// 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
|
// 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.
|
// 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}"
|
"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;
|
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!(
|
log::info!(
|
||||||
"Wayfern process cleanup completed for profile: {} (ID: {})",
|
"Wayfern process cleanup completed for profile: {} (ID: {})",
|
||||||
profile.name,
|
profile.name,
|
||||||
|
|||||||
@@ -804,6 +804,13 @@ impl CloudAuthManager {
|
|||||||
/// Account is in a paid/active state. Used for the "any active plan" gates
|
/// Account is in a paid/active state. Used for the "any active plan" gates
|
||||||
/// (sync token); per-feature access uses the capability helpers.
|
/// (sync token); per-feature access uses the capability helpers.
|
||||||
pub async fn has_active_paid_subscription(&self) -> bool {
|
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)
|
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::{
|
use extension_manager::{
|
||||||
add_extension, add_extension_to_group, assign_extension_group_to_profile, create_extension_group,
|
add_extension, add_extension_to_group, add_unpacked_extension, assign_extension_group_to_profile,
|
||||||
delete_extension, delete_extension_group, get_extension_group_for_profile, get_extension_icon,
|
create_extension_group, delete_extension, delete_extension_group,
|
||||||
list_extension_groups, list_extensions, remove_extension_from_group, update_extension,
|
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,
|
update_extension_group,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2750,7 +2751,9 @@ pub fn run_with_builder(
|
|||||||
list_extensions,
|
list_extensions,
|
||||||
get_extension_icon,
|
get_extension_icon,
|
||||||
add_extension,
|
add_extension,
|
||||||
|
add_unpacked_extension,
|
||||||
update_extension,
|
update_extension,
|
||||||
|
update_extension_from_path,
|
||||||
delete_extension,
|
delete_extension,
|
||||||
list_extension_groups,
|
list_extension_groups,
|
||||||
create_extension_group,
|
create_extension_group,
|
||||||
|
|||||||
+262
-2
@@ -1307,6 +1307,33 @@ impl McpServer {
|
|||||||
"required": []
|
"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 {
|
McpTool {
|
||||||
name: "create_extension_group".to_string(),
|
name: "create_extension_group".to_string(),
|
||||||
description: "Create a new extension group. Requires Pro subscription.".to_string(),
|
description: "Create a new extension group. Requires Pro subscription.".to_string(),
|
||||||
@@ -1318,6 +1345,47 @@ impl McpServer {
|
|||||||
"required": ["name"]
|
"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 {
|
McpTool {
|
||||||
name: "delete_extension".to_string(),
|
name: "delete_extension".to_string(),
|
||||||
description: "Delete a managed extension. Requires Pro subscription.".to_string(),
|
description: "Delete a managed extension. Requires Pro subscription.".to_string(),
|
||||||
@@ -2215,7 +2283,12 @@ impl McpServer {
|
|||||||
// Extension management
|
// Extension management
|
||||||
"list_extensions" => self.handle_list_extensions().await,
|
"list_extensions" => self.handle_list_extensions().await,
|
||||||
"list_extension_groups" => self.handle_list_extension_groups().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,
|
"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" => self.handle_delete_extension_mcp(arguments).await,
|
||||||
"delete_extension_group" => self.handle_delete_extension_group_mcp(arguments).await,
|
"delete_extension_group" => self.handle_delete_extension_group_mcp(arguments).await,
|
||||||
"assign_extension_group_to_profile" => {
|
"assign_extension_group_to_profile" => {
|
||||||
@@ -4402,6 +4475,88 @@ impl McpServer {
|
|||||||
Ok(serde_json::to_value(groups).unwrap())
|
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(
|
async fn handle_create_extension_group(
|
||||||
&self,
|
&self,
|
||||||
arguments: &serde_json::Value,
|
arguments: &serde_json::Value,
|
||||||
@@ -4427,6 +4582,106 @@ impl McpServer {
|
|||||||
Ok(serde_json::to_value(group).unwrap())
|
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(
|
async fn handle_delete_extension_mcp(
|
||||||
&self,
|
&self,
|
||||||
arguments: &serde_json::Value,
|
arguments: &serde_json::Value,
|
||||||
@@ -6035,9 +6290,9 @@ mod tests {
|
|||||||
let server = McpServer::new();
|
let server = McpServer::new();
|
||||||
let tools = server.get_tools();
|
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)
|
// 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
|
// 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
|
// silently shadows one of the two in dispatch and the tool that loses is
|
||||||
@@ -6092,7 +6347,12 @@ mod tests {
|
|||||||
// Extension tools
|
// Extension tools
|
||||||
assert!(tool_names.contains(&"list_extensions"));
|
assert!(tool_names.contains(&"list_extensions"));
|
||||||
assert!(tool_names.contains(&"list_extension_groups"));
|
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(&"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"));
|
||||||
assert!(tool_names.contains(&"delete_extension_group"));
|
assert!(tool_names.contains(&"delete_extension_group"));
|
||||||
assert!(tool_names.contains(&"assign_extension_group_to_profile"));
|
assert!(tool_names.contains(&"assign_extension_group_to_profile"));
|
||||||
|
|||||||
@@ -2084,6 +2084,13 @@ impl SyncEngine {
|
|||||||
manager.get_extension(ext_id).ok()
|
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 remote_key = format!("extensions/{}.json", ext_id);
|
||||||
let stat = self.client.stat(&remote_key).await?;
|
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
|
manager
|
||||||
.get_extension(ext_id)
|
.get_extension(ext_id)
|
||||||
.ok()
|
.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)
|
.unwrap_or(true)
|
||||||
};
|
};
|
||||||
if !already_synced {
|
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}"))?
|
.map_err(|e| format!("Failed to list extensions: {e}"))?
|
||||||
};
|
};
|
||||||
for ext in &exts {
|
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 {
|
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);
|
log::warn!("Failed to enable sync for extension {}: {e}", ext.id);
|
||||||
}
|
}
|
||||||
@@ -4029,6 +4040,11 @@ pub async fn set_extension_sync_enabled(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if 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?;
|
ensure_sync_configured(&app_handle).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FaChrome } from "react-icons/fa";
|
import { FaChrome } from "react-icons/fa";
|
||||||
@@ -19,6 +20,8 @@ import {
|
|||||||
LuChevronDown,
|
LuChevronDown,
|
||||||
LuChevronUp,
|
LuChevronUp,
|
||||||
LuExternalLink,
|
LuExternalLink,
|
||||||
|
LuFolderOpen,
|
||||||
|
LuLink,
|
||||||
LuPencil,
|
LuPencil,
|
||||||
LuPuzzle,
|
LuPuzzle,
|
||||||
LuRefreshCw,
|
LuRefreshCw,
|
||||||
@@ -82,6 +85,18 @@ import { RippleButton } from "./ui/ripple";
|
|||||||
|
|
||||||
type SyncStatus = "disabled" | "syncing" | "synced" | "error" | "waiting";
|
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(
|
function getSyncStatusDot(
|
||||||
item: { sync_enabled?: boolean; last_sync?: number },
|
item: { sync_enabled?: boolean; last_sync?: number },
|
||||||
liveStatus: SyncStatus | undefined,
|
liveStatus: SyncStatus | undefined,
|
||||||
@@ -148,14 +163,13 @@ export function ExtensionManagementDialog({
|
|||||||
const [extensionGroups, setExtensionGroups] = useState<ExtensionGroup[]>([]);
|
const [extensionGroups, setExtensionGroups] = useState<ExtensionGroup[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
// Extension upload state
|
// Extension import state
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [extensionName, setExtensionName] = useState("");
|
const [extensionName, setExtensionName] = useState("");
|
||||||
const [showUploadForm, setShowUploadForm] = useState(false);
|
const [pendingSource, setPendingSource] = useState<PendingSource | null>(
|
||||||
const [pendingFile, setPendingFile] = useState<{
|
null,
|
||||||
name: string;
|
);
|
||||||
data: number[];
|
const [linkFolder, setLinkFolder] = useState(false);
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
// Group state
|
// Group state
|
||||||
const [showCreateGroup, setShowCreateGroup] = useState(false);
|
const [showCreateGroup, setShowCreateGroup] = useState(false);
|
||||||
@@ -192,10 +206,9 @@ export function ExtensionManagementDialog({
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [editExtensionName, setEditExtensionName] = useState("");
|
const [editExtensionName, setEditExtensionName] = useState("");
|
||||||
const [pendingUpdateFile, setPendingUpdateFile] = useState<{
|
const [pendingUpdateSource, setPendingUpdateSource] =
|
||||||
name: string;
|
useState<PendingSource | null>(null);
|
||||||
data: number[];
|
const [editLinkFolder, setEditLinkFolder] = useState(false);
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
// Extension icons
|
// Extension icons
|
||||||
const [extensionIcons, setExtensionIcons] = useState<Record<string, string>>(
|
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(
|
const handleToggleExtSync = useCallback(
|
||||||
async (ext: Extension) => {
|
async (ext: Extension) => {
|
||||||
setIsTogglingExtSync((prev) => ({ ...prev, [ext.id]: true }));
|
setIsTogglingExtSync((prev) => ({ ...prev, [ext.id]: true }));
|
||||||
@@ -310,16 +347,12 @@ export function ExtensionManagementDialog({
|
|||||||
);
|
);
|
||||||
void loadData();
|
void loadData();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showErrorToast(
|
showActionError(err, t("proxies.management.updateSyncFailed"));
|
||||||
parseBackendError(err)
|
|
||||||
? translateBackendError(t, err)
|
|
||||||
: t("proxies.management.updateSyncFailed"),
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsTogglingExtSync((prev) => ({ ...prev, [ext.id]: false }));
|
setIsTogglingExtSync((prev) => ({ ...prev, [ext.id]: false }));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[loadData, t],
|
[loadData, showActionError, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleToggleGroupSync = useCallback(
|
const handleToggleGroupSync = useCallback(
|
||||||
@@ -337,119 +370,178 @@ export function ExtensionManagementDialog({
|
|||||||
);
|
);
|
||||||
void loadData();
|
void loadData();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showErrorToast(
|
showActionError(err, t("proxies.management.updateSyncFailed"));
|
||||||
parseBackendError(err)
|
|
||||||
? translateBackendError(t, err)
|
|
||||||
: t("proxies.management.updateSyncFailed"),
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsTogglingGroupSync((prev) => ({ ...prev, [group.id]: false }));
|
setIsTogglingGroupSync((prev) => ({ ...prev, [group.id]: false }));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[loadData, t],
|
[loadData, showActionError, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleUpdateExtension = useCallback(async () => {
|
const handleUpdateExtension = useCallback(async () => {
|
||||||
if (!editingExtension || !editExtensionName.trim()) return;
|
if (!editingExtension || !editExtensionName.trim()) return;
|
||||||
try {
|
try {
|
||||||
await invoke("update_extension", {
|
if (pendingUpdateSource?.kind === "folder") {
|
||||||
extensionId: editingExtension.id,
|
await invoke("update_extension_from_path", {
|
||||||
name: editExtensionName.trim(),
|
extensionId: editingExtension.id,
|
||||||
fileName: pendingUpdateFile?.name ?? null,
|
name: editExtensionName.trim(),
|
||||||
fileData: pendingUpdateFile?.data ?? null,
|
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"));
|
showSuccessToast(t("extensions.updateSuccess"));
|
||||||
setEditingExtension(null);
|
closeEditExtension();
|
||||||
setEditExtensionName("");
|
|
||||||
setPendingUpdateFile(null);
|
|
||||||
void loadData();
|
void loadData();
|
||||||
} catch (err) {
|
} 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(
|
/** Reads a picked archive into memory, shared by the import and the replace
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
* flows. Resolves to null when the file is rejected or unreadable. */
|
||||||
const file = e.target.files?.[0];
|
const readArchiveFile = useCallback(
|
||||||
if (!file) return;
|
(file: File): Promise<PendingSource | null> => {
|
||||||
|
const isValid = ARCHIVE_EXTENSIONS.some((ext) =>
|
||||||
const validExtensions = [".xpi", ".crx", ".zip"];
|
|
||||||
const isValid = validExtensions.some((ext) =>
|
|
||||||
file.name.toLowerCase().endsWith(ext),
|
file.name.toLowerCase().endsWith(ext),
|
||||||
);
|
);
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
showErrorToast(t("extensions.invalidFileType"));
|
showErrorToast(t("extensions.invalidFileType"));
|
||||||
return;
|
return Promise.resolve(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const reader = new FileReader();
|
return new Promise((resolve) => {
|
||||||
reader.onload = (event) => {
|
const reader = new FileReader();
|
||||||
const arrayBuffer = event.target?.result as ArrayBuffer;
|
reader.onload = (event) => {
|
||||||
const data = Array.from(new Uint8Array(arrayBuffer));
|
const arrayBuffer = event.target?.result as ArrayBuffer;
|
||||||
setPendingUpdateFile({ name: file.name, data });
|
resolve({
|
||||||
};
|
kind: "archive",
|
||||||
reader.readAsArrayBuffer(file);
|
fileName: file.name,
|
||||||
e.target.value = "";
|
data: Array.from(new Uint8Array(arrayBuffer)),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
reader.onerror = () => {
|
||||||
|
showErrorToast(t("extensions.readError"));
|
||||||
|
resolve(null);
|
||||||
|
};
|
||||||
|
reader.readAsArrayBuffer(file);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[t],
|
[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(
|
const handleFileSelect = useCallback(
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = "";
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
const validExtensions = [".xpi", ".crx", ".zip"];
|
void readArchiveFile(file).then((source) => {
|
||||||
const isValid = validExtensions.some((ext) =>
|
if (!source) return;
|
||||||
file.name.toLowerCase().endsWith(ext),
|
setExtensionName(
|
||||||
);
|
file.name.replace(/\.(crx|zip)$/i, "").replace(/[-_]/g, " "),
|
||||||
if (!isValid) {
|
);
|
||||||
showErrorToast(t("extensions.invalidFileType"));
|
setLinkFolder(false);
|
||||||
return;
|
setPendingSource(source);
|
||||||
}
|
});
|
||||||
|
|
||||||
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 = "";
|
|
||||||
},
|
},
|
||||||
[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 () => {
|
const handleUpload = useCallback(async () => {
|
||||||
if (!pendingFile || !extensionName.trim()) return;
|
if (!pendingSource || !extensionName.trim()) return;
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
try {
|
try {
|
||||||
await invoke("add_extension", {
|
if (pendingSource.kind === "folder") {
|
||||||
name: extensionName.trim(),
|
await invoke("add_unpacked_extension", {
|
||||||
fileName: pendingFile.name,
|
name: extensionName.trim(),
|
||||||
fileData: pendingFile.data,
|
path: pendingSource.path,
|
||||||
});
|
link: linkFolder,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await invoke("add_extension", {
|
||||||
|
name: extensionName.trim(),
|
||||||
|
fileName: pendingSource.fileName,
|
||||||
|
fileData: pendingSource.data,
|
||||||
|
});
|
||||||
|
}
|
||||||
showSuccessToast(t("extensions.uploadSuccess"));
|
showSuccessToast(t("extensions.uploadSuccess"));
|
||||||
setShowUploadForm(false);
|
resetImportForm();
|
||||||
setPendingFile(null);
|
|
||||||
setExtensionName("");
|
|
||||||
void loadData();
|
void loadData();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showErrorToast(err instanceof Error ? err.message : String(err));
|
showActionError(err, t("extensions.uploadFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
}
|
}
|
||||||
}, [pendingFile, extensionName, loadData, t]);
|
}, [
|
||||||
|
pendingSource,
|
||||||
|
extensionName,
|
||||||
|
linkFolder,
|
||||||
|
resetImportForm,
|
||||||
|
loadData,
|
||||||
|
showActionError,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleDeleteExtension = useCallback(async () => {
|
const handleDeleteExtension = useCallback(async () => {
|
||||||
if (!extensionToDelete) return;
|
if (!extensionToDelete) return;
|
||||||
@@ -460,11 +552,11 @@ export function ExtensionManagementDialog({
|
|||||||
setExtensionToDelete(null);
|
setExtensionToDelete(null);
|
||||||
void loadData();
|
void loadData();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showErrorToast(err instanceof Error ? err.message : String(err));
|
showActionError(err, t("extensions.deleteFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
setIsDeleting(false);
|
setIsDeleting(false);
|
||||||
}
|
}
|
||||||
}, [extensionToDelete, loadData, t]);
|
}, [extensionToDelete, loadData, showActionError, t]);
|
||||||
|
|
||||||
const handleCreateGroup = useCallback(async () => {
|
const handleCreateGroup = useCallback(async () => {
|
||||||
if (!newGroupName.trim()) return;
|
if (!newGroupName.trim()) return;
|
||||||
@@ -475,9 +567,9 @@ export function ExtensionManagementDialog({
|
|||||||
setNewGroupName("");
|
setNewGroupName("");
|
||||||
void loadData();
|
void loadData();
|
||||||
} catch (err) {
|
} 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 () => {
|
const handleSaveGroupEdits = useCallback(async () => {
|
||||||
if (!editingGroup || !editGroupName.trim()) return;
|
if (!editingGroup || !editGroupName.trim()) return;
|
||||||
@@ -518,9 +610,16 @@ export function ExtensionManagementDialog({
|
|||||||
setEditGroupExtensionIds([]);
|
setEditGroupExtensionIds([]);
|
||||||
void loadData();
|
void loadData();
|
||||||
} catch (err) {
|
} 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 () => {
|
const handleDeleteGroup = useCallback(async () => {
|
||||||
if (!groupToDelete) return;
|
if (!groupToDelete) return;
|
||||||
@@ -531,11 +630,11 @@ export function ExtensionManagementDialog({
|
|||||||
setGroupToDelete(null);
|
setGroupToDelete(null);
|
||||||
void loadData();
|
void loadData();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showErrorToast(err instanceof Error ? err.message : String(err));
|
showActionError(err, t("extensions.groupDeleteFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
setIsDeleting(false);
|
setIsDeleting(false);
|
||||||
}
|
}
|
||||||
}, [groupToDelete, loadData, t]);
|
}, [groupToDelete, loadData, showActionError, t]);
|
||||||
|
|
||||||
const selectedExtensions = useMemo(
|
const selectedExtensions = useMemo(
|
||||||
() => extensions.filter((ext) => extRowSelection[ext.id]),
|
() => extensions.filter((ext) => extRowSelection[ext.id]),
|
||||||
@@ -561,11 +660,11 @@ export function ExtensionManagementDialog({
|
|||||||
setExtRowSelection({});
|
setExtRowSelection({});
|
||||||
void loadData();
|
void loadData();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showErrorToast(err instanceof Error ? err.message : String(err));
|
showActionError(err, t("extensions.deleteFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
setIsDeleting(false);
|
setIsDeleting(false);
|
||||||
}
|
}
|
||||||
}, [selectedExtensions, loadData, t]);
|
}, [selectedExtensions, loadData, showActionError, t]);
|
||||||
|
|
||||||
const handleBulkDeleteGroups = useCallback(async () => {
|
const handleBulkDeleteGroups = useCallback(async () => {
|
||||||
if (selectedGroups.length === 0) return;
|
if (selectedGroups.length === 0) return;
|
||||||
@@ -581,18 +680,27 @@ export function ExtensionManagementDialog({
|
|||||||
setGroupRowSelection({});
|
setGroupRowSelection({});
|
||||||
void loadData();
|
void loadData();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showErrorToast(err instanceof Error ? err.message : String(err));
|
showActionError(err, t("extensions.groupDeleteFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
setIsDeleting(false);
|
setIsDeleting(false);
|
||||||
}
|
}
|
||||||
}, [selectedGroups, loadData, t]);
|
}, [selectedGroups, loadData, showActionError, t]);
|
||||||
|
|
||||||
const handleBulkToggleExtSync = useCallback(async () => {
|
const handleBulkToggleExtSync = useCallback(async () => {
|
||||||
if (selectedExtensions.length === 0) return;
|
if (selectedExtensions.length === 0) return;
|
||||||
const allOn = selectedExtensions.every((e) => e.sync_enabled);
|
const allOn = selectedExtensions.every((e) => e.sync_enabled);
|
||||||
const targetEnabled = !allOn;
|
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(
|
const results = await Promise.allSettled(
|
||||||
selectedExtensions.map((ext) =>
|
targets.map((ext) =>
|
||||||
invoke("set_extension_sync_enabled", {
|
invoke("set_extension_sync_enabled", {
|
||||||
extensionId: ext.id,
|
extensionId: ext.id,
|
||||||
enabled: targetEnabled,
|
enabled: targetEnabled,
|
||||||
@@ -603,10 +711,9 @@ export function ExtensionManagementDialog({
|
|||||||
| PromiseRejectedResult
|
| PromiseRejectedResult
|
||||||
| undefined;
|
| undefined;
|
||||||
if (firstRejection) {
|
if (firstRejection) {
|
||||||
showErrorToast(
|
showActionError(
|
||||||
parseBackendError(firstRejection.reason)
|
firstRejection.reason,
|
||||||
? translateBackendError(t, firstRejection.reason)
|
t("proxies.management.updateSyncFailed"),
|
||||||
: t("proxies.management.updateSyncFailed"),
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
showSuccessToast(
|
showSuccessToast(
|
||||||
@@ -616,7 +723,7 @@ export function ExtensionManagementDialog({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
void loadData();
|
void loadData();
|
||||||
}, [selectedExtensions, loadData, t]);
|
}, [selectedExtensions, loadData, showActionError, t]);
|
||||||
|
|
||||||
const handleBulkToggleGroupSync = useCallback(async () => {
|
const handleBulkToggleGroupSync = useCallback(async () => {
|
||||||
if (selectedGroups.length === 0) return;
|
if (selectedGroups.length === 0) return;
|
||||||
@@ -634,10 +741,9 @@ export function ExtensionManagementDialog({
|
|||||||
| PromiseRejectedResult
|
| PromiseRejectedResult
|
||||||
| undefined;
|
| undefined;
|
||||||
if (firstRejection) {
|
if (firstRejection) {
|
||||||
showErrorToast(
|
showActionError(
|
||||||
parseBackendError(firstRejection.reason)
|
firstRejection.reason,
|
||||||
? translateBackendError(t, firstRejection.reason)
|
t("proxies.management.updateSyncFailed"),
|
||||||
: t("proxies.management.updateSyncFailed"),
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
showSuccessToast(
|
showSuccessToast(
|
||||||
@@ -647,7 +753,7 @@ export function ExtensionManagementDialog({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
void loadData();
|
void loadData();
|
||||||
}, [selectedGroups, loadData, t]);
|
}, [selectedGroups, loadData, showActionError, t]);
|
||||||
|
|
||||||
const renderCompatIcons = useCallback(
|
const renderCompatIcons = useCallback(
|
||||||
(compat: string[]) => {
|
(compat: string[]) => {
|
||||||
@@ -691,6 +797,42 @@ export function ExtensionManagementDialog({
|
|||||||
[extensionIcons],
|
[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 MAX_VISIBLE_ICONS = 3;
|
||||||
|
|
||||||
const extensionColumns = useMemo<ColumnDef<Extension>[]>(
|
const extensionColumns = useMemo<ColumnDef<Extension>[]>(
|
||||||
@@ -762,6 +904,13 @@ export function ExtensionManagementDialog({
|
|||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
renderCompatIcons(row.original.browser_compatibility),
|
renderCompatIcons(row.original.browser_compatibility),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "source",
|
||||||
|
size: 128,
|
||||||
|
enableSorting: false,
|
||||||
|
header: () => null,
|
||||||
|
cell: ({ row }) => renderSource(row.original),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "sync",
|
id: "sync",
|
||||||
size: 88,
|
size: 88,
|
||||||
@@ -770,6 +919,7 @@ export function ExtensionManagementDialog({
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const ext = row.original;
|
const ext = row.original;
|
||||||
const syncDot = getSyncStatusDot(ext, extSyncStatus[ext.id], t);
|
const syncDot = getSyncStatusDot(ext, extSyncStatus[ext.id], t);
|
||||||
|
const isLinked = Boolean(ext.linked_path);
|
||||||
return (
|
return (
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -790,15 +940,17 @@ export function ExtensionManagementDialog({
|
|||||||
<AnimatedSwitch
|
<AnimatedSwitch
|
||||||
checked={ext.sync_enabled}
|
checked={ext.sync_enabled}
|
||||||
onCheckedChange={() => void handleToggleExtSync(ext)}
|
onCheckedChange={() => void handleToggleExtSync(ext)}
|
||||||
disabled={isTogglingExtSync[ext.id]}
|
disabled={isLinked || isTogglingExtSync[ext.id]}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>
|
<p>
|
||||||
{ext.sync_enabled
|
{isLinked
|
||||||
? t("syncTooltips.disable")
|
? t("extensions.linkedNoSync")
|
||||||
: t("syncTooltips.enable")}
|
: ext.sync_enabled
|
||||||
|
? t("syncTooltips.disable")
|
||||||
|
: t("syncTooltips.enable")}
|
||||||
</p>
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -824,7 +976,8 @@ export function ExtensionManagementDialog({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditingExtension(ext);
|
setEditingExtension(ext);
|
||||||
setEditExtensionName(ext.name);
|
setEditExtensionName(ext.name);
|
||||||
setPendingUpdateFile(null);
|
setPendingUpdateSource(null);
|
||||||
|
setEditLinkFolder(Boolean(ext.linked_path));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LuPencil className="size-3.5" />
|
<LuPencil className="size-3.5" />
|
||||||
@@ -859,6 +1012,7 @@ export function ExtensionManagementDialog({
|
|||||||
handleToggleExtSync,
|
handleToggleExtSync,
|
||||||
renderExtensionIcon,
|
renderExtensionIcon,
|
||||||
renderCompatIcons,
|
renderCompatIcons,
|
||||||
|
renderSource,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1160,25 +1314,48 @@ export function ExtensionManagementDialog({
|
|||||||
</AnimatedTabsList>
|
</AnimatedTabsList>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{activeTab === "extensions" && (
|
{activeTab === "extensions" && (
|
||||||
<Tooltip>
|
<>
|
||||||
<TooltipTrigger asChild>
|
<Tooltip>
|
||||||
<RippleButton
|
<TooltipTrigger asChild>
|
||||||
size="sm"
|
<RippleButton
|
||||||
variant="outline"
|
size="sm"
|
||||||
disabled={limitedMode}
|
variant="outline"
|
||||||
onClick={() =>
|
disabled={limitedMode}
|
||||||
document.getElementById("ext-file-input")?.click()
|
onClick={() =>
|
||||||
}
|
document.getElementById("ext-file-input")?.click()
|
||||||
aria-label={t("extensions.upload")}
|
}
|
||||||
>
|
aria-label={t("extensions.upload")}
|
||||||
<LuUpload className="size-4" />
|
>
|
||||||
<span className="hidden @2xl:inline">
|
<LuUpload className="size-4" />
|
||||||
{t("extensions.upload")}
|
<span className="hidden @2xl:inline">
|
||||||
</span>
|
{t("extensions.upload")}
|
||||||
</RippleButton>
|
</span>
|
||||||
</TooltipTrigger>
|
</RippleButton>
|
||||||
<TooltipContent>{t("extensions.upload")}</TooltipContent>
|
</TooltipTrigger>
|
||||||
</Tooltip>
|
<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" && (
|
{activeTab === "groups" && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -1216,21 +1393,51 @@ export function ExtensionManagementDialog({
|
|||||||
<Input
|
<Input
|
||||||
id="ext-file-input"
|
id="ext-file-input"
|
||||||
type="file"
|
type="file"
|
||||||
accept=".xpi,.crx,.zip"
|
accept=".crx,.zip"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleFileSelect}
|
onChange={handleFileSelect}
|
||||||
disabled={limitedMode}
|
disabled={limitedMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Upload form */}
|
{/* Import form */}
|
||||||
{showUploadForm && pendingFile && (
|
{pendingSource && (
|
||||||
<div className="space-y-3 rounded-md border p-3">
|
<div className="space-y-3 rounded-md border p-3">
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{t("extensions.selectedFile")}:{" "}
|
{pendingSource.kind === "folder"
|
||||||
<span className="font-medium text-foreground">
|
? t("extensions.selectedFolder")
|
||||||
{pendingFile.name}
|
: t("extensions.selectedFile")}
|
||||||
|
:{" "}
|
||||||
|
<span className="font-medium break-all text-foreground">
|
||||||
|
{pendingSource.kind === "folder"
|
||||||
|
? pendingSource.path
|
||||||
|
: pendingSource.fileName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
value={extensionName}
|
value={extensionName}
|
||||||
@@ -1252,11 +1459,7 @@ export function ExtensionManagementDialog({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={resetImportForm}
|
||||||
setShowUploadForm(false);
|
|
||||||
setPendingFile(null);
|
|
||||||
setExtensionName("");
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{t("common.buttons.cancel")}
|
{t("common.buttons.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1611,11 +1814,7 @@ export function ExtensionManagementDialog({
|
|||||||
<Dialog
|
<Dialog
|
||||||
open={editingExtension !== null}
|
open={editingExtension !== null}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) closeEditExtension();
|
||||||
setEditingExtension(null);
|
|
||||||
setEditExtensionName("");
|
|
||||||
setPendingUpdateFile(null);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent className="flex max-h-[90vh] max-w-lg flex-col">
|
<DialogContent className="flex max-h-[90vh] max-w-lg flex-col">
|
||||||
@@ -1684,9 +1883,35 @@ export function ExtensionManagementDialog({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{t("common.labels.type")}
|
{t("extensions.source.label")}
|
||||||
</span>
|
</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 && (
|
{editingExtension.homepage_url && (
|
||||||
<>
|
<>
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
@@ -1716,10 +1941,10 @@ export function ExtensionManagementDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Re-upload */}
|
{/* Replace the payload with another archive or folder */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t("extensions.reupload")}</Label>
|
<Label>{t("extensions.replaceSource")}</Label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<RippleButton
|
<RippleButton
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -1733,30 +1958,58 @@ export function ExtensionManagementDialog({
|
|||||||
<input
|
<input
|
||||||
id="ext-edit-file-input"
|
id="ext-edit-file-input"
|
||||||
type="file"
|
type="file"
|
||||||
accept=".xpi,.crx,.zip"
|
accept=".crx,.zip"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleEditFileSelect}
|
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">
|
<span className="max-w-[200px] truncate text-xs text-muted-foreground">
|
||||||
{pendingUpdateFile.name}
|
{pendingUpdateSource.kind === "folder"
|
||||||
|
? pendingUpdateSource.path
|
||||||
|
: pendingUpdateSource.fileName}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button variant="outline" onClick={closeEditExtension}>
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setEditingExtension(null);
|
|
||||||
setEditExtensionName("");
|
|
||||||
setPendingUpdateFile(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("common.buttons.cancel")}
|
{t("common.buttons.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<RippleButton
|
<RippleButton
|
||||||
|
|||||||
@@ -1271,7 +1271,7 @@
|
|||||||
"deleteConfirmDescription": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
"deleteConfirmDescription": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||||
"deleteGroupConfirmTitle": "Delete Extension Group",
|
"deleteGroupConfirmTitle": "Delete Extension Group",
|
||||||
"deleteGroupConfirmDescription": "Are you sure you want to delete the group \"{{name}}\"? This action cannot be undone.",
|
"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.",
|
"readError": "Failed to read the extension file.",
|
||||||
"assignTitle": "Assign Extension Group",
|
"assignTitle": "Assign Extension Group",
|
||||||
"assignDescription": "Assign {{count}} selected profile(s) to an extension group.",
|
"assignDescription": "Assign {{count}} selected profile(s) to an extension group.",
|
||||||
@@ -1279,7 +1279,6 @@
|
|||||||
"assignSuccess": "Extension group assigned successfully",
|
"assignSuccess": "Extension group assigned successfully",
|
||||||
"editExtension": "Edit extension",
|
"editExtension": "Edit extension",
|
||||||
"updateSuccess": "Extension updated successfully",
|
"updateSuccess": "Extension updated successfully",
|
||||||
"reupload": "Re-upload",
|
|
||||||
"version": "Version",
|
"version": "Version",
|
||||||
"author": "Author",
|
"author": "Author",
|
||||||
"homepage": "Homepage",
|
"homepage": "Homepage",
|
||||||
@@ -1287,10 +1286,26 @@
|
|||||||
"editGroupDescription": "Update the group name and manage which extensions are included.",
|
"editGroupDescription": "Update the group name and manage which extensions are included.",
|
||||||
"groupExtensions": "Extensions in this group",
|
"groupExtensions": "Extensions in this group",
|
||||||
"noExtensionsInGroup": "No extensions added yet",
|
"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",
|
"metadata": "Metadata",
|
||||||
"noMetadata": "No metadata available from manifest.",
|
"noMetadata": "No metadata available from manifest.",
|
||||||
"selectFile": "Choose File",
|
"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",
|
"syncEnabled": "Sync enabled",
|
||||||
"syncDisabled": "Sync disabled",
|
"syncDisabled": "Sync disabled",
|
||||||
"syncEnableTooltip": "Enable sync",
|
"syncEnableTooltip": "Enable sync",
|
||||||
@@ -1303,6 +1318,14 @@
|
|||||||
"groupsTitle": "Delete extension groups",
|
"groupsTitle": "Delete extension groups",
|
||||||
"groupsDescription": "Delete {{count}} extension groups? {{names}}",
|
"groupsDescription": "Delete {{count}} extension groups? {{names}}",
|
||||||
"confirmButton": "Delete"
|
"confirmButton": "Delete"
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"label": "Source",
|
||||||
|
"archive": "Archive",
|
||||||
|
"unpacked": "Unpacked folder",
|
||||||
|
"linked": "Linked folder",
|
||||||
|
"folderLabel": "Folder",
|
||||||
|
"linkedTooltip": "Loaded in place from {{path}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pro": {
|
"pro": {
|
||||||
@@ -1842,6 +1865,15 @@
|
|||||||
"vpnNotFound": "VPN not found",
|
"vpnNotFound": "VPN not found",
|
||||||
"extensionNotFound": "Extension not found",
|
"extensionNotFound": "Extension not found",
|
||||||
"extensionGroupNotFound": "Extension group 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",
|
"cannotModifyCloudManagedProxy": "Cannot modify sync for a cloud-managed proxy",
|
||||||
"syncLockedByProfile": "Sync cannot be disabled while this is used by synced profiles",
|
"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.",
|
"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.",
|
"deleteConfirmDescription": "¿Estás seguro de que deseas eliminar \"{{name}}\"? Esta acción no se puede deshacer.",
|
||||||
"deleteGroupConfirmTitle": "Eliminar Grupo de Extensiones",
|
"deleteGroupConfirmTitle": "Eliminar Grupo de Extensiones",
|
||||||
"deleteGroupConfirmDescription": "¿Estás seguro de que deseas eliminar el grupo \"{{name}}\"? Esta acción no se puede deshacer.",
|
"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.",
|
"readError": "No se pudo leer el archivo de extensión.",
|
||||||
"assignTitle": "Asignar Grupo de Extensiones",
|
"assignTitle": "Asignar Grupo de Extensiones",
|
||||||
"assignDescription": "Asignar {{count}} perfil(es) seleccionado(s) a un 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",
|
"assignSuccess": "Grupo de extensiones asignado exitosamente",
|
||||||
"editExtension": "Editar extensión",
|
"editExtension": "Editar extensión",
|
||||||
"updateSuccess": "Extensión actualizada exitosamente",
|
"updateSuccess": "Extensión actualizada exitosamente",
|
||||||
"reupload": "Re-subir",
|
|
||||||
"version": "Versión",
|
"version": "Versión",
|
||||||
"author": "Autor",
|
"author": "Autor",
|
||||||
"homepage": "Página de inicio",
|
"homepage": "Página de inicio",
|
||||||
@@ -1290,10 +1289,26 @@
|
|||||||
"editGroupDescription": "Actualiza el nombre del grupo y gestiona qué extensiones están incluidas.",
|
"editGroupDescription": "Actualiza el nombre del grupo y gestiona qué extensiones están incluidas.",
|
||||||
"groupExtensions": "Extensiones en este grupo",
|
"groupExtensions": "Extensiones en este grupo",
|
||||||
"noExtensionsInGroup": "Aún no se han añadido extensiones",
|
"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",
|
"metadata": "Metadatos",
|
||||||
"noMetadata": "No hay metadatos disponibles del manifiesto.",
|
"noMetadata": "No hay metadatos disponibles del manifiesto.",
|
||||||
"selectFile": "Elegir archivo",
|
"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",
|
"syncEnabled": "Sincronización habilitada",
|
||||||
"syncDisabled": "Sincronización deshabilitada",
|
"syncDisabled": "Sincronización deshabilitada",
|
||||||
"syncEnableTooltip": "Habilitar sincronización",
|
"syncEnableTooltip": "Habilitar sincronización",
|
||||||
@@ -1306,6 +1321,14 @@
|
|||||||
"groupsTitle": "Eliminar grupos de extensiones",
|
"groupsTitle": "Eliminar grupos de extensiones",
|
||||||
"groupsDescription": "¿Eliminar {{count}} grupos de extensiones? {{names}}",
|
"groupsDescription": "¿Eliminar {{count}} grupos de extensiones? {{names}}",
|
||||||
"confirmButton": "Eliminar"
|
"confirmButton": "Eliminar"
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"label": "Origen",
|
||||||
|
"archive": "Archivo comprimido",
|
||||||
|
"unpacked": "Carpeta sin empaquetar",
|
||||||
|
"linked": "Carpeta enlazada",
|
||||||
|
"folderLabel": "Carpeta",
|
||||||
|
"linkedTooltip": "Se carga directamente desde {{path}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pro": {
|
"pro": {
|
||||||
@@ -1849,6 +1872,15 @@
|
|||||||
"vpnNotFound": "VPN no encontrada",
|
"vpnNotFound": "VPN no encontrada",
|
||||||
"extensionNotFound": "Extensión no encontrada",
|
"extensionNotFound": "Extensión no encontrada",
|
||||||
"extensionGroupNotFound": "Grupo de extensiones no encontrado",
|
"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",
|
"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",
|
"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.",
|
"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.",
|
"deleteConfirmDescription": "Êtes-vous sûr de vouloir supprimer \"{{name}}\" ? Cette action est irréversible.",
|
||||||
"deleteGroupConfirmTitle": "Supprimer le Groupe d'Extensions",
|
"deleteGroupConfirmTitle": "Supprimer le Groupe d'Extensions",
|
||||||
"deleteGroupConfirmDescription": "Êtes-vous sûr de vouloir supprimer le groupe \"{{name}}\" ? Cette action est irréversible.",
|
"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.",
|
"readError": "Impossible de lire le fichier d'extension.",
|
||||||
"assignTitle": "Assigner un Groupe d'Extensions",
|
"assignTitle": "Assigner un Groupe d'Extensions",
|
||||||
"assignDescription": "Assigner {{count}} profil(s) sélectionné(s) à 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",
|
"assignSuccess": "Groupe d'extensions assigné avec succès",
|
||||||
"editExtension": "Modifier l'extension",
|
"editExtension": "Modifier l'extension",
|
||||||
"updateSuccess": "Extension mise à jour avec succès",
|
"updateSuccess": "Extension mise à jour avec succès",
|
||||||
"reupload": "Re-télécharger",
|
|
||||||
"version": "Version",
|
"version": "Version",
|
||||||
"author": "Auteur",
|
"author": "Auteur",
|
||||||
"homepage": "Page d'accueil",
|
"homepage": "Page d'accueil",
|
||||||
@@ -1290,10 +1289,26 @@
|
|||||||
"editGroupDescription": "Mettez à jour le nom du groupe et gérez les extensions incluses.",
|
"editGroupDescription": "Mettez à jour le nom du groupe et gérez les extensions incluses.",
|
||||||
"groupExtensions": "Extensions dans ce groupe",
|
"groupExtensions": "Extensions dans ce groupe",
|
||||||
"noExtensionsInGroup": "Aucune extension ajoutée",
|
"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",
|
"metadata": "Métadonnées",
|
||||||
"noMetadata": "Aucune métadonnée disponible depuis le manifeste.",
|
"noMetadata": "Aucune métadonnée disponible depuis le manifeste.",
|
||||||
"selectFile": "Choisir un fichier",
|
"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",
|
"syncEnabled": "Synchronisation activée",
|
||||||
"syncDisabled": "Synchronisation désactivée",
|
"syncDisabled": "Synchronisation désactivée",
|
||||||
"syncEnableTooltip": "Activer la synchronisation",
|
"syncEnableTooltip": "Activer la synchronisation",
|
||||||
@@ -1306,6 +1321,14 @@
|
|||||||
"groupsTitle": "Supprimer les groupes d'extensions",
|
"groupsTitle": "Supprimer les groupes d'extensions",
|
||||||
"groupsDescription": "Supprimer {{count}} groupes d'extensions ? {{names}}",
|
"groupsDescription": "Supprimer {{count}} groupes d'extensions ? {{names}}",
|
||||||
"confirmButton": "Supprimer"
|
"confirmButton": "Supprimer"
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"label": "Source",
|
||||||
|
"archive": "Archive",
|
||||||
|
"unpacked": "Dossier non empaqueté",
|
||||||
|
"linked": "Dossier lié",
|
||||||
|
"folderLabel": "Dossier",
|
||||||
|
"linkedTooltip": "Chargée directement depuis {{path}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pro": {
|
"pro": {
|
||||||
@@ -1849,6 +1872,15 @@
|
|||||||
"vpnNotFound": "VPN introuvable",
|
"vpnNotFound": "VPN introuvable",
|
||||||
"extensionNotFound": "Extension introuvable",
|
"extensionNotFound": "Extension introuvable",
|
||||||
"extensionGroupNotFound": "Groupe d'extensions 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",
|
"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",
|
"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é.",
|
"syncNotConfigured": "La synchronisation n'est pas configurée. Connectez-vous ou configurez un serveur auto-hébergé.",
|
||||||
|
|||||||
@@ -1271,7 +1271,7 @@
|
|||||||
"deleteConfirmDescription": "「{{name}}」を削除してもよろしいですか?この操作は元に戻せません。",
|
"deleteConfirmDescription": "「{{name}}」を削除してもよろしいですか?この操作は元に戻せません。",
|
||||||
"deleteGroupConfirmTitle": "拡張機能グループを削除",
|
"deleteGroupConfirmTitle": "拡張機能グループを削除",
|
||||||
"deleteGroupConfirmDescription": "グループ「{{name}}」を削除してもよろしいですか?この操作は元に戻せません。",
|
"deleteGroupConfirmDescription": "グループ「{{name}}」を削除してもよろしいですか?この操作は元に戻せません。",
|
||||||
"invalidFileType": "無効なファイルタイプです。.crx、.xpi、または .zip ファイルをアップロードしてください。",
|
"invalidFileType": "ファイル形式が正しくありません。.crx または .zip ファイルを選択してください。",
|
||||||
"readError": "拡張機能ファイルの読み取りに失敗しました。",
|
"readError": "拡張機能ファイルの読み取りに失敗しました。",
|
||||||
"assignTitle": "拡張機能グループの割り当て",
|
"assignTitle": "拡張機能グループの割り当て",
|
||||||
"assignDescription": "選択した{{count}}件のプロファイルを拡張機能グループに割り当てます。",
|
"assignDescription": "選択した{{count}}件のプロファイルを拡張機能グループに割り当てます。",
|
||||||
@@ -1279,7 +1279,6 @@
|
|||||||
"assignSuccess": "拡張機能グループが正常に割り当てられました",
|
"assignSuccess": "拡張機能グループが正常に割り当てられました",
|
||||||
"editExtension": "拡張機能を編集",
|
"editExtension": "拡張機能を編集",
|
||||||
"updateSuccess": "拡張機能が正常に更新されました",
|
"updateSuccess": "拡張機能が正常に更新されました",
|
||||||
"reupload": "再アップロード",
|
|
||||||
"version": "バージョン",
|
"version": "バージョン",
|
||||||
"author": "作者",
|
"author": "作者",
|
||||||
"homepage": "ホームページ",
|
"homepage": "ホームページ",
|
||||||
@@ -1287,10 +1286,26 @@
|
|||||||
"editGroupDescription": "グループ名を更新し、含まれる拡張機能を管理します。",
|
"editGroupDescription": "グループ名を更新し、含まれる拡張機能を管理します。",
|
||||||
"groupExtensions": "このグループの拡張機能",
|
"groupExtensions": "このグループの拡張機能",
|
||||||
"noExtensionsInGroup": "拡張機能がまだ追加されていません",
|
"noExtensionsInGroup": "拡張機能がまだ追加されていません",
|
||||||
"editExtensionDescription": "拡張機能の名前を更新、メタデータを表示、またはファイルを再アップロードします。",
|
"editExtensionDescription": "拡張機能の名前を変更したり、メタデータを確認したり、別のアーカイブやフォルダに置き換えたりできます。",
|
||||||
"metadata": "メタデータ",
|
"metadata": "メタデータ",
|
||||||
"noMetadata": "マニフェストからのメタデータはありません。",
|
"noMetadata": "マニフェストからのメタデータはありません。",
|
||||||
"selectFile": "ファイルを選択",
|
"selectFile": "ファイルを選択",
|
||||||
|
"loadUnpacked": "フォルダから読み込む",
|
||||||
|
"loadUnpackedTooltip": "manifest.json を含むフォルダから拡張機能を読み込みます",
|
||||||
|
"selectFolderTitle": "拡張機能のフォルダを選択",
|
||||||
|
"selectedFolder": "選択したフォルダ",
|
||||||
|
"selectFolder": "フォルダを選択",
|
||||||
|
"linkFolder": "このフォルダから直接読み込む",
|
||||||
|
"linkFolderOff": "フォルダは Donut にコピーされます。拡張機能は持ち運べるようになり、他のデバイスにも同期されます。",
|
||||||
|
"linkFolderOn": "Donut は起動のたびにこのフォルダから直接拡張機能を読み込みます。編集内容は次回のブラウザ起動時に反映されますが、拡張機能はこの端末にのみ残り、同期されません。",
|
||||||
|
"replaceSource": "ソースを置き換える",
|
||||||
|
"linkedNoSync": "リンクした拡張機能はこの端末にのみ残るため、同期できません。",
|
||||||
|
"uploadFailed": "拡張機能を追加できませんでした",
|
||||||
|
"updateFailed": "拡張機能を更新できませんでした",
|
||||||
|
"deleteFailed": "拡張機能を削除できませんでした",
|
||||||
|
"groupCreateFailed": "拡張機能グループを作成できませんでした",
|
||||||
|
"groupUpdateFailed": "拡張機能グループを更新できませんでした",
|
||||||
|
"groupDeleteFailed": "拡張機能グループを削除できませんでした",
|
||||||
"syncEnabled": "同期が有効",
|
"syncEnabled": "同期が有効",
|
||||||
"syncDisabled": "同期が無効",
|
"syncDisabled": "同期が無効",
|
||||||
"syncEnableTooltip": "同期を有効にする",
|
"syncEnableTooltip": "同期を有効にする",
|
||||||
@@ -1303,6 +1318,14 @@
|
|||||||
"groupsTitle": "拡張機能グループを削除",
|
"groupsTitle": "拡張機能グループを削除",
|
||||||
"groupsDescription": "{{count}}件の拡張機能グループを削除しますか? {{names}}",
|
"groupsDescription": "{{count}}件の拡張機能グループを削除しますか? {{names}}",
|
||||||
"confirmButton": "削除"
|
"confirmButton": "削除"
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"label": "ソース",
|
||||||
|
"archive": "アーカイブ",
|
||||||
|
"unpacked": "展開済みフォルダ",
|
||||||
|
"linked": "リンクされたフォルダ",
|
||||||
|
"folderLabel": "フォルダ",
|
||||||
|
"linkedTooltip": "{{path}} から直接読み込みます"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pro": {
|
"pro": {
|
||||||
@@ -1842,6 +1865,15 @@
|
|||||||
"vpnNotFound": "VPNが見つかりません",
|
"vpnNotFound": "VPNが見つかりません",
|
||||||
"extensionNotFound": "拡張機能が見つかりません",
|
"extensionNotFound": "拡張機能が見つかりません",
|
||||||
"extensionGroupNotFound": "拡張機能グループが見つかりません",
|
"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": "クラウド管理のプロキシの同期は変更できません",
|
"cannotModifyCloudManagedProxy": "クラウド管理のプロキシの同期は変更できません",
|
||||||
"syncLockedByProfile": "同期済みプロファイルで使用中のため、同期を無効にできません",
|
"syncLockedByProfile": "同期済みプロファイルで使用中のため、同期を無効にできません",
|
||||||
"syncNotConfigured": "同期が設定されていません。サインインするか、セルフホストサーバーを設定してください。",
|
"syncNotConfigured": "同期が設定されていません。サインインするか、セルフホストサーバーを設定してください。",
|
||||||
|
|||||||
@@ -1271,7 +1271,7 @@
|
|||||||
"deleteConfirmDescription": "\"{{name}}\"을(를) 정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
|
"deleteConfirmDescription": "\"{{name}}\"을(를) 정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
|
||||||
"deleteGroupConfirmTitle": "확장 프로그램 그룹 삭제",
|
"deleteGroupConfirmTitle": "확장 프로그램 그룹 삭제",
|
||||||
"deleteGroupConfirmDescription": "그룹 \"{{name}}\"을(를) 정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
|
"deleteGroupConfirmDescription": "그룹 \"{{name}}\"을(를) 정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
|
||||||
"invalidFileType": "잘못된 파일 유형입니다. .crx, .xpi 또는 .zip 파일을 업로드하세요.",
|
"invalidFileType": "지원하지 않는 파일 형식입니다. .crx 또는 .zip 파일을 선택하세요.",
|
||||||
"readError": "확장 프로그램 파일 읽기 실패.",
|
"readError": "확장 프로그램 파일 읽기 실패.",
|
||||||
"assignTitle": "확장 프로그램 그룹 할당",
|
"assignTitle": "확장 프로그램 그룹 할당",
|
||||||
"assignDescription": "선택한 {{count}}개 프로필을 확장 프로그램 그룹에 할당합니다.",
|
"assignDescription": "선택한 {{count}}개 프로필을 확장 프로그램 그룹에 할당합니다.",
|
||||||
@@ -1279,7 +1279,6 @@
|
|||||||
"assignSuccess": "확장 프로그램 그룹이 할당되었습니다",
|
"assignSuccess": "확장 프로그램 그룹이 할당되었습니다",
|
||||||
"editExtension": "확장 프로그램 편집",
|
"editExtension": "확장 프로그램 편집",
|
||||||
"updateSuccess": "확장 프로그램이 업데이트되었습니다",
|
"updateSuccess": "확장 프로그램이 업데이트되었습니다",
|
||||||
"reupload": "다시 업로드",
|
|
||||||
"version": "버전",
|
"version": "버전",
|
||||||
"author": "작성자",
|
"author": "작성자",
|
||||||
"homepage": "홈페이지",
|
"homepage": "홈페이지",
|
||||||
@@ -1287,10 +1286,26 @@
|
|||||||
"editGroupDescription": "그룹 이름을 업데이트하고 포함된 확장 프로그램을 관리합니다.",
|
"editGroupDescription": "그룹 이름을 업데이트하고 포함된 확장 프로그램을 관리합니다.",
|
||||||
"groupExtensions": "이 그룹의 확장 프로그램",
|
"groupExtensions": "이 그룹의 확장 프로그램",
|
||||||
"noExtensionsInGroup": "아직 추가된 확장 프로그램이 없습니다",
|
"noExtensionsInGroup": "아직 추가된 확장 프로그램이 없습니다",
|
||||||
"editExtensionDescription": "확장 프로그램 이름을 업데이트하거나, 메타데이터를 보거나, 확장 프로그램 파일을 다시 업로드합니다.",
|
"editExtensionDescription": "확장 프로그램 이름을 변경하고, 메타데이터를 확인하고, 다른 압축 파일이나 폴더로 교체할 수 있습니다.",
|
||||||
"metadata": "메타데이터",
|
"metadata": "메타데이터",
|
||||||
"noMetadata": "manifest에서 사용할 수 있는 메타데이터가 없습니다.",
|
"noMetadata": "manifest에서 사용할 수 있는 메타데이터가 없습니다.",
|
||||||
"selectFile": "파일 선택",
|
"selectFile": "파일 선택",
|
||||||
|
"loadUnpacked": "폴더에서 불러오기",
|
||||||
|
"loadUnpackedTooltip": "manifest.json이 있는 폴더에서 확장 프로그램을 불러옵니다",
|
||||||
|
"selectFolderTitle": "확장 프로그램 폴더 선택",
|
||||||
|
"selectedFolder": "선택한 폴더",
|
||||||
|
"selectFolder": "폴더 선택",
|
||||||
|
"linkFolder": "이 폴더에서 바로 불러오기",
|
||||||
|
"linkFolderOff": "폴더가 Donut으로 복사됩니다. 확장 프로그램을 옮길 수 있고 다른 기기와 동기화됩니다.",
|
||||||
|
"linkFolderOn": "Donut이 실행할 때마다 이 폴더에서 바로 확장 프로그램을 불러옵니다. 수정한 내용은 브라우저를 다시 시작할 때 적용되지만, 확장 프로그램은 이 컴퓨터에만 남고 동기화되지 않습니다.",
|
||||||
|
"replaceSource": "소스 교체",
|
||||||
|
"linkedNoSync": "연결된 확장 프로그램은 이 컴퓨터에만 있어 동기화할 수 없습니다.",
|
||||||
|
"uploadFailed": "확장 프로그램을 추가하지 못했습니다",
|
||||||
|
"updateFailed": "확장 프로그램을 업데이트하지 못했습니다",
|
||||||
|
"deleteFailed": "확장 프로그램을 삭제하지 못했습니다",
|
||||||
|
"groupCreateFailed": "확장 프로그램 그룹을 만들지 못했습니다",
|
||||||
|
"groupUpdateFailed": "확장 프로그램 그룹을 업데이트하지 못했습니다",
|
||||||
|
"groupDeleteFailed": "확장 프로그램 그룹을 삭제하지 못했습니다",
|
||||||
"syncEnabled": "동기화 사용됨",
|
"syncEnabled": "동기화 사용됨",
|
||||||
"syncDisabled": "동기화 사용 안 함",
|
"syncDisabled": "동기화 사용 안 함",
|
||||||
"syncEnableTooltip": "동기화 사용",
|
"syncEnableTooltip": "동기화 사용",
|
||||||
@@ -1303,6 +1318,14 @@
|
|||||||
"groupsTitle": "확장 프로그램 그룹 삭제",
|
"groupsTitle": "확장 프로그램 그룹 삭제",
|
||||||
"groupsDescription": "{{count}}개의 확장 프로그램 그룹을 삭제하시겠습니까? {{names}}",
|
"groupsDescription": "{{count}}개의 확장 프로그램 그룹을 삭제하시겠습니까? {{names}}",
|
||||||
"confirmButton": "삭제"
|
"confirmButton": "삭제"
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"label": "소스",
|
||||||
|
"archive": "압축 파일",
|
||||||
|
"unpacked": "압축 해제된 폴더",
|
||||||
|
"linked": "연결된 폴더",
|
||||||
|
"folderLabel": "폴더",
|
||||||
|
"linkedTooltip": "{{path}}에서 바로 불러옵니다"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pro": {
|
"pro": {
|
||||||
@@ -1842,6 +1865,15 @@
|
|||||||
"vpnNotFound": "VPN을 찾을 수 없습니다",
|
"vpnNotFound": "VPN을 찾을 수 없습니다",
|
||||||
"extensionNotFound": "확장 프로그램을 찾을 수 없습니다",
|
"extensionNotFound": "확장 프로그램을 찾을 수 없습니다",
|
||||||
"extensionGroupNotFound": "확장 프로그램 그룹을 찾을 수 없습니다",
|
"extensionGroupNotFound": "확장 프로그램 그룹을 찾을 수 없습니다",
|
||||||
|
"extensionUnsupportedFileType": "지원하지 않는 파일 형식입니다. 확장 프로그램은 .crx 또는 .zip 압축 파일이거나 폴더여야 합니다.",
|
||||||
|
"extensionDirNotFound": "해당 폴더가 더 이상 존재하지 않습니다.",
|
||||||
|
"extensionNotADirectory": "해당 경로는 폴더가 아닙니다.",
|
||||||
|
"extensionManifestMissing": "해당 폴더에 manifest.json이 없습니다. 확장 프로그램의 manifest.json이 있는 폴더를 선택하세요.",
|
||||||
|
"extensionManifestInvalid": "해당 폴더의 manifest.json을 읽을 수 없습니다.",
|
||||||
|
"extensionDirTooLarge": "폴더가 너무 커서 Donut으로 복사할 수 없습니다(최대 256MB, 20,000개 파일). 대신 폴더를 연결해 사용하세요.",
|
||||||
|
"extensionPathHasComma": "폴더 경로에 쉼표가 있어 Chromium이 불러올 수 없습니다. 폴더 이름을 바꾸거나 옮기세요.",
|
||||||
|
"extensionLinkRequiresDirectory": "폴더만 그 자리에서 불러올 수 있습니다. 압축 파일을 추가하려면 연결을 끄세요.",
|
||||||
|
"extensionLinkedCannotSync": "이 확장 프로그램은 이 컴퓨터의 폴더에서 불러오므로 동기화할 항목이 없습니다.",
|
||||||
"cannotModifyCloudManagedProxy": "클라우드 관리 프록시의 동기화는 수정할 수 없습니다",
|
"cannotModifyCloudManagedProxy": "클라우드 관리 프록시의 동기화는 수정할 수 없습니다",
|
||||||
"syncLockedByProfile": "동기화된 프로필에서 사용 중인 동안에는 동기화를 비활성화할 수 없습니다",
|
"syncLockedByProfile": "동기화된 프로필에서 사용 중인 동안에는 동기화를 비활성화할 수 없습니다",
|
||||||
"syncNotConfigured": "동기화가 구성되지 않았습니다. 먼저 로그인하거나 자체 호스팅 서버를 구성하세요.",
|
"syncNotConfigured": "동기화가 구성되지 않았습니다. 먼저 로그인하거나 자체 호스팅 서버를 구성하세요.",
|
||||||
|
|||||||
@@ -1274,7 +1274,7 @@
|
|||||||
"deleteConfirmDescription": "Tem certeza de que deseja excluir \"{{name}}\"? Esta ação não pode ser desfeita.",
|
"deleteConfirmDescription": "Tem certeza de que deseja excluir \"{{name}}\"? Esta ação não pode ser desfeita.",
|
||||||
"deleteGroupConfirmTitle": "Excluir Grupo de Extensões",
|
"deleteGroupConfirmTitle": "Excluir Grupo de Extensões",
|
||||||
"deleteGroupConfirmDescription": "Tem certeza de que deseja excluir o grupo \"{{name}}\"? Esta ação não pode ser desfeita.",
|
"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.",
|
"readError": "Falha ao ler o arquivo de extensão.",
|
||||||
"assignTitle": "Atribuir Grupo de Extensões",
|
"assignTitle": "Atribuir Grupo de Extensões",
|
||||||
"assignDescription": "Atribuir {{count}} perfil(is) selecionado(s) a um 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",
|
"assignSuccess": "Grupo de extensões atribuído com sucesso",
|
||||||
"editExtension": "Editar extensão",
|
"editExtension": "Editar extensão",
|
||||||
"updateSuccess": "Extensão atualizada com sucesso",
|
"updateSuccess": "Extensão atualizada com sucesso",
|
||||||
"reupload": "Re-enviar",
|
|
||||||
"version": "Versão",
|
"version": "Versão",
|
||||||
"author": "Autor",
|
"author": "Autor",
|
||||||
"homepage": "Página inicial",
|
"homepage": "Página inicial",
|
||||||
@@ -1290,10 +1289,26 @@
|
|||||||
"editGroupDescription": "Atualize o nome do grupo e gerencie quais extensões estão incluídas.",
|
"editGroupDescription": "Atualize o nome do grupo e gerencie quais extensões estão incluídas.",
|
||||||
"groupExtensions": "Extensões neste grupo",
|
"groupExtensions": "Extensões neste grupo",
|
||||||
"noExtensionsInGroup": "Nenhuma extensão adicionada ainda",
|
"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",
|
"metadata": "Metadados",
|
||||||
"noMetadata": "Nenhum metadado disponível do manifesto.",
|
"noMetadata": "Nenhum metadado disponível do manifesto.",
|
||||||
"selectFile": "Escolher arquivo",
|
"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",
|
"syncEnabled": "Sincronização ativada",
|
||||||
"syncDisabled": "Sincronização desativada",
|
"syncDisabled": "Sincronização desativada",
|
||||||
"syncEnableTooltip": "Ativar sincronização",
|
"syncEnableTooltip": "Ativar sincronização",
|
||||||
@@ -1306,6 +1321,14 @@
|
|||||||
"groupsTitle": "Excluir grupos de extensões",
|
"groupsTitle": "Excluir grupos de extensões",
|
||||||
"groupsDescription": "Excluir {{count}} grupos de extensões? {{names}}",
|
"groupsDescription": "Excluir {{count}} grupos de extensões? {{names}}",
|
||||||
"confirmButton": "Excluir"
|
"confirmButton": "Excluir"
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"label": "Origem",
|
||||||
|
"archive": "Arquivo compactado",
|
||||||
|
"unpacked": "Pasta descompactada",
|
||||||
|
"linked": "Pasta vinculada",
|
||||||
|
"folderLabel": "Pasta",
|
||||||
|
"linkedTooltip": "Carregada direto de {{path}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pro": {
|
"pro": {
|
||||||
@@ -1849,6 +1872,15 @@
|
|||||||
"vpnNotFound": "VPN não encontrada",
|
"vpnNotFound": "VPN não encontrada",
|
||||||
"extensionNotFound": "Extensão não encontrada",
|
"extensionNotFound": "Extensão não encontrada",
|
||||||
"extensionGroupNotFound": "Grupo de extensões não encontrado",
|
"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",
|
"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",
|
"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.",
|
"syncNotConfigured": "A sincronização não está configurada. Faça login ou configure um servidor auto-hospedado.",
|
||||||
|
|||||||
@@ -1277,7 +1277,7 @@
|
|||||||
"deleteConfirmDescription": "Вы уверены, что хотите удалить «{{name}}»? Это действие нельзя отменить.",
|
"deleteConfirmDescription": "Вы уверены, что хотите удалить «{{name}}»? Это действие нельзя отменить.",
|
||||||
"deleteGroupConfirmTitle": "Удалить группу расширений",
|
"deleteGroupConfirmTitle": "Удалить группу расширений",
|
||||||
"deleteGroupConfirmDescription": "Вы уверены, что хотите удалить группу «{{name}}»? Это действие нельзя отменить.",
|
"deleteGroupConfirmDescription": "Вы уверены, что хотите удалить группу «{{name}}»? Это действие нельзя отменить.",
|
||||||
"invalidFileType": "Недопустимый тип файла. Загрузите файл .crx, .xpi или .zip.",
|
"invalidFileType": "Неподдерживаемый тип файла. Выберите файл .crx или .zip.",
|
||||||
"readError": "Не удалось прочитать файл расширения.",
|
"readError": "Не удалось прочитать файл расширения.",
|
||||||
"assignTitle": "Назначить группу расширений",
|
"assignTitle": "Назначить группу расширений",
|
||||||
"assignDescription": "Назначить {{count}} выбранных профилей в группу расширений.",
|
"assignDescription": "Назначить {{count}} выбранных профилей в группу расширений.",
|
||||||
@@ -1285,7 +1285,6 @@
|
|||||||
"assignSuccess": "Группа расширений успешно назначена",
|
"assignSuccess": "Группа расширений успешно назначена",
|
||||||
"editExtension": "Редактировать расширение",
|
"editExtension": "Редактировать расширение",
|
||||||
"updateSuccess": "Расширение успешно обновлено",
|
"updateSuccess": "Расширение успешно обновлено",
|
||||||
"reupload": "Загрузить заново",
|
|
||||||
"version": "Версия",
|
"version": "Версия",
|
||||||
"author": "Автор",
|
"author": "Автор",
|
||||||
"homepage": "Домашняя страница",
|
"homepage": "Домашняя страница",
|
||||||
@@ -1293,10 +1292,26 @@
|
|||||||
"editGroupDescription": "Обновите название группы и управляйте включёнными расширениями.",
|
"editGroupDescription": "Обновите название группы и управляйте включёнными расширениями.",
|
||||||
"groupExtensions": "Расширения в этой группе",
|
"groupExtensions": "Расширения в этой группе",
|
||||||
"noExtensionsInGroup": "Расширения ещё не добавлены",
|
"noExtensionsInGroup": "Расширения ещё не добавлены",
|
||||||
"editExtensionDescription": "Обновите имя расширения, просмотрите метаданные или загрузите файл расширения повторно.",
|
"editExtensionDescription": "Измените имя расширения, посмотрите его метаданные или замените его другим архивом либо папкой.",
|
||||||
"metadata": "Метаданные",
|
"metadata": "Метаданные",
|
||||||
"noMetadata": "Метаданные из манифеста недоступны.",
|
"noMetadata": "Метаданные из манифеста недоступны.",
|
||||||
"selectFile": "Выбрать файл",
|
"selectFile": "Выбрать файл",
|
||||||
|
"loadUnpacked": "Загрузить из папки",
|
||||||
|
"loadUnpackedTooltip": "Загрузить расширение из папки с файлом manifest.json",
|
||||||
|
"selectFolderTitle": "Выберите папку расширения",
|
||||||
|
"selectedFolder": "Выбранная папка",
|
||||||
|
"selectFolder": "Выбрать папку",
|
||||||
|
"linkFolder": "Загружать прямо из этой папки",
|
||||||
|
"linkFolderOff": "Папка копируется в Donut. Расширение можно переносить, и оно синхронизируется с другими устройствами.",
|
||||||
|
"linkFolderOn": "Donut загружает расширение прямо из этой папки при каждом запуске. Изменения применяются при следующем старте браузера, но расширение остаётся только на этом компьютере и не синхронизируется.",
|
||||||
|
"replaceSource": "Заменить источник",
|
||||||
|
"linkedNoSync": "Связанные расширения остаются только на этом компьютере и не синхронизируются.",
|
||||||
|
"uploadFailed": "Не удалось добавить расширение",
|
||||||
|
"updateFailed": "Не удалось обновить расширение",
|
||||||
|
"deleteFailed": "Не удалось удалить расширение",
|
||||||
|
"groupCreateFailed": "Не удалось создать группу расширений",
|
||||||
|
"groupUpdateFailed": "Не удалось обновить группу расширений",
|
||||||
|
"groupDeleteFailed": "Не удалось удалить группу расширений",
|
||||||
"syncEnabled": "Синхронизация включена",
|
"syncEnabled": "Синхронизация включена",
|
||||||
"syncDisabled": "Синхронизация отключена",
|
"syncDisabled": "Синхронизация отключена",
|
||||||
"syncEnableTooltip": "Включить синхронизацию",
|
"syncEnableTooltip": "Включить синхронизацию",
|
||||||
@@ -1309,6 +1324,14 @@
|
|||||||
"groupsTitle": "Удалить группы расширений",
|
"groupsTitle": "Удалить группы расширений",
|
||||||
"groupsDescription": "Удалить {{count}} групп расширений? {{names}}",
|
"groupsDescription": "Удалить {{count}} групп расширений? {{names}}",
|
||||||
"confirmButton": "Удалить"
|
"confirmButton": "Удалить"
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"label": "Источник",
|
||||||
|
"archive": "Архив",
|
||||||
|
"unpacked": "Распакованная папка",
|
||||||
|
"linked": "Связанная папка",
|
||||||
|
"folderLabel": "Папка",
|
||||||
|
"linkedTooltip": "Загружается прямо из {{path}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pro": {
|
"pro": {
|
||||||
@@ -1856,6 +1879,15 @@
|
|||||||
"vpnNotFound": "VPN не найден",
|
"vpnNotFound": "VPN не найден",
|
||||||
"extensionNotFound": "Расширение не найдено",
|
"extensionNotFound": "Расширение не найдено",
|
||||||
"extensionGroupNotFound": "Группа расширений не найдена",
|
"extensionGroupNotFound": "Группа расширений не найдена",
|
||||||
|
"extensionUnsupportedFileType": "Этот тип файла не поддерживается. Расширение должно быть архивом .crx или .zip либо папкой.",
|
||||||
|
"extensionDirNotFound": "Эта папка больше не существует.",
|
||||||
|
"extensionNotADirectory": "Указанный путь не является папкой.",
|
||||||
|
"extensionManifestMissing": "В этой папке нет файла manifest.json. Выберите папку, в которой лежит manifest.json расширения.",
|
||||||
|
"extensionManifestInvalid": "Не удалось прочитать manifest.json в этой папке.",
|
||||||
|
"extensionDirTooLarge": "Папка слишком большая, чтобы скопировать её в Donut (не более 256 МБ и 20 000 файлов). Вместо этого свяжите её.",
|
||||||
|
"extensionPathHasComma": "В пути к папке есть запятая, которую Chromium не может обработать. Переименуйте или переместите папку.",
|
||||||
|
"extensionLinkRequiresDirectory": "Загружать на месте можно только папку. Отключите связывание, чтобы добавить архив.",
|
||||||
|
"extensionLinkedCannotSync": "Это расширение загружается из папки на этом компьютере, поэтому синхронизировать нечего.",
|
||||||
"cannotModifyCloudManagedProxy": "Невозможно изменить синхронизацию для облачного прокси",
|
"cannotModifyCloudManagedProxy": "Невозможно изменить синхронизацию для облачного прокси",
|
||||||
"syncLockedByProfile": "Невозможно отключить синхронизацию, пока используется синхронизированными профилями",
|
"syncLockedByProfile": "Невозможно отключить синхронизацию, пока используется синхронизированными профилями",
|
||||||
"syncNotConfigured": "Синхронизация не настроена. Войдите или настройте собственный сервер.",
|
"syncNotConfigured": "Синхронизация не настроена. Войдите или настройте собственный сервер.",
|
||||||
|
|||||||
@@ -1271,7 +1271,7 @@
|
|||||||
"deleteConfirmDescription": "\"{{name}}\" uzantısını silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.",
|
"deleteConfirmDescription": "\"{{name}}\" uzantısını silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.",
|
||||||
"deleteGroupConfirmTitle": "Uzantı Grubunu Sil",
|
"deleteGroupConfirmTitle": "Uzantı Grubunu Sil",
|
||||||
"deleteGroupConfirmDescription": "\"{{name}}\" grubunu silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.",
|
"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ı.",
|
"readError": "Uzantı dosyası okunamadı.",
|
||||||
"assignTitle": "Uzantı Grubu Ata",
|
"assignTitle": "Uzantı Grubu Ata",
|
||||||
"assignDescription": "Seçili {{count}} profili bir uzantı grubuna atayın.",
|
"assignDescription": "Seçili {{count}} profili bir uzantı grubuna atayın.",
|
||||||
@@ -1279,7 +1279,6 @@
|
|||||||
"assignSuccess": "Uzantı grubu başarıyla atandı",
|
"assignSuccess": "Uzantı grubu başarıyla atandı",
|
||||||
"editExtension": "Uzantıyı düzenle",
|
"editExtension": "Uzantıyı düzenle",
|
||||||
"updateSuccess": "Uzantı başarıyla güncellendi",
|
"updateSuccess": "Uzantı başarıyla güncellendi",
|
||||||
"reupload": "Yeniden yükle",
|
|
||||||
"version": "Sürüm",
|
"version": "Sürüm",
|
||||||
"author": "Yazar",
|
"author": "Yazar",
|
||||||
"homepage": "Ana sayfa",
|
"homepage": "Ana sayfa",
|
||||||
@@ -1287,10 +1286,26 @@
|
|||||||
"editGroupDescription": "Grup adını güncelleyin ve gruba dahil uzantıları yönetin.",
|
"editGroupDescription": "Grup adını güncelleyin ve gruba dahil uzantıları yönetin.",
|
||||||
"groupExtensions": "Bu gruptaki uzantılar",
|
"groupExtensions": "Bu gruptaki uzantılar",
|
||||||
"noExtensionsInGroup": "Henüz uzantı eklenmedi",
|
"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",
|
"metadata": "Üst Veriler",
|
||||||
"noMetadata": "Manifest'te üst veri yok.",
|
"noMetadata": "Manifest'te üst veri yok.",
|
||||||
"selectFile": "Dosya Seç",
|
"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",
|
"syncEnabled": "Eşitleme etkinleştirildi",
|
||||||
"syncDisabled": "Eşitleme devre dışı bırakıldı",
|
"syncDisabled": "Eşitleme devre dışı bırakıldı",
|
||||||
"syncEnableTooltip": "Eşitlemeyi etkinleştir",
|
"syncEnableTooltip": "Eşitlemeyi etkinleştir",
|
||||||
@@ -1303,6 +1318,14 @@
|
|||||||
"groupsTitle": "Uzantı gruplarını sil",
|
"groupsTitle": "Uzantı gruplarını sil",
|
||||||
"groupsDescription": "{{count}} uzantı grubu silinsin mi? {{names}}",
|
"groupsDescription": "{{count}} uzantı grubu silinsin mi? {{names}}",
|
||||||
"confirmButton": "Sil"
|
"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": {
|
"pro": {
|
||||||
@@ -1842,6 +1865,15 @@
|
|||||||
"vpnNotFound": "VPN bulunamadı",
|
"vpnNotFound": "VPN bulunamadı",
|
||||||
"extensionNotFound": "Uzantı bulunamadı",
|
"extensionNotFound": "Uzantı bulunamadı",
|
||||||
"extensionGroupNotFound": "Uzantı grubu 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",
|
"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",
|
"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.",
|
"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.",
|
"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",
|
"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.",
|
"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.",
|
"readError": "Đọc tệp tiện ích thất bại.",
|
||||||
"assignTitle": "Gán nhóm tiện ích",
|
"assignTitle": "Gán nhóm tiện ích",
|
||||||
"assignDescription": "Gán {{count}} profile đã chọn vào 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",
|
"assignSuccess": "Gán nhóm tiện ích thành công",
|
||||||
"editExtension": "Chỉnh sửa tiện ích",
|
"editExtension": "Chỉnh sửa tiện ích",
|
||||||
"updateSuccess": "Cập nhật tiện ích thành công",
|
"updateSuccess": "Cập nhật tiện ích thành công",
|
||||||
"reupload": "Tải lên lại",
|
|
||||||
"version": "Phiên bản",
|
"version": "Phiên bản",
|
||||||
"author": "Tác giả",
|
"author": "Tác giả",
|
||||||
"homepage": "Trang chủ",
|
"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.",
|
"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",
|
"groupExtensions": "Tiện ích trong nhóm này",
|
||||||
"noExtensionsInGroup": "Chưa thêm tiện ích nào",
|
"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",
|
"metadata": "Metadata",
|
||||||
"noMetadata": "Không có metadata từ manifest.",
|
"noMetadata": "Không có metadata từ manifest.",
|
||||||
"selectFile": "Chọn tệp",
|
"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ộ",
|
"syncEnabled": "Đã bật đồng bộ",
|
||||||
"syncDisabled": "Đã tắt đồng bộ",
|
"syncDisabled": "Đã tắt đồng bộ",
|
||||||
"syncEnableTooltip": "Bật đồng bộ",
|
"syncEnableTooltip": "Bật đồng bộ",
|
||||||
@@ -1303,6 +1318,14 @@
|
|||||||
"groupsTitle": "Xóa nhóm tiện ích",
|
"groupsTitle": "Xóa nhóm tiện ích",
|
||||||
"groupsDescription": "Xóa {{count}} nhóm tiện ích? {{names}}",
|
"groupsDescription": "Xóa {{count}} nhóm tiện ích? {{names}}",
|
||||||
"confirmButton": "Xóa"
|
"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": {
|
"pro": {
|
||||||
@@ -1842,6 +1865,15 @@
|
|||||||
"vpnNotFound": "Không tìm thấy VPN",
|
"vpnNotFound": "Không tìm thấy VPN",
|
||||||
"extensionNotFound": "Không tìm thấy tiện ích",
|
"extensionNotFound": "Không tìm thấy tiện ích",
|
||||||
"extensionGroupNotFound": "Không tìm thấy nhóm 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",
|
"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ộ",
|
"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.",
|
"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}}」吗?此操作无法撤消。",
|
"deleteConfirmDescription": "确定要删除「{{name}}」吗?此操作无法撤消。",
|
||||||
"deleteGroupConfirmTitle": "删除扩展程序组",
|
"deleteGroupConfirmTitle": "删除扩展程序组",
|
||||||
"deleteGroupConfirmDescription": "确定要删除分组「{{name}}」吗?此操作无法撤消。",
|
"deleteGroupConfirmDescription": "确定要删除分组「{{name}}」吗?此操作无法撤消。",
|
||||||
"invalidFileType": "无效的文件类型。请上传 .crx、.xpi 或 .zip 文件。",
|
"invalidFileType": "文件类型无效。请选择 .crx 或 .zip 文件。",
|
||||||
"readError": "读取扩展程序文件失败。",
|
"readError": "读取扩展程序文件失败。",
|
||||||
"assignTitle": "分配扩展程序组",
|
"assignTitle": "分配扩展程序组",
|
||||||
"assignDescription": "将 {{count}} 个选定的配置文件分配到扩展程序组。",
|
"assignDescription": "将 {{count}} 个选定的配置文件分配到扩展程序组。",
|
||||||
@@ -1279,7 +1279,6 @@
|
|||||||
"assignSuccess": "扩展程序组分配成功",
|
"assignSuccess": "扩展程序组分配成功",
|
||||||
"editExtension": "编辑扩展",
|
"editExtension": "编辑扩展",
|
||||||
"updateSuccess": "扩展更新成功",
|
"updateSuccess": "扩展更新成功",
|
||||||
"reupload": "重新上传",
|
|
||||||
"version": "版本",
|
"version": "版本",
|
||||||
"author": "作者",
|
"author": "作者",
|
||||||
"homepage": "主页",
|
"homepage": "主页",
|
||||||
@@ -1287,10 +1286,26 @@
|
|||||||
"editGroupDescription": "更新分组名称并管理包含的扩展。",
|
"editGroupDescription": "更新分组名称并管理包含的扩展。",
|
||||||
"groupExtensions": "此分组中的扩展",
|
"groupExtensions": "此分组中的扩展",
|
||||||
"noExtensionsInGroup": "尚未添加扩展",
|
"noExtensionsInGroup": "尚未添加扩展",
|
||||||
"editExtensionDescription": "更新扩展名称、查看元数据或重新上传扩展文件。",
|
"editExtensionDescription": "修改扩展名称、查看元数据,或用其他压缩包或文件夹替换它。",
|
||||||
"metadata": "元数据",
|
"metadata": "元数据",
|
||||||
"noMetadata": "清单中没有可用的元数据。",
|
"noMetadata": "清单中没有可用的元数据。",
|
||||||
"selectFile": "选择文件",
|
"selectFile": "选择文件",
|
||||||
|
"loadUnpacked": "加载文件夹",
|
||||||
|
"loadUnpackedTooltip": "从包含 manifest.json 的文件夹加载扩展",
|
||||||
|
"selectFolderTitle": "选择扩展文件夹",
|
||||||
|
"selectedFolder": "已选文件夹",
|
||||||
|
"selectFolder": "选择文件夹",
|
||||||
|
"linkFolder": "直接从该文件夹加载",
|
||||||
|
"linkFolderOff": "文件夹会复制到 Donut,扩展可随身携带并同步到你的其他设备。",
|
||||||
|
"linkFolderOn": "Donut 每次启动都直接从该文件夹加载扩展。你的修改会在下次启动浏览器时生效,但扩展只保留在本机,不会同步。",
|
||||||
|
"replaceSource": "替换来源",
|
||||||
|
"linkedNoSync": "已链接的扩展只保留在本机,无法同步。",
|
||||||
|
"uploadFailed": "添加扩展失败",
|
||||||
|
"updateFailed": "更新扩展失败",
|
||||||
|
"deleteFailed": "删除扩展失败",
|
||||||
|
"groupCreateFailed": "创建扩展组失败",
|
||||||
|
"groupUpdateFailed": "更新扩展组失败",
|
||||||
|
"groupDeleteFailed": "删除扩展组失败",
|
||||||
"syncEnabled": "同步已启用",
|
"syncEnabled": "同步已启用",
|
||||||
"syncDisabled": "同步已禁用",
|
"syncDisabled": "同步已禁用",
|
||||||
"syncEnableTooltip": "启用同步",
|
"syncEnableTooltip": "启用同步",
|
||||||
@@ -1303,6 +1318,14 @@
|
|||||||
"groupsTitle": "删除扩展组",
|
"groupsTitle": "删除扩展组",
|
||||||
"groupsDescription": "删除 {{count}} 个扩展组?{{names}}",
|
"groupsDescription": "删除 {{count}} 个扩展组?{{names}}",
|
||||||
"confirmButton": "删除"
|
"confirmButton": "删除"
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"label": "来源",
|
||||||
|
"archive": "压缩包",
|
||||||
|
"unpacked": "解压文件夹",
|
||||||
|
"linked": "链接文件夹",
|
||||||
|
"folderLabel": "文件夹",
|
||||||
|
"linkedTooltip": "直接从 {{path}} 加载"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pro": {
|
"pro": {
|
||||||
@@ -1842,6 +1865,15 @@
|
|||||||
"vpnNotFound": "未找到 VPN",
|
"vpnNotFound": "未找到 VPN",
|
||||||
"extensionNotFound": "未找到扩展",
|
"extensionNotFound": "未找到扩展",
|
||||||
"extensionGroupNotFound": "未找到扩展分组",
|
"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": "无法修改云管理代理的同步",
|
"cannotModifyCloudManagedProxy": "无法修改云管理代理的同步",
|
||||||
"syncLockedByProfile": "在被已同步的配置文件使用时无法禁用同步",
|
"syncLockedByProfile": "在被已同步的配置文件使用时无法禁用同步",
|
||||||
"syncNotConfigured": "同步未配置。请先登录或配置自托管服务器。",
|
"syncNotConfigured": "同步未配置。请先登录或配置自托管服务器。",
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ export type BackendErrorCode =
|
|||||||
| "VPN_NOT_FOUND"
|
| "VPN_NOT_FOUND"
|
||||||
| "EXTENSION_NOT_FOUND"
|
| "EXTENSION_NOT_FOUND"
|
||||||
| "EXTENSION_GROUP_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"
|
| "CANNOT_MODIFY_CLOUD_MANAGED_PROXY"
|
||||||
| "SYNC_LOCKED_BY_PROFILE"
|
| "SYNC_LOCKED_BY_PROFILE"
|
||||||
| "SYNC_NOT_CONFIGURED"
|
| "SYNC_NOT_CONFIGURED"
|
||||||
@@ -219,6 +228,24 @@ export function translateBackendError(t: TFunction, err: unknown): string {
|
|||||||
return t("backendErrors.extensionNotFound");
|
return t("backendErrors.extensionNotFound");
|
||||||
case "EXTENSION_GROUP_NOT_FOUND":
|
case "EXTENSION_GROUP_NOT_FOUND":
|
||||||
return t("backendErrors.extensionGroupNotFound");
|
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":
|
case "CANNOT_MODIFY_CLOUD_MANAGED_PROXY":
|
||||||
return t("backendErrors.cannotModifyCloudManagedProxy");
|
return t("backendErrors.cannotModifyCloudManagedProxy");
|
||||||
case "SYNC_LOCKED_BY_PROFILE":
|
case "SYNC_LOCKED_BY_PROFILE":
|
||||||
|
|||||||
@@ -62,6 +62,11 @@ export interface Extension {
|
|||||||
description?: string;
|
description?: string;
|
||||||
author?: string;
|
author?: string;
|
||||||
homepage_url?: 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 {
|
export interface ExtensionGroup {
|
||||||
|
|||||||
Reference in New Issue
Block a user