feat(browser): prefer default chromium browser

This commit is contained in:
Peter Steinberger
2026-01-16 05:37:37 +00:00
parent 1ab1e312b2
commit 41d44021e7
5 changed files with 373 additions and 3 deletions

View File

@@ -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>): 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();