Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions plugins/_browser/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
- Annotation voice input reuses Whisper STT's configured draft/send delivery mode and shared microphone state.
- Internal-browser proxy settings map directly to Playwright's persistent-context proxy option, never to Bring Your Own Browser, and changes must restart active internal runtimes.
- Run internal Chromium headful through Patchright on the private virtual display; do not add user-agent or header spoofing on top of the patched driver.
- Browser keyboard layout settings (`keyboard_layout`/`keyboard_variant`, e.g. `de`/`mac`) apply the configured XKB layout to the private browser display with setxkbmap and pin it on the Xpra shadow server so non-US keyboards type their printed characters; layout changes flow through `browser_runtime_config` and restart internal runtimes. Fallback canvas input forwards AltGraph and macOS Option text without converting ordinary Alt shortcuts into text.
- Browser startup and on-demand launch must converge on the Chromium revision declared by Patchright; let its installer select the host architecture rather than hardcoding x64 or ARM downloads.
- `hooks.prepare_playwright_cache()` owns reconciliation of the pinned Patchright package and Chromium binary so repository self-updates and fresh images use the same setup path.
- Browser startup must install the shared virtual-desktop route hook itself; do not make Browser depend on the Desktop plugin being enabled.
Expand Down
6 changes: 6 additions & 0 deletions plugins/_browser/default_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,9 @@ host_browser_selection: ""
# Optional _model_config preset used by Browser-owned model helpers.
# Empty uses the effective Main Model.
model_preset: ""

# XKB keyboard layout applied to the interactive Browser display so typed keys
# match the physical keyboard. German Mac example: keyboard_layout: "de",
# keyboard_variant: "mac" (Option+L then types "@"). Empty keeps US behavior.
keyboard_layout: ""
keyboard_variant: ""
16 changes: 16 additions & 0 deletions plugins/_browser/helpers/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
PROXY_BYPASS_KEY = "proxy_bypass"
PROXY_USERNAME_KEY = "proxy_username"
PROXY_PASSWORD_KEY = "proxy_password"
KEYBOARD_LAYOUT_KEY = "keyboard_layout"
KEYBOARD_VARIANT_KEY = "keyboard_variant"
RUNTIME_BACKENDS = {"container", "host_required"}
BROWSER_TAB_SCOPES = {"per_context", "shared"}
HOST_BROWSER_PRIVACY_POLICIES = {"enforce_local", "warn", "allow"}
Expand Down Expand Up @@ -100,6 +102,12 @@ def _normalize_int(value: Any, *, default: int, minimum: int, maximum: int) -> i
return max(minimum, min(maximum, number))


def _normalize_xkb_token(value: Any) -> str:
return "".join(
ch for ch in str(value or "").strip().lower() if ch.isalnum() or ch in {"_", "-"}
)[:32]


