diff --git a/plugins/_browser/AGENTS.md b/plugins/_browser/AGENTS.md index ce97ff7dad..20b8fb6b59 100644 --- a/plugins/_browser/AGENTS.md +++ b/plugins/_browser/AGENTS.md @@ -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. diff --git a/plugins/_browser/default_config.yaml b/plugins/_browser/default_config.yaml index e15d3d880e..9f7dd057aa 100644 --- a/plugins/_browser/default_config.yaml +++ b/plugins/_browser/default_config.yaml @@ -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: "" diff --git a/plugins/_browser/helpers/config.py b/plugins/_browser/helpers/config.py index f000a6f3cf..eecd970037 100644 --- a/plugins/_browser/helpers/config.py +++ b/plugins/_browser/helpers/config.py @@ -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"} @@ -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: @@ -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( @@ -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, "")), } @@ -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], } diff --git a/plugins/_browser/helpers/interactive_view.py b/plugins/_browser/helpers/interactive_view.py index a603633d0f..6689e55bbb 100644 --- a/plugins/_browser/helpers/interactive_view.py +++ b/plugins/_browser/helpers/interactive_view.py @@ -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 "" @@ -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 @@ -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" @@ -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}", diff --git a/plugins/_browser/webui/browser-config-store.js b/plugins/_browser/webui/browser-config-store.js index 8220f9e33e..0c941674c0 100644 --- a/plugins/_browser/webui/browser-config-store.js +++ b/plugins/_browser/webui/browser-config-store.js @@ -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, @@ -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"; diff --git a/plugins/_browser/webui/browser-store.js b/plugins/_browser/webui/browser-store.js index 1b59f68388..3cd80f4137 100644 --- a/plugins/_browser/webui/browser-store.js +++ b/plugins/_browser/webui/browser-store.js @@ -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)); @@ -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, diff --git a/plugins/_browser/webui/config.html b/plugins/_browser/webui/config.html index 765e562887..326b513b69 100644 --- a/plugins/_browser/webui/config.html +++ b/plugins/_browser/webui/config.html @@ -243,6 +243,37 @@ + + + +