Add browser extension uninstall controls

Expose extension deletion from the Browser internal settings page and keep the compact Browser dropdown focused on quick enable/install actions.\n\nAdd a guarded uninstall API that only deletes Browser-managed extension folders, updates enabled extension paths, refreshes the settings UI, and covers managed versus external paths with regression tests.
This commit is contained in:
Alessandro 2026-05-02 17:05:33 +02:00
parent 39a96012f9
commit 9fc3ff20a4
5 changed files with 258 additions and 10 deletions

View file

@ -16,6 +16,7 @@ from plugins._browser.helpers.extension_manager import (
install_chrome_web_store_extension,
list_browser_extensions,
set_browser_extension_enabled,
uninstall_browser_extension,
)
@ -47,6 +48,16 @@ class Extensions(ApiHandler):
return {"ok": False, "error": str(exc)}
return self._browser_extension_payload(agent=agent)
if action == "uninstall_extension":
try:
result = uninstall_browser_extension(str(input.get("path", "")))
except ValueError as exc:
return {"ok": False, "error": str(exc)}
return {
**self._browser_extension_payload(agent=agent),
**result,
}
if action == "set_model_preset":
preset_name = str(input.get(MODEL_PRESET_KEY, "") or "").strip()
available_presets = {
@ -83,6 +94,7 @@ class Extensions(ApiHandler):
"ok": True,
"root": str(get_extensions_root()),
"extensions": list_browser_extensions(),
"extension_paths": config["extension_paths"],
DEFAULT_HOMEPAGE_KEY: config[DEFAULT_HOMEPAGE_KEY],
AUTOFOCUS_ACTIVE_PAGE_KEY: config[AUTOFOCUS_ACTIVE_PAGE_KEY],
MODEL_PRESET_KEY: config[MODEL_PRESET_KEY],

View file

@ -134,6 +134,45 @@ def set_browser_extension_enabled(extension_path: str, enabled: bool) -> dict[st
return config
def uninstall_browser_extension(extension_path: str) -> dict[str, Any]:
raw_path = str(extension_path or "").strip()
if not raw_path:
raise ValueError("Choose an extension first.")
root = get_extensions_root().resolve()
extension_dir = Path(raw_path).expanduser().resolve()
if extension_dir == root or not extension_dir.is_relative_to(root):
raise ValueError("Only Browser-managed extension folders can be deleted.")
if not extension_dir.is_dir():
raise ValueError("Extension folder was not found.")
manifest = _read_manifest(extension_dir)
name = (
_manifest_label(extension_dir, manifest, "name")
or _manifest_label(extension_dir, manifest, "short_name")
or extension_dir.name
)
config = get_browser_config()
config["extension_paths"] = [
path
for path in config["extension_paths"]
if Path(path).expanduser().resolve() != extension_dir
]
try:
shutil.rmtree(extension_dir)
except OSError as exc:
raise ValueError(f"Could not delete extension folder: {exc}") from exc
plugins.save_plugin_config(PLUGIN_NAME, "", "", config)
return {
"ok": True,
"name": name,
"path": str(extension_dir),
"extension_paths": config["extension_paths"],
}
def _download_crx(extension_id: str, archive_path: Path) -> None:
prodversion = _detect_chrome_prodversion()
url = _build_web_store_download_url(extension_id, prodversion=prodversion)
@ -261,6 +300,7 @@ def _enable_extension_path(extension_path: Path) -> dict[str, Any]:
def _extension_entry(extension_dir: Path, enabled_paths: set[str]) -> dict[str, Any]:
manifest = _read_manifest(extension_dir)
extension_path = str(extension_dir)
can_delete = _is_managed_extension_dir(extension_dir)
name = (
_manifest_label(extension_dir, manifest, "name")
or _manifest_label(extension_dir, manifest, "short_name")
@ -273,9 +313,20 @@ def _extension_entry(extension_dir: Path, enabled_paths: set[str]) -> dict[str,
"version": manifest.get("version") or "",
"path": extension_path,
"enabled": extension_path in enabled_paths,
"managed": can_delete,
"can_delete": can_delete,
}
def _is_managed_extension_dir(extension_dir: Path) -> bool:
try:
root = get_extensions_root().resolve()
path = extension_dir.expanduser().resolve()
except OSError:
return False
return path != root and path.is_relative_to(root)
def _read_manifest(extension_path: Path) -> dict[str, Any]:
manifest_path = extension_path / "manifest.json"
try:

View file

@ -43,6 +43,8 @@ export const store = createStore("browserConfig", {
extensionsList: [],
extensionsLoading: false,
extensionsError: "",
extensionsMessage: "",
extensionDeleteLoadingPath: "",
async init(config) {
this.bindConfig(config);
@ -53,6 +55,8 @@ export const store = createStore("browserConfig", {
this.config = null;
this.extensionsList = [];
this.extensionsError = "";
this.extensionsMessage = "";
this.extensionDeleteLoadingPath = "";
},
bindConfig(config) {
@ -99,7 +103,7 @@ export const store = createStore("browserConfig", {
if (!response?.ok) {
throw new Error(response?.error || "Could not load browser extensions.");
}
this.extensionsList = Array.isArray(response.extensions) ? response.extensions : [];
this.applyExtensionPayload(response);
} catch (error) {
this.extensionsList = [];
this.extensionsError = error instanceof Error ? error.message : String(error);
@ -108,6 +112,13 @@ export const store = createStore("browserConfig", {
}
},
applyExtensionPayload(response = {}) {
this.extensionsList = Array.isArray(response.extensions) ? response.extensions : [];
if (Array.isArray(response.extension_paths) && this.config) {
this.config.extension_paths = normalizePathList(response.extension_paths);
}
},
extensionEnabled(extension) {
const path = typeof extension === "string" ? extension : extension?.path;
return normalizePathList(this.config?.extension_paths).includes(String(path || ""));
@ -128,6 +139,48 @@ export const store = createStore("browserConfig", {
safeConfig.extension_paths = paths;
},
extensionCanDelete(extension) {
return Boolean(extension?.can_delete);
},
extensionDeleteTitle(extension) {
return this.extensionCanDelete(extension)
? "Delete extension"
: "Only Browser-managed extensions can be deleted";
},
async deleteExtension(extension) {
const path = String(extension?.path || "").trim();
if (!path) return;
this.extensionsError = "";
this.extensionsMessage = "";
if (!this.extensionCanDelete(extension)) {
this.extensionsError = "Only Browser-managed extensions can be deleted.";
return;
}
const name = String(extension?.name || "this extension").trim();
if (globalThis.confirm && !globalThis.confirm(`Delete ${name}? This removes the extension folder from Browser.`)) {
return;
}
this.extensionDeleteLoadingPath = path;
try {
const response = await callJsonApi(BROWSER_EXTENSIONS_API, {
action: "uninstall_extension",
path,
});
if (!response?.ok) {
throw new Error(response?.error || "Could not delete extension.");
}
this.applyExtensionPayload(response);
this.extensionsMessage = `Deleted ${response.name || name}.`;
} catch (error) {
this.extensionsError = error instanceof Error ? error.message : String(error);
} finally {
this.extensionDeleteLoadingPath = "";
}
},
extensionVersionLabel(extension) {
const version = String(extension?.version || "").trim();
return version ? `v${version}` : "Unpacked extension";

View file

@ -73,21 +73,42 @@
<div class="browser-config-empty">No installed extensions found.</div>
</template>
<template x-for="extension in $store.browserConfig.extensionsList" :key="extension.path">
<label class="browser-config-extension-row" :title="extension.path">
<div class="browser-config-extension-row" :title="extension.path">
<span class="browser-config-extension-text">
<span class="browser-config-extension-name" x-text="extension.name || 'Unnamed extension'"></span>
<span class="browser-config-extension-meta" x-text="$store.browserConfig.extensionVersionLabel(extension)"></span>
</span>
<span class="browser-config-toggle">
<input
type="checkbox"
:checked="$store.browserConfig.extensionEnabled(extension)"
@change="$store.browserConfig.setExtensionEnabled(extension, $event.target.checked)"
/>
<span class="browser-config-switch"></span>
<span class="browser-config-extension-actions">
<label class="browser-config-toggle">
<input
type="checkbox"
:checked="$store.browserConfig.extensionEnabled(extension)"
:disabled="$store.browserConfig.extensionDeleteLoadingPath === extension.path"
@change="$store.browserConfig.setExtensionEnabled(extension, $event.target.checked)"
/>
<span class="browser-config-switch"></span>
</label>
<button
type="button"
class="browser-config-extension-delete"
:disabled="!$store.browserConfig.extensionCanDelete(extension) || $store.browserConfig.extensionDeleteLoadingPath === extension.path"
:title="$store.browserConfig.extensionDeleteTitle(extension)"
:aria-label="'Delete ' + (extension.name || 'extension')"
@click.stop="$store.browserConfig.deleteExtension(extension)"
>
<span
class="material-symbols-outlined"
:class="{ spinning: $store.browserConfig.extensionDeleteLoadingPath === extension.path }"
x-text="$store.browserConfig.extensionDeleteLoadingPath === extension.path ? 'progress_activity' : 'delete'"
></span>
</button>
</span>
</label>
</div>
</template>
<div class="browser-config-note browser-config-success" x-show="$store.browserConfig.extensionsMessage">
<span class="material-symbols-outlined">check_circle</span>
<span x-text="$store.browserConfig.extensionsMessage"></span>
</div>
<div class="browser-config-note" x-show="$store.browserConfig.extensionsError">
<span class="material-symbols-outlined">error</span>
<span x-text="$store.browserConfig.extensionsError"></span>
@ -238,6 +259,12 @@
font-weight: 650;
}
.browser-config-extension-actions {
display: inline-flex;
align-items: center;
gap: 8px;
}
.browser-config-toggle {
position: relative;
display: inline-flex;
@ -255,6 +282,10 @@
cursor: pointer;
}
.browser-config-toggle input:disabled {
cursor: wait;
}
.browser-config-switch {
width: 100%;
height: 100%;
@ -285,6 +316,40 @@
transform: translateX(18px);
}
.browser-config-toggle input:disabled + .browser-config-switch {
opacity: 0.58;
}
.browser-config-extension-delete {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
min-width: 28px;
height: 28px;
min-height: 28px;
padding: 0;
border: 1px solid color-mix(in srgb, #be123c 30%, var(--color-border));
border-radius: 7px;
background: transparent;
color: color-mix(in srgb, #be123c 78%, var(--color-text));
cursor: pointer;
}
.browser-config-extension-delete:hover:not(:disabled) {
background: color-mix(in srgb, #be123c 12%, var(--color-background));
color: #be123c;
}
.browser-config-extension-delete:disabled {
cursor: not-allowed;
opacity: 0.48;
}
.browser-config-extension-delete .material-symbols-outlined {
font-size: 17px;
}
.browser-config-note {
display: flex;
align-items: flex-start;
@ -296,6 +361,11 @@
font-size: var(--font-size-small);
}
.browser-config-note.browser-config-success {
background: color-mix(in srgb, #15803d 12%, var(--color-background));
color: color-mix(in srgb, var(--color-text) 88%, #166534);
}
.browser-config-warning {
display: flex;
align-items: flex-start;

View file

@ -1,4 +1,5 @@
import asyncio
import json
import sys
import threading
from pathlib import Path
@ -92,6 +93,7 @@ from plugins._browser.helpers.extension_manager import (
_normalize_chrome_prodversion,
get_extensions_root,
parse_chrome_web_store_extension_id,
uninstall_browser_extension,
)
import plugins._browser.helpers.extension_manager as browser_extension_manager_module
from plugins._browser.helpers.runtime import (
@ -289,6 +291,59 @@ def test_browser_extension_storage_uses_plugin_user_path(monkeypatch, tmp_path):
assert get_extensions_root() == tmp_path / "usr" / "plugins" / "_browser" / "extensions"
def test_browser_extension_manager_uninstalls_only_managed_extensions(monkeypatch, tmp_path):
monkeypatch.setattr(
browser_extension_manager_module.files,
"get_abs_path",
lambda *parts: str(tmp_path.joinpath(*parts)),
)
managed_extension = get_extensions_root() / "chrome-web-store" / ("a" * 32)
external_extension = tmp_path / "external-extension"
for extension_dir, name in (
(managed_extension, "Managed Extension"),
(external_extension, "External Extension"),
):
extension_dir.mkdir(parents=True)
(extension_dir / "manifest.json").write_text(
json.dumps({"name": name, "version": "1.0.0"}),
encoding="utf-8",
)
saved_configs = []
monkeypatch.setattr(
browser_extension_manager_module,
"get_browser_config",
lambda: {
"extension_paths": [str(managed_extension), str(external_extension)],
},
)
monkeypatch.setattr(
browser_extension_manager_module.plugins,
"save_plugin_config",
lambda _plugin, _project, _agent, config: saved_configs.append(config.copy()),
)
entries = browser_extension_manager_module.list_browser_extensions()
managed_entry = next(item for item in entries if item["path"] == str(managed_extension))
external_entry = next(item for item in entries if item["path"] == str(external_extension))
assert managed_entry["can_delete"] is True
assert managed_entry["managed"] is True
assert external_entry["can_delete"] is False
result = uninstall_browser_extension(str(managed_extension))
assert result["name"] == "Managed Extension"
assert result["extension_paths"] == [str(external_extension)]
assert not managed_extension.exists()
assert external_extension.exists()
assert saved_configs[-1]["extension_paths"] == [str(external_extension)]
with pytest.raises(ValueError, match="Only Browser-managed"):
uninstall_browser_extension(str(external_extension))
assert external_extension.exists()
def test_browser_extension_manager_parses_web_store_urls():
extension_id = "a" * 32
@ -343,6 +398,7 @@ def test_browser_extension_menu_exposes_agent_and_url_paths():
assert "Browser LLM Preset" in html
assert "Chrome Extensions" in html
assert "Installed extensions" in html
assert "deleteExtension(extension)" not in html
assert "No extensions installed yet." not in html
assert "Browser Extension Settings" not in html
assert "<span>Settings</span>" in html
@ -640,9 +696,15 @@ def test_browser_extension_settings_stay_user_facing():
config_html = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "config.html").read_text(
encoding="utf-8"
)
config_store = (
PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-config-store.js"
).read_text(encoding="utf-8")
assert "Choose which installed Chrome extensions Browser loads." in config_html
assert "Installed extensions" in config_html
assert "extensionDeleteTitle(extension)" in config_html
assert "deleteExtension(extension)" in config_html
assert "Delete extension" in config_store
assert "<textarea" not in config_html
assert "Enabled extension directories" not in config_html
assert "Chrome Web Store URL installs" not in config_html