def _normalize_choice(value: Any, *, allowed: set[str], default: str) -> str:
normalized = str(value or "").strip().lower().replace("-", "_")
if normalized in allowed:
Expand All @@ -125,6 +133,10 @@ def _model_config_summary(config: dict[str, Any] | None) -> str:
def normalize_browser_config(settings: dict[str, Any] | None) -> dict[str, Any]:
raw = settings if isinstance(settings, dict) else {}
extension_paths = _normalize_extension_paths(raw.get("extension_paths", []))
keyboard_layout = _normalize_xkb_token(raw.get(KEYBOARD_LAYOUT_KEY, ""))
keyboard_variant = (
_normalize_xkb_token(raw.get(KEYBOARD_VARIANT_KEY, "")) if keyboard_layout else ""
)
return {
"extension_paths": extension_paths,
DEFAULT_HOMEPAGE_KEY: _normalize_default_homepage(
Expand Down Expand Up @@ -165,6 +177,8 @@ def normalize_browser_config(settings: dict[str, Any] | None) -> dict[str, Any]:
PROXY_BYPASS_KEY: str(raw.get(PROXY_BYPASS_KEY, "") or "").strip()[:4096],
PROXY_USERNAME_KEY: str(raw.get(PROXY_USERNAME_KEY, "") or "")[:1024],
PROXY_PASSWORD_KEY: str(raw.get(PROXY_PASSWORD_KEY, "") or "")[:4096],
KEYBOARD_LAYOUT_KEY: keyboard_layout,
KEYBOARD_VARIANT_KEY: keyboard_variant,
MODEL_PRESET_KEY: _normalize_model_preset(raw.get(MODEL_PRESET_KEY, "")),
}

Expand All @@ -177,6 +191,8 @@ def browser_runtime_config(settings: dict[str, Any] | None) -> dict[str, Any]:
PROXY_BYPASS_KEY: config[PROXY_BYPASS_KEY],
PROXY_USERNAME_KEY: config[PROXY_USERNAME_KEY],
PROXY_PASSWORD_KEY: config[PROXY_PASSWORD_KEY],
KEYBOARD_LAYOUT_KEY: config[KEYBOARD_LAYOUT_KEY],
KEYBOARD_VARIANT_KEY: config[KEYBOARD_VARIANT_KEY],
}


Expand Down
54 changes: 54 additions & 0 deletions plugins/_browser/helpers/interactive_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,21 @@
START_TIMEOUT_SECONDS = 15.0


def keyboard_options() -> dict[str, str]:
"""Resolve the configured XKB keyboard layout for browser displays."""
from plugins._browser.helpers.config import (
KEYBOARD_LAYOUT_KEY,
KEYBOARD_VARIANT_KEY,
get_browser_config,
)

config = get_browser_config()
return {
"layout": str(config.get(KEYBOARD_LAYOUT_KEY, "") or "").strip(),
"variant": str(config.get(KEYBOARD_VARIANT_KEY, "") or "").strip(),
}


def collect_status() -> dict[str, Any]:
binaries = {
name: shutil.which(name) or ""
Expand Down Expand Up @@ -114,6 +129,7 @@ def ensure_display(self) -> str:

self._xvfb = process
self.display = int(display_number)
self._apply_keyboard_layout()
self.resize(self.width, self.height)
return self.display_name

Expand Down Expand Up @@ -190,6 +206,43 @@ def close(self) -> None:
self._stop_locked()
shutil.rmtree(self.state_dir, ignore_errors=True)

def _apply_keyboard_layout(self) -> None:
"""Apply the configured XKB layout to this private display."""
if self.display is None:
return
options = keyboard_options()
if not options["layout"]:
return
setxkbmap = shutil.which("setxkbmap")
if not setxkbmap:
return
command = [setxkbmap, "-display", self.display_name, "-layout", options["layout"]]
if options["variant"]:
command.extend(["-variant", options["variant"]])
try:
subprocess.run(
command,
check=False,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=5,
)
except (OSError, subprocess.TimeoutExpired):
pass

def _keyboard_xpra_args(self) -> list[str]:
options = keyboard_options()
if not options["layout"]:
return []
args = [
"--keyboard-sync=no",
"--keyboard-layout", options["layout"],
]
if options["variant"]:
args.extend(["--keyboard-variant", options["variant"]])
return args

def _start_xpra(self, xpra: str) -> None:
self.port = self._free_port()
runtime_dir = self.state_dir / "runtime"
Expand Down Expand Up @@ -227,6 +280,7 @@ def _start_xpra(self, xpra: str) -> None:
"--encoding=auto",
"--quality=90",
"--speed=90",
*self._keyboard_xpra_args(),
f"--bind-tcp=127.0.0.1:{self.port}",
f"--socket-dir={socket_dir}",
f"--log-dir={self.state_dir}",
Expand Down
10 changes: 10 additions & 0 deletions plugins/_browser/webui/browser-config-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ function ensureConfig(config) {
config.proxy_bypass = String(config.proxy_bypass || "").trim();
config.proxy_username = String(config.proxy_username || "");
config.proxy_password = String(config.proxy_password || "");
config.keyboard_layout = normalizeXkbToken(config.keyboard_layout);
config.keyboard_variant = normalizeXkbToken(config.keyboard_variant);
config.host_browser_privacy_policy = normalizeChoice(
config.host_browser_privacy_policy,
HOST_PRIVACY_POLICIES,
Expand Down Expand Up @@ -68,6 +70,14 @@ function normalizeInt(value, fallback, minimum, maximum) {
return Math.max(minimum, Math.min(maximum, number));
}

function normalizeXkbToken(value) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]/g, "")
.slice(0, 32);
}

