From d7f002d8acf74c2b42a135a2f389c490ab926050 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:50:12 +0400 Subject: [PATCH] feat: extension export via api --- e2e/coverage-map.mjs | 2 + e2e/lib/fixtures.mjs | 207 +++ e2e/tests/browser.test.mjs | 180 +++ e2e/tests/entities.test.mjs | 108 +- e2e/tests/integrations.test.mjs | 553 +++++++ e2e/tests/ui.test.mjs | 462 +++++- src-tauri/src/api_server.rs | 776 +++++++++- src-tauri/src/browser_runner.rs | 7 + src-tauri/src/cloud_auth.rs | 7 + src-tauri/src/extension_manager.rs | 1338 ++++++++++++++--- src-tauri/src/lib.rs | 9 +- src-tauri/src/mcp_server.rs | 264 +++- src-tauri/src/sync/engine.rs | 20 +- .../extension-management-dialog.tsx | 587 ++++++-- src/i18n/locales/en.json | 38 +- src/i18n/locales/es.json | 38 +- src/i18n/locales/fr.json | 38 +- src/i18n/locales/ja.json | 38 +- src/i18n/locales/ko.json | 38 +- src/i18n/locales/pt.json | 38 +- src/i18n/locales/ru.json | 38 +- src/i18n/locales/tr.json | 38 +- src/i18n/locales/vi.json | 38 +- src/i18n/locales/zh.json | 38 +- src/lib/backend-errors.ts | 27 + src/types.ts | 5 + 26 files changed, 4489 insertions(+), 443 deletions(-) diff --git a/e2e/coverage-map.mjs b/e2e/coverage-map.mjs index 87724ba..ac3f86b 100644 --- a/e2e/coverage-map.mjs +++ b/e2e/coverage-map.mjs @@ -87,7 +87,9 @@ export const commandCoverage = { "list_extensions", "get_extension_icon", "add_extension", + "add_unpacked_extension", "update_extension", + "update_extension_from_path", "delete_extension", "list_extension_groups", "create_extension_group", diff --git a/e2e/lib/fixtures.mjs b/e2e/lib/fixtures.mjs index f111150..2b939e0 100644 --- a/e2e/lib/fixtures.mjs +++ b/e2e/lib/fixtures.mjs @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; +import { randomBytes } from "node:crypto"; import { existsSync } from "node:fs"; import { chmod, @@ -13,6 +14,7 @@ import { import os from "node:os"; import path from "node:path"; import { DatabaseSync } from "node:sqlite"; +import { crc32 } from "node:zlib"; export const TEST_BROWSER_VERSION = "150.0.7871.100"; @@ -214,6 +216,211 @@ export function extensionZipBase64() { return "UEsDBBQAAAAAAE8K9Fxo1IfNawAAAGsAAAANAAAAbWFuaWZlc3QuanNvbnsibWFuaWZlc3RfdmVyc2lvbiI6MywibmFtZSI6IkRvbnV0IEUyRSBGaXh0dXJlIiwidmVyc2lvbiI6IjEuMC4wIiwiZGVzY3JpcHRpb24iOiJJc29sYXRlZCB0ZXN0IGV4dGVuc2lvbiJ9UEsBAhQDFAAAAAAATwr0XGjUh81rAAAAawAAAA0AAAAAAAAAAAAAAIABAAAAAG1hbmlmZXN0Lmpzb25QSwUGAAAAAAEAAQA7AAAAlgAAAAAA"; } +// 1980-01-01 00:00, the earliest timestamp the ZIP format can carry. Fixed so +// two calls with the same entries produce byte-identical archives. +const DOS_TIME = 0; +const DOS_DATE = 0x0021; + +/** + * Build a ZIP archive from `entries` (`{ name, data }`) with every member + * stored, not deflated. + * + * Stored is what the inline fixture above already is, and it is load-bearing + * for the oversized fixture below: the assertion is about a request body that + * has to stay over the limit under test, so nothing in the archive may shrink + * the padding back under it. + */ +export function buildStoredZip(entries) { + const locals = []; + const central = []; + let offset = 0; + + for (const { name, data } of entries) { + const nameBytes = Buffer.from(name, "utf8"); + const body = Buffer.isBuffer(data) ? data : Buffer.from(data); + const checksum = crc32(body); + + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(DOS_TIME, 10); + local.writeUInt16LE(DOS_DATE, 12); + local.writeUInt32LE(checksum, 14); + local.writeUInt32LE(body.length, 18); + local.writeUInt32LE(body.length, 22); + local.writeUInt16LE(nameBytes.length, 26); + locals.push(local, nameBytes, body); + + const entry = Buffer.alloc(46); + entry.writeUInt32LE(0x02014b50, 0); + entry.writeUInt16LE(20, 4); + entry.writeUInt16LE(20, 6); + entry.writeUInt16LE(DOS_TIME, 12); + entry.writeUInt16LE(DOS_DATE, 14); + entry.writeUInt32LE(checksum, 16); + entry.writeUInt32LE(body.length, 20); + entry.writeUInt32LE(body.length, 24); + entry.writeUInt16LE(nameBytes.length, 28); + entry.writeUInt32LE(offset, 42); + central.push(entry, nameBytes); + + offset += local.length + nameBytes.length + body.length; + } + + const directory = Buffer.concat(central); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(entries.length, 8); + end.writeUInt16LE(entries.length, 10); + end.writeUInt32LE(directory.length, 12); + end.writeUInt32LE(offset, 16); + + return Buffer.concat([...locals, directory, end]); +} + +export const OVERSIZED_EXTENSION_NAME = "Donut E2E Oversized Fixture"; + +/** + * A valid Manifest V3 ZIP padded past the 2 MiB body limit axum applies by + * default, so the raised limit on the extension routes is the only reason a + * request carrying it can succeed. + * + * The padding is random bytes, and the archive stores rather than deflates + * them, so neither the fixture nor the transport can quietly shrink the body + * back under the limit and turn the assertion into a tautology. + */ +export function oversizedExtensionZipBase64(paddingBytes = 3 * 1024 * 1024) { + return buildStoredZip([ + { + name: "manifest.json", + data: `${JSON.stringify( + { + manifest_version: 3, + name: OVERSIZED_EXTENSION_NAME, + version: "1.0.0", + description: "Isolated oversized test extension", + }, + null, + 2, + )}\n`, + }, + { name: "payload.bin", data: randomBytes(paddingBytes) }, + ]).toString("base64"); +} + +// What `_locales//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//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:///background.js` target. + */ +export async function writeUnpackedExtension( + directory, + { name = "Donut E2E Unpacked", version = "1.0.0" } = {}, +) { + const absolute = path.resolve(directory); + await mkdir(path.join(absolute, "icons"), { recursive: true }); + await writeFile( + path.join(absolute, "manifest.json"), + `${JSON.stringify( + { + manifest_version: 3, + name, + version, + description: "Isolated unpacked test extension", + icons: { 16: "icons/icon-16.png", 48: "icons/icon-48.png" }, + background: { service_worker: "background.js" }, + }, + null, + 2, + )}\n`, + ); + await writeFile( + path.join(absolute, "background.js"), + [ + "globalThis.__donutE2eExtension = chrome.runtime.id;", + "chrome.runtime.onInstalled.addListener(() => {", + " console.log('donut e2e extension installed');", + "});", + "", + ].join("\n"), + ); + const icon = Buffer.from(EXTENSION_ICON_PNG_BASE64, "base64"); + for (const size of [16, 48]) { + await writeFile(path.join(absolute, "icons", `icon-${size}.png`), icon); + } + return absolute; +} + export function currentHostOs() { return os.platform() === "darwin" ? "macos" diff --git a/e2e/tests/browser.test.mjs b/e2e/tests/browser.test.mjs index 7d23126..f295534 100644 --- a/e2e/tests/browser.test.mjs +++ b/e2e/tests/browser.test.mjs @@ -11,6 +11,7 @@ import { defaultWayfernPath, inspectWayfern, prepareWayfern, + writeUnpackedExtension, } from "../lib/fixtures.mjs"; const fixtureUrl = process.env.DONUT_E2E_FIXTURE_URL; @@ -709,3 +710,182 @@ test("a proxy worker dies with its browser, with and without the app running", a await app.close(); } }); + +// Two things nothing else covers. First, that an assigned extension group +// actually reaches Wayfern: a loaded MV3 extension registers a +// `chrome-extension:///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(); + } +}); diff --git a/e2e/tests/entities.test.mjs b/e2e/tests/entities.test.mjs index fbcbcc3..52e3d57 100644 --- a/e2e/tests/entities.test.mjs +++ b/e2e/tests/entities.test.mjs @@ -1,15 +1,23 @@ import assert from "node:assert/strict"; import { existsSync } from "node:fs"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { + mkdir, + readdir, + readFile, + realpath, + writeFile, +} from "node:fs/promises"; import path from "node:path"; import { DatabaseSync } from "node:sqlite"; import test from "node:test"; import { withApp } from "../lib/app.mjs"; import { + extensionIconPngBase64, extensionZipBase64, wireGuardFixture, writeChromiumCookies, writeChromiumHistory, + writeUnpackedExtension, } from "../lib/fixtures.mjs"; async function createProfile(app, name = "Entity Profile") { @@ -555,6 +563,104 @@ test("extensions, extension groups, VPN storage, DNS rules, and event-backed ass await app.invoke("delete_extension_group", { groupId: extensionGroup.id }); await app.invoke("delete_extension", { extensionId: extension.id }); + // Folder imports, the "Load unpacked" flow. Copying packs the folder into + // the store; linking loads it from where the user keeps it, which only + // exists on this machine and therefore never syncs. + const unpackedDir = await writeUnpackedExtension( + path.join(app.root, "fixtures", "unpacked-extension"), + ); + const copied = await app.invoke("add_unpacked_extension", { + name: "Overridden By The Manifest", + path: unpackedDir, + link: false, + }); + assert.equal(copied.source_kind, "unpacked"); + assert.equal(copied.linked_path, null); + assert.equal(copied.file_type, "zip"); + assert.equal(copied.file_name, "unpacked-extension.zip"); + assert.equal(copied.name, "Donut E2E Unpacked"); + assert.equal(copied.version, "1.0.0"); + // The folder declares icons, so packing it must carry one through into the + // store rather than dropping it the way the icon-less ZIP fixture does. + assert.equal( + await app.invoke("get_extension_icon", { extensionId: copied.id }), + `data:image/png;base64,${extensionIconPngBase64()}`, + ); + + const linked = await app.invoke("add_unpacked_extension", { + name: "Linked Fixture", + path: unpackedDir, + link: true, + }); + assert.equal(linked.source_kind, "unpacked"); + assert.equal(linked.file_type, "unpacked"); + assert.equal(linked.linked_path, await realpath(unpackedDir)); + assert.equal( + linked.sync_enabled, + false, + "a linked extension has no payload to upload, so it must never be synced", + ); + + const repackedDir = await writeUnpackedExtension( + path.join(app.root, "fixtures", "unpacked-extension-v2"), + { name: "Donut E2E Unpacked v2", version: "2.0.0" }, + ); + const repacked = await app.invoke("update_extension_from_path", { + extensionId: copied.id, + name: "Repacked Fixture Extension", + path: repackedDir, + link: false, + }); + assert.equal(repacked.name, "Repacked Fixture Extension"); + assert.equal(repacked.version, "2.0.0"); + assert.equal(repacked.file_name, "unpacked-extension-v2.zip"); + assert.deepEqual( + await readdir( + path.join(app.dataRoot, "data", "extensions", copied.id, "file"), + ), + ["unpacked-extension-v2.zip"], + "re-importing replaces the stored payload instead of stacking a second one", + ); + + // Re-importing a linked extension as a copy ends the link, which is what + // makes it portable again. With no explicit name the manifest names it. + const unlinked = await app.invoke("update_extension_from_path", { + extensionId: linked.id, + name: null, + path: repackedDir, + link: false, + }); + assert.equal(unlinked.linked_path, null); + assert.equal(unlinked.source_kind, "unpacked"); + assert.equal(unlinked.name, "Donut E2E Unpacked v2"); + + assert.match( + await app.invokeError("add_unpacked_extension", { + name: "Not An Extension", + path: app.root, + link: false, + }), + /EXTENSION_MANIFEST_MISSING/, + ); + assert.match( + await app.invokeError("update_extension_from_path", { + extensionId: unlinked.id, + name: null, + path: path.join(app.root, "fixtures", "absent"), + link: false, + }), + /EXTENSION_DIR_NOT_FOUND/, + ); + + for (const id of [copied.id, unlinked.id]) { + await app.invoke("delete_extension", { extensionId: id }); + } + assert.deepEqual(await app.invoke("list_extensions"), []); + assert.ok( + existsSync(path.join(unpackedDir, "manifest.json")), + "importing a folder must never move or consume the user's copy of it", + ); + const vpn = await app.invoke("create_vpn_config_manual", { name: "E2E WireGuard", vpnType: "WireGuard", diff --git a/e2e/tests/integrations.test.mjs b/e2e/tests/integrations.test.mjs index 85a5bd3..f251d9d 100644 --- a/e2e/tests/integrations.test.mjs +++ b/e2e/tests/integrations.test.mjs @@ -3,6 +3,14 @@ import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import test from "node:test"; import { withApp } from "../lib/app.mjs"; +import { + extensionZipBase64, + LOCALIZED_EXTENSION_MESSAGES, + localizedExtensionZipBase64, + OVERSIZED_EXTENSION_NAME, + oversizedExtensionZipBase64, + writeUnpackedExtension, +} from "../lib/fixtures.mjs"; const VLESS_URI = "vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@127.0.0.1:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=www.example.com&fp=chrome&pbk=BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc&sid=0123456789abcdef&spx=%2F&type=tcp&headerType=none#MCP"; @@ -93,6 +101,27 @@ test("authenticated REST API serves its complete OpenAPI contract and CRUD lifec ]) { assert.ok(paths.includes(required), `OpenAPI is missing ${required}`); } + // The served spec comes from the hand-maintained ApiDoc derive, not from + // the router, so an extension route can answer requests while being absent + // from the contract an agent generates its client from. + for (const [route, methods] of [ + ["/v1/extensions", ["get", "post"]], + ["/v1/extensions/{id}", ["get", "put", "delete"]], + ["/v1/extension-groups", ["get", "post"]], + ["/v1/extension-groups/{id}", ["get", "put", "delete"]], + [ + "/v1/extension-groups/{id}/extensions/{extension_id}", + ["post", "delete"], + ], + ]) { + assert.ok(paths.includes(route), `OpenAPI is missing ${route}`); + for (const method of methods) { + assert.ok( + openapi.value.paths[route][method], + `OpenAPI is missing ${method.toUpperCase()} ${route}`, + ); + } + } const unauthorized = await jsonRequest(`${base}/v1/profiles`); assert.equal(unauthorized.response.status, 401); @@ -197,6 +226,428 @@ test("authenticated REST API serves its complete OpenAPI contract and CRUD lifec assert.equal(imported.response.status, 200); assert.equal(imported.value.imported_count, 1); + // Extensions arrive either as an inline payload or as a path the app can + // read, and the folder form is the whole point: it is how an agent reaches + // the "load unpacked" flow that the desktop offers through a file picker. + const archiveExtension = await jsonRequest(`${base}/v1/extensions`, { + method: "POST", + token: saved.api_token, + body: { + name: "REST Archive Extension", + file_name: "fixture.zip", + file_data_base64: extensionZipBase64(), + }, + }); + assert.equal( + archiveExtension.response.status, + 201, + JSON.stringify(archiveExtension.value), + ); + assert.equal(archiveExtension.value.name, "Donut E2E Fixture"); + assert.equal(archiveExtension.value.source_kind, "archive"); + assert.equal(archiveExtension.value.linked_path, null); + + const unpackedDir = await writeUnpackedExtension( + path.join(app.root, "fixtures", "rest-unpacked-extension"), + { name: "Donut REST Unpacked", version: "1.2.0" }, + ); + const folderExtension = await jsonRequest(`${base}/v1/extensions`, { + method: "POST", + token: saved.api_token, + body: { name: "REST Folder Extension", source_path: unpackedDir }, + }); + assert.equal( + folderExtension.response.status, + 201, + JSON.stringify(folderExtension.value), + ); + assert.equal(folderExtension.value.name, "Donut REST Unpacked"); + assert.equal(folderExtension.value.version, "1.2.0"); + assert.equal(folderExtension.value.source_kind, "unpacked"); + assert.equal(folderExtension.value.linked_path, null); + + // Two sources in one request have no defined winner, so the request is + // refused rather than silently resolved. + const ambiguousSource = await jsonRequest(`${base}/v1/extensions`, { + method: "POST", + token: saved.api_token, + body: { + name: "REST Ambiguous Extension", + file_name: "fixture.zip", + file_data_base64: extensionZipBase64(), + source_path: unpackedDir, + }, + }); + assert.equal(ambiguousSource.response.status, 400); + assert.match( + JSON.stringify(ambiguousSource.value), + /EXTENSION_SOURCE_REQUIRED/, + ); + const sourcelessExtension = await jsonRequest(`${base}/v1/extensions`, { + method: "POST", + token: saved.api_token, + body: { name: "REST Sourceless Extension" }, + }); + assert.equal(sourcelessExtension.response.status, 400); + assert.match( + JSON.stringify(sourcelessExtension.value), + /EXTENSION_SOURCE_REQUIRED/, + ); + // An archive has no folder to keep loading from, so linking one is refused + // rather than quietly stored as a copy. + const linkedArchive = await jsonRequest(`${base}/v1/extensions`, { + method: "POST", + token: saved.api_token, + body: { + name: "REST Linked Archive", + file_name: "fixture.zip", + file_data_base64: extensionZipBase64(), + link: true, + }, + }); + assert.equal(linkedArchive.response.status, 400); + assert.match( + JSON.stringify(linkedArchive.value), + /EXTENSION_LINK_REQUIRES_DIRECTORY/, + ); + + const extensionId = folderExtension.value.id; + assert.equal( + ( + await jsonRequest(`${base}/v1/extensions/${extensionId}`, { + token: saved.api_token, + }) + ).value.id, + extensionId, + ); + const renamedExtension = await jsonRequest( + `${base}/v1/extensions/${extensionId}`, + { + method: "PUT", + token: saved.api_token, + body: { name: "REST Renamed Extension" }, + }, + ); + assert.equal( + renamedExtension.response.status, + 200, + JSON.stringify(renamedExtension.value), + ); + assert.equal(renamedExtension.value.name, "REST Renamed Extension"); + assert.equal( + (await jsonRequest(`${base}/v1/extensions`, { token: saved.api_token })) + .value.length, + 2, + ); + + // Axum's default body limit is 2 MiB, which plenty of real `.crx` files + // exceed: every one of them was refused before the handler ran until the + // extension payload routes got a limit of their own. The fixture below is + // stored rather than deflated, so the body genuinely stays over the + // default and a 201 can only come from the raised limit. + const oversizedBody = { + name: "REST Oversized Extension", + file_name: "oversized.zip", + file_data_base64: oversizedExtensionZipBase64(), + }; + assert.ok( + Buffer.byteLength(JSON.stringify(oversizedBody)) > 2 * 1024 * 1024, + "the oversized fixture must exceed the default body limit it tests", + ); + const oversized = await jsonRequest(`${base}/v1/extensions`, { + method: "POST", + token: saved.api_token, + body: oversizedBody, + }); + assert.equal( + oversized.response.status, + 201, + JSON.stringify(oversized.value), + ); + // Read out of the archive that arrived, so the payload landed whole rather + // than merely being accepted. + assert.equal(oversized.value.name, OVERSIZED_EXTENSION_NAME); + assert.equal(oversized.value.version, "1.0.0"); + assert.equal(oversized.value.file_type, "zip"); + + // The raised limit is scoped to the two paths that carry a payload. A + // group name is never megabytes long, so a route that accepted one would + // mean the layer had been attached to the whole router. + const oversizedGroupBody = { name: "G".repeat(3 * 1024 * 1024) }; + assert.ok( + Buffer.byteLength(JSON.stringify(oversizedGroupBody)) > 2 * 1024 * 1024, + ); + const oversizedGroup = await jsonRequest(`${base}/v1/extension-groups`, { + method: "POST", + token: saved.api_token, + body: oversizedGroupBody, + }); + assert.equal( + oversizedGroup.response.status, + 413, + JSON.stringify(oversizedGroup.value), + ); + assert.deepEqual( + ( + await jsonRequest(`${base}/v1/extension-groups`, { + token: saved.api_token, + }) + ).value, + [], + "the refused group request must not have stored anything", + ); + + // Chrome Web Store extensions overwhelmingly localize their manifest: the + // name a user recognizes sits in `_locales//messages.json` + // and the manifest holds `__MSG_extName__`. Storing the manifest verbatim + // is what puts a raw placeholder in the extension list. + const localized = await jsonRequest(`${base}/v1/extensions`, { + method: "POST", + token: saved.api_token, + body: { + name: "REST Localized Extension", + file_name: "localized.zip", + file_data_base64: localizedExtensionZipBase64(), + }, + }); + assert.equal( + localized.response.status, + 201, + JSON.stringify(localized.value), + ); + assert.equal(localized.value.name, LOCALIZED_EXTENSION_MESSAGES.extName); + assert.equal( + localized.value.description, + LOCALIZED_EXTENSION_MESSAGES.extDescription, + ); + assert.equal( + localized.value.author, + LOCALIZED_EXTENSION_MESSAGES.extAuthor, + ); + assert.doesNotMatch(JSON.stringify(localized.value), /__MSG_/); + // The resolved strings have to be what was persisted, not something the + // create response computed on its way out. + assert.equal( + ( + await jsonRequest(`${base}/v1/extensions/${localized.value.id}`, { + token: saved.api_token, + }) + ).value.name, + LOCALIZED_EXTENSION_MESSAGES.extName, + ); + + // A placeholder the locale file cannot resolve falls back to the name the + // caller sent. What it must never do is store `__MSG_extName__` itself. + const unresolved = await jsonRequest(`${base}/v1/extensions`, { + method: "POST", + token: saved.api_token, + body: { + name: "REST Unresolved Placeholder", + file_name: "unresolved.zip", + file_data_base64: localizedExtensionZipBase64({ messages: {} }), + }, + }); + assert.equal( + unresolved.response.status, + 201, + JSON.stringify(unresolved.value), + ); + assert.equal(unresolved.value.name, "REST Unresolved Placeholder"); + assert.equal(unresolved.value.description, null); + assert.equal(unresolved.value.author, null); + assert.doesNotMatch(JSON.stringify(unresolved.value), /__MSG_/); + + const extensionGroup = await jsonRequest(`${base}/v1/extension-groups`, { + method: "POST", + token: saved.api_token, + body: { name: "REST Extension Group" }, + }); + assert.equal( + extensionGroup.response.status, + 201, + JSON.stringify(extensionGroup.value), + ); + assert.equal(extensionGroup.value.name, "REST Extension Group"); + assert.deepEqual(extensionGroup.value.extension_ids, []); + const extensionGroupId = extensionGroup.value.id; + const renamedExtensionGroup = await jsonRequest( + `${base}/v1/extension-groups/${extensionGroupId}`, + { + method: "PUT", + token: saved.api_token, + body: { name: "REST Extension Group Updated" }, + }, + ); + assert.equal( + renamedExtensionGroup.response.status, + 200, + JSON.stringify(renamedExtensionGroup.value), + ); + assert.equal( + renamedExtensionGroup.value.name, + "REST Extension Group Updated", + ); + + const membershipUrl = `${base}/v1/extension-groups/${extensionGroupId}/extensions/${extensionId}`; + const joined = await jsonRequest(membershipUrl, { + method: "POST", + token: saved.api_token, + }); + assert.equal(joined.response.status, 200, JSON.stringify(joined.value)); + assert.deepEqual(joined.value.extension_ids, [extensionId]); + assert.deepEqual( + ( + await jsonRequest(`${base}/v1/extension-groups/${extensionGroupId}`, { + token: saved.api_token, + }) + ).value.extension_ids, + [extensionId], + ); + const left = await jsonRequest(membershipUrl, { + method: "DELETE", + token: saved.api_token, + }); + assert.equal(left.response.status, 200, JSON.stringify(left.value)); + assert.deepEqual(left.value.extension_ids, []); + assert.deepEqual( + ( + await jsonRequest(`${base}/v1/extension-groups/${extensionGroupId}`, { + token: saved.api_token, + }) + ).value.extension_ids, + [], + ); + + // The whole path an automation client takes: an extension, a group holding + // it, and a profile that will load that group the next time it launches. + // Each piece already had coverage; the sequence did not, and it is the + // sequence that has to work for extensions to be usable over REST at all. + const launchProfile = await app.invoke("create_browser_profile_new", { + name: "REST Extension Profile", + browserStr: "wayfern", + version: "150.0.7871.100", + releaseType: "stable", + proxyId: null, + vpnId: null, + // A stored fingerprint keeps this suite off the real browser; the + // browser suite covers generation. + wayfernConfig: { fingerprint: "{}" }, + groupId: null, + ephemeral: false, + dnsBlocklist: null, + launchHook: null, + }); + const launchGroup = await jsonRequest(`${base}/v1/extension-groups`, { + method: "POST", + token: saved.api_token, + body: { name: "REST Launch Extension Group" }, + }); + assert.equal( + launchGroup.response.status, + 201, + JSON.stringify(launchGroup.value), + ); + const launchGroupId = launchGroup.value.id; + assert.deepEqual( + ( + await jsonRequest( + `${base}/v1/extension-groups/${launchGroupId}/extensions/${archiveExtension.value.id}`, + { method: "POST", token: saved.api_token }, + ) + ).value.extension_ids, + [archiveExtension.value.id], + ); + const assigned = await jsonRequest( + `${base}/v1/profiles/${launchProfile.id}`, + { + method: "PUT", + token: saved.api_token, + body: { extension_group_id: launchGroupId }, + }, + ); + assert.equal(assigned.response.status, 200, JSON.stringify(assigned.value)); + assert.equal(assigned.value.profile.id, launchProfile.id); + // `ApiProfile` carries no `extension_group_id`, so the assignment can only + // be read back through the surface the launcher itself resolves. + const assignedGroup = () => + app.invoke("get_extension_group_for_profile", { + profileId: launchProfile.id, + }); + assert.equal((await assignedGroup()).id, launchGroupId); + assert.deepEqual((await assignedGroup()).extension_ids, [ + archiveExtension.value.id, + ]); + + // A group that does not exist used to be stored anyway and fail at launch, + // far from the request that caused it. + const missingExtensionGroup = await jsonRequest( + `${base}/v1/profiles/${launchProfile.id}`, + { + method: "PUT", + token: saved.api_token, + body: { extension_group_id: "00000000-0000-0000-0000-0000000000ee" }, + }, + ); + assert.equal( + missingExtensionGroup.response.status, + 404, + JSON.stringify(missingExtensionGroup.value), + ); + assert.equal( + (await assignedGroup()).id, + launchGroupId, + "a refused assignment must leave the previous one in place", + ); + + assert.equal( + ( + await jsonRequest(`${base}/v1/profiles/${launchProfile.id}`, { + method: "PUT", + token: saved.api_token, + body: { extension_group_id: "" }, + }) + ).response.status, + 200, + ); + assert.equal(await assignedGroup(), null); + assert.equal( + ( + await jsonRequest(`${base}/v1/extension-groups/${launchGroupId}`, { + method: "DELETE", + token: saved.api_token, + }) + ).response.status, + 204, + ); + await app.invoke("delete_profile", { profileId: launchProfile.id }); + + for (const id of [ + extensionId, + archiveExtension.value.id, + oversized.value.id, + localized.value.id, + unresolved.value.id, + ]) { + assert.equal( + ( + await jsonRequest(`${base}/v1/extensions/${id}`, { + method: "DELETE", + token: saved.api_token, + }) + ).response.status, + 204, + ); + } + assert.equal( + ( + await jsonRequest(`${base}/v1/extension-groups/${extensionGroupId}`, { + method: "DELETE", + token: saved.api_token, + }) + ).response.status, + 204, + ); + const missing = await jsonRequest(`${base}/v1/groups/missing`, { token: saved.api_token, }); @@ -323,6 +774,13 @@ test("MCP Streamable HTTP initialization, auth, discovery, calls, and isolated a "run_profile_remote", "get_remote_session", "stop_remote_session", + // Extension management is only usable from an agent if importing and + // grouping are reachable, not just listing and deleting. + "add_extension", + "update_extension", + "add_extension_to_group", + "remove_extension_from_group", + "update_extension_group", ]) { assert.ok(names.includes(name), `MCP is missing ${name}`); } @@ -414,6 +872,101 @@ test("MCP Streamable HTTP initialization, auth, discovery, calls, and isolated a ); await app.invoke("delete_stored_proxy", { proxyId: vlessProxy.id }); + let toolCallId = 7; + const callTool = (name, args) => + jsonRequest(`${base}/mcp/${config.token}`, { + method: "POST", + headers: mcpHeaders, + body: { + jsonrpc: "2.0", + id: toolCallId++, + method: "tools/call", + params: { name, arguments: args }, + }, + }); + + const unpackedDir = await writeUnpackedExtension( + path.join(app.root, "fixtures", "mcp-unpacked-extension"), + { name: "Donut MCP Unpacked", version: "1.0.0" }, + ); + const addedExtension = await callTool("add_extension", { + path: unpackedDir, + name: "MCP Folder Extension", + }); + assert.equal(addedExtension.response.status, 200); + const subscriptionGated = /subscription/i.test( + addedExtension.value.error?.message ?? "", + ); + // The e2e build overrides the paid-plan gate whenever a Wayfern test token + // is present, so with one in the environment a gated answer means the + // override stopped working and everything below it silently stopped + // running. + assert.ok( + !subscriptionGated || !process.env.WAYFERN_TEST_TOKEN, + `the e2e paid-plan override did not apply: ${addedExtension.value.error?.message}`, + ); + if (subscriptionGated) { + // Every extension tool is gated on an active paid plan and this session + // is signed out, so the call path is unreachable here. The tool list + // above still proves the tools are published. + console.warn( + "Skipping the MCP extension tool calls: this session has no paid entitlement", + ); + } else { + assert.equal(addedExtension.value.error, undefined); + const stored = (await app.invoke("list_extensions")).find( + (item) => item.name === "Donut MCP Unpacked", + ); + assert.ok(stored, "the MCP import must produce a stored extension"); + assert.equal(stored.source_kind, "unpacked"); + assert.equal(stored.linked_path, null); + + const renamedExtension = await callTool("update_extension", { + extension_id: stored.id, + name: "MCP Renamed Extension", + }); + assert.equal(renamedExtension.value.error, undefined); + assert.equal( + (await app.invoke("list_extensions")).find( + (item) => item.id === stored.id, + ).name, + "MCP Renamed Extension", + ); + + const extensionGroup = await app.invoke("create_extension_group", { + name: "MCP Extension Group", + }); + const joined = await callTool("add_extension_to_group", { + group_id: extensionGroup.id, + extension_id: stored.id, + }); + assert.equal(joined.value.error, undefined); + const readGroup = async () => + (await app.invoke("list_extension_groups")).find( + (item) => item.id === extensionGroup.id, + ); + assert.deepEqual((await readGroup()).extension_ids, [stored.id]); + + const renamedGroup = await callTool("update_extension_group", { + group_id: extensionGroup.id, + name: "MCP Extension Group Updated", + }); + assert.equal(renamedGroup.value.error, undefined); + assert.equal((await readGroup()).name, "MCP Extension Group Updated"); + + const removed = await callTool("remove_extension_from_group", { + group_id: extensionGroup.id, + extension_id: stored.id, + }); + assert.equal(removed.value.error, undefined); + assert.deepEqual((await readGroup()).extension_ids, []); + + await app.invoke("delete_extension", { extensionId: stored.id }); + await app.invoke("delete_extension_group", { + groupId: extensionGroup.id, + }); + } + const agents = await app.invoke("list_mcp_agents"); assert.ok(agents.some((agent) => agent.id === "cursor")); await assertCommandErrorCode(app, "add_mcp_to_agent", "MCP_AGENT_UNKNOWN", { diff --git a/e2e/tests/ui.test.mjs b/e2e/tests/ui.test.mjs index e7d80b6..6fe7121 100644 --- a/e2e/tests/ui.test.mjs +++ b/e2e/tests/ui.test.mjs @@ -1,8 +1,15 @@ import assert from "node:assert/strict"; +import { mkdir, realpath, writeFile } from "node:fs/promises"; +import path from "node:path"; import test from "node:test"; import Color from "color"; +import en from "../../src/i18n/locales/en.json" with { type: "json" }; import { getDerivedThemeColors, THEMES } from "../../src/lib/themes.ts"; import { withApp } from "../lib/app.mjs"; +import { + extensionZipBase64, + writeUnpackedExtension, +} from "../lib/fixtures.mjs"; const THEME_VARIABLES = [ "--background", @@ -87,6 +94,21 @@ function themeVariablesEqual(actual, expected) { async function applyThemeForContrastAudit(app, theme) { await app.execute( ` + // This audit reads settled colour tokens, not the animation between + // them. Tab triggers carry "transition-colors duration-150" and start + // from --muted-foreground, so a computed style sampled mid-transition + // returns an intermediate colour and the assertion fails on whichever + // theme the machine happened to be slow on. Kill transitions for the + // duration of the audit rather than racing them with a fixed sleep. + let freeze = document.getElementById("donut-e2e-freeze-transitions"); + if (!freeze) { + freeze = document.createElement("style"); + freeze.id = "donut-e2e-freeze-transitions"; + freeze.textContent = + "*, *::before, *::after { transition: none !important; animation: none !important; }"; + document.head.appendChild(freeze); + } + const [colors, derived, mode] = arguments; const root = document.documentElement; root.classList.remove("light", "dark"); @@ -97,7 +119,11 @@ async function applyThemeForContrastAudit(app, theme) { `, [theme.colors, getDerivedThemeColors(theme.colors), theme.mode], ); - await new Promise((resolve) => setTimeout(resolve, 200)); + // One frame is enough once transitions are off; the value cannot drift after + // style recalculation. + await app.execute( + `return new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve(true))));`, + ); } async function animatedTabContrastSnapshot(app) { @@ -1013,3 +1039,437 @@ test("a light custom preset keeps light component behavior after restart", async ); }); }); + +const EXTENSION_STRINGS = en.extensions; + +/** + * Answer the native directory picker from inside the webview. + * + * "Load unpacked" calls `open({ directory: true })` from + * `@tauri-apps/plugin-dialog`, which puts an OS window on screen that no + * WebDriver can reach. The call leaves the page as the `plugin:dialog|open` IPC + * command, but Tauri locks its own entry points down: `invoke`, `ipc` and + * `postMessage` are all installed with + * `Object.defineProperty(window.__TAURI_INTERNALS__, name, { value })`, so they + * are non-writable and cannot be wrapped. The seam underneath them is the + * transport, which POSTs the command through `fetch` to + * `ipc://localhost/`. 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); + }); +}); diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index 6c7946e..3197bc5 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -7,7 +7,7 @@ use crate::tag_manager::TAG_MANAGER; use axum::{ extract::{ ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}, - Path, Query, State, + DefaultBodyLimit, Path, Query, State, }, http::{header, HeaderMap, Method, StatusCode}, middleware::{self, Next}, @@ -41,6 +41,10 @@ pub struct ApiProfile { pub is_running: bool, pub proxy_bypass_rules: Vec, pub vpn_id: Option, + /// 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, pub clear_on_close: bool, /// Cloud sync mode: `"Disabled"`, `"Regular"` or `"Encrypted"`. /// Settable via `PUT /v1/profiles/{id}`; exposed here so a caller can read @@ -84,6 +88,7 @@ impl From<&crate::profile::types::BrowserProfile> for ApiProfile { is_running: profile.process_id.is_some(), proxy_bypass_rules: profile.proxy_bypass_rules.clone(), vpn_id: profile.vpn_id.clone(), + extension_group_id: profile.extension_group_id.clone(), clear_on_close: profile.clear_on_close, sync_mode: format!("{:?}", profile.sync_mode), cloud_sync_enabled: profile.is_sync_enabled(), @@ -416,6 +421,59 @@ struct ImportCookiesResponse { errors: Vec, } +/// 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, + /// Name of the uploaded file. Its suffix picks the type: `.crx` or `.zip`. + pub file_name: Option, + /// Payload bytes, standard base64. Only meaningful with `file_name`. + pub file_data_base64: Option, + /// Path on this machine: a `.crx`/`.zip`, or an unpacked extension + /// directory holding a top-level `manifest.json`. + pub source_path: Option, + /// 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, +} + +/// 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, + /// Name of the replacement upload. Its suffix picks the type: `.crx` or `.zip`. + pub file_name: Option, + /// Replacement payload bytes, standard base64. Only meaningful with `file_name`. + pub file_data_base64: Option, + /// Path on this machine to re-import from: a `.crx`/`.zip`, or an unpacked + /// extension directory. + pub source_path: Option, + /// Load a `source_path` directory in place instead of copying it in. + pub link: Option, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateExtensionGroupRequest { + pub name: String, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdateExtensionGroupRequest { + /// New group name. + pub name: Option, + /// 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>, +} + #[derive(Debug, Deserialize, ToSchema)] struct BatchRunRequest { /// Profile IDs to launch. @@ -561,9 +619,17 @@ struct ImportProxiesResponse { update_vpn, delete_vpn, get_extensions, - get_extension_groups, + create_extension_api, + get_extension_api, + update_extension_api, delete_extension_api, + get_extension_groups, + create_extension_group_api, + get_extension_group_api, + update_extension_group_api, delete_extension_group_api, + add_extension_to_group_api, + remove_extension_from_group_api, download_browser_api, get_browser_versions, check_browser_downloaded, @@ -624,6 +690,12 @@ struct ImportProxiesResponse { OpenUrlRequest, ImportCookiesRequest, ImportCookiesResponse, + CreateExtensionRequest, + UpdateExtensionRequest, + CreateExtensionGroupRequest, + UpdateExtensionGroupRequest, + crate::extension_manager::Extension, + crate::extension_manager::ExtensionGroup, ProxySettings, DetectedProfilesResponse, ImportProfilesRequest, @@ -838,15 +910,37 @@ fn build_v1_router() -> Router { .routes(routes!(import_vpn)) .routes(routes!(export_vpn)) .routes(routes!(get_vpn, update_vpn, delete_vpn)) - .routes(routes!(get_extensions)) - .routes(routes!(delete_extension_api)) - .routes(routes!(get_extension_groups)) - .routes(routes!(delete_extension_group_api)) + .routes(routes!(get_extension_groups, create_extension_group_api)) + .routes(routes!( + get_extension_group_api, + update_extension_group_api, + delete_extension_group_api + )) + .routes(routes!( + add_extension_to_group_api, + remove_extension_from_group_api + )) .routes(routes!(download_browser_api)) .routes(routes!(get_browser_versions)) .routes(routes!(check_browser_downloaded)) .split_for_parts(); - routes + + // The two paths that carry an extension payload, kept apart so the raised + // body limit reaches them and nothing else. Axum's 2 MiB default is smaller + // than plenty of real `.crx` files, and a create that 413s before the + // handler runs is indistinguishable from a broken endpoint. The GET and + // DELETE on these paths ride along because a limit has to be attached per + // path, and neither reads a body. + let (extension_payload_routes, _) = OpenApiRouter::new() + .routes(routes!(get_extensions, create_extension_api)) + .routes(routes!( + get_extension_api, + update_extension_api, + delete_extension_api + )) + .split_for_parts(); + + routes.merge(extension_payload_routes.layer(DefaultBodyLimit::max(64 * 1024 * 1024))) } // Terms and Conditions check middleware @@ -1140,6 +1234,11 @@ fn manager_error_response(err: impl std::fmt::Display) -> (StatusCode, String) { || lower.contains("not supported on your platform") || lower.contains("is not downloaded") || lower.contains("terms and conditions") + // Extension-group compatibility: a group holding a Firefox-only add-on, + // or a browser that takes no extensions at all. Both are a caller pairing + // two things that don't go together, not a fault of this machine. + || lower.contains("is not compatible with") + || lower.contains("not supported for browser") { StatusCode::BAD_REQUEST } else { @@ -1509,6 +1608,27 @@ async fn update_profile( } else { Some(extension_group_id) }; + // Assigning a group that does not exist, or one holding an extension this + // profile's browser cannot load, fails at launch instead of here unless it + // is checked now — which is what the Tauri and MCP paths already do. + if let Some(group_id) = ext_group.as_deref() { + let browser = { + let profiles = profile_manager + .list_profiles() + .map_err(manager_error_response)?; + profiles + .iter() + .find(|p| p.id.to_string() == id) + .map(|p| p.browser.clone()) + .ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + format!("Profile with ID '{id}' not found"), + ) + })? + }; + with_extension_manager(|mgr| mgr.validate_group_compatibility(group_id, &browser))?; + } if let Err(e) = profile_manager.update_profile_extension_group(&id, ext_group) { return Err(manager_error_response(e)); } @@ -2255,44 +2375,214 @@ async fn delete_vpn( // Extension API endpoints +/// Take the extension store once, in one place, so a poisoned lock answers 500 +/// instead of panicking the request thread, and every handler classifies the +/// manager's `{"code": ...}` errors identically. +fn with_extension_manager( + action: impl FnOnce( + &crate::extension_manager::ExtensionManager, + ) -> Result>, +) -> Result { + 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, + }, + 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, + file_data_base64: Option, + source_path: Option, + link: Option, +) -> Result, (StatusCode, String)> { + use base64::Engine as _; + + let upload = match (file_name, file_data_base64) { + (Some(name), Some(encoded)) => Some((name, encoded)), + (None, None) => None, + // Half an upload is not a source: honouring it would mean storing an empty + // payload, or inventing a file name and with it a file type. + _ => return Err(extension_request_error("EXTENSION_SOURCE_REQUIRED")), + }; + let path = source_path.filter(|p| !p.trim().is_empty()); + let link = link.unwrap_or(false); + + match (upload, path) { + // Two sources is as unanswerable as none: there is no rule for which one + // the caller meant, so neither is guessed at. + (Some(_), Some(_)) => Err(extension_request_error("EXTENSION_SOURCE_REQUIRED")), + (Some((file_name, encoded)), None) => { + if link { + // Linking means "keep loading the folder where it already is". An + // uploaded archive has no folder on this machine to point at. + return Err(extension_request_error("EXTENSION_LINK_REQUIRES_DIRECTORY")); + } + let data = base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|_| extension_request_error("EXTENSION_INVALID_BASE64"))?; + Ok(Some(ExtensionSource::Upload { file_name, data })) + } + (None, Some(path)) => Ok(Some(ExtensionSource::LocalPath { + path: std::path::PathBuf::from(path), + link, + })), + (None, None) => Ok(None), + } +} + #[utoipa::path( get, path = "/v1/extensions", responses( - (status = 200, description = "List of extensions"), + (status = 200, description = "List of extensions", body = Vec), (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), ), security(("bearer_auth" = [])), tag = "extensions" )] async fn get_extensions( State(_state): State, -) -> Result>, StatusCode> { - let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); - mgr - .list_extensions() - .map(Json) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +) -> Result>, (StatusCode, String)> { + with_extension_manager(|mgr| mgr.list_extensions()).map(Json) } +/// Add an extension. +/// +/// The body names exactly one source: +/// - `file_name` plus `file_data_base64` uploads a `.crx`/`.zip`. +/// - `source_path` reads a `.crx`/`.zip`, or packs an unpacked extension +/// directory, from the machine running Donut. `link: true` loads that +/// directory in place instead, so edits apply on the next browser start; +/// a linked extension is machine-local and never syncs. +/// +/// `name` may be omitted: the manifest's own name is preferred anyway, and it +/// is only an error when the manifest has none either. #[utoipa::path( - get, - path = "/v1/extension-groups", + post, + path = "/v1/extensions", + request_body = CreateExtensionRequest, responses( - (status = 200, description = "List of extension groups"), + (status = 201, description = "Extension added", body = crate::extension_manager::Extension), + (status = 400, description = "No source, two sources, undecodable payload, unsupported file type, or an unreadable unpacked directory"), (status = 401, description = "Unauthorized"), + (status = 404, description = "source_path does not exist"), + (status = 500, description = "Internal server error"), ), security(("bearer_auth" = [])), tag = "extensions" )] -async fn get_extension_groups( - State(_state): State, -) -> Result>, StatusCode> { - let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); - mgr - .list_groups() - .map(Json) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +async fn create_extension_api( + Json(request): Json, +) -> Result<(StatusCode, Json), (StatusCode, String)> { + let source = resolve_extension_source( + request.file_name, + request.file_data_base64, + request.source_path, + request.link, + )? + .ok_or_else(|| extension_request_error("EXTENSION_SOURCE_REQUIRED"))?; + let name = request.name.unwrap_or_default(); + + let extension = with_extension_manager(|mgr| match source { + ExtensionSource::Upload { file_name, data } => mgr.add_extension(name, file_name, data), + ExtensionSource::LocalPath { path, link } => mgr.add_extension_from_path(name, &path, link), + })?; + Ok((StatusCode::CREATED, Json(extension))) +} + +#[utoipa::path( + get, + path = "/v1/extensions/{id}", + params(("id" = String, Path, description = "Extension ID")), + responses( + (status = 200, description = "Extension", body = crate::extension_manager::Extension), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Extension not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])), + tag = "extensions" +)] +async fn get_extension_api( + Path(id): Path, +) -> Result, (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, + Json(request): Json, +) -> Result, (StatusCode, String)> { + let name = request.name; + let source = resolve_extension_source( + request.file_name, + request.file_data_base64, + request.source_path, + request.link, + )?; + if name.is_none() && source.is_none() { + return Err(extension_request_error("EXTENSION_SOURCE_REQUIRED")); + } + + with_extension_manager(|mgr| match source { + Some(ExtensionSource::Upload { file_name, data }) => { + mgr.update_extension(&id, name, Some(file_name), Some(data)) + } + Some(ExtensionSource::LocalPath { path, link }) => { + mgr.update_extension_from_path(&id, name, &path, link) + } + None => mgr.update_extension(&id, name, None, None), + }) + .map(Json) } #[utoipa::path( @@ -2312,11 +2602,88 @@ async fn delete_extension_api( Path(id): Path, State(state): State, ) -> Result { - let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); - mgr - .delete_extension(&state.app_handle, &id) + with_extension_manager(|mgr| mgr.delete_extension(&state.app_handle, &id)) .map(|_| StatusCode::NO_CONTENT) - .map_err(manager_error_response) +} + +#[utoipa::path( + get, + path = "/v1/extension-groups", + responses( + (status = 200, description = "List of extension groups", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])), + tag = "extensions" +)] +async fn get_extension_groups( + State(_state): State, +) -> Result>, (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, +) -> Result<(StatusCode, Json), (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, +) -> Result, (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, + Json(request): Json, +) -> Result, (StatusCode, String)> { + with_extension_manager(|mgr| mgr.update_group(&id, request.name, request.extension_ids)).map(Json) } #[utoipa::path( @@ -2336,11 +2703,55 @@ async fn delete_extension_group_api( Path(id): Path, State(state): State, ) -> Result { - let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); - mgr - .delete_group(&state.app_handle, &id) + with_extension_manager(|mgr| mgr.delete_group(&state.app_handle, &id)) .map(|_| StatusCode::NO_CONTENT) - .map_err(manager_error_response) +} + +/// Add one extension to a group. Adding a member it already has is a no-op, +/// not an error, so a client re-running its setup converges. +#[utoipa::path( + post, + path = "/v1/extension-groups/{id}/extensions/{extension_id}", + params( + ("id" = String, Path, description = "Extension Group ID"), + ("extension_id" = String, Path, description = "Extension ID to add"), + ), + responses( + (status = 200, description = "Extension group with the extension added", body = crate::extension_manager::ExtensionGroup), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Extension or extension group not found"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])), + tag = "extensions" +)] +async fn add_extension_to_group_api( + Path((id, extension_id)): Path<(String, String)>, +) -> Result, (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, (StatusCode, String)> { + with_extension_manager(|mgr| mgr.remove_extension_from_group(&id, &extension_id)).map(Json) } // API Handler - Run Profile with Remote Debugging @@ -4304,6 +4715,21 @@ mod tests { (Method::GET, "/v1/remote-hours"), // A run id is required; the collection DELETE is not a route. (Method::DELETE, "/v1/cookie-bot/runs/"), + // Extension writes touch this machine's own store. They start no + // browser, so metering them would spend an automation client's quota on + // uploading a `.crx`. + (Method::POST, "/v1/extensions"), + (Method::PUT, "/v1/extensions/extension-id"), + (Method::DELETE, "/v1/extensions/extension-id"), + (Method::POST, "/v1/extension-groups"), + ( + Method::POST, + "/v1/extension-groups/group-id/extensions/extension-id", + ), + ( + Method::DELETE, + "/v1/extension-groups/group-id/extensions/extension-id", + ), ] { assert!( !is_automation_request(&method, path), @@ -4642,6 +5068,44 @@ mod tests { "{field} must be optional on the quota, required list: {quota:?}" ); } + + // An extension arrives either as an upload or as a path, and its name is + // usually read from the manifest. Marking any one of these required would + // make a generated client send a field that contradicts the source it + // actually has. + for request in ["CreateExtensionRequest", "UpdateExtensionRequest"] { + let fields = schema_required(&spec, request); + assert!( + fields.is_empty(), + "every field of {request} must be optional, required list: {fields:?}" + ); + } + + let update_extension_group = schema_required(&spec, "UpdateExtensionGroupRequest"); + for field in ["name", "extension_ids"] { + assert!( + !update_extension_group.iter().any(|f| f == field), + "{field} must be optional on a group update, required list: {update_extension_group:?}" + ); + } + + // A group cannot be created without one. + let create_extension_group = schema_required(&spec, "CreateExtensionGroupRequest"); + assert!( + create_extension_group.iter().any(|f| f == "name"), + "name is required to create a group, required list: {create_extension_group:?}" + ); + + // `linked_path` is only set for an extension loaded in place, and every + // extension stored before unpacked support has neither it nor a + // `source_kind`, so a required marking would break reading them back. + let extension = schema_required(&spec, "Extension"); + for field in ["linked_path", "version", "description", "author"] { + assert!( + !extension.iter().any(|f| f == field), + "{field} must be optional on an extension, required list: {extension:?}" + ); + } } #[test] @@ -4686,6 +5150,174 @@ mod tests { assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); } + #[test] + fn a_rejected_extension_upload_is_the_callers_problem_not_a_server_fault() { + // Every one of these is something about the request: the wrong file type, a + // folder with no readable manifest, a link asked for on a file. Answering + // 500 would tell a client to retry a body that can never be accepted. + for code in [ + "EXTENSION_UNSUPPORTED_FILE_TYPE", + "EXTENSION_NOT_A_DIRECTORY", + "EXTENSION_MANIFEST_MISSING", + "EXTENSION_MANIFEST_INVALID", + "EXTENSION_DIR_TOO_LARGE", + "EXTENSION_PATH_HAS_COMMA", + "EXTENSION_LINK_REQUIRES_DIRECTORY", + "NAME_CANNOT_BE_EMPTY", + ] { + let (status, body) = manager_error_response(serde_json::json!({ "code": code }).to_string()); + assert_eq!(status, StatusCode::BAD_REQUEST, "{code} must be a 400"); + assert!(body.contains(code), "{code} must reach the caller"); + } + + // A path that is not there is the one refusal that names a missing thing. + let (status, _) = + manager_error_response(serde_json::json!({ "code": "EXTENSION_DIR_NOT_FOUND" }).to_string()); + assert_eq!(status, StatusCode::NOT_FOUND); + } + + #[test] + fn an_incompatible_extension_group_is_refused_rather_than_reported_as_broken() { + // `validate_group_compatibility` answers in prose, so both of its refusals + // fell through to 500 — which reads as "this server is broken" for what is + // really a group the caller cannot put on that profile. + for message in [ + "Extension 'uBlock' (crx) is not compatible with gecko browsers", + "Extensions are not supported for browser 'firefox'", + ] { + let (status, body) = manager_error_response(message); + assert_eq!(status, StatusCode::BAD_REQUEST, "{message} must be a 400"); + assert_eq!(body, message, "the diagnostic must reach the caller"); + } + + // A group that is simply absent stays a 404. + let (status, _) = manager_error_response("Extension group with id 'gone' not found"); + assert_eq!(status, StatusCode::NOT_FOUND); + } + + #[test] + fn an_extension_write_names_exactly_one_source() { + use base64::Engine as _; + + let encoded = base64::engine::general_purpose::STANDARD.encode(b"PK\x03\x04"); + + // An upload decodes to the bytes the caller sent. + match resolve_extension_source( + Some("ublock.crx".to_string()), + Some(encoded.clone()), + None, + None, + ) { + Ok(Some(ExtensionSource::Upload { file_name, data })) => { + assert_eq!(file_name, "ublock.crx"); + assert_eq!(data, b"PK\x03\x04"); + } + other => panic!("an upload must resolve to its bytes: {other:?}"), + } + + // A path is taken as-is, and `link` rides with it. + match resolve_extension_source(None, None, Some("/srv/ext".to_string()), Some(true)) { + Ok(Some(ExtensionSource::LocalPath { path, link })) => { + assert_eq!(path, std::path::PathBuf::from("/srv/ext")); + assert!(link); + } + other => panic!("a path must resolve to a path: {other:?}"), + } + + // Naming both sources has no answer: neither one is guessed at. + let both = resolve_extension_source( + Some("ublock.crx".to_string()), + Some(encoded.clone()), + Some("/srv/ext".to_string()), + None, + ); + assert_eq!( + both.expect_err("two sources must be refused"), + extension_request_error("EXTENSION_SOURCE_REQUIRED") + ); + + // Half an upload is not a source. Storing an empty payload, or inventing a + // file name, would both produce an extension that never loads. + for half in [ + (Some("ublock.crx".to_string()), None), + (None, Some(encoded.clone())), + ] { + let (file_name, data) = half; + assert_eq!( + resolve_extension_source(file_name, data, None, None) + .expect_err("half an upload must be refused"), + extension_request_error("EXTENSION_SOURCE_REQUIRED") + ); + } + + // Linking means "load the folder where it is"; an upload has no folder. + assert_eq!( + resolve_extension_source( + Some("ublock.crx".to_string()), + Some(encoded), + None, + Some(true) + ) + .expect_err("a linked upload must be refused"), + extension_request_error("EXTENSION_LINK_REQUIRES_DIRECTORY") + ); + + // Undecodable base64 is named as such rather than reaching the store as an + // empty or truncated archive. + assert_eq!( + resolve_extension_source( + Some("ublock.crx".to_string()), + Some("not base64!!".to_string()), + None, + None + ) + .expect_err("undecodable base64 must be refused"), + extension_request_error("EXTENSION_INVALID_BASE64") + ); + + // No source at all is legal on the wire — it is a rename, and only the + // update handler accepts it. + assert!(resolve_extension_source(None, None, None, None) + .expect("naming no source is not an error here") + .is_none()); + + // An empty string is not a path. + assert!( + resolve_extension_source(None, None, Some(" ".to_string()), None) + .expect("a blank path is not an error here") + .is_none() + ); + } + + #[test] + fn an_extension_update_that_asks_for_nothing_is_refused() { + // Every field is optional, so an empty body parses. Answering 200 with an + // untouched extension would tell a client its rename landed. + let empty: UpdateExtensionRequest = + serde_json::from_str("{}").expect("an empty update body must deserialize"); + assert!(empty.name.is_none()); + assert!(resolve_extension_source( + empty.file_name, + empty.file_data_base64, + empty.source_path, + empty.link + ) + .expect("no source is not an error on update") + .is_none()); + } + + #[test] + fn creating_an_extension_needs_neither_a_name_nor_a_link() { + // The manifest's own name is preferred, so a caller uploading a `.crx` + // sends two fields and nothing else. + let minimal: CreateExtensionRequest = + serde_json::from_str(r#"{"file_name": "ublock.crx", "file_data_base64": "UEsDBA=="}"#) + .expect("a minimal create body must deserialize"); + assert!(minimal.name.is_none()); + assert!(minimal.link.is_none()); + assert_eq!(minimal.file_name.as_deref(), Some("ublock.crx")); + } + #[test] fn a_remote_session_exposes_a_cdp_endpoint_an_external_client_can_attach_to() { // Without this route `run-remote` hands back a session id that nothing @@ -4763,6 +5395,7 @@ mod tests { "/v1/extension-groups", "/v1/extensions/{id}", "/v1/extension-groups/{id}", + "/v1/extension-groups/{id}/extensions/{extension_id}", "/v1/profiles/import", "/v1/profiles/import/detect", "/v1/proxies/import", @@ -4801,6 +5434,26 @@ mod tests { ("/v1/cookie-bot/runs", "get"), ("/v1/cookie-bot/runs", "post"), ("/v1/cookie-bot/runs/{run_id}", "delete"), + // The extension surface is five paths carrying eleven methods, so it is + // the densest place in the router for one `routes!` to swallow another. + ("/v1/extensions", "get"), + ("/v1/extensions", "post"), + ("/v1/extensions/{id}", "get"), + ("/v1/extensions/{id}", "put"), + ("/v1/extensions/{id}", "delete"), + ("/v1/extension-groups", "get"), + ("/v1/extension-groups", "post"), + ("/v1/extension-groups/{id}", "get"), + ("/v1/extension-groups/{id}", "put"), + ("/v1/extension-groups/{id}", "delete"), + ( + "/v1/extension-groups/{id}/extensions/{extension_id}", + "post", + ), + ( + "/v1/extension-groups/{id}/extensions/{extension_id}", + "delete", + ), ] { assert!( paths[path].get(method).is_some(), @@ -4865,6 +5518,14 @@ mod tests { "RemoteHoursQuota", "RemoteHoursMember", "RemoteHoursBreakdown", + // Neither extension type was registered while only the list and delete + // routes existed, so every extension response resolved to nothing. + "Extension", + "ExtensionGroup", + "CreateExtensionRequest", + "UpdateExtensionRequest", + "CreateExtensionGroupRequest", + "UpdateExtensionGroupRequest", ] { assert!( spec["components"]["schemas"][schema]["properties"].is_object(), @@ -4895,6 +5556,15 @@ mod tests { "RemoteSessionState", ), ("/v1/remote-hours", "get", "200", "RemoteHoursQuota"), + ("/v1/extensions", "post", "201", "Extension"), + ("/v1/extensions/{id}", "put", "200", "Extension"), + ("/v1/extension-groups", "post", "201", "ExtensionGroup"), + ( + "/v1/extension-groups/{id}/extensions/{extension_id}", + "post", + "200", + "ExtensionGroup", + ), ] { let reference = &paths[path][method]["responses"][status]["content"]["application/json"]["schema"]["$ref"]; @@ -4905,6 +5575,23 @@ mod tests { ); } + // Both extension lists declared a 200 with no body at all, so a generated + // client got a call that returns nothing from a route that returns + // everything. Each must be an array of the same component its single-item + // route resolves to. + for (path, schema) in [ + ("/v1/extensions", "Extension"), + ("/v1/extension-groups", "ExtensionGroup"), + ] { + let item = &paths[path]["get"]["responses"]["200"]["content"]["application/json"]["schema"] + ["items"]["$ref"]; + assert_eq!( + item.as_str(), + Some(format!("#/components/schemas/{schema}").as_str()), + "get {path} 200 is not a list of {schema}: {item:?}" + ); + } + // The presets a client may choose from must never carry the behaviour they // expand to. A site list, a dwell range or a step programme appearing here // would mean the browsing model had leaked out of the server. @@ -4967,5 +5654,28 @@ mod tests { "a schedule write must not declare a 429: {method}" ); } + + // Extension writes launch nothing and lease nothing, so the limiter never + // sees them. A declared 429 would be a status the server cannot produce. + for (path, method) in [ + ("/v1/extensions", "post"), + ("/v1/extensions/{id}", "put"), + ("/v1/extensions/{id}", "delete"), + ("/v1/extension-groups", "post"), + ("/v1/extension-groups/{id}", "put"), + ( + "/v1/extension-groups/{id}/extensions/{extension_id}", + "post", + ), + ( + "/v1/extension-groups/{id}/extensions/{extension_id}", + "delete", + ), + ] { + assert!( + paths[path][method]["responses"].get("429").is_none(), + "an extension write must not declare a 429: {method} {path}" + ); + } } } diff --git a/src-tauri/src/browser_runner.rs b/src-tauri/src/browser_runner.rs index 17d269d..df96a9c 100644 --- a/src-tauri/src/browser_runner.rs +++ b/src-tauri/src/browser_runner.rs @@ -1328,6 +1328,13 @@ impl BrowserRunner { crate::profile::clear_on_close::clear_profile_browsing_data(profile).await; } + // The browser held these open for the life of the process; nothing reads + // them once it has exited, and they are plaintext extension code sitting + // on real disk even for an ephemeral profile. + crate::extension_manager::ExtensionManager::cleanup_unpacked_for_profile( + &profile.id.to_string(), + ); + log::info!( "Wayfern process cleanup completed for profile: {} (ID: {})", profile.name, diff --git a/src-tauri/src/cloud_auth.rs b/src-tauri/src/cloud_auth.rs index becc4b4..4269dac 100644 --- a/src-tauri/src/cloud_auth.rs +++ b/src-tauri/src/cloud_auth.rs @@ -804,6 +804,13 @@ impl CloudAuthManager { /// Account is in a paid/active state. Used for the "any active plan" gates /// (sync token); per-feature access uses the capability helpers. pub async fn has_active_paid_subscription(&self) -> bool { + #[cfg(feature = "e2e")] + if crate::e2e_automation_enabled() + && std::env::var_os("WAYFERN_TEST_TOKEN").is_some_and(|token| !token.is_empty()) + { + return true; + } + self.entitlements().await.map(|e| e.active).unwrap_or(false) } diff --git a/src-tauri/src/extension_manager.rs b/src-tauri/src/extension_manager.rs index 49ba396..d02f150 100644 --- a/src-tauri/src/extension_manager.rs +++ b/src-tauri/src/extension_manager.rs @@ -1,12 +1,26 @@ use serde::{Deserialize, Serialize}; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Mutex; use std::time::{SystemTime, UNIX_EPOCH}; +use utoipa::ToSchema; use crate::events; -#[derive(Debug, Clone, Serialize, Deserialize)] +/// Where an extension's payload came from. `archive` is a user-supplied +/// `.crx`/`.zip`; `unpacked` is a directory that held a top-level +/// `manifest.json`, the "Load unpacked" flow. An unpacked extension is still +/// stored as a zip so the archive pipeline (manifest parsing, icon extraction, +/// sync, launch staging) has exactly one payload shape to handle — except when +/// it is *linked*, in which case nothing is stored at all. +pub const SOURCE_KIND_ARCHIVE: &str = "archive"; +pub const SOURCE_KIND_UNPACKED: &str = "unpacked"; + +fn default_source_kind() -> String { + SOURCE_KIND_ARCHIVE.to_string() +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct Extension { pub id: String, pub name: String, @@ -27,9 +41,27 @@ pub struct Extension { pub author: Option, #[serde(default)] pub homepage_url: Option, + /// `archive` or `unpacked`. Absent in metadata written before unpacked + /// support, which is exactly the archive case. + #[serde(default = "default_source_kind")] + pub source_kind: String, + /// Absolute directory this extension is loaded from in place. `Some` means + /// nothing is copied into the store: Chromium reads the folder directly, so + /// edits land on the next browser start. Linked extensions are machine-local + /// and never sync. + #[serde(default)] + pub linked_path: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +impl Extension { + /// A linked extension has no payload in the store, so every path that reads + /// `file/` has to branch on this. + pub fn is_linked(&self) -> bool { + self.linked_path.is_some() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct ExtensionGroup { pub id: String, pub name: String, @@ -64,7 +96,10 @@ fn extension_groups_file() -> PathBuf { fn determine_browser_compatibility(file_type: &str) -> Vec { match file_type { - "crx" | "zip" => vec!["chromium".to_string()], + // `unpacked` is a linked folder, which has no archive but is still a + // Chromium extension. Leaving it unmapped here would make it silently + // ineligible at launch, which filters on this list. + "crx" | "zip" | "unpacked" => vec!["chromium".to_string()], _ => vec![], } } @@ -142,38 +177,98 @@ pub(crate) fn resolve_archive_i18n( crate::vpn_extension_detect::lookup_message(&messages, &key) } -#[allow(clippy::type_complexity)] -fn extract_manifest_metadata( - file_data: &[u8], - file_type: &str, -) -> ( - Option, - Option, - Option, - Option, - Option, -) { - let manifest = match read_manifest_from_archive(file_data, file_type) { - Some(v) => v, - None => return (None, None, None, None, None), - }; +/// Read an unpacked extension's `manifest.json` off disk. The directory +/// equivalent of `read_manifest_from_archive`, so both payload shapes feed the +/// same metadata and icon extraction below. +pub(crate) fn read_manifest_from_dir(dir: &Path) -> Option { + let contents = fs::read_to_string(dir.join("manifest.json")).ok()?; + serde_json::from_str(&contents).ok() +} - let name = manifest - .get("name") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); +/// Directory equivalent of `resolve_archive_i18n`. +pub(crate) fn resolve_dir_i18n( + dir: &Path, + manifest: &serde_json::Value, + value: &str, +) -> Option { + let key = crate::vpn_extension_detect::message_placeholder_key(value)?; + let default_locale = manifest.get("default_locale")?.as_str()?; + let contents = fs::read_to_string( + dir + .join("_locales") + .join(default_locale) + .join("messages.json"), + ) + .ok()?; + let messages: serde_json::Value = serde_json::from_str(&contents).ok()?; + crate::vpn_extension_detect::lookup_message(&messages, &key) +} + +/// Where a manifest was read from, kept alongside it so localized fields can be +/// resolved. Chromium extensions routinely set `"name": "__MSG_extName__"`, and +/// storing that literal shows the placeholder to the user instead of the name. +pub(crate) enum ManifestSource<'a> { + Archive { data: &'a [u8], file_type: &'a str }, + Dir(&'a Path), +} + +impl ManifestSource<'_> { + fn resolve(&self, manifest: &serde_json::Value, value: &str) -> Option { + match self { + ManifestSource::Archive { data, file_type } => { + resolve_archive_i18n(data, file_type, manifest, value) + } + ManifestSource::Dir(dir) => resolve_dir_i18n(dir, manifest, value), + } + } + + /// Read a manifest string, substituting a `__MSG_key__` placeholder with the + /// default locale's message when there is one. A placeholder that cannot be + /// resolved is dropped rather than shown raw. + fn localized(&self, manifest: &serde_json::Value, key: &str) -> Option { + let raw = manifest.get(key).and_then(|v| v.as_str())?; + if crate::vpn_extension_detect::message_placeholder_key(raw).is_some() { + return self.resolve(manifest, raw); + } + Some(raw.to_string()) + } +} + +/// `(name, version, description, author, homepage_url)`, with any +/// `__MSG_key__` placeholder already resolved through the manifest's default +/// locale. +type ManifestMetadata = ( + Option, + Option, + Option, + Option, + Option, +); + +fn extract_manifest_metadata(file_data: &[u8], file_type: &str) -> ManifestMetadata { + match read_manifest_from_archive(file_data, file_type) { + Some(v) => manifest_metadata( + &v, + &ManifestSource::Archive { + data: file_data, + file_type, + }, + ), + None => (None, None, None, None, None), + } +} + +fn manifest_metadata( + manifest: &serde_json::Value, + source: &ManifestSource<'_>, +) -> ManifestMetadata { + let name = source.localized(manifest, "name"); let version = manifest .get("version") .and_then(|v| v.as_str()) .map(|s| s.to_string()); - let description = manifest - .get("description") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let author = manifest - .get("author") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); + let description = source.localized(manifest, "description"); + let author = source.localized(manifest, "author"); let homepage_url = manifest .get("homepage_url") .or_else(|| manifest.get("homepage")) @@ -183,61 +278,36 @@ fn extract_manifest_metadata( (name, version, description, author, homepage_url) } -fn extract_icon_from_archive(file_data: &[u8], file_type: &str) -> Option<(Vec, String)> { - let zip_start = if file_type == "crx" { - find_zip_start(file_data) - } else { - 0 - }; +/// Pick the largest declared icon from a manifest, falling back to the +/// action/browser_action default icon. Shared by the archive and directory +/// readers so both agree on which icon represents an extension. +fn icon_path_from_manifest(manifest: &serde_json::Value) -> Option { + let mut best_path: Option = None; + let mut best_size: u32 = 0; - let cursor = std::io::Cursor::new(&file_data[zip_start..]); - let mut archive = match zip::ZipArchive::new(cursor) { - Ok(a) => a, - Err(_) => return None, - }; - - let icon_path = { - let manifest_content = if let Ok(mut file) = archive.by_name("manifest.json") { - let mut contents = String::new(); - if std::io::Read::read_to_string(&mut file, &mut contents).is_ok() { - Some(contents) - } else { - None - } - } else { - None - }; - - let manifest_content = manifest_content?; - let manifest: serde_json::Value = serde_json::from_str(&manifest_content).ok()?; - - let mut best_path: Option = None; - let mut best_size: u32 = 0; - - if let Some(icons) = manifest.get("icons").and_then(|v| v.as_object()) { - for (size_str, path_val) in icons { - if let (Ok(size), Some(path)) = (size_str.parse::(), path_val.as_str()) { - if size > best_size { - best_size = size; - best_path = Some(path.to_string()); - } + if let Some(icons) = manifest.get("icons").and_then(|v| v.as_object()) { + for (size_str, path_val) in icons { + if let (Ok(size), Some(path)) = (size_str.parse::(), path_val.as_str()) { + if size > best_size { + best_size = size; + best_path = Some(path.to_string()); } } } + } - if best_path.is_none() { - for key in &["action", "browser_action"] { - if let Some(action) = manifest.get(*key) { - if let Some(icon) = action.get("default_icon") { - if let Some(path) = icon.as_str() { - best_path = Some(path.to_string()); - } else if let Some(icons) = icon.as_object() { - for (size_str, path_val) in icons { - if let (Ok(size), Some(path)) = (size_str.parse::(), path_val.as_str()) { - if size > best_size { - best_size = size; - best_path = Some(path.to_string()); - } + if best_path.is_none() { + for key in &["action", "browser_action"] { + if let Some(action) = manifest.get(*key) { + if let Some(icon) = action.get("default_icon") { + if let Some(path) = icon.as_str() { + best_path = Some(path.to_string()); + } else if let Some(icons) = icon.as_object() { + for (size_str, path_val) in icons { + if let (Ok(size), Some(path)) = (size_str.parse::(), path_val.as_str()) { + if size > best_size { + best_size = size; + best_path = Some(path.to_string()); } } } @@ -245,24 +315,202 @@ fn extract_icon_from_archive(file_data: &[u8], file_type: &str) -> Option<(Vec String { + path.rsplit('.').next().unwrap_or("png").to_lowercase() +} + +fn extract_icon_from_archive(file_data: &[u8], file_type: &str) -> Option<(Vec, String)> { + let zip_start = if file_type == "crx" { + find_zip_start(file_data) + } else { + 0 }; - let icon_path = icon_path?; + let cursor = std::io::Cursor::new(file_data.get(zip_start..)?); + let mut archive = zip::ZipArchive::new(cursor).ok()?; + + let icon_path = { + let mut contents = String::new(); + { + let mut file = archive.by_name("manifest.json").ok()?; + std::io::Read::read_to_string(&mut file, &mut contents).ok()?; + } + let manifest: serde_json::Value = serde_json::from_str(&contents).ok()?; + icon_path_from_manifest(&manifest)? + }; let clean_path = icon_path.trim_start_matches('/'); let mut file = archive.by_name(clean_path).ok()?; let mut data = Vec::new(); std::io::Read::read_to_end(&mut file, &mut data).ok()?; - let ext = clean_path - .rsplit('.') - .next() - .unwrap_or("png") - .to_lowercase(); + Some((data, icon_extension(clean_path))) +} - Some((data, ext)) +/// Directory equivalent of `extract_icon_from_archive`. The icon path is +/// resolved against the extension root and kept inside it, so a manifest +/// pointing at `../../secret.png` cannot pull a file out of the folder. +fn extract_icon_from_dir(dir: &Path, manifest: &serde_json::Value) -> Option<(Vec, String)> { + let icon_path = icon_path_from_manifest(manifest)?; + let clean_path = icon_path.trim_start_matches('/'); + let resolved = resolve_inside(dir, clean_path)?; + let data = fs::read(resolved).ok()?; + Some((data, icon_extension(clean_path))) +} + +/// Join `relative` onto `root`, refusing anything that escapes `root` (via +/// `..`, an absolute component, or a symlink pointing outside). +fn resolve_inside(root: &Path, relative: &str) -> Option { + let mut out = root.to_path_buf(); + for component in Path::new(relative).components() { + match component { + std::path::Component::Normal(part) => out.push(part), + std::path::Component::CurDir => {} + _ => return None, + } + } + let canonical_root = root.canonicalize().ok()?; + let canonical_out = out.canonicalize().ok()?; + canonical_out.starts_with(&canonical_root).then_some(out) +} + +/// Ceilings for reading an unpacked extension folder. A real extension is +/// orders of magnitude under both; these exist so pointing the importer at a +/// home directory fails fast instead of exhausting memory. +const MAX_UNPACKED_FILES: usize = 20_000; +const MAX_UNPACKED_BYTES: u64 = 256 * 1024 * 1024; + +/// Never part of an extension, and `.git` in particular can dwarf the payload. +fn is_ignored_unpacked_entry(name: &str) -> bool { + name == ".git" || name == ".DS_Store" +} + +/// Chromium takes `--load-extension` as a comma-separated list with no +/// escaping, so a comma anywhere in a path silently splits it into two +/// nonexistent paths and every extension in that launch fails to load. There +/// is no way to encode it, so such a path is rejected at import. +pub(crate) fn path_is_load_extension_safe(path: &Path) -> bool { + !path.to_string_lossy().contains(',') +} + +fn err_code(code: &str) -> Box { + serde_json::json!({ "code": code }).to_string().into() +} + +/// Validate that `dir` is a loadable unpacked extension and return its parsed +/// manifest. +fn validate_unpacked_dir(dir: &Path) -> Result> { + if !dir.exists() { + return Err(err_code("EXTENSION_DIR_NOT_FOUND")); + } + if !dir.is_dir() { + return Err(err_code("EXTENSION_NOT_A_DIRECTORY")); + } + let manifest_path = dir.join("manifest.json"); + if !manifest_path.exists() { + return Err(err_code("EXTENSION_MANIFEST_MISSING")); + } + let contents = + fs::read_to_string(&manifest_path).map_err(|_| err_code("EXTENSION_MANIFEST_INVALID"))?; + serde_json::from_str(&contents).map_err(|_| err_code("EXTENSION_MANIFEST_INVALID")) +} + +/// Recursively collect an extension folder's files as (relative, absolute) +/// pairs, enforcing the size ceilings. Symlinks are not followed: an unpacked +/// extension that links outside its own root is not something to copy into the +/// store. +fn collect_unpacked_files( + dir: &Path, +) -> Result, Box> { + let mut files = Vec::new(); + let mut total_bytes: u64 = 0; + let mut stack = vec![(dir.to_path_buf(), String::new())]; + + while let Some((current, prefix)) = stack.pop() { + for entry in fs::read_dir(¤t)? { + let entry = entry?; + let name = entry.file_name().to_string_lossy().to_string(); + if is_ignored_unpacked_entry(&name) { + continue; + } + let relative = if prefix.is_empty() { + name.clone() + } else { + format!("{prefix}/{name}") + }; + + // `symlink_metadata` so a symlink is classified as a symlink rather than + // as whatever it points at. + let metadata = entry.path().symlink_metadata()?; + if metadata.is_symlink() { + continue; + } + if metadata.is_dir() { + stack.push((entry.path(), relative)); + continue; + } + + total_bytes = total_bytes.saturating_add(metadata.len()); + if total_bytes > MAX_UNPACKED_BYTES { + return Err(err_code("EXTENSION_DIR_TOO_LARGE")); + } + files.push((relative, entry.path())); + if files.len() > MAX_UNPACKED_FILES { + return Err(err_code("EXTENSION_DIR_TOO_LARGE")); + } + } + } + + Ok(files) +} + +/// Pack an unpacked extension folder into a zip in memory, so a folder import +/// becomes an ordinary archive extension everywhere downstream. +fn zip_unpacked_dir(dir: &Path) -> Result, Box> { + let files = collect_unpacked_files(dir)?; + let mut buffer = std::io::Cursor::new(Vec::new()); + { + let mut writer = zip::ZipWriter::new(&mut buffer); + let options: zip::write::FileOptions<'_, ()> = + zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Deflated); + for (relative, absolute) in files { + let data = fs::read(&absolute)?; + writer.start_file(relative, options)?; + std::io::Write::write_all(&mut writer, &data)?; + } + writer.finish()?; + } + Ok(buffer.into_inner()) +} + +/// The stored archive name for a folder import, derived from the folder name so +/// the UI and the sync key stay recognisable. +fn unpacked_archive_name(dir: &Path) -> String { + let stem = dir + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "extension".to_string()); + let sanitized: String = stem + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '-' + } + }) + .collect(); + let trimmed = sanitized.trim_matches('-'); + if trimmed.is_empty() { + "extension.zip".to_string() + } else { + format!("{trimmed}.zip") + } } pub struct ExtensionManager; @@ -297,35 +545,92 @@ impl ExtensionManager { file_data: Vec, ) -> Result> { let file_type = - get_file_type(&file_name).ok_or_else(|| format!("Unsupported file type: {file_name}"))?; + get_file_type(&file_name).ok_or_else(|| err_code("EXTENSION_UNSUPPORTED_FILE_TYPE"))?; + self.store_archive_extension( + name, + file_name, + file_data, + file_type, + SOURCE_KIND_ARCHIVE.to_string(), + ) + } + + /// Import a folder containing a top-level `manifest.json`, the "Load + /// unpacked" flow. `link` keeps the folder where it is and loads it in place + /// (edits apply on the next browser start, machine-local, never synced); + /// otherwise the folder is packed into the store so it is portable and syncs + /// like any other extension. + pub fn add_unpacked_extension( + &self, + name: String, + dir: &Path, + link: bool, + ) -> Result> { + let manifest = validate_unpacked_dir(dir)?; + + if link { + return self.store_linked_extension(name, dir, &manifest); + } + + let file_data = zip_unpacked_dir(dir)?; + self.store_archive_extension( + name, + unpacked_archive_name(dir), + file_data, + "zip".to_string(), + SOURCE_KIND_UNPACKED.to_string(), + ) + } + + /// Import an archive that already exists on disk. Used by the REST and MCP + /// surfaces, where a caller supplies a server-local path rather than bytes. + pub fn add_extension_from_path( + &self, + name: String, + path: &Path, + link: bool, + ) -> Result> { + if !path.exists() { + return Err(err_code("EXTENSION_DIR_NOT_FOUND")); + } + if path.is_dir() { + return self.add_unpacked_extension(name, path, link); + } + if link { + // Linking means "load this folder in place"; there is nothing to link to + // for a single archive file. + return Err(err_code("EXTENSION_LINK_REQUIRES_DIRECTORY")); + } + let file_name = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .ok_or_else(|| err_code("EXTENSION_UNSUPPORTED_FILE_TYPE"))?; + let data = fs::read(path)?; + self.add_extension(name, file_name, data) + } + + /// Persist a new extension whose payload is a single archive. + fn store_archive_extension( + &self, + name: String, + file_name: String, + file_data: Vec, + file_type: String, + source_kind: String, + ) -> Result> { let browser_compatibility = determine_browser_compatibility(&file_type); if browser_compatibility.is_empty() { - return Err(format!("Unsupported file type: {file_name}").into()); + return Err(err_code("EXTENSION_UNSUPPORTED_FILE_TYPE")); } let now = now_secs(); let (manifest_name, version, description, author, homepage_url) = extract_manifest_metadata(&file_data, &file_type); - // An empty/whitespace-only manifest name counts as absent so the - // user-provided name still applies. - let final_name = match manifest_name.clone() { - Some(n) if !n.trim().is_empty() => n, - _ => name, - }; - - if final_name.trim().is_empty() { - return Err( - serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" }) - .to_string() - .into(), - ); - } - let ext = Extension { id: uuid::Uuid::new_v4().to_string(), - name: final_name, + name: Self::resolve_name(name, manifest_name)?, file_name: file_name.clone(), file_type, browser_compatibility, @@ -337,6 +642,8 @@ impl ExtensionManager { description, author, homepage_url, + source_kind, + linked_path: None, }; let file_dir = self.get_file_dir(&ext.id); @@ -344,13 +651,86 @@ impl ExtensionManager { fs::write(file_dir.join(&file_name), &file_data)?; if let Some((icon_data, icon_ext)) = extract_icon_from_archive(&file_data, &ext.file_type) { - let icon_path = self - .get_extension_dir(&ext.id) - .join(format!("icon.{icon_ext}")); - let _ = fs::write(icon_path, icon_data); + self.write_icon(&ext.id, &icon_data, &icon_ext); } + self.persist_new_extension(ext) + } + + /// Persist a new extension that is loaded in place from `dir`. Nothing is + /// copied, so sync is forced off: the remote could never reconstruct a path + /// that only exists on this machine. + fn store_linked_extension( + &self, + name: String, + dir: &Path, + manifest: &serde_json::Value, + ) -> Result> { + let absolute = dir.canonicalize()?; + if !path_is_load_extension_safe(&absolute) { + return Err(err_code("EXTENSION_PATH_HAS_COMMA")); + } + + let now = now_secs(); + let (manifest_name, version, description, author, homepage_url) = + manifest_metadata(manifest, &ManifestSource::Dir(&absolute)); + + let ext = Extension { + id: uuid::Uuid::new_v4().to_string(), + name: Self::resolve_name(name, manifest_name)?, + file_name: absolute + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(), + file_type: "unpacked".to_string(), + browser_compatibility: determine_browser_compatibility("unpacked"), + created_at: now, + updated_at: now, + sync_enabled: false, + last_sync: None, + version, + description, + author, + homepage_url, + source_kind: SOURCE_KIND_UNPACKED.to_string(), + linked_path: Some(absolute.to_string_lossy().to_string()), + }; + + if let Some((icon_data, icon_ext)) = extract_icon_from_dir(&absolute, manifest) { + self.write_icon(&ext.id, &icon_data, &icon_ext); + } + + self.persist_new_extension(ext) + } + + /// A manifest name always wins over the caller-supplied one, except when it + /// is absent or blank. + fn resolve_name( + provided: String, + manifest_name: Option, + ) -> Result> { + let name = match manifest_name { + Some(n) if !n.trim().is_empty() => n, + _ => provided, + }; + if name.trim().is_empty() { + return Err(err_code("NAME_CANNOT_BE_EMPTY")); + } + Ok(name) + } + + fn write_icon(&self, ext_id: &str, data: &[u8], icon_ext: &str) { + let icon_path = self + .get_extension_dir(ext_id) + .join(format!("icon.{icon_ext}")); + let _ = fs::write(icon_path, data); + } + + fn persist_new_extension(&self, ext: Extension) -> Result> { let metadata_path = self.get_metadata_path(&ext.id); + if let Some(parent) = metadata_path.parent() { + fs::create_dir_all(parent)?; + } let json = serde_json::to_string_pretty(&ext)?; fs::write(metadata_path, json)?; @@ -412,57 +792,226 @@ impl ExtensionManager { file_data: Option>, ) -> Result> { let mut ext = self.get_extension(id)?; - - let explicit_name_provided = name.is_some(); - if let Some(new_name) = name { - ext.name = new_name; - } + let explicit_name_provided = Self::apply_name(&mut ext, name)?; if let (Some(new_file_name), Some(data)) = (file_name, file_data) { - let new_file_type = get_file_type(&new_file_name) - .ok_or_else(|| format!("Unsupported file type: {new_file_name}"))?; - - // Remove old file - let file_dir = self.get_file_dir(id); - if file_dir.exists() { - fs::remove_dir_all(&file_dir)?; - } - fs::create_dir_all(&file_dir)?; - fs::write(file_dir.join(&new_file_name), &data)?; - - ext.file_name = new_file_name; - ext.file_type = new_file_type.clone(); - ext.browser_compatibility = determine_browser_compatibility(&new_file_type); - - let (manifest_name, version, description, author, homepage_url) = - extract_manifest_metadata(&data, &new_file_type); - if let Some(v) = version { - ext.version = Some(v); - } - if let Some(d) = description { - ext.description = Some(d); - } - if let Some(a) = author { - ext.author = Some(a); - } - if let Some(h) = homepage_url { - ext.homepage_url = Some(h); - } - if let Some(mn) = manifest_name { - if !explicit_name_provided { - ext.name = mn; - } - } - - if let Some((icon_data, icon_ext)) = extract_icon_from_archive(&data, &new_file_type) { - let icon_path = self.get_extension_dir(id).join(format!("icon.{icon_ext}")); - let _ = fs::write(icon_path, icon_data); - } + let new_file_type = + get_file_type(&new_file_name).ok_or_else(|| err_code("EXTENSION_UNSUPPORTED_FILE_TYPE"))?; + self.apply_archive_payload( + &mut ext, + new_file_name, + &data, + new_file_type, + SOURCE_KIND_ARCHIVE.to_string(), + explicit_name_provided, + )?; } + self.finish_update(ext) + } + + /// Replace an extension's payload from a server-local path: a `.crx`/`.zip` + /// archive, or a folder to pack in (or to link, with `link`). This is how an + /// unpacked extension is re-imported after the source folder changes. + pub fn update_extension_from_path( + &self, + id: &str, + name: Option, + path: &Path, + link: bool, + ) -> Result> { + let mut ext = self.get_extension(id)?; + let explicit_name_provided = Self::apply_name(&mut ext, name)?; + + if !path.exists() { + return Err(err_code("EXTENSION_DIR_NOT_FOUND")); + } + + if path.is_dir() { + let manifest = validate_unpacked_dir(path)?; + if link { + self.apply_linked_payload(&mut ext, path, &manifest, explicit_name_provided)?; + } else { + let data = zip_unpacked_dir(path)?; + self.apply_archive_payload( + &mut ext, + unpacked_archive_name(path), + &data, + "zip".to_string(), + SOURCE_KIND_UNPACKED.to_string(), + explicit_name_provided, + )?; + } + } else { + if link { + return Err(err_code("EXTENSION_LINK_REQUIRES_DIRECTORY")); + } + let file_name = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .ok_or_else(|| err_code("EXTENSION_UNSUPPORTED_FILE_TYPE"))?; + let file_type = + get_file_type(&file_name).ok_or_else(|| err_code("EXTENSION_UNSUPPORTED_FILE_TYPE"))?; + let data = fs::read(path)?; + self.apply_archive_payload( + &mut ext, + file_name, + &data, + file_type, + SOURCE_KIND_ARCHIVE.to_string(), + explicit_name_provided, + )?; + } + + self.finish_update(ext) + } + + /// Returns whether the caller named the extension explicitly, which decides + /// if a manifest name may overwrite it. + fn apply_name( + ext: &mut Extension, + name: Option, + ) -> Result> { + match name { + Some(new_name) => { + if new_name.trim().is_empty() { + return Err(err_code("NAME_CANNOT_BE_EMPTY")); + } + ext.name = new_name; + Ok(true) + } + None => Ok(false), + } + } + + fn apply_archive_payload( + &self, + ext: &mut Extension, + file_name: String, + data: &[u8], + file_type: String, + source_kind: String, + explicit_name_provided: bool, + ) -> Result<(), Box> { + let browser_compatibility = determine_browser_compatibility(&file_type); + if browser_compatibility.is_empty() { + return Err(err_code("EXTENSION_UNSUPPORTED_FILE_TYPE")); + } + + let file_dir = self.get_file_dir(&ext.id); + if file_dir.exists() { + fs::remove_dir_all(&file_dir)?; + } + fs::create_dir_all(&file_dir)?; + fs::write(file_dir.join(&file_name), data)?; + + ext.file_name = file_name; + ext.file_type = file_type; + ext.browser_compatibility = browser_compatibility; + ext.source_kind = source_kind; + // Replacing the payload with stored bytes ends any link. + ext.linked_path = None; + + let (manifest_name, version, description, author, homepage_url) = + extract_manifest_metadata(data, &ext.file_type); + Self::apply_manifest_metadata( + ext, + manifest_name, + version, + description, + author, + homepage_url, + explicit_name_provided, + ); + + if let Some((icon_data, icon_ext)) = extract_icon_from_archive(data, &ext.file_type) { + self.write_icon(&ext.id, &icon_data, &icon_ext); + } + + Ok(()) + } + + fn apply_linked_payload( + &self, + ext: &mut Extension, + dir: &Path, + manifest: &serde_json::Value, + explicit_name_provided: bool, + ) -> Result<(), Box> { + let absolute = dir.canonicalize()?; + if !path_is_load_extension_safe(&absolute) { + return Err(err_code("EXTENSION_PATH_HAS_COMMA")); + } + + // A linked extension keeps no payload in the store. + let file_dir = self.get_file_dir(&ext.id); + if file_dir.exists() { + fs::remove_dir_all(&file_dir)?; + } + + ext.file_name = absolute + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + ext.file_type = "unpacked".to_string(); + ext.browser_compatibility = determine_browser_compatibility(&ext.file_type); + ext.source_kind = SOURCE_KIND_UNPACKED.to_string(); + ext.linked_path = Some(absolute.to_string_lossy().to_string()); + // The path only exists on this machine, so there is nothing to sync. + ext.sync_enabled = false; + + let (manifest_name, version, description, author, homepage_url) = + manifest_metadata(manifest, &ManifestSource::Dir(&absolute)); + Self::apply_manifest_metadata( + ext, + manifest_name, + version, + description, + author, + homepage_url, + explicit_name_provided, + ); + + if let Some((icon_data, icon_ext)) = extract_icon_from_dir(&absolute, manifest) { + self.write_icon(&ext.id, &icon_data, &icon_ext); + } + + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + fn apply_manifest_metadata( + ext: &mut Extension, + manifest_name: Option, + version: Option, + description: Option, + author: Option, + homepage_url: Option, + explicit_name_provided: bool, + ) { + if let Some(v) = version { + ext.version = Some(v); + } + if let Some(d) = description { + ext.description = Some(d); + } + if let Some(a) = author { + ext.author = Some(a); + } + if let Some(h) = homepage_url { + ext.homepage_url = Some(h); + } + if let Some(mn) = manifest_name { + if !explicit_name_provided && !mn.trim().is_empty() { + ext.name = mn; + } + } + } + + fn finish_update(&self, mut ext: Extension) -> Result> { ext.updated_at = now_secs(); - let metadata_path = self.get_metadata_path(id); + let metadata_path = self.get_metadata_path(&ext.id); let json = serde_json::to_string_pretty(&ext)?; fs::write(metadata_path, json)?; @@ -492,6 +1041,7 @@ impl ExtensionManager { if ext_dir.exists() { fs::remove_dir_all(&ext_dir)?; } + self.cleanup_staged_copies(id); // Remove from all groups let mut groups_data = self.load_groups_data()?; @@ -824,6 +1374,7 @@ impl ExtensionManager { if ext_dir.exists() { fs::remove_dir_all(&ext_dir)?; } + self.cleanup_staged_copies(id); // Remove from all groups let mut groups_data = self.load_groups_data()?; for group in &mut groups_data.groups { @@ -929,8 +1480,11 @@ impl ExtensionManager { let mut extension_paths = Vec::new(); - // Unpack Chromium extensions and return paths for --load-extension - let unpacked_base = extensions_base_dir().join("unpacked"); + // Staging is per-profile. Chromium records the absolute staging path and + // reads the extension's files lazily for the life of the process, so a + // shared directory would let one profile's launch pull the files out from + // under every browser already running. + let unpacked_base = Self::unpacked_dir_for_profile(&profile.id.to_string()); if unpacked_base.exists() { fs::remove_dir_all(&unpacked_base)?; } @@ -941,6 +1495,30 @@ impl ExtensionManager { if !ext.browser_compatibility.contains(&"chromium".to_string()) { continue; } + + // A linked extension is loaded from where the user keeps it, so there + // is nothing to stage. + if let Some(linked) = &ext.linked_path { + let linked_path = PathBuf::from(linked); + if !linked_path.join("manifest.json").exists() { + log::warn!( + "Skipping linked extension '{}': {} is no longer an extension folder", + ext.name, + linked + ); + continue; + } + if !path_is_load_extension_safe(&linked_path) { + log::warn!( + "Skipping linked extension '{}': path contains a comma, which --load-extension cannot express", + ext.name + ); + continue; + } + extension_paths.push(linked.clone()); + continue; + } + let src_file = self.get_file_dir(ext_id).join(&ext.file_name); if src_file.exists() { let unpack_dir = unpacked_base.join(ext_id); @@ -962,6 +1540,37 @@ impl ExtensionManager { Ok(extension_paths) } + fn unpacked_dir_for_profile(profile_id: &str) -> PathBuf { + extensions_base_dir().join("unpacked").join(profile_id) + } + + /// Drop a profile's staged extension copies once its browser has exited. + /// Nothing reads them after that, and they are plaintext extension code left + /// on disk. + pub fn cleanup_unpacked_for_profile(profile_id: &str) { + let dir = Self::unpacked_dir_for_profile(profile_id); + if dir.exists() { + if let Err(e) = fs::remove_dir_all(&dir) { + log::warn!("Failed to clean staged extensions for profile {profile_id}: {e}"); + } + } + } + + /// Drop every profile's staged copy of one extension, so deleting it does not + /// leave its code behind until some later launch happens to wipe the folder. + fn cleanup_staged_copies(&self, ext_id: &str) { + let base = extensions_base_dir().join("unpacked"); + let Ok(entries) = fs::read_dir(&base) else { + return; + }; + for entry in entries.filter_map(|e| e.ok()) { + let staged = entry.path().join(ext_id); + if staged.exists() { + let _ = fs::remove_dir_all(&staged); + } + } + } + fn unpack_extension( src: &std::path::Path, dest: &std::path::Path, @@ -1003,6 +1612,9 @@ impl ExtensionManager { data.windows(4).position(|window| window == magic) } + /// Backfill icons and manifest metadata for extensions stored before either + /// existed, and repair records holding an unresolved `__MSG_key__` + /// placeholder where a name or description should be. pub fn ensure_icons_extracted(&self) { let extensions = match self.list_extensions() { Ok(exts) => exts, @@ -1010,8 +1622,8 @@ impl ExtensionManager { }; for ext in extensions { - let ext_dir = self.get_extension_dir(&ext.id); - let has_icon = ext_dir + let has_icon = self + .get_extension_dir(&ext.id) .read_dir() .map(|entries| { entries @@ -1020,59 +1632,97 @@ impl ExtensionManager { }) .unwrap_or(false); - if has_icon { + // A linked extension has no stored payload; everything comes from the + // folder it is loaded from, which may since have moved. + if let Some(linked) = &ext.linked_path { + let linked_dir = PathBuf::from(linked); + let Some(manifest) = read_manifest_from_dir(&linked_dir) else { + continue; + }; + if !has_icon { + if let Some((icon_data, icon_ext)) = extract_icon_from_dir(&linked_dir, &manifest) { + self.write_icon(&ext.id, &icon_data, &icon_ext); + } + } + let metadata = manifest_metadata(&manifest, &ManifestSource::Dir(&linked_dir)); + self.backfill_metadata(&ext, metadata); continue; } - let file_dir = self.get_file_dir(&ext.id); - let file_path = file_dir.join(&ext.file_name); - if let Ok(file_data) = fs::read(&file_path) { + let file_path = self.get_file_dir(&ext.id).join(&ext.file_name); + let Ok(file_data) = fs::read(&file_path) else { + continue; + }; + + if !has_icon { if let Some((icon_data, icon_ext)) = extract_icon_from_archive(&file_data, &ext.file_type) { - let icon_path = ext_dir.join(format!("icon.{icon_ext}")); - let _ = fs::write(icon_path, icon_data); + self.write_icon(&ext.id, &icon_data, &icon_ext); } } - let needs_meta_backfill = ext.version.is_none() && ext.description.is_none(); + let metadata = extract_manifest_metadata(&file_data, &ext.file_type); + self.backfill_metadata(&ext, metadata); + } + } - if needs_meta_backfill { - let file_path = file_dir.join(&ext.file_name); - if let Ok(file_data) = fs::read(&file_path) { - let mut updated_ext = ext.clone(); - let mut changed = false; + /// Fill in metadata the stored record is missing, and replace any value that + /// is still a raw localization placeholder. Values the user can see are + /// otherwise left alone, so a rename is never undone. + fn backfill_metadata(&self, ext: &Extension, metadata: ManifestMetadata) { + fn is_placeholder(value: &str) -> bool { + crate::vpn_extension_detect::message_placeholder_key(value).is_some() + } + fn needs(current: &Option) -> bool { + current.as_deref().is_none_or(is_placeholder) + } - if needs_meta_backfill { - let (manifest_name, version, description, author, homepage_url) = - extract_manifest_metadata(&file_data, &ext.file_type); - if version.is_some() - || description.is_some() - || author.is_some() - || homepage_url.is_some() - || manifest_name.is_some() - { - if let Some(v) = version { - updated_ext.version = Some(v); - } - if let Some(d) = description { - updated_ext.description = Some(d); - } - if let Some(a) = author { - updated_ext.author = Some(a); - } - if let Some(h) = homepage_url { - updated_ext.homepage_url = Some(h); - } - changed = true; - } - } + let (manifest_name, version, description, author, homepage_url) = metadata; + let mut updated = ext.clone(); + let mut changed = false; - if changed { - let metadata_path = self.get_metadata_path(&ext.id); - if let Ok(json) = serde_json::to_string_pretty(&updated_ext) { - let _ = fs::write(metadata_path, json); - } - } - } + // The name is user-editable, so it is only touched when what is stored is + // an unresolved placeholder. + if let Some(n) = manifest_name { + if is_placeholder(&ext.name) && !n.trim().is_empty() { + updated.name = n; + changed = true; + } + } + if let Some(v) = version { + if needs(&ext.version) { + updated.version = Some(v); + changed = true; + } + } + if let Some(d) = description { + if needs(&ext.description) { + updated.description = Some(d); + changed = true; + } + } + if let Some(a) = author { + if needs(&ext.author) { + updated.author = Some(a); + changed = true; + } + } + if let Some(h) = homepage_url { + if needs(&ext.homepage_url) { + updated.homepage_url = Some(h); + changed = true; + } + } + + // A stored placeholder with no resolvable message is worse than nothing. + if updated.description.as_deref().is_some_and(is_placeholder) { + updated.description = None; + changed = true; + } + + if changed { + let metadata_path = self.get_metadata_path(&ext.id); + if let Ok(json) = serde_json::to_string_pretty(&updated) { + let _ = fs::write(metadata_path, json); } } } @@ -1136,6 +1786,20 @@ pub async fn add_extension( .map_err(|e| crate::wrap_backend_error(e, "Failed to add extension")) } +/// Import a folder holding a top-level `manifest.json`. `link` loads it in +/// place instead of copying it into the store. +#[tauri::command] +pub async fn add_unpacked_extension( + name: String, + path: String, + link: Option, +) -> Result { + let mgr = EXTENSION_MANAGER.lock().unwrap(); + mgr + .add_unpacked_extension(name, Path::new(&path), link.unwrap_or(false)) + .map_err(|e| crate::wrap_backend_error(e, "Failed to add unpacked extension")) +} + #[tauri::command] pub async fn update_extension( extension_id: String, @@ -1146,7 +1810,21 @@ pub async fn update_extension( let mgr = EXTENSION_MANAGER.lock().unwrap(); mgr .update_extension(&extension_id, name, file_name, file_data) - .map_err(|e| format!("Failed to update extension: {e}")) + .map_err(|e| crate::wrap_backend_error(e, "Failed to update extension")) +} + +/// Replace an extension's payload from a local archive or folder. +#[tauri::command] +pub async fn update_extension_from_path( + extension_id: String, + name: Option, + path: String, + link: Option, +) -> Result { + let mgr = EXTENSION_MANAGER.lock().unwrap(); + mgr + .update_extension_from_path(&extension_id, name, Path::new(&path), link.unwrap_or(false)) + .map_err(|e| crate::wrap_backend_error(e, "Failed to update extension")) } #[tauri::command] @@ -1417,6 +2095,152 @@ mod tests { assert_eq!(ExtensionManager::find_zip_start(&data), None); } + /// Write a loadable unpacked extension and return its directory. + fn write_unpacked_fixture(dir: &Path, name: &str) -> PathBuf { + fs::create_dir_all(dir).unwrap(); + fs::write( + dir.join("manifest.json"), + serde_json::json!({ + "manifest_version": 3, + "name": name, + "version": "1.0.0", + "background": { "service_worker": "background.js" } + }) + .to_string(), + ) + .unwrap(); + fs::write(dir.join("background.js"), "globalThis.__staged = true;\n").unwrap(); + dir.to_path_buf() + } + + fn profile_with_group(name: &str, group_id: &str) -> crate::profile::BrowserProfile { + crate::profile::BrowserProfile { + id: uuid::Uuid::new_v4(), + name: name.to_string(), + browser: "wayfern".to_string(), + version: "150.0.7871.100".to_string(), + extension_group_id: Some(group_id.to_string()), + ..Default::default() + } + } + + /// Staging is per profile. Chromium records the absolute staged path and + /// reads those files lazily for the life of the process, so a directory + /// shared between profiles meant launching one profile pulled the extension + /// out from under every browser already running. + #[test] + fn test_install_extensions_stages_per_profile() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = crate::app_dirs::set_test_data_dir(tmp.path().to_path_buf()); + + let mgr = ExtensionManager::new(); + let source = write_unpacked_fixture(&tmp.path().join("source-extension"), "Staged Fixture"); + let ext = mgr + .add_unpacked_extension("Ignored".to_string(), &source, false) + .unwrap(); + assert_eq!(ext.name, "Staged Fixture"); + assert_eq!(ext.source_kind, SOURCE_KIND_UNPACKED); + assert!(ext.linked_path.is_none()); + + let group = mgr.create_group("Staged Group".to_string()).unwrap(); + mgr.add_extension_to_group(&group.id, &ext.id).unwrap(); + + let first = profile_with_group("First", &group.id); + let second = profile_with_group("Second", &group.id); + let first_paths = mgr + .install_extensions_for_profile(&first, Path::new("")) + .unwrap(); + let second_paths = mgr + .install_extensions_for_profile(&second, Path::new("")) + .unwrap(); + assert_eq!(first_paths.len(), 1); + assert_eq!(second_paths.len(), 1); + assert_ne!(first_paths[0], second_paths[0]); + + for staged in [&first_paths[0], &second_paths[0]] { + assert!(Path::new(staged).join("manifest.json").exists()); + assert!(Path::new(staged).join("background.js").exists()); + } + + // Relaunching one profile rebuilds only its own copy. + let relaunched = mgr + .install_extensions_for_profile(&first, Path::new("")) + .unwrap(); + assert_eq!(relaunched, first_paths); + assert!(Path::new(&second_paths[0]).join("manifest.json").exists()); + + ExtensionManager::cleanup_unpacked_for_profile(&second.id.to_string()); + assert!(!Path::new(&second_paths[0]).exists()); + assert!(Path::new(&first_paths[0]).join("manifest.json").exists()); + } + + #[test] + fn test_linked_extension_is_loaded_in_place() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = crate::app_dirs::set_test_data_dir(tmp.path().to_path_buf()); + + let mgr = ExtensionManager::new(); + let source = write_unpacked_fixture(&tmp.path().join("linked-extension"), "Linked Fixture"); + let canonical = source.canonicalize().unwrap().to_string_lossy().to_string(); + let ext = mgr + .add_unpacked_extension("Ignored".to_string(), &source, true) + .unwrap(); + assert_eq!(ext.linked_path.as_deref(), Some(canonical.as_str())); + assert!(!ext.sync_enabled); + assert!(!mgr.get_file_dir_public(&ext.id).exists()); + + let group = mgr.create_group("Linked Group".to_string()).unwrap(); + mgr.add_extension_to_group(&group.id, &ext.id).unwrap(); + let profile = profile_with_group("Linked", &group.id); + assert_eq!( + mgr + .install_extensions_for_profile(&profile, Path::new("")) + .unwrap(), + vec![canonical.clone()] + ); + assert!( + !extensions_base_dir() + .join("unpacked") + .join(profile.id.to_string()) + .join(&ext.id) + .exists(), + "a linked extension has nothing to stage" + ); + + // Re-importing the same folder as a copy ends the link and restores the + // stored payload. + let copied = mgr + .update_extension_from_path(&ext.id, None, &source, false) + .unwrap(); + assert!(copied.linked_path.is_none()); + assert_eq!(copied.source_kind, SOURCE_KIND_UNPACKED); + assert!(mgr + .get_file_dir_public(&ext.id) + .join(&copied.file_name) + .exists()); + } + + #[test] + fn test_unpacked_import_rejects_a_folder_without_a_manifest() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = crate::app_dirs::set_test_data_dir(tmp.path().to_path_buf()); + + let mgr = ExtensionManager::new(); + let empty = tmp.path().join("not-an-extension"); + fs::create_dir_all(&empty).unwrap(); + assert!(mgr + .add_unpacked_extension("Nope".to_string(), &empty, false) + .unwrap_err() + .to_string() + .contains("EXTENSION_MANIFEST_MISSING")); + assert!(mgr + .add_unpacked_extension("Nope".to_string(), &tmp.path().join("absent"), false) + .unwrap_err() + .to_string() + .contains("EXTENSION_DIR_NOT_FOUND")); + assert!(mgr.list_extensions().unwrap().is_empty()); + } + #[test] fn test_delete_extension_removes_from_groups() { let tmp = tempfile::tempdir().unwrap(); @@ -1437,4 +2261,110 @@ mod tests { let updated_group = mgr.get_group(&group.id).unwrap(); assert!(updated_group.extension_ids.is_empty()); } + + /// Build a zip whose manifest localizes its name and description, the shape + /// uBlock Origin Lite and most Chrome Web Store extensions ship. + fn localized_extension_zip() -> Vec { + let mut buffer = std::io::Cursor::new(Vec::new()); + { + let mut writer = zip::ZipWriter::new(&mut buffer); + let options: zip::write::FileOptions<'_, ()> = zip::write::FileOptions::default(); + let manifest = serde_json::json!({ + "manifest_version": 3, + "name": "__MSG_extName__", + "description": "__MSG_extShortDesc__", + "version": "1.2.3", + "default_locale": "en" + }); + writer.start_file("manifest.json", options).unwrap(); + std::io::Write::write_all(&mut writer, manifest.to_string().as_bytes()).unwrap(); + + let messages = serde_json::json!({ + "extName": { "message": "uBlock Origin Lite" }, + "extShortDesc": { "message": "An efficient content blocker." } + }); + writer + .start_file("_locales/en/messages.json", options) + .unwrap(); + std::io::Write::write_all(&mut writer, messages.to_string().as_bytes()).unwrap(); + writer.finish().unwrap(); + } + buffer.into_inner() + } + + #[test] + fn a_localized_manifest_shows_its_real_name_not_the_placeholder() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = crate::app_dirs::set_test_data_dir(tmp.path().to_path_buf()); + + let mgr = ExtensionManager::new(); + let ext = mgr + .add_extension( + "fallback".to_string(), + "ublock.zip".to_string(), + localized_extension_zip(), + ) + .unwrap(); + + assert_eq!(ext.name, "uBlock Origin Lite"); + assert_eq!( + ext.description.as_deref(), + Some("An efficient content blocker.") + ); + assert_eq!(ext.version.as_deref(), Some("1.2.3")); + } + + #[test] + fn a_stored_placeholder_is_repaired_rather_than_shown_to_the_user() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = crate::app_dirs::set_test_data_dir(tmp.path().to_path_buf()); + + let mgr = ExtensionManager::new(); + let ext = mgr + .add_extension( + "fallback".to_string(), + "ublock.zip".to_string(), + localized_extension_zip(), + ) + .unwrap(); + + // Rewind to what older builds persisted: the raw placeholders. + let mut stale = ext.clone(); + stale.name = "__MSG_extName__".to_string(); + stale.description = Some("__MSG_extShortDesc__".to_string()); + mgr.update_extension_internal(&stale).unwrap(); + + mgr.ensure_icons_extracted(); + + let repaired = mgr.get_extension(&ext.id).unwrap(); + assert_eq!(repaired.name, "uBlock Origin Lite"); + assert_eq!( + repaired.description.as_deref(), + Some("An efficient content blocker.") + ); + } + + #[test] + fn a_user_chosen_name_survives_the_backfill() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = crate::app_dirs::set_test_data_dir(tmp.path().to_path_buf()); + + let mgr = ExtensionManager::new(); + let ext = mgr + .add_extension( + "fallback".to_string(), + "ublock.zip".to_string(), + localized_extension_zip(), + ) + .unwrap(); + + let renamed = mgr + .update_extension(&ext.id, Some("My Blocker".to_string()), None, None) + .unwrap(); + assert_eq!(renamed.name, "My Blocker"); + + mgr.ensure_icons_extracted(); + + assert_eq!(mgr.get_extension(&ext.id).unwrap().name, "My Blocker"); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1ca32c8..30931ff 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -188,9 +188,10 @@ use profile_importer::{ }; use extension_manager::{ - add_extension, add_extension_to_group, assign_extension_group_to_profile, create_extension_group, - delete_extension, delete_extension_group, get_extension_group_for_profile, get_extension_icon, - list_extension_groups, list_extensions, remove_extension_from_group, update_extension, + add_extension, add_extension_to_group, add_unpacked_extension, assign_extension_group_to_profile, + create_extension_group, delete_extension, delete_extension_group, + get_extension_group_for_profile, get_extension_icon, list_extension_groups, list_extensions, + remove_extension_from_group, update_extension, update_extension_from_path, update_extension_group, }; @@ -2750,7 +2751,9 @@ pub fn run_with_builder( list_extensions, get_extension_icon, add_extension, + add_unpacked_extension, update_extension, + update_extension_from_path, delete_extension, list_extension_groups, create_extension_group, diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index c9074a7..4ebd76a 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -1307,6 +1307,33 @@ impl McpServer { "required": [] }), }, + McpTool { + name: "add_extension".to_string(), + description: "Add a managed browser extension from a path on the machine running Donut: a .crx or .zip archive file, or an unpacked extension folder holding a top-level manifest.json. With link set to true, which only applies to a folder, the folder is loaded in place instead of being copied into Donut, so edits to it apply on the next browser start and the extension is machine-local and never synced. Requires Pro subscription.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "path": { "type": "string", "description": "Path on the machine running Donut to a .crx/.zip file or to an unpacked extension folder" }, + "name": { "type": "string", "description": "Display name, used only when the manifest carries no name of its own" }, + "link": { "type": "boolean", "description": "Folders only: load the folder in place instead of copying it into Donut. Linked extensions never sync. Defaults to false." } + }, + "required": ["path"] + }), + }, + McpTool { + name: "update_extension".to_string(), + description: "Rename a managed extension and/or replace its payload from a path on the machine running Donut: a .crx or .zip archive file, or an unpacked extension folder holding a top-level manifest.json. With link set to true, which only applies to a folder, the folder is loaded in place instead of being copied into Donut, so the extension becomes machine-local and never syncs. At least one of name or path must be given. Requires Pro subscription.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "extension_id": { "type": "string", "description": "The extension ID to update" }, + "name": { "type": "string", "description": "New display name" }, + "path": { "type": "string", "description": "Path on the machine running Donut to the .crx/.zip file or unpacked extension folder to replace the payload with" }, + "link": { "type": "boolean", "description": "Folders only: load the folder in place instead of copying it into Donut. Linked extensions never sync. Defaults to false." } + }, + "required": ["extension_id"] + }), + }, McpTool { name: "create_extension_group".to_string(), description: "Create a new extension group. Requires Pro subscription.".to_string(), @@ -1318,6 +1345,47 @@ impl McpServer { "required": ["name"] }), }, + McpTool { + name: "update_extension_group".to_string(), + description: "Rename an extension group and/or replace its membership with an exact list of extension IDs. Requires Pro subscription.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "group_id": { "type": "string", "description": "The extension group ID to update" }, + "name": { "type": "string", "description": "New name for the extension group" }, + "extension_ids": { + "type": "array", + "items": { "type": "string" }, + "description": "The complete set of extension IDs the group should contain, replacing the current membership" + } + }, + "required": ["group_id"] + }), + }, + McpTool { + name: "add_extension_to_group".to_string(), + description: "Add an extension to an extension group. Requires Pro subscription.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "group_id": { "type": "string", "description": "The extension group ID" }, + "extension_id": { "type": "string", "description": "The extension ID to add to the group" } + }, + "required": ["group_id", "extension_id"] + }), + }, + McpTool { + name: "remove_extension_from_group".to_string(), + description: "Remove an extension from an extension group. Requires Pro subscription.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "group_id": { "type": "string", "description": "The extension group ID" }, + "extension_id": { "type": "string", "description": "The extension ID to remove from the group" } + }, + "required": ["group_id", "extension_id"] + }), + }, McpTool { name: "delete_extension".to_string(), description: "Delete a managed extension. Requires Pro subscription.".to_string(), @@ -2215,7 +2283,12 @@ impl McpServer { // Extension management "list_extensions" => self.handle_list_extensions().await, "list_extension_groups" => self.handle_list_extension_groups().await, + "add_extension" => self.handle_add_extension(arguments).await, + "update_extension" => self.handle_update_extension(arguments).await, "create_extension_group" => self.handle_create_extension_group(arguments).await, + "update_extension_group" => self.handle_update_extension_group(arguments).await, + "add_extension_to_group" => self.handle_add_extension_to_group(arguments).await, + "remove_extension_from_group" => self.handle_remove_extension_from_group(arguments).await, "delete_extension" => self.handle_delete_extension_mcp(arguments).await, "delete_extension_group" => self.handle_delete_extension_group_mcp(arguments).await, "assign_extension_group_to_profile" => { @@ -4402,6 +4475,88 @@ impl McpServer { Ok(serde_json::to_value(groups).unwrap()) } + async fn handle_add_extension( + &self, + arguments: &serde_json::Value, + ) -> Result { + 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 { + if !CLOUD_AUTH.has_active_paid_subscription().await { + return Err(McpError { + code: -32000, + message: "Extension management requires an active Pro subscription".to_string(), + }); + } + let extension_id = arguments + .get("extension_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| McpError { + code: -32602, + message: "Missing required parameter: extension_id".to_string(), + })?; + let name = arguments + .get("name") + .and_then(|v| v.as_str()) + .map(str::to_string); + let path = arguments.get("path").and_then(|v| v.as_str()); + if name.is_none() && path.is_none() { + return Err(McpError { + code: -32602, + message: "Provide at least one of: name, path".to_string(), + }); + } + let link = arguments + .get("link") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); + let extension = match path { + Some(path) => { + mgr.update_extension_from_path(extension_id, name, std::path::Path::new(path), link) + } + None => mgr.update_extension(extension_id, name, None, None), + } + .map_err(|e| McpError { + code: -32000, + message: format!("Failed to update extension: {e}"), + })?; + Ok(serde_json::to_value(extension).unwrap()) + } + async fn handle_create_extension_group( &self, arguments: &serde_json::Value, @@ -4427,6 +4582,106 @@ impl McpServer { Ok(serde_json::to_value(group).unwrap()) } + async fn handle_update_extension_group( + &self, + arguments: &serde_json::Value, + ) -> Result { + 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::>() + }); + 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 { + 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 { + if !CLOUD_AUTH.has_active_paid_subscription().await { + return Err(McpError { + code: -32000, + message: "Extension management requires an active Pro subscription".to_string(), + }); + } + let (group_id, extension_id) = Self::group_and_extension_ids(arguments)?; + let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); + let group = mgr + .remove_extension_from_group(group_id, extension_id) + .map_err(|e| McpError { + code: -32000, + message: format!("Failed to remove extension from group: {e}"), + })?; + Ok(serde_json::to_value(group).unwrap()) + } + + fn group_and_extension_ids(arguments: &serde_json::Value) -> Result<(&str, &str), McpError> { + let group_id = arguments + .get("group_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| McpError { + code: -32602, + message: "Missing required parameter: group_id".to_string(), + })?; + let extension_id = arguments + .get("extension_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| McpError { + code: -32602, + message: "Missing required parameter: extension_id".to_string(), + })?; + Ok((group_id, extension_id)) + } + async fn handle_delete_extension_mcp( &self, arguments: &serde_json::Value, @@ -6035,9 +6290,9 @@ mod tests { let server = McpServer::new(); let tools = server.get_tools(); - // Should have at least 54 tools (34 + 7 browser interaction + 13 remote + // Should have at least 59 tools (39 + 7 browser interaction + 13 remote // fleet and cookie-bot tools) - assert!(tools.len() >= 54); + assert!(tools.len() >= 59); // Names are the contract an MCP client is written against, so a duplicate // silently shadows one of the two in dispatch and the tool that loses is @@ -6092,7 +6347,12 @@ mod tests { // Extension tools assert!(tool_names.contains(&"list_extensions")); assert!(tool_names.contains(&"list_extension_groups")); + assert!(tool_names.contains(&"add_extension")); + assert!(tool_names.contains(&"update_extension")); assert!(tool_names.contains(&"create_extension_group")); + assert!(tool_names.contains(&"update_extension_group")); + assert!(tool_names.contains(&"add_extension_to_group")); + assert!(tool_names.contains(&"remove_extension_from_group")); assert!(tool_names.contains(&"delete_extension")); assert!(tool_names.contains(&"delete_extension_group")); assert!(tool_names.contains(&"assign_extension_group_to_profile")); diff --git a/src-tauri/src/sync/engine.rs b/src-tauri/src/sync/engine.rs index 14d9559..897b7c5 100644 --- a/src-tauri/src/sync/engine.rs +++ b/src-tauri/src/sync/engine.rs @@ -2084,6 +2084,13 @@ impl SyncEngine { manager.get_extension(ext_id).ok() }; + // A linked extension is an absolute path on this machine with no payload in + // the store. Uploading it would publish metadata another device could never + // resolve, so it stays local whatever queued this run. + if local_ext.as_ref().is_some_and(|e| e.is_linked()) { + return Ok(()); + } + let remote_key = format!("extensions/{}.json", ext_id); let stat = self.client.stat(&remote_key).await?; @@ -3251,7 +3258,9 @@ pub async fn enable_extension_group_sync_if_needed(extension_group_id: &str) -> manager .get_extension(ext_id) .ok() - .map(|e| e.sync_enabled) + // A linked extension has no binary to hand the other device, only a + // path that means nothing there, so the cascade must not pick it up. + .map(|e| e.sync_enabled || e.is_linked()) .unwrap_or(true) }; if !already_synced { @@ -3983,7 +3992,9 @@ pub async fn enable_sync_for_all_entities(app_handle: tauri::AppHandle) -> Resul .map_err(|e| format!("Failed to list extensions: {e}"))? }; for ext in &exts { - if !ext.sync_enabled { + // Linked extensions are machine-local by definition and are skipped + // rather than reported as a failure on every sync setup. + if !ext.sync_enabled && !ext.is_linked() { if let Err(e) = set_extension_sync_enabled(app_handle.clone(), ext.id.clone(), true).await { log::warn!("Failed to enable sync for extension {}: {e}", ext.id); } @@ -4029,6 +4040,11 @@ pub async fn set_extension_sync_enabled( }; if enabled { + // A linked extension is a path on this machine and nothing else; there is + // no payload to upload and the path would be meaningless on another device. + if ext.is_linked() { + return Err(serde_json::json!({ "code": "EXTENSION_LINKED_CANNOT_SYNC" }).to_string()); + } ensure_sync_configured(&app_handle).await?; } diff --git a/src/components/extension-management-dialog.tsx b/src/components/extension-management-dialog.tsx index 1418bbb..1625728 100644 --- a/src/components/extension-management-dialog.tsx +++ b/src/components/extension-management-dialog.tsx @@ -11,6 +11,7 @@ import { } from "@tanstack/react-table"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; +import { open } from "@tauri-apps/plugin-dialog"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { FaChrome } from "react-icons/fa"; @@ -19,6 +20,8 @@ import { LuChevronDown, LuChevronUp, LuExternalLink, + LuFolderOpen, + LuLink, LuPencil, LuPuzzle, LuRefreshCw, @@ -82,6 +85,18 @@ import { RippleButton } from "./ui/ripple"; type SyncStatus = "disabled" | "syncing" | "synced" | "error" | "waiting"; +/** A payload staged in the UI, before it is handed to the backend. */ +type PendingSource = + | { kind: "archive"; fileName: string; data: number[] } + | { kind: "folder"; path: string }; + +const ARCHIVE_EXTENSIONS = [".crx", ".zip"]; + +function pathBaseName(path: string): string { + const segments = path.split(/[/\\]/).filter(Boolean); + return segments[segments.length - 1] ?? path; +} + function getSyncStatusDot( item: { sync_enabled?: boolean; last_sync?: number }, liveStatus: SyncStatus | undefined, @@ -148,14 +163,13 @@ export function ExtensionManagementDialog({ const [extensionGroups, setExtensionGroups] = useState([]); const [isLoading, setIsLoading] = useState(false); - // Extension upload state + // Extension import state const [isUploading, setIsUploading] = useState(false); const [extensionName, setExtensionName] = useState(""); - const [showUploadForm, setShowUploadForm] = useState(false); - const [pendingFile, setPendingFile] = useState<{ - name: string; - data: number[]; - } | null>(null); + const [pendingSource, setPendingSource] = useState( + null, + ); + const [linkFolder, setLinkFolder] = useState(false); // Group state const [showCreateGroup, setShowCreateGroup] = useState(false); @@ -192,10 +206,9 @@ export function ExtensionManagementDialog({ null, ); const [editExtensionName, setEditExtensionName] = useState(""); - const [pendingUpdateFile, setPendingUpdateFile] = useState<{ - name: string; - data: number[]; - } | null>(null); + const [pendingUpdateSource, setPendingUpdateSource] = + useState(null); + const [editLinkFolder, setEditLinkFolder] = useState(false); // Extension icons const [extensionIcons, setExtensionIcons] = useState>( @@ -295,6 +308,30 @@ export function ExtensionManagementDialog({ }; }, []); + /** Structured backend codes win; anything else falls back to a local message + * so the user never sees a raw Rust string. */ + const showActionError = useCallback( + (err: unknown, fallback: string) => { + showErrorToast( + parseBackendError(err) ? translateBackendError(t, err) : fallback, + ); + }, + [t], + ); + + const resetImportForm = useCallback(() => { + setPendingSource(null); + setExtensionName(""); + setLinkFolder(false); + }, []); + + const closeEditExtension = useCallback(() => { + setEditingExtension(null); + setEditExtensionName(""); + setPendingUpdateSource(null); + setEditLinkFolder(false); + }, []); + const handleToggleExtSync = useCallback( async (ext: Extension) => { setIsTogglingExtSync((prev) => ({ ...prev, [ext.id]: true })); @@ -310,16 +347,12 @@ export function ExtensionManagementDialog({ ); void loadData(); } catch (err) { - showErrorToast( - parseBackendError(err) - ? translateBackendError(t, err) - : t("proxies.management.updateSyncFailed"), - ); + showActionError(err, t("proxies.management.updateSyncFailed")); } finally { setIsTogglingExtSync((prev) => ({ ...prev, [ext.id]: false })); } }, - [loadData, t], + [loadData, showActionError, t], ); const handleToggleGroupSync = useCallback( @@ -337,119 +370,178 @@ export function ExtensionManagementDialog({ ); void loadData(); } catch (err) { - showErrorToast( - parseBackendError(err) - ? translateBackendError(t, err) - : t("proxies.management.updateSyncFailed"), - ); + showActionError(err, t("proxies.management.updateSyncFailed")); } finally { setIsTogglingGroupSync((prev) => ({ ...prev, [group.id]: false })); } }, - [loadData, t], + [loadData, showActionError, t], ); const handleUpdateExtension = useCallback(async () => { if (!editingExtension || !editExtensionName.trim()) return; try { - await invoke("update_extension", { - extensionId: editingExtension.id, - name: editExtensionName.trim(), - fileName: pendingUpdateFile?.name ?? null, - fileData: pendingUpdateFile?.data ?? null, - }); + if (pendingUpdateSource?.kind === "folder") { + await invoke("update_extension_from_path", { + extensionId: editingExtension.id, + name: editExtensionName.trim(), + path: pendingUpdateSource.path, + link: editLinkFolder, + }); + } else { + await invoke("update_extension", { + extensionId: editingExtension.id, + name: editExtensionName.trim(), + fileName: pendingUpdateSource?.fileName ?? null, + fileData: pendingUpdateSource?.data ?? null, + }); + } showSuccessToast(t("extensions.updateSuccess")); - setEditingExtension(null); - setEditExtensionName(""); - setPendingUpdateFile(null); + closeEditExtension(); void loadData(); } catch (err) { - showErrorToast(err instanceof Error ? err.message : String(err)); + showActionError(err, t("extensions.updateFailed")); } - }, [editingExtension, editExtensionName, pendingUpdateFile, loadData, t]); + }, [ + editingExtension, + editExtensionName, + pendingUpdateSource, + editLinkFolder, + closeEditExtension, + loadData, + showActionError, + t, + ]); - const handleEditFileSelect = useCallback( - (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - - const validExtensions = [".xpi", ".crx", ".zip"]; - const isValid = validExtensions.some((ext) => + /** Reads a picked archive into memory, shared by the import and the replace + * flows. Resolves to null when the file is rejected or unreadable. */ + const readArchiveFile = useCallback( + (file: File): Promise => { + const isValid = ARCHIVE_EXTENSIONS.some((ext) => file.name.toLowerCase().endsWith(ext), ); if (!isValid) { showErrorToast(t("extensions.invalidFileType")); - return; + return Promise.resolve(null); } - const reader = new FileReader(); - reader.onload = (event) => { - const arrayBuffer = event.target?.result as ArrayBuffer; - const data = Array.from(new Uint8Array(arrayBuffer)); - setPendingUpdateFile({ name: file.name, data }); - }; - reader.readAsArrayBuffer(file); - e.target.value = ""; + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = (event) => { + const arrayBuffer = event.target?.result as ArrayBuffer; + resolve({ + kind: "archive", + fileName: file.name, + data: Array.from(new Uint8Array(arrayBuffer)), + }); + }; + reader.onerror = () => { + showErrorToast(t("extensions.readError")); + resolve(null); + }; + reader.readAsArrayBuffer(file); + }); }, [t], ); + const handleEditFileSelect = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + e.target.value = ""; + if (!file) return; + + void readArchiveFile(file).then((source) => { + if (!source) return; + setPendingUpdateSource(source); + setEditLinkFolder(false); + }); + }, + [readArchiveFile], + ); + const handleFileSelect = useCallback( (e: React.ChangeEvent) => { const file = e.target.files?.[0]; + e.target.value = ""; if (!file) return; - const validExtensions = [".xpi", ".crx", ".zip"]; - const isValid = validExtensions.some((ext) => - file.name.toLowerCase().endsWith(ext), - ); - if (!isValid) { - showErrorToast(t("extensions.invalidFileType")); - return; - } - - const reader = new FileReader(); - reader.onload = (event) => { - const arrayBuffer = event.target?.result as ArrayBuffer; - const data = Array.from(new Uint8Array(arrayBuffer)); - const baseName = file.name - .replace(/\.(xpi|crx|zip)$/i, "") - .replace(/[-_]/g, " "); - setExtensionName(baseName); - setPendingFile({ name: file.name, data }); - setShowUploadForm(true); - }; - reader.onerror = () => { - showErrorToast(t("extensions.readError")); - }; - reader.readAsArrayBuffer(file); - - // Reset input - e.target.value = ""; + void readArchiveFile(file).then((source) => { + if (!source) return; + setExtensionName( + file.name.replace(/\.(crx|zip)$/i, "").replace(/[-_]/g, " "), + ); + setLinkFolder(false); + setPendingSource(source); + }); }, - [t], + [readArchiveFile], ); + /** Native directory picker, the "Load unpacked" entry point. */ + const pickExtensionFolder = useCallback(async (): Promise => { + try { + const selected = await open({ + directory: true, + multiple: false, + title: t("extensions.selectFolderTitle"), + }); + return typeof selected === "string" ? selected : null; + } catch (err) { + console.error("Failed to open folder dialog:", err); + showErrorToast(t("importProfile.folderDialogFailed")); + return null; + } + }, [t]); + + const handleLoadUnpacked = useCallback(async () => { + const folder = await pickExtensionFolder(); + if (!folder) return; + setExtensionName(pathBaseName(folder).replace(/[-_]/g, " ")); + setLinkFolder(false); + setPendingSource({ kind: "folder", path: folder }); + }, [pickExtensionFolder]); + + const handleEditFolderSelect = useCallback(async () => { + const folder = await pickExtensionFolder(); + if (!folder) return; + setPendingUpdateSource({ kind: "folder", path: folder }); + }, [pickExtensionFolder]); + const handleUpload = useCallback(async () => { - if (!pendingFile || !extensionName.trim()) return; + if (!pendingSource || !extensionName.trim()) return; setIsUploading(true); try { - await invoke("add_extension", { - name: extensionName.trim(), - fileName: pendingFile.name, - fileData: pendingFile.data, - }); + if (pendingSource.kind === "folder") { + await invoke("add_unpacked_extension", { + name: extensionName.trim(), + path: pendingSource.path, + link: linkFolder, + }); + } else { + await invoke("add_extension", { + name: extensionName.trim(), + fileName: pendingSource.fileName, + fileData: pendingSource.data, + }); + } showSuccessToast(t("extensions.uploadSuccess")); - setShowUploadForm(false); - setPendingFile(null); - setExtensionName(""); + resetImportForm(); void loadData(); } catch (err) { - showErrorToast(err instanceof Error ? err.message : String(err)); + showActionError(err, t("extensions.uploadFailed")); } finally { setIsUploading(false); } - }, [pendingFile, extensionName, loadData, t]); + }, [ + pendingSource, + extensionName, + linkFolder, + resetImportForm, + loadData, + showActionError, + t, + ]); const handleDeleteExtension = useCallback(async () => { if (!extensionToDelete) return; @@ -460,11 +552,11 @@ export function ExtensionManagementDialog({ setExtensionToDelete(null); void loadData(); } catch (err) { - showErrorToast(err instanceof Error ? err.message : String(err)); + showActionError(err, t("extensions.deleteFailed")); } finally { setIsDeleting(false); } - }, [extensionToDelete, loadData, t]); + }, [extensionToDelete, loadData, showActionError, t]); const handleCreateGroup = useCallback(async () => { if (!newGroupName.trim()) return; @@ -475,9 +567,9 @@ export function ExtensionManagementDialog({ setNewGroupName(""); void loadData(); } catch (err) { - showErrorToast(err instanceof Error ? err.message : String(err)); + showActionError(err, t("extensions.groupCreateFailed")); } - }, [newGroupName, loadData, t]); + }, [newGroupName, loadData, showActionError, t]); const handleSaveGroupEdits = useCallback(async () => { if (!editingGroup || !editGroupName.trim()) return; @@ -518,9 +610,16 @@ export function ExtensionManagementDialog({ setEditGroupExtensionIds([]); void loadData(); } catch (err) { - showErrorToast(err instanceof Error ? err.message : String(err)); + showActionError(err, t("extensions.groupUpdateFailed")); } - }, [editingGroup, editGroupName, editGroupExtensionIds, loadData, t]); + }, [ + editingGroup, + editGroupName, + editGroupExtensionIds, + loadData, + showActionError, + t, + ]); const handleDeleteGroup = useCallback(async () => { if (!groupToDelete) return; @@ -531,11 +630,11 @@ export function ExtensionManagementDialog({ setGroupToDelete(null); void loadData(); } catch (err) { - showErrorToast(err instanceof Error ? err.message : String(err)); + showActionError(err, t("extensions.groupDeleteFailed")); } finally { setIsDeleting(false); } - }, [groupToDelete, loadData, t]); + }, [groupToDelete, loadData, showActionError, t]); const selectedExtensions = useMemo( () => extensions.filter((ext) => extRowSelection[ext.id]), @@ -561,11 +660,11 @@ export function ExtensionManagementDialog({ setExtRowSelection({}); void loadData(); } catch (err) { - showErrorToast(err instanceof Error ? err.message : String(err)); + showActionError(err, t("extensions.deleteFailed")); } finally { setIsDeleting(false); } - }, [selectedExtensions, loadData, t]); + }, [selectedExtensions, loadData, showActionError, t]); const handleBulkDeleteGroups = useCallback(async () => { if (selectedGroups.length === 0) return; @@ -581,18 +680,27 @@ export function ExtensionManagementDialog({ setGroupRowSelection({}); void loadData(); } catch (err) { - showErrorToast(err instanceof Error ? err.message : String(err)); + showActionError(err, t("extensions.groupDeleteFailed")); } finally { setIsDeleting(false); } - }, [selectedGroups, loadData, t]); + }, [selectedGroups, loadData, showActionError, t]); const handleBulkToggleExtSync = useCallback(async () => { if (selectedExtensions.length === 0) return; const allOn = selectedExtensions.every((e) => e.sync_enabled); const targetEnabled = !allOn; + // A linked extension has no payload to upload, so enabling sync on one is + // refused by the backend. Skip them instead of failing the whole batch. + const targets = targetEnabled + ? selectedExtensions.filter((ext) => !ext.linked_path) + : selectedExtensions; + if (targets.length === 0) { + showErrorToast(t("extensions.linkedNoSync")); + return; + } const results = await Promise.allSettled( - selectedExtensions.map((ext) => + targets.map((ext) => invoke("set_extension_sync_enabled", { extensionId: ext.id, enabled: targetEnabled, @@ -603,10 +711,9 @@ export function ExtensionManagementDialog({ | PromiseRejectedResult | undefined; if (firstRejection) { - showErrorToast( - parseBackendError(firstRejection.reason) - ? translateBackendError(t, firstRejection.reason) - : t("proxies.management.updateSyncFailed"), + showActionError( + firstRejection.reason, + t("proxies.management.updateSyncFailed"), ); } else { showSuccessToast( @@ -616,7 +723,7 @@ export function ExtensionManagementDialog({ ); } void loadData(); - }, [selectedExtensions, loadData, t]); + }, [selectedExtensions, loadData, showActionError, t]); const handleBulkToggleGroupSync = useCallback(async () => { if (selectedGroups.length === 0) return; @@ -634,10 +741,9 @@ export function ExtensionManagementDialog({ | PromiseRejectedResult | undefined; if (firstRejection) { - showErrorToast( - parseBackendError(firstRejection.reason) - ? translateBackendError(t, firstRejection.reason) - : t("proxies.management.updateSyncFailed"), + showActionError( + firstRejection.reason, + t("proxies.management.updateSyncFailed"), ); } else { showSuccessToast( @@ -647,7 +753,7 @@ export function ExtensionManagementDialog({ ); } void loadData(); - }, [selectedGroups, loadData, t]); + }, [selectedGroups, loadData, showActionError, t]); const renderCompatIcons = useCallback( (compat: string[]) => { @@ -691,6 +797,42 @@ export function ExtensionManagementDialog({ [extensionIcons], ); + /** What the extension actually is: a stored archive, a folder packed into + * the store, or a folder loaded in place from the user's disk. */ + const renderSource = useCallback( + (ext: Extension) => { + if (ext.linked_path) { + return ( + + + + + + {t("extensions.source.linked")} + + + + +

+ {t("extensions.source.linkedTooltip", { + path: ext.linked_path, + })} +

+
+
+ ); + } + return ( + + {ext.source_kind === "unpacked" + ? t("extensions.source.unpacked") + : t("extensions.source.archive")} + + ); + }, + [t], + ); + const MAX_VISIBLE_ICONS = 3; const extensionColumns = useMemo[]>( @@ -762,6 +904,13 @@ export function ExtensionManagementDialog({ cell: ({ row }) => renderCompatIcons(row.original.browser_compatibility), }, + { + id: "source", + size: 128, + enableSorting: false, + header: () => null, + cell: ({ row }) => renderSource(row.original), + }, { id: "sync", size: 88, @@ -770,6 +919,7 @@ export function ExtensionManagementDialog({ cell: ({ row }) => { const ext = row.original; const syncDot = getSyncStatusDot(ext, extSyncStatus[ext.id], t); + const isLinked = Boolean(ext.linked_path); return (
@@ -790,15 +940,17 @@ export function ExtensionManagementDialog({ void handleToggleExtSync(ext)} - disabled={isTogglingExtSync[ext.id]} + disabled={isLinked || isTogglingExtSync[ext.id]} />

- {ext.sync_enabled - ? t("syncTooltips.disable") - : t("syncTooltips.enable")} + {isLinked + ? t("extensions.linkedNoSync") + : ext.sync_enabled + ? t("syncTooltips.disable") + : t("syncTooltips.enable")}

@@ -824,7 +976,8 @@ export function ExtensionManagementDialog({ onClick={() => { setEditingExtension(ext); setEditExtensionName(ext.name); - setPendingUpdateFile(null); + setPendingUpdateSource(null); + setEditLinkFolder(Boolean(ext.linked_path)); }} > @@ -859,6 +1012,7 @@ export function ExtensionManagementDialog({ handleToggleExtSync, renderExtensionIcon, renderCompatIcons, + renderSource, ], ); @@ -1160,25 +1314,48 @@ export function ExtensionManagementDialog({
{activeTab === "extensions" && ( - - - - document.getElementById("ext-file-input")?.click() - } - aria-label={t("extensions.upload")} - > - - - {t("extensions.upload")} - - - - {t("extensions.upload")} - + <> + + + + document.getElementById("ext-file-input")?.click() + } + aria-label={t("extensions.upload")} + > + + + {t("extensions.upload")} + + + + + {t("extensions.upload")} + + + + + void handleLoadUnpacked()} + aria-label={t("extensions.loadUnpacked")} + > + + + {t("extensions.loadUnpacked")} + + + + + {t("extensions.loadUnpackedTooltip")} + + + )} {activeTab === "groups" && ( @@ -1216,21 +1393,51 @@ export function ExtensionManagementDialog({ - {/* Upload form */} - {showUploadForm && pendingFile && ( + {/* Import form */} + {pendingSource && (
- {t("extensions.selectedFile")}:{" "} - - {pendingFile.name} + {pendingSource.kind === "folder" + ? t("extensions.selectedFolder") + : t("extensions.selectedFile")} + :{" "} + + {pendingSource.kind === "folder" + ? pendingSource.path + : pendingSource.fileName}
+ {pendingSource.kind === "folder" && ( +
+ { + setLinkFolder(value === true); + }} + className="mt-0.5" + /> +
+ +

+ {linkFolder + ? t("extensions.linkFolderOn") + : t("extensions.linkFolderOff")} +

+
+
+ )}
{ - setShowUploadForm(false); - setPendingFile(null); - setExtensionName(""); - }} + onClick={resetImportForm} > {t("common.buttons.cancel")} @@ -1611,11 +1814,7 @@ export function ExtensionManagementDialog({ { - if (!open) { - setEditingExtension(null); - setEditExtensionName(""); - setPendingUpdateFile(null); - } + if (!open) closeEditExtension(); }} > @@ -1684,9 +1883,35 @@ export function ExtensionManagementDialog({ )}
- {t("common.labels.type")} + {t("extensions.source.label")} - .{editingExtension.file_type} + + {editingExtension.linked_path + ? t("extensions.source.linked") + : editingExtension.source_kind === "unpacked" + ? t("extensions.source.unpacked") + : t("extensions.source.archive")} + + {editingExtension.linked_path ? ( + <> + + {t("extensions.source.folderLabel")} + + + {editingExtension.linked_path} + +

+ {t("extensions.linkFolderOn")} +

+ + ) : ( + <> + + {t("common.labels.type")} + + .{editingExtension.file_type} + + )} {editingExtension.homepage_url && ( <> @@ -1716,10 +1941,10 @@ export function ExtensionManagementDialog({
- {/* Re-upload */} + {/* Replace the payload with another archive or folder */}
- -
+ +
- {pendingUpdateFile && ( + void handleEditFolderSelect()} + > + + {t("extensions.selectFolder")} + + {pendingUpdateSource && ( - {pendingUpdateFile.name} + {pendingUpdateSource.kind === "folder" + ? pendingUpdateSource.path + : pendingUpdateSource.fileName} )}
+ {pendingUpdateSource?.kind === "folder" && ( +
+ { + setEditLinkFolder(value === true); + }} + className="mt-0.5" + /> +
+ +

+ {editLinkFolder + ? t("extensions.linkFolderOn") + : t("extensions.linkFolderOff")} +

+
+
+ )}
)} -