From 41d44021e750233997c2bad7452a24750d5d6626 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 16 Jan 2026 05:37:37 +0000 Subject: [PATCH] feat(browser): prefer default chromium browser --- docs/gateway/configuration.md | 2 +- docs/tools/browser-linux-troubleshooting.md | 2 +- docs/tools/browser.md | 2 +- src/browser/chrome.default-browser.test.ts | 72 +++++ src/browser/chrome.executables.ts | 298 ++++++++++++++++++++ 5 files changed, 373 insertions(+), 3 deletions(-) create mode 100644 src/browser/chrome.default-browser.test.ts diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 1510163cf..bc86799c7 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -2396,7 +2396,7 @@ Defaults: - CDP URL: `http://127.0.0.1:18792` (control URL + 1, legacy single-profile) - profile color: `#FF4500` (lobster-orange) - Note: the control server is started by the running gateway (Clawdbot.app menubar, or `clawdbot gateway`). -- Auto-detect order: Chrome → Brave → Edge → Chromium → Chrome Canary. +- Auto-detect order: default browser if Chromium-based; otherwise Chrome → Brave → Edge → Chromium → Chrome Canary. ```json5 { diff --git a/docs/tools/browser-linux-troubleshooting.md b/docs/tools/browser-linux-troubleshooting.md index 0d8da8cf5..1cf4844fe 100644 --- a/docs/tools/browser-linux-troubleshooting.md +++ b/docs/tools/browser-linux-troubleshooting.md @@ -107,7 +107,7 @@ curl -s http://127.0.0.1:18791/tabs | Option | Description | Default | |--------|-------------|---------| | `browser.enabled` | Enable browser control | `true` | -| `browser.executablePath` | Path to a Chromium-based browser binary (Chrome/Brave/Edge/Chromium) | auto-detected | +| `browser.executablePath` | Path to a Chromium-based browser binary (Chrome/Brave/Edge/Chromium) | auto-detected (prefers default browser when Chromium-based) | | `browser.headless` | Run without GUI | `false` | | `browser.noSandbox` | Add `--no-sandbox` flag (needed for some Linux setups) | `false` | | `browser.attachOnly` | Don't launch browser, only attach to existing | `false` | diff --git a/docs/tools/browser.md b/docs/tools/browser.md index 388bf9338..07154a3fb 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -71,7 +71,7 @@ Notes: - `cdpUrl` defaults to `controlUrl + 1` when unset. - `attachOnly: true` means “never launch a local browser; only attach if it is already running.” - `color` + per-profile `color` tint the browser UI so you can see which profile is active. -- Auto-detect order: Chrome → Brave → Edge → Chromium → Chrome Canary. +- Auto-detect order: default browser if Chromium-based; otherwise Chrome → Brave → Edge → Chromium → Chrome Canary. ## Use Brave (or another Chromium-based browser) diff --git a/src/browser/chrome.default-browser.test.ts b/src/browser/chrome.default-browser.test.ts new file mode 100644 index 000000000..04c7631b1 --- /dev/null +++ b/src/browser/chrome.default-browser.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("node:child_process", () => ({ + execFileSync: vi.fn(), +})); +vi.mock("node:fs", () => { + const existsSync = vi.fn(); + const readFileSync = vi.fn(); + return { + existsSync, + readFileSync, + default: { existsSync, readFileSync }, + }; +}); +import { execFileSync } from "node:child_process"; +import * as fs from "node:fs"; + +describe("browser default executable detection", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + }); + + it("prefers default Chromium browser on macOS", async () => { + vi.mocked(execFileSync).mockImplementation((cmd, args) => { + const argsStr = Array.isArray(args) ? args.join(" ") : ""; + if (cmd === "/usr/bin/osascript" && argsStr.includes("id of application")) { + return "com.google.Chrome"; + } + if (cmd === "/usr/bin/osascript" && argsStr.includes("POSIX path")) { + return "/Applications/Google Chrome.app"; + } + if (cmd === "/usr/bin/defaults") { + return "Google Chrome"; + } + return ""; + }); + vi.mocked(fs.existsSync).mockImplementation((p) => + String(p).includes("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"), + ); + + const { resolveBrowserExecutableForPlatform } = await import("./chrome.executables.js"); + const exe = resolveBrowserExecutableForPlatform( + {} as Parameters[0], + "darwin", + ); + + expect(exe?.path).toContain("Google Chrome.app/Contents/MacOS/Google Chrome"); + expect(exe?.kind).toBe("chrome"); + }); + + it("falls back when default browser is non-Chromium on macOS", async () => { + vi.mocked(execFileSync).mockImplementation((cmd, args) => { + const argsStr = Array.isArray(args) ? args.join(" ") : ""; + if (cmd === "/usr/bin/osascript" && argsStr.includes("id of application")) { + return "com.apple.Safari"; + } + return ""; + }); + vi.mocked(fs.existsSync).mockImplementation((p) => + String(p).includes("Google Chrome.app/Contents/MacOS/Google Chrome"), + ); + + const { resolveBrowserExecutableForPlatform } = await import("./chrome.executables.js"); + const exe = resolveBrowserExecutableForPlatform( + {} as Parameters[0], + "darwin", + ); + + expect(exe?.path).toContain("Google Chrome.app/Contents/MacOS/Google Chrome"); + }); +}); diff --git a/src/browser/chrome.executables.ts b/src/browser/chrome.executables.ts index aa56ff7be..de130bcf1 100644 --- a/src/browser/chrome.executables.ts +++ b/src/browser/chrome.executables.ts @@ -1,3 +1,4 @@ +import { execFileSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -9,6 +10,83 @@ export type BrowserExecutable = { path: string; }; +const CHROMIUM_BUNDLE_IDS = new Set([ + "com.google.Chrome", + "com.google.Chrome.beta", + "com.google.Chrome.canary", + "com.google.Chrome.dev", + "com.brave.Browser", + "com.brave.Browser.beta", + "com.brave.Browser.nightly", + "com.microsoft.Edge", + "com.microsoft.EdgeBeta", + "com.microsoft.EdgeDev", + "com.microsoft.EdgeCanary", + "org.chromium.Chromium", + "com.vivaldi.Vivaldi", + "com.operasoftware.Opera", + "com.operasoftware.OperaGX", + "com.yandex.desktop.yandex-browser", + "company.thebrowser.Browser", // Arc +]); + +const CHROMIUM_DESKTOP_IDS = new Set([ + "google-chrome.desktop", + "google-chrome-beta.desktop", + "google-chrome-unstable.desktop", + "brave-browser.desktop", + "microsoft-edge.desktop", + "microsoft-edge-beta.desktop", + "microsoft-edge-dev.desktop", + "microsoft-edge-canary.desktop", + "chromium.desktop", + "chromium-browser.desktop", + "vivaldi.desktop", + "vivaldi-stable.desktop", + "opera.desktop", + "opera-gx.desktop", + "yandex-browser.desktop", + "org.chromium.Chromium.desktop", +]); + +const CHROMIUM_EXE_NAMES = new Set([ + "chrome.exe", + "msedge.exe", + "brave.exe", + "brave-browser.exe", + "chromium.exe", + "vivaldi.exe", + "opera.exe", + "launcher.exe", + "yandex.exe", + "yandexbrowser.exe", + // mac/linux names + "google chrome", + "google chrome canary", + "brave browser", + "microsoft edge", + "chromium", + "chrome", + "brave", + "msedge", + "brave-browser", + "google-chrome", + "google-chrome-stable", + "google-chrome-beta", + "google-chrome-unstable", + "microsoft-edge", + "microsoft-edge-beta", + "microsoft-edge-dev", + "microsoft-edge-canary", + "chromium-browser", + "vivaldi", + "vivaldi-stable", + "opera", + "opera-stable", + "opera-gx", + "yandex-browser", +]); + function exists(filePath: string) { try { return fs.existsSync(filePath); @@ -17,6 +95,223 @@ function exists(filePath: string) { } } +function execText(command: string, args: string[], timeoutMs = 1200): string | null { + try { + const output = execFileSync(command, args, { + timeout: timeoutMs, + encoding: "utf8", + maxBuffer: 1024 * 1024, + }); + return String(output ?? "").trim() || null; + } catch { + return null; + } +} + +function inferKindFromIdentifier(identifier: string): BrowserExecutable["kind"] { + const id = identifier.toLowerCase(); + if (id.includes("brave")) return "brave"; + if (id.includes("edge")) return "edge"; + if (id.includes("chromium")) return "chromium"; + if (id.includes("canary")) return "canary"; + if (id.includes("opera") || id.includes("vivaldi") || id.includes("yandex") || id.includes("thebrowser")) { + return "chromium"; + } + return "chrome"; +} + +function inferKindFromExecutableName(name: string): BrowserExecutable["kind"] { + const lower = name.toLowerCase(); + if (lower.includes("brave")) return "brave"; + if (lower.includes("edge") || lower.includes("msedge")) return "edge"; + if (lower.includes("chromium")) return "chromium"; + if (lower.includes("canary") || lower.includes("sxs")) return "canary"; + if (lower.includes("opera") || lower.includes("vivaldi") || lower.includes("yandex")) return "chromium"; + return "chrome"; +} + +function detectDefaultChromiumExecutable( + platform: NodeJS.Platform, +): BrowserExecutable | null { + if (platform === "darwin") return detectDefaultChromiumExecutableMac(); + if (platform === "linux") return detectDefaultChromiumExecutableLinux(); + if (platform === "win32") return detectDefaultChromiumExecutableWindows(); + return null; +} + +function detectDefaultChromiumExecutableMac(): BrowserExecutable | null { + const bundleId = execText("/usr/bin/osascript", [ + "-e", + 'id of application (path to default application for URL "http://example.com")', + ]); + if (!bundleId || !CHROMIUM_BUNDLE_IDS.has(bundleId.trim())) return null; + const appPathRaw = execText("/usr/bin/osascript", [ + "-e", + 'POSIX path of (path to default application for URL "http://example.com")', + ]); + if (!appPathRaw) return null; + const appPath = appPathRaw.trim().replace(/\/$/, ""); + const exeName = execText("/usr/bin/defaults", [ + "read", + path.join(appPath, "Contents", "Info"), + "CFBundleExecutable", + ]); + if (!exeName) return null; + const exePath = path.join(appPath, "Contents", "MacOS", exeName.trim()); + if (!exists(exePath)) return null; + return { kind: inferKindFromIdentifier(bundleId), path: exePath }; +} + +function detectDefaultChromiumExecutableLinux(): BrowserExecutable | null { + const desktopId = + execText("xdg-settings", ["get", "default-web-browser"]) || + execText("xdg-mime", ["query", "default", "x-scheme-handler/http"]); + if (!desktopId) return null; + const trimmed = desktopId.trim(); + if (!CHROMIUM_DESKTOP_IDS.has(trimmed)) return null; + const desktopPath = findDesktopFilePath(trimmed); + if (!desktopPath) return null; + const execLine = readDesktopExecLine(desktopPath); + if (!execLine) return null; + const command = extractExecutableFromExecLine(execLine); + if (!command) return null; + const resolved = resolveLinuxExecutablePath(command); + if (!resolved) return null; + const exeName = path.posix.basename(resolved).toLowerCase(); + if (!CHROMIUM_EXE_NAMES.has(exeName)) return null; + return { kind: inferKindFromExecutableName(exeName), path: resolved }; +} + +function detectDefaultChromiumExecutableWindows(): BrowserExecutable | null { + const progId = readWindowsProgId(); + const command = + (progId ? readWindowsCommandForProgId(progId) : null) || + readWindowsCommandForProgId("http"); + if (!command) return null; + const expanded = expandWindowsEnvVars(command); + const exePath = extractWindowsExecutablePath(expanded); + if (!exePath) return null; + if (!exists(exePath)) return null; + const exeName = path.win32.basename(exePath).toLowerCase(); + if (!CHROMIUM_EXE_NAMES.has(exeName)) return null; + return { kind: inferKindFromExecutableName(exeName), path: exePath }; +} + +function findDesktopFilePath(desktopId: string): string | null { + const candidates = [ + path.join(os.homedir(), ".local", "share", "applications", desktopId), + path.join("/usr/local/share/applications", desktopId), + path.join("/usr/share/applications", desktopId), + path.join("/var/lib/snapd/desktop/applications", desktopId), + ]; + for (const candidate of candidates) { + if (exists(candidate)) return candidate; + } + return null; +} + +function readDesktopExecLine(desktopPath: string): string | null { + try { + const raw = fs.readFileSync(desktopPath, "utf8"); + const lines = raw.split(/\r?\n/); + for (const line of lines) { + if (line.startsWith("Exec=")) { + return line.slice("Exec=".length).trim(); + } + } + } catch { + // ignore + } + return null; +} + +function extractExecutableFromExecLine(execLine: string): string | null { + const tokens = splitExecLine(execLine); + for (const token of tokens) { + if (!token) continue; + if (token === "env") continue; + if (token.includes("=") && !token.startsWith("/") && !token.includes("\\")) continue; + return token.replace(/^["']|["']$/g, ""); + } + return null; +} + +function splitExecLine(line: string): string[] { + const tokens: string[] = []; + let current = ""; + let inQuotes = false; + let quoteChar = ""; + for (let i = 0; i < line.length; i += 1) { + const ch = line[i]; + if ((ch === "\"" || ch === "'") && (!inQuotes || ch === quoteChar)) { + if (inQuotes) { + inQuotes = false; + quoteChar = ""; + } else { + inQuotes = true; + quoteChar = ch; + } + continue; + } + if (!inQuotes && /\s/.test(ch)) { + if (current) { + tokens.push(current); + current = ""; + } + continue; + } + current += ch; + } + if (current) tokens.push(current); + return tokens; +} + +function resolveLinuxExecutablePath(command: string): string | null { + const cleaned = command.trim().replace(/%[a-zA-Z]/g, ""); + if (!cleaned) return null; + if (cleaned.startsWith("/")) return cleaned; + const resolved = execText("which", [cleaned], 800); + return resolved ? resolved.trim() : null; +} + +function readWindowsProgId(): string | null { + const output = execText("reg", [ + "query", + "HKCU\\Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\http\\UserChoice", + "/v", + "ProgId", + ]); + if (!output) return null; + const match = output.match(/ProgId\s+REG_\w+\s+(.+)$/im); + return match?.[1]?.trim() || null; +} + +function readWindowsCommandForProgId(progId: string): string | null { + const key = + progId === "http" + ? "HKCR\\http\\shell\\open\\command" + : `HKCR\\${progId}\\shell\\open\\command`; + const output = execText("reg", ["query", key, "/ve"]); + if (!output) return null; + const match = output.match(/REG_\w+\s+(.+)$/im); + return match?.[1]?.trim() || null; +} + +function expandWindowsEnvVars(value: string): string { + return value.replace(/%([^%]+)%/g, (_match, name) => { + const key = String(name ?? "").trim(); + return key ? process.env[key] ?? `%${key}%` : _match; + }); +} + +function extractWindowsExecutablePath(command: string): string | null { + const quoted = command.match(/"([^"]+\\.exe)"/i); + if (quoted?.[1]) return quoted[1]; + const unquoted = command.match(/([^\\s]+\\.exe)/i); + if (unquoted?.[1]) return unquoted[1]; + return null; +} + function findFirstExecutable(candidates: Array): BrowserExecutable | null { for (const candidate of candidates) { if (exists(candidate.path)) return candidate; @@ -179,6 +474,9 @@ export function resolveBrowserExecutableForPlatform( return { kind: "custom", path: resolved.executablePath }; } + const detected = detectDefaultChromiumExecutable(platform); + if (detected) return detected; + if (platform === "darwin") return findChromeExecutableMac(); if (platform === "linux") return findChromeExecutableLinux(); if (platform === "win32") return findChromeExecutableWindows();