function normalizeRuntimeBackend(value) {
const normalized = String(value || "").trim().toLowerCase().replace(/-/g, "_");
if (normalized === "host_when_available") return "host_required";
Expand Down
21 changes: 19 additions & 2 deletions plugins/_browser/webui/browser-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,22 @@ function isLocalEditableTarget(target) {
return ["", "true", "plaintext-only"].includes(value);
}

function isAltTextInput(event, platform = "") {
const key = String(event?.key || "");
if (key.length !== 1 || !event?.altKey || event.metaKey) return false;
const targetPlatform = String(
platform
|| globalThis.navigator?.userAgentData?.platform
|| globalThis.navigator?.platform
|| "",
);
return Boolean(
event.ctrlKey
|| event.getModifierState?.("AltGraph")
|| /mac/i.test(targetPlatform),
);
}

function nextAnimationFrame() {
return new Promise((resolve) => {
const schedule = globalThis.requestAnimationFrame || ((callback) => globalThis.setTimeout(callback, 16));
Expand Down Expand Up @@ -2908,10 +2924,11 @@ const model = {
if (this.annotating) return;
const contextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId);
if (!contextId || !this.activeBrowserId) return;
if (event.ctrlKey || event.metaKey || event.altKey) return;
const printable = event.key && event.key.length === 1;
const altText = isAltTextInput(event);
if ((event.ctrlKey || event.metaKey || event.altKey) && !altText) return;
if (isLocalEditableTarget(event?.target)) return;
event.preventDefault();
const printable = event.key && event.key.length === 1;
await websocket.emit("browser_viewer_input", {
context_id: contextId,
browser_id: this.activeBrowserId,
Expand Down
31 changes: 31 additions & 0 deletions plugins/_browser/webui/config.html
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,37 @@
</span>
</label>

<label class="browser-config-field">
<span class="browser-config-field-label">Keyboard layout</span>
<input
type="text"
x-model="$store.browserConfig.config.keyboard_layout"
placeholder="de, fr, us, ..."
autocomplete="off"
/>
<span class="browser-config-field-help">
XKB layout of the interactive Browser window, e.g. de for a German keyboard. Match your physical keyboard so characters like @, umlauts, and brackets land on the printed keys. Empty keeps US.
<a href="https://man.archlinux.org/man/xkeyboard-config.7#LAYOUTS" target="_blank" rel="noreferrer">Full layout list</a>. Changing it restarts the internal browser on save.
</span>
</label>

<label
class="browser-config-field"
x-show="$store.browserConfig.config.keyboard_layout"
>
<span class="browser-config-field-label">Keyboard variant</span>
<input
type="text"
x-model="$store.browserConfig.config.keyboard_variant"
placeholder="mac, nodeadkeys, colemak, ..."
autocomplete="off"
/>
<span class="browser-config-field-help">
XKB variant for the layout above. German Mac keyboards use mac, so Option+L types @.
<a href="https://man.archlinux.org/man/xkeyboard-config.7#LAYOUTS" target="_blank" rel="noreferrer">Variants per layout</a>. Changing it restarts the internal browser on save.
</span>
</label>

<label class="browser-config-switch-row">
<span class="browser-config-switch-copy">
<span class="browser-config-field-label">Autofocus active page</span>
Expand Down
Loading