diff --git a/README.md b/README.md
index 4f354d5..6728118 100644
--- a/README.md
+++ b/README.md
@@ -8,9 +8,9 @@
-
-
-
+
+
+
## Overview
@@ -40,8 +40,9 @@ This project uses the custom GREED-1 license. It grants permission only to fork
- Uses temporary OpenBrowser-generated element references such as `e_1`, `e_2`, and `e_3`.
- Invalidates references after navigation or relevant DOM updates and returns stale-reference errors for old refs.
- Saves screenshots to `~/OpenBrowser/screenshots/.png` or returns Base64 image data.
-- Includes an extensible browser-adapter architecture for future Firefox-family and Chromium-family browsers.
-- Initially supports Zen, a Firefox-based browser.
+- Includes an extensible browser-adapter architecture covering Firefox-family and Chromium-family browsers.
+- Supports Zen (Firefox-based) and Chrome (Chromium-based).
+- Lets you set a default browser with `config browser ` so `--browser` is optional.
- Includes a minimal extension logo and uses it as the Firefox extension icon.
- Builds the Firefox extension into a bundled `.xpi` artifact.
- Includes a release-signing pipeline for Mozilla unlisted signing with `web-ext sign --channel unlisted`.
@@ -50,19 +51,21 @@ This project uses the custom GREED-1 license. It grants permission only to fork
- Node.js 20 or newer.
- npm access to install or run `@pxlarified/browser`.
-- Zen Browser for the initial supported browser target.
-- A Zen profile created on disk. Open Zen once before running the installer.
+- A supported browser: Zen (Firefox-based) or Chrome (Chromium-based).
+- For Zen, a Zen profile created on disk. Open Zen once before running the installer.
+- For Chrome, Developer mode enabled so the staged extension can be loaded unpacked.
- For signed Firefox releases, Mozilla Add-ons API credentials.
## Installation
-Install the bundled extension and native bridge for Zen.
+Install the bundled extension and native bridge for a browser.
```sh
npx @pxlarified/browser install zen
+npx @pxlarified/browser install chrome
```
-The installer does the following.
+For Zen (Firefox-family) the installer does the following.
1. Copies the bundled Firefox `.xpi` into detected Zen profiles as `openbrowser@mizius.com.xpi`.
2. Installs the user-scoped native messaging manifest.
@@ -71,6 +74,32 @@ The installer does the following.
Restart Zen if the extension update does not load immediately.
+For Chrome (Chromium-family) the installer does the following.
+
+1. Installs the native host launcher under `~/OpenBrowser/native-host/`.
+2. Writes the Chromium native messaging manifest (using `allowed_origins`) into Chrome's `NativeMessagingHosts` directory.
+3. Stages the unpacked extension under `~/OpenBrowser/extensions/chrome/`.
+
+Chromium browsers cannot side-load a packed extension from a profile, so load the staged extension manually: open `chrome://extensions`, enable Developer mode, choose "Load unpacked", and select `~/OpenBrowser/extensions/chrome/`. The bundled extension ships a fixed `key`, so it always loads with the same extension id that the native messaging manifest allows.
+
+### Default browser
+
+Set the browser used when `--browser` is omitted.
+
+```sh
+npx @pxlarified/browser config browser chrome
+npx @pxlarified/browser config browser zen
+```
+
+Show the current configuration.
+
+```sh
+npx @pxlarified/browser config browser
+npx @pxlarified/browser config
+```
+
+The default is stored in `~/OpenBrowser/config.json`. Resolution order is `--browser`, then the configured default, then Zen.
+
### Agent skill installation
Install the bundled `browser` skill into a Pi agent directory or an existing `skills` directory.
@@ -85,13 +114,13 @@ If `--to` points at an agent directory such as `.pi/agent`, OpenBrowser writes `
## Usage
-`--browser zen` is optional while Zen is the only supported browser.
+`--browser ` selects the target browser. It is optional and defaults to the browser set with `config browser`, or Zen when none is configured.
```sh
npx @pxlarified/browser open https://example.com --browser zen
-npx @pxlarified/browser state --browser zen
+npx @pxlarified/browser state --browser chrome
npx @pxlarified/browser click e_1 --browser zen
-npx @pxlarified/browser screenshot --browser zen
+npx @pxlarified/browser screenshot --browser chrome
npx @pxlarified/browser close --browser zen
```
@@ -188,24 +217,29 @@ OpenBrowser uses browser-specific adapters so additional browsers can be added l
Currently supported.
-- Zen - Firefox-based browser.
-
-Planned architecture support.
+- Zen - Firefox-based browser, installed through a signed `.xpi` artifact.
+- Chrome - Chromium-based browser, loaded unpacked from a `.zip` extension artifact.
-- Firefox-family browsers through signed `.xpi` artifacts.
-- Chromium-family browsers through `.zip` extension artifacts.
+The adapter architecture also covers other Firefox-family browsers through signed `.xpi` artifacts and other Chromium-family browsers through `.zip` extension artifacts.
## Extension artifacts
The browser extension source lives in `extensions/`.
-Build the Firefox development artifact.
+Build both extension artifacts.
+
+```sh
+npm run build
+```
+
+Build a single family.
```sh
npm run build:firefox
+npm run build:chromium
```
-The unsigned development artifact is written to.
+The unsigned Firefox development artifact is written to.
```text
dist/extensions/firefox/openbrowser-dev.xpi
@@ -219,6 +253,13 @@ dist/extensions/firefox/openbrowser.xpi
Otherwise it falls back to the unsigned development artifact.
+The Chromium build writes an MV3 extension. The installer loads the unpacked directory, and the `.zip` is provided for distribution.
+
+```text
+dist/extensions/chromium/unpacked/
+dist/extensions/chromium/openbrowser.zip
+```
+
## Firefox signing
Firefox release signing is handled as a separate release step. Save Mozilla Add-ons credentials in a local uncommitted `.env` file.
@@ -304,8 +345,12 @@ Project entry points.
- Logo source - `assets/logo.svg`
- CLI - `bin/OpenBrowser.js`
- CLI implementation - `src/cli.js`
+- Browser registry - `src/browsers/registry.js`
- Zen adapter - `src/browsers/zen.js`
+- Chrome adapter - `src/browsers/chrome.js`
- Firefox-family installer - `src/browsers/firefox-family.js`
+- Chromium-family installer - `src/browsers/chromium-family.js`
+- Default-browser config - `src/util/config.js`
- Native bridge host - `src/native-host.cjs`
- Extension background script - `extensions/background.js`
- Extension content script - `extensions/content.js`
diff --git a/coverage/chrome.test.js b/coverage/chrome.test.js
new file mode 100644
index 0000000..dd198b1
--- /dev/null
+++ b/coverage/chrome.test.js
@@ -0,0 +1,67 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import crypto from "node:crypto";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { ChromeBrowserAdapter } from "../src/browsers/chrome.js";
+import { ChromiumFamilyAdapter } from "../src/browsers/chromium-family.js";
+import { CHROME_EXTENSION_ID, CHROME_EXTENSION_KEY, NATIVE_HOST_NAME } from "../src/constants.js";
+
+const originalHome = process.env.OPENBROWSER_HOME;
+
+test.afterEach(() => {
+ if (originalHome === undefined) delete process.env.OPENBROWSER_HOME;
+ else process.env.OPENBROWSER_HOME = originalHome;
+});
+
+test("ChromeBrowserAdapter identifies as chrome", () => {
+ const adapter = new ChromeBrowserAdapter();
+ assert.equal(adapter.name, "chrome");
+ assert.equal(adapter.displayName, "Chrome");
+});
+
+test("CHROME_EXTENSION_ID is derived from CHROME_EXTENSION_KEY", () => {
+ const der = Buffer.from(CHROME_EXTENSION_KEY, "base64");
+ const hex = crypto.createHash("sha256").update(der).digest().subarray(0, 16).toString("hex");
+ const id = [...hex].map((nibble) => String.fromCharCode(97 + parseInt(nibble, 16))).join("");
+ assert.equal(id, CHROME_EXTENSION_ID);
+});
+
+test("install stages the extension and writes a Chromium native-messaging manifest", { skip: process.platform === "win32" }, async () => {
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "ob-chrome-"));
+ const manifestRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ob-chrome-root-"));
+ process.env.OPENBROWSER_HOME = home;
+
+ const adapter = new ChromiumFamilyAdapter({
+ name: "chrome",
+ displayName: "Chrome",
+ nativeManifestRoots: [manifestRoot],
+ registryRoots: [],
+ launchCommands: [],
+ });
+
+ try {
+ const result = await adapter.install();
+
+ // Native host launcher exists and is executable.
+ assert.ok(fs.existsSync(result.nativeHost));
+
+ // Extension is staged as an unpacked directory the user can load.
+ assert.equal(result.unpackedExtension, path.join(home, "extensions", "chrome"));
+ assert.ok(fs.existsSync(path.join(result.unpackedExtension, "manifest.json")));
+ assert.ok(fs.existsSync(path.join(result.unpackedExtension, "background.js")));
+
+ // Native-messaging manifest uses the Chromium allowed_origins format.
+ const manifestPath = path.join(manifestRoot, "NativeMessagingHosts", `${NATIVE_HOST_NAME}.json`);
+ assert.deepEqual(result.nativeManifests, [manifestPath]);
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
+ assert.equal(manifest.name, NATIVE_HOST_NAME);
+ assert.equal(manifest.type, "stdio");
+ assert.equal(manifest.path, result.nativeHost);
+ assert.deepEqual(manifest.allowed_origins, [`chrome-extension://${CHROME_EXTENSION_ID}/`]);
+ } finally {
+ fs.rmSync(home, { recursive: true, force: true });
+ fs.rmSync(manifestRoot, { recursive: true, force: true });
+ }
+});
diff --git a/coverage/config.test.js b/coverage/config.test.js
new file mode 100644
index 0000000..f5e03e0
--- /dev/null
+++ b/coverage/config.test.js
@@ -0,0 +1,52 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { configPath, getConfiguredBrowser, readConfig, setConfiguredBrowser } from "../src/util/config.js";
+
+const originalHome = process.env.OPENBROWSER_HOME;
+
+function useTempHome() {
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "ob-config-"));
+ process.env.OPENBROWSER_HOME = home;
+ return home;
+}
+
+test.afterEach(() => {
+ if (originalHome === undefined) delete process.env.OPENBROWSER_HOME;
+ else process.env.OPENBROWSER_HOME = originalHome;
+});
+
+test("readConfig returns an empty object when no config exists", () => {
+ useTempHome();
+ assert.deepEqual(readConfig(), {});
+ assert.equal(getConfiguredBrowser(), undefined);
+});
+
+test("setConfiguredBrowser persists the default browser", () => {
+ const home = useTempHome();
+ setConfiguredBrowser("chrome");
+
+ assert.equal(getConfiguredBrowser(), "chrome");
+ assert.equal(configPath(), path.join(home, "config.json"));
+ assert.deepEqual(JSON.parse(fs.readFileSync(configPath(), "utf8")), { browser: "chrome" });
+});
+
+test("setConfiguredBrowser preserves unrelated config keys", () => {
+ useTempHome();
+ fs.mkdirSync(path.dirname(configPath()), { recursive: true });
+ fs.writeFileSync(configPath(), JSON.stringify({ other: 1 }), "utf8");
+
+ setConfiguredBrowser("zen");
+ assert.deepEqual(readConfig(), { other: 1, browser: "zen" });
+});
+
+test("getConfiguredBrowser ignores malformed config files", () => {
+ useTempHome();
+ fs.mkdirSync(path.dirname(configPath()), { recursive: true });
+ fs.writeFileSync(configPath(), "not json", "utf8");
+
+ assert.deepEqual(readConfig(), {});
+ assert.equal(getConfiguredBrowser(), undefined);
+});
diff --git a/coverage/registry.test.js b/coverage/registry.test.js
index 251786b..cad1e1a 100644
--- a/coverage/registry.test.js
+++ b/coverage/registry.test.js
@@ -3,8 +3,8 @@ import assert from "node:assert/strict";
import { resolveBrowser, supportedBrowsers } from "../src/browsers/registry.js";
-test("supportedBrowsers lists zen", () => {
- assert.deepEqual(supportedBrowsers(), ["zen"]);
+test("supportedBrowsers lists zen and chrome", () => {
+ assert.deepEqual(supportedBrowsers(), ["zen", "chrome"]);
});
test("resolveBrowser uses zen by default", () => {
@@ -13,9 +13,19 @@ test("resolveBrowser uses zen by default", () => {
assert.equal(adapter.displayName, "Zen");
});
+test("resolveBrowser resolves chrome", () => {
+ const adapter = resolveBrowser("chrome");
+ assert.equal(adapter.name, "chrome");
+ assert.equal(adapter.displayName, "Chrome");
+});
+
+test("resolveBrowser is case-insensitive", () => {
+ assert.equal(resolveBrowser("Chrome").name, "chrome");
+});
+
test("resolveBrowser rejects unsupported browsers", () => {
assert.throws(
() => resolveBrowser("unknown"),
- /Unsupported browser: unknown\. Supported browsers: zen\./,
+ /Unsupported browser: unknown\. Supported browsers: zen, chrome\./,
);
});
diff --git a/dist/extensions/chromium/openbrowser.zip b/dist/extensions/chromium/openbrowser.zip
new file mode 100644
index 0000000..4f506ca
Binary files /dev/null and b/dist/extensions/chromium/openbrowser.zip differ
diff --git a/dist/extensions/chromium/unpacked/assets/logo.svg b/dist/extensions/chromium/unpacked/assets/logo.svg
new file mode 100644
index 0000000..cb17876
--- /dev/null
+++ b/dist/extensions/chromium/unpacked/assets/logo.svg
@@ -0,0 +1,19 @@
+
diff --git a/dist/extensions/chromium/unpacked/background.js b/dist/extensions/chromium/unpacked/background.js
new file mode 100644
index 0000000..8bd91db
--- /dev/null
+++ b/dist/extensions/chromium/unpacked/background.js
@@ -0,0 +1,211 @@
+// Firefox exposes the promise-based `browser` namespace; Chromium only exposes
+// `chrome`. Prefer `browser` and fall back to `chrome` so the same background
+// script runs on both browser families.
+const browser = globalThis.browser || globalThis.chrome;
+
+const NATIVE_HOST = "openbrowser";
+const SESSION_KEY = "session";
+let port;
+let reconnectTimer;
+
+connectNativeHost();
+browser.tabs.onRemoved.addListener(handleTabRemoved);
+
+function connectNativeHost() {
+ try {
+ port = browser.runtime.connectNative(NATIVE_HOST);
+ port.onMessage.addListener(handleNativeMessage);
+ port.onDisconnect.addListener(() => {
+ port = null;
+ scheduleReconnect();
+ });
+ } catch (error) {
+ scheduleReconnect();
+ }
+}
+
+function scheduleReconnect() {
+ if (reconnectTimer) return;
+ reconnectTimer = setTimeout(() => {
+ reconnectTimer = null;
+ connectNativeHost();
+ }, 1000);
+}
+
+async function handleNativeMessage(message) {
+ const id = message && message.id;
+ if (!id) return;
+
+ try {
+ const result = await dispatchCommand(message.command, message.args || {});
+ postNative({ replyTo: id, ok: true, result });
+ } catch (error) {
+ postNative({
+ replyTo: id,
+ ok: false,
+ error: {
+ code: error.code || "COMMAND_FAILED",
+ message: error.message || String(error),
+ },
+ });
+ }
+}
+
+function postNative(message) {
+ if (!port) return;
+ port.postMessage(message);
+}
+
+async function dispatchCommand(command, args) {
+ switch (command) {
+ case "open": return openSession(args.url);
+ case "close": return closeSession();
+ case "status": return status();
+ case "navigate": return navigate(args.url);
+ case "reload": return tabAction((tabId) => browser.tabs.reload(tabId));
+ case "back": return contentCommand("historyBack", {});
+ case "forward": return contentCommand("historyForward", {});
+ case "state": return contentCommand("state", {});
+ case "screenshot": return screenshot();
+ case "click": return contentCommand("click", { ref: args.ref });
+ case "keys": return contentCommand("keys", { text: args.text });
+ case "press": return contentCommand("press", { key: args.key });
+ case "select": return contentCommand("select", { ref: args.ref, option: args.option });
+ case "getHtml": return contentCommand("getHtml", { ref: args.ref });
+ case "scroll": return contentCommand("scroll", args);
+ default: throw codedError("UNKNOWN_COMMAND", `Unknown OpenBrowser command: ${command}`);
+ }
+}
+
+async function openSession(url) {
+ const existing = await getLiveSession();
+ if (existing) throw codedError("SESSION_EXISTS", "An OpenBrowser session already exists for this browser. Close it first.");
+
+ const tab = await browser.tabs.create({ url });
+ const session = { tabId: tab.id, createdAt: new Date().toISOString() };
+ await browser.storage.local.set({ [SESSION_KEY]: session });
+ return { ok: true, session, url: tab.url || url };
+}
+
+async function closeSession() {
+ const session = await getStoredSession();
+ if (!session) return { ok: true, open: false };
+
+ try {
+ await browser.tabs.remove(session.tabId);
+ } catch {
+ // The owned tab may already have been manually closed.
+ }
+ await clearSession();
+ return { ok: true, open: false };
+}
+
+async function status() {
+ const session = await getLiveSession();
+ if (!session) return { open: false };
+ const tab = await browser.tabs.get(session.tabId);
+ return {
+ open: true,
+ session,
+ tab: {
+ id: tab.id,
+ url: tab.url,
+ title: tab.title,
+ status: tab.status,
+ },
+ };
+}
+
+async function navigate(url) {
+ const session = await requireLiveSession();
+ await browser.tabs.update(session.tabId, { url });
+ return { ok: true };
+}
+
+async function tabAction(action) {
+ const session = await requireLiveSession();
+ await action(session.tabId);
+ return { ok: true };
+}
+
+async function screenshot() {
+ const session = await requireLiveSession();
+ await browser.tabs.update(session.tabId, { active: true });
+
+ // Firefox supports per-tab captureTab; Chromium only captures the visible tab.
+ if (typeof browser.tabs.captureTab === "function") {
+ return { dataUrl: await browser.tabs.captureTab(session.tabId, { format: "png" }) };
+ }
+
+ const tab = await browser.tabs.get(session.tabId);
+ const dataUrl = await browser.tabs.captureVisibleTab(tab.windowId, { format: "png" });
+ return { dataUrl };
+}
+
+async function contentCommand(type, payload) {
+ const session = await requireLiveSession();
+ await ensureContentScript(session.tabId);
+ try {
+ return await browser.tabs.sendMessage(session.tabId, { type, ...payload });
+ } catch (error) {
+ throw codedError("CONTENT_UNAVAILABLE", error.message || "OpenBrowser content script is unavailable on this page.");
+ }
+}
+
+async function ensureContentScript(tabId) {
+ try {
+ await browser.tabs.sendMessage(tabId, { type: "ping" });
+ return;
+ } catch {
+ // Inject below.
+ }
+
+ try {
+ // MV3 (Chromium) exposes scripting.executeScript; MV2 (Firefox) uses tabs.executeScript.
+ if (browser.scripting && typeof browser.scripting.executeScript === "function") {
+ await browser.scripting.executeScript({ target: { tabId }, files: ["content.js"] });
+ } else {
+ await browser.tabs.executeScript(tabId, { file: "content.js", runAt: "document_idle" });
+ }
+ } catch (error) {
+ throw codedError("INJECTION_FAILED", `Cannot control this page: ${error.message || error}`);
+ }
+}
+
+async function getStoredSession() {
+ const stored = await browser.storage.local.get(SESSION_KEY);
+ return stored[SESSION_KEY] || null;
+}
+
+async function getLiveSession() {
+ const session = await getStoredSession();
+ if (!session) return null;
+ try {
+ await browser.tabs.get(session.tabId);
+ return session;
+ } catch {
+ await clearSession();
+ return null;
+ }
+}
+
+async function requireLiveSession() {
+ const session = await getLiveSession();
+ if (!session) throw codedError("NO_SESSION", "No active OpenBrowser session exists for this browser.");
+ return session;
+}
+
+async function clearSession() {
+ await browser.storage.local.remove(SESSION_KEY);
+}
+
+async function handleTabRemoved(tabId) {
+ const session = await getStoredSession();
+ if (session && session.tabId === tabId) await clearSession();
+}
+
+function codedError(code, message) {
+ const error = new Error(message);
+ error.code = code;
+ return error;
+}
diff --git a/dist/extensions/chromium/unpacked/content.js b/dist/extensions/chromium/unpacked/content.js
new file mode 100644
index 0000000..62b6043
--- /dev/null
+++ b/dist/extensions/chromium/unpacked/content.js
@@ -0,0 +1,281 @@
+(function installOpenBrowserContentScript() {
+ // Firefox exposes the promise-based `browser` namespace; Chromium only exposes
+ // `chrome`. Prefer `browser` and fall back to `chrome` so the same content
+ // script runs on both families. Read from globalThis so the hoisted local
+ // declaration does not shadow the real global to `undefined`.
+ var browser = globalThis.browser || globalThis.chrome;
+
+ if (window.__openbrowserContentInstalled) return;
+ window.__openbrowserContentInstalled = true;
+
+ var refs = new Map();
+ var generation = 0;
+
+ var observer = new MutationObserver(function () {
+ invalidateReferences();
+ });
+
+ observer.observe(document.documentElement || document, {
+ childList: true,
+ subtree: true,
+ attributes: true,
+ characterData: true,
+ attributeFilter: ["href", "disabled", "aria-label", "aria-hidden", "role", "tabindex", "value"],
+ });
+
+ window.addEventListener("pageshow", invalidateReferences, true);
+ window.addEventListener("hashchange", invalidateReferences, true);
+
+ browser.runtime.onMessage.addListener(function (message) {
+ if (!message || !message.type) return undefined;
+
+ try {
+ switch (message.type) {
+ case "ping": return Promise.resolve({ ok: true });
+ case "state": return Promise.resolve(getState());
+ case "click": return Promise.resolve(clickRef(message.ref));
+ case "keys": return Promise.resolve(typeKeys(message.text || ""));
+ case "press": return Promise.resolve(pressKey(message.key));
+ case "select": return Promise.resolve(selectOption(message.ref, message.option));
+ case "getHtml": return Promise.resolve(getHtml(message.ref));
+ case "scroll": return Promise.resolve(scrollPage(message));
+ case "historyBack": history.back(); return Promise.resolve({ ok: true });
+ case "historyForward": history.forward(); return Promise.resolve({ ok: true });
+ default: return Promise.reject(codedError("UNKNOWN_CONTENT_COMMAND", "Unknown content command."));
+ }
+ } catch (error) {
+ return Promise.reject(error);
+ }
+ });
+
+ function invalidateReferences() {
+ generation += 1;
+ refs.clear();
+ }
+
+ function getState() {
+ refs.clear();
+ var elements = collectActionableElements();
+ var stateGeneration = generation;
+ var result = [];
+
+ elements.forEach(function (element, index) {
+ var ref = "e_" + (index + 1);
+ refs.set(ref, { element: element, generation: stateGeneration });
+ result.push({
+ ref: ref,
+ role: roleOf(element),
+ name: accessibleName(element),
+ });
+ });
+
+ return {
+ url: location.href,
+ title: document.title,
+ viewport: {
+ width: window.innerWidth,
+ height: window.innerHeight,
+ scrollX: window.scrollX,
+ scrollY: window.scrollY,
+ devicePixelRatio: window.devicePixelRatio,
+ },
+ elements: result,
+ };
+ }
+
+ function collectActionableElements() {
+ var selector = [
+ "a[href]",
+ "button",
+ "input:not([type=hidden])",
+ "select",
+ "textarea",
+ "summary",
+ "[role=button]",
+ "[role=link]",
+ "[role=menuitem]",
+ "[role=checkbox]",
+ "[role=radio]",
+ "[tabindex]",
+ "[contenteditable=true]",
+ ].join(",");
+
+ return Array.prototype.slice.call(document.querySelectorAll(selector))
+ .filter(function (element) {
+ return isVisible(element) && !isDisabled(element) && accessibleName(element);
+ })
+ .slice(0, 200);
+ }
+
+ function getRef(ref) {
+ var entry = refs.get(ref);
+ if (!entry || entry.generation !== generation || !entry.element.isConnected) {
+ throw codedError("STALE_REFERENCE", "Stale OpenBrowser reference. Run state again and use a fresh ref.");
+ }
+ return entry.element;
+ }
+
+ function clickRef(ref) {
+ var element = getRef(ref);
+ element.scrollIntoView({ block: "center", inline: "center" });
+ element.focus({ preventScroll: true });
+ dispatchMouse(element, "mouseover");
+ dispatchMouse(element, "mousedown");
+ dispatchMouse(element, "mouseup");
+ if (typeof element.click === "function") element.click();
+ else dispatchMouse(element, "click");
+ return { ok: true };
+ }
+
+ function typeKeys(text) {
+ var element = document.activeElement;
+ if (!element || element === document.body) throw codedError("NO_FOCUS", "No focused element is available for text input.");
+
+ if (isTextInput(element)) {
+ insertText(element, text);
+ return { ok: true };
+ }
+
+ if (element.isContentEditable) {
+ document.execCommand("insertText", false, text);
+ element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: text }));
+ return { ok: true };
+ }
+
+ throw codedError("NOT_EDITABLE", "The focused element does not accept text input.");
+ }
+
+ function pressKey(key) {
+ var element = document.activeElement || document.body;
+ var options = { key: key, bubbles: true, cancelable: true };
+ element.dispatchEvent(new KeyboardEvent("keydown", options));
+ element.dispatchEvent(new KeyboardEvent("keypress", options));
+
+ if (key === "Enter" && typeof element.click === "function" && /^(BUTTON|A)$/.test(element.tagName)) element.click();
+ if (key === "Tab") focusNextElement();
+
+ element.dispatchEvent(new KeyboardEvent("keyup", options));
+ return { ok: true };
+ }
+
+ function selectOption(ref, option) {
+ var element = getRef(ref);
+ if (element.tagName !== "SELECT") throw codedError("NOT_SELECT", "Referenced element is not a select control.");
+
+ var options = Array.prototype.slice.call(element.options);
+ var match = options.find(function (candidate, index) {
+ return candidate.value === option || candidate.text.trim() === option || String(index) === option;
+ });
+ if (!match) throw codedError("OPTION_NOT_FOUND", "Select option was not found.");
+
+ element.value = match.value;
+ element.dispatchEvent(new Event("input", { bubbles: true }));
+ element.dispatchEvent(new Event("change", { bubbles: true }));
+ return { ok: true };
+ }
+
+ function getHtml(ref) {
+ if (!ref) return { html: document.documentElement.outerHTML };
+ return { html: getRef(ref).outerHTML };
+ }
+
+ function scrollPage(message) {
+ if (message.to) {
+ getRef(message.to).scrollIntoView({ block: "center", inline: "nearest" });
+ return { ok: true, scrollX: window.scrollX, scrollY: window.scrollY };
+ }
+
+ var pixels = Number(message.pixels || 600);
+ if (!Number.isFinite(pixels) || pixels <= 0) pixels = 600;
+ window.scrollBy({ top: message.direction === "up" ? -pixels : pixels, left: 0, behavior: "auto" });
+ return { ok: true, scrollX: window.scrollX, scrollY: window.scrollY };
+ }
+
+ function accessibleName(element) {
+ return (
+ element.getAttribute("aria-label") ||
+ element.getAttribute("title") ||
+ element.getAttribute("alt") ||
+ associatedLabel(element) ||
+ element.value ||
+ element.textContent ||
+ element.getAttribute("placeholder") ||
+ ""
+ ).replace(/\s+/g, " ").trim().slice(0, 160);
+ }
+
+ function associatedLabel(element) {
+ if (element.id) {
+ var label = document.querySelector('label[for="' + cssEscape(element.id) + '"]');
+ if (label) return label.textContent;
+ }
+ var parent = element.closest("label");
+ return parent ? parent.textContent : "";
+ }
+
+ function roleOf(element) {
+ var explicit = element.getAttribute("role");
+ if (explicit) return explicit;
+ var tag = element.tagName.toLowerCase();
+ if (tag === "a") return "link";
+ if (tag === "button") return "button";
+ if (tag === "select") return "select";
+ if (tag === "textarea") return "textbox";
+ if (tag === "summary") return "button";
+ if (tag === "input") {
+ var type = (element.getAttribute("type") || "text").toLowerCase();
+ if (["button", "submit", "reset"].includes(type)) return "button";
+ if (["checkbox", "radio"].includes(type)) return type;
+ return "textbox";
+ }
+ return "control";
+ }
+
+ function isVisible(element) {
+ var style = getComputedStyle(element);
+ if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity) === 0) return false;
+ var rect = element.getBoundingClientRect();
+ return rect.width > 0 && rect.height > 0;
+ }
+
+ function isDisabled(element) {
+ return Boolean(element.disabled || element.getAttribute("aria-disabled") === "true");
+ }
+
+ function dispatchMouse(element, type) {
+ element.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window }));
+ }
+
+ function isTextInput(element) {
+ return element.tagName === "TEXTAREA" || (element.tagName === "INPUT" && !["button", "checkbox", "file", "radio", "reset", "submit"].includes((element.type || "text").toLowerCase()));
+ }
+
+ function insertText(element, text) {
+ var start = element.selectionStart || 0;
+ var end = element.selectionEnd || start;
+ var value = element.value || "";
+ element.value = value.slice(0, start) + text + value.slice(end);
+ var cursor = start + text.length;
+ element.setSelectionRange(cursor, cursor);
+ element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: text }));
+ element.dispatchEvent(new Event("change", { bubbles: true }));
+ }
+
+ function focusNextElement() {
+ var focusable = collectActionableElements();
+ var index = focusable.indexOf(document.activeElement);
+ var next = focusable[(index + 1) % focusable.length];
+ if (next) next.focus();
+ }
+
+ function cssEscape(value) {
+ if (window.CSS && CSS.escape) return CSS.escape(value);
+ return String(value).replace(/"/g, '\\"');
+ }
+
+ function codedError(code, message) {
+ var error = new Error(message);
+ error.code = code;
+ return error;
+ }
+})();
diff --git a/dist/extensions/chromium/unpacked/manifest.json b/dist/extensions/chromium/unpacked/manifest.json
new file mode 100644
index 0000000..a06e9a9
--- /dev/null
+++ b/dist/extensions/chromium/unpacked/manifest.json
@@ -0,0 +1,21 @@
+{
+ "manifest_version": 3,
+ "name": "OpenBrowser",
+ "version": "2.1.0",
+ "description": "Local user-scoped browser control bridge for the OpenBrowser CLI.",
+ "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyzAut1adVCcD0F/acoPkZeYvT8uijALA+77yvOFFlDRWWDwUoW/pmK4vrdVwppPPbjm7l2IBZgHvTrYjFVS++1BngM97h5O9bZRd8QgFTofRpqJWJMOB5li52c6CcIwtm9BpZTVXZE+YpifgGeeZ7QJ9voeT+Cg18dGVXLT1A0RyNVqyBRhaInIcjbDvZReMWxvI+Gpi/8eVF+pLu7uogtPFjIaOap3jK5YvK0f0JcnQaD6s61IMM3S1LBwZhfiWMwYz6K52d/sQNA9x9iw1EYBAEecohngYQw8n/VMXIWDAB9Sdhe5RoekJ3Lw7xt2U0CvgloPVRNi0p48MqnGIrwIDAQAB",
+ "minimum_chrome_version": "109",
+ "permissions": [
+ "nativeMessaging",
+ "storage",
+ "tabs",
+ "activeTab",
+ "scripting"
+ ],
+ "host_permissions": [
+ ""
+ ],
+ "background": {
+ "service_worker": "background.js"
+ }
+}
diff --git a/dist/extensions/firefox/openbrowser-dev.xpi b/dist/extensions/firefox/openbrowser-dev.xpi
index 2e9914a..ee2acf2 100644
Binary files a/dist/extensions/firefox/openbrowser-dev.xpi and b/dist/extensions/firefox/openbrowser-dev.xpi differ
diff --git a/extensions/background.js b/extensions/background.js
index 4556620..8bd91db 100644
--- a/extensions/background.js
+++ b/extensions/background.js
@@ -1,3 +1,8 @@
+// Firefox exposes the promise-based `browser` namespace; Chromium only exposes
+// `chrome`. Prefer `browser` and fall back to `chrome` so the same background
+// script runs on both browser families.
+const browser = globalThis.browser || globalThis.chrome;
+
const NATIVE_HOST = "openbrowser";
const SESSION_KEY = "session";
let port;
@@ -126,7 +131,14 @@ async function tabAction(action) {
async function screenshot() {
const session = await requireLiveSession();
await browser.tabs.update(session.tabId, { active: true });
- const dataUrl = await browser.tabs.captureTab(session.tabId, { format: "png" });
+
+ // Firefox supports per-tab captureTab; Chromium only captures the visible tab.
+ if (typeof browser.tabs.captureTab === "function") {
+ return { dataUrl: await browser.tabs.captureTab(session.tabId, { format: "png" }) };
+ }
+
+ const tab = await browser.tabs.get(session.tabId);
+ const dataUrl = await browser.tabs.captureVisibleTab(tab.windowId, { format: "png" });
return { dataUrl };
}
@@ -149,7 +161,12 @@ async function ensureContentScript(tabId) {
}
try {
- await browser.tabs.executeScript(tabId, { file: "content.js", runAt: "document_idle" });
+ // MV3 (Chromium) exposes scripting.executeScript; MV2 (Firefox) uses tabs.executeScript.
+ if (browser.scripting && typeof browser.scripting.executeScript === "function") {
+ await browser.scripting.executeScript({ target: { tabId }, files: ["content.js"] });
+ } else {
+ await browser.tabs.executeScript(tabId, { file: "content.js", runAt: "document_idle" });
+ }
} catch (error) {
throw codedError("INJECTION_FAILED", `Cannot control this page: ${error.message || error}`);
}
diff --git a/extensions/content.js b/extensions/content.js
index 9f62098..62b6043 100644
--- a/extensions/content.js
+++ b/extensions/content.js
@@ -1,4 +1,10 @@
(function installOpenBrowserContentScript() {
+ // Firefox exposes the promise-based `browser` namespace; Chromium only exposes
+ // `chrome`. Prefer `browser` and fall back to `chrome` so the same content
+ // script runs on both families. Read from globalThis so the hoisted local
+ // declaration does not shadow the real global to `undefined`.
+ var browser = globalThis.browser || globalThis.chrome;
+
if (window.__openbrowserContentInstalled) return;
window.__openbrowserContentInstalled = true;
diff --git a/extensions/manifest.json b/extensions/manifest.json
index 6975040..cd21c30 100644
--- a/extensions/manifest.json
+++ b/extensions/manifest.json
@@ -1,7 +1,7 @@
{
"manifest_version": 2,
"name": "OpenBrowser",
- "version": "2.0.1",
+ "version": "2.1.0",
"description": "Local user-scoped browser control bridge for the OpenBrowser CLI.",
"icons": {
"48": "assets/logo.svg",
diff --git a/package.json b/package.json
index 9aac64b..517d13c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@pxlarified/browser",
- "version": "2.0.1",
+ "version": "2.1.0",
"description": "Minimal local browser-control CLI for OpenBrowser.",
"repository": {
"type": "git",
@@ -12,8 +12,9 @@
},
"scripts": {
"sync:version": "node scripts/sync-version.js",
- "build": "node scripts/build.js firefox",
+ "build": "npm run build:firefox && npm run build:chromium",
"build:firefox": "node scripts/build.js firefox",
+ "build:chromium": "node scripts/build.js chromium",
"test": "node --test coverage/*.test.js",
"release:firefox": "node scripts/sign-firefox.js",
"prepack": "npm run build"
diff --git a/scripts/build.js b/scripts/build.js
index a890ee1..8892d71 100755
--- a/scripts/build.js
+++ b/scripts/build.js
@@ -7,26 +7,83 @@ import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const target = process.argv[2] || "firefox";
-if (target !== "firefox") {
+const sourceDir = path.join(root, "extensions");
+
+if (target === "firefox") {
+ await buildFirefox();
+} else if (target === "chromium") {
+ await buildChromium();
+} else {
throw new Error(`Unsupported build target: ${target}`);
}
-const sourceDir = path.join(root, "extensions");
-const buildDir = path.join(root, "build", "firefox-extension");
-const outDir = path.join(root, "dist", "extensions", "firefox");
-const outFile = path.join(outDir, "openbrowser-dev.xpi");
+async function buildFirefox() {
+ const buildDir = path.join(root, "build", "firefox-extension");
+ const outDir = path.join(root, "dist", "extensions", "firefox");
+ const outFile = path.join(outDir, "openbrowser-dev.xpi");
+
+ fs.rmSync(buildDir, { recursive: true, force: true });
+ fs.mkdirSync(buildDir, { recursive: true });
+ fs.mkdirSync(outDir, { recursive: true });
+
+ copyFile(path.join(sourceDir, "manifest.json"), path.join(buildDir, "manifest.json"));
+ copyExtensionCode(buildDir);
+
+ await zipDirectory(buildDir, outFile);
+ console.log(`Built ${path.relative(root, outFile)}`);
+}
+
+async function buildChromium() {
+ const outDir = path.join(root, "dist", "extensions", "chromium");
+ const unpackedDir = path.join(outDir, "unpacked");
+ const outFile = path.join(outDir, "openbrowser.zip");
-fs.rmSync(buildDir, { recursive: true, force: true });
-fs.mkdirSync(buildDir, { recursive: true });
-fs.mkdirSync(outDir, { recursive: true });
+ fs.rmSync(unpackedDir, { recursive: true, force: true });
+ fs.mkdirSync(unpackedDir, { recursive: true });
-copyFile(path.join(sourceDir, "manifest.json"), path.join(buildDir, "manifest.json"));
-copyFile(path.join(sourceDir, "background.js"), path.join(buildDir, "background.js"));
-copyFile(path.join(sourceDir, "content.js"), path.join(buildDir, "content.js"));
-copyDirectory(path.join(sourceDir, "assets"), path.join(buildDir, "assets"));
+ fs.writeFileSync(path.join(unpackedDir, "manifest.json"), `${JSON.stringify(chromiumManifest(), null, 2)}\n`);
+ copyExtensionCode(unpackedDir);
-await zipDirectory(buildDir, outFile);
-console.log(`Built ${path.relative(root, outFile)}`);
+ fs.rmSync(outFile, { force: true });
+ await zipDirectory(unpackedDir, outFile);
+ console.log(`Built ${path.relative(root, unpackedDir)}`);
+ console.log(`Built ${path.relative(root, outFile)}`);
+}
+
+// Transforms the shared MV2 manifest into an MV3 manifest for Chromium. The two
+// extension scripts are feature-detected so the same code runs on both families.
+function chromiumManifest() {
+ const source = JSON.parse(fs.readFileSync(path.join(sourceDir, "manifest.json"), "utf8"));
+ const hostPermissions = (source.permissions || []).filter((permission) => permission.includes("://") || permission === "");
+ const permissions = (source.permissions || []).filter((permission) => !hostPermissions.includes(permission));
+
+ return {
+ manifest_version: 3,
+ name: source.name,
+ version: source.version,
+ description: source.description,
+ key: readChromeKey(),
+ minimum_chrome_version: "109",
+ permissions: [...new Set([...permissions, "scripting"])],
+ host_permissions: hostPermissions,
+ background: {
+ service_worker: "background.js",
+ },
+ };
+}
+
+function readChromeKey() {
+ const constants = fs.readFileSync(path.join(root, "src", "constants.js"), "utf8");
+ const match = constants.match(/CHROME_EXTENSION_KEY\s*=\s*"([^"]+)"/);
+ if (!match) throw new Error("Could not read CHROME_EXTENSION_KEY from src/constants.js.");
+ return match[1];
+}
+
+function copyExtensionCode(destinationDir) {
+ copyFile(path.join(sourceDir, "background.js"), path.join(destinationDir, "background.js"));
+ copyFile(path.join(sourceDir, "content.js"), path.join(destinationDir, "content.js"));
+ copyDirectory(path.join(sourceDir, "assets"), path.join(destinationDir, "assets"));
+}
function copyFile(from, to) {
fs.mkdirSync(path.dirname(to), { recursive: true });
diff --git a/skills/browser/SKILL.md b/skills/browser/SKILL.md
index d74473f..c5ba398 100644
--- a/skills/browser/SKILL.md
+++ b/skills/browser/SKILL.md
@@ -13,11 +13,16 @@ npx @pxlarified/browser
Close the session after you are done (unless it contains important information or you want to keep it warm for another session).
### Usage
-`--browser zen` is optional.
+`--browser ` selects the browser (`zen` or `chrome`). It is optional and
+defaults to the browser set with `config browser`, or Zen when none is configured.
```bash
# Installation
npx @pxlarified/browser install zen
+npx @pxlarified/browser install chrome
+
+# Default browser (used when --browser is omitted)
+npx @pxlarified/browser config browser chrome
# Session management
npx @pxlarified/browser open --browser zen
diff --git a/src/browsers/chrome.js b/src/browsers/chrome.js
new file mode 100644
index 0000000..5d21683
--- /dev/null
+++ b/src/browsers/chrome.js
@@ -0,0 +1,47 @@
+import os from "node:os";
+import path from "node:path";
+import { ChromiumFamilyAdapter } from "./chromium-family.js";
+
+function platformNativeManifestRoots() {
+ if (process.platform === "darwin") {
+ return ["~/Library/Application Support/Google/Chrome"];
+ }
+
+ if (process.platform === "win32") {
+ return [];
+ }
+
+ return ["~/.config/google-chrome"];
+}
+
+function platformRegistryRoots() {
+ if (process.platform !== "win32") return [];
+ return ["HKCU\\Software\\Google\\Chrome\\NativeMessagingHosts"];
+}
+
+function platformLaunchCommands() {
+ if (process.platform === "darwin") {
+ return [{ command: "open", args: ["-a", "Google Chrome"] }];
+ }
+
+ if (process.platform === "win32") {
+ return [{ command: "cmd", args: ["/c", "start", "", "chrome"] }];
+ }
+
+ return [
+ { command: "google-chrome", args: [] },
+ { command: "google-chrome-stable", args: [] },
+ ];
+}
+
+export class ChromeBrowserAdapter extends ChromiumFamilyAdapter {
+ constructor() {
+ super({
+ name: "chrome",
+ displayName: "Chrome",
+ nativeManifestRoots: platformNativeManifestRoots(),
+ registryRoots: platformRegistryRoots(),
+ launchCommands: platformLaunchCommands(),
+ });
+ }
+}
diff --git a/src/browsers/chromium-family.js b/src/browsers/chromium-family.js
new file mode 100644
index 0000000..c73b989
--- /dev/null
+++ b/src/browsers/chromium-family.js
@@ -0,0 +1,110 @@
+import fs from "node:fs";
+import path from "node:path";
+import { spawn } from "node:child_process";
+import { CHROME_EXTENSION_ID, CHROMIUM_UNPACKED_DIR, NATIVE_HOST_NAME } from "../constants.js";
+import { openBrowserHome, packageRoot } from "../util/paths.js";
+import { expandHome, installNativeHost } from "./shared.js";
+
+export class ChromiumFamilyAdapter {
+ constructor(options) {
+ this.name = options.name;
+ this.displayName = options.displayName;
+ this.nativeManifestRoots = options.nativeManifestRoots;
+ this.launchCommands = options.launchCommands;
+ this.registryRoots = options.registryRoots || [];
+ }
+
+ artifactPath() {
+ return path.join(packageRoot(), CHROMIUM_UNPACKED_DIR);
+ }
+
+ async install() {
+ const source = this.artifactPath();
+ if (!fs.existsSync(source)) {
+ throw new Error(`Missing Chromium extension artifact: ${source}. Run npm run build:chromium first.`);
+ }
+
+ const nativeHost = installNativeHost(this.name);
+ const manifests = installNativeMessagingManifests(this.nativeManifestRoots, this.registryRoots, nativeHost);
+
+ // Chromium browsers cannot side-load a packed extension from the profile the
+ // way Firefox does, so stage the unpacked extension and let the user load it.
+ const unpackedDir = path.join(openBrowserHome(), "extensions", this.name);
+ fs.rmSync(unpackedDir, { recursive: true, force: true });
+ fs.mkdirSync(path.dirname(unpackedDir), { recursive: true });
+ fs.cpSync(source, unpackedDir, { recursive: true });
+
+ return {
+ browser: this.name,
+ unpackedExtension: unpackedDir,
+ extensionId: CHROME_EXTENSION_ID,
+ nativeHost,
+ nativeManifests: manifests,
+ note: `Load the OpenBrowser extension in ${this.displayName}: open the extensions page, enable Developer mode, choose "Load unpacked", and select ${unpackedDir}.`,
+ };
+ }
+
+ async launch() {
+ for (const command of this.launchCommands) {
+ try {
+ const child = spawn(command.command, command.args, {
+ detached: true,
+ stdio: "ignore",
+ });
+ child.unref();
+ return true;
+ } catch {
+ // Try the next known command.
+ }
+ }
+ return false;
+ }
+}
+
+function installNativeMessagingManifests(roots, registryRoots, hostPath) {
+ const manifest = {
+ name: NATIVE_HOST_NAME,
+ description: "OpenBrowser local user-scoped native bridge",
+ path: hostPath,
+ type: "stdio",
+ allowed_origins: [`chrome-extension://${CHROME_EXTENSION_ID}/`],
+ };
+
+ const written = [];
+ for (const root of roots.map((entry) => expandHome(entry))) {
+ const dir = path.join(root, "NativeMessagingHosts");
+ try {
+ fs.mkdirSync(dir, { recursive: true });
+ const target = path.join(dir, `${NATIVE_HOST_NAME}.json`);
+ fs.writeFileSync(target, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
+ written.push(target);
+ } catch {
+ // Some candidate vendor directories may not be present on this platform.
+ }
+ }
+
+ if (process.platform === "win32") {
+ const manifestDir = path.join(openBrowserHome(), "native-messaging-hosts");
+ fs.mkdirSync(manifestDir, { recursive: true });
+ const manifestPath = path.join(manifestDir, `${NATIVE_HOST_NAME}.json`);
+ fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
+ written.push(manifestPath);
+
+ for (const registryRoot of registryRoots) {
+ try {
+ spawn("reg", [
+ "add",
+ `${registryRoot}\\${NATIVE_HOST_NAME}`,
+ "/ve",
+ "/t",
+ "REG_SZ",
+ "/d",
+ manifestPath,
+ "/f",
+ ], { stdio: "ignore" });
+ } catch {}
+ }
+ }
+
+ return written;
+}
diff --git a/src/browsers/firefox-family.js b/src/browsers/firefox-family.js
index 2c921c0..bfe0246 100644
--- a/src/browsers/firefox-family.js
+++ b/src/browsers/firefox-family.js
@@ -1,10 +1,9 @@
import fs from "node:fs";
-import os from "node:os";
import path from "node:path";
-import { execFileSync, spawn } from "node:child_process";
-import { fileURLToPath } from "node:url";
+import { spawn } from "node:child_process";
import { EXTENSION_ID, FIREFOX_DEV_ARTIFACT, FIREFOX_SIGNED_ARTIFACT, NATIVE_HOST_NAME } from "../constants.js";
import { openBrowserHome, packageRoot } from "../util/paths.js";
+import { expandHome, findRunningProcess, installNativeHost, uniqueExistingDirectories } from "./shared.js";
export class FirefoxFamilyAdapter {
constructor(options) {
@@ -92,73 +91,6 @@ export class FirefoxFamilyAdapter {
}
}
-function findRunningProcess(processNames) {
- if (processNames.length === 0) return null;
-
- if (process.platform === "win32") return findRunningWindowsProcess(processNames);
- return findRunningUnixProcess(processNames);
-}
-
-function findRunningUnixProcess(processNames) {
- for (const processName of processNames) {
- try {
- execFileSync("pgrep", ["-x", processName], { stdio: "ignore" });
- return processName;
- } catch {
- // Process is not running, or pgrep is unavailable. Try the next known name.
- }
- }
- return null;
-}
-
-function findRunningWindowsProcess(processNames) {
- let output;
- try {
- output = execFileSync("tasklist", ["/fo", "csv", "/nh"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
- } catch {
- return null;
- }
-
- const runningNames = new Set(output
- .split(/\r?\n/)
- .map((line) => parseTasklistImageName(line))
- .filter(Boolean)
- .map((name) => name.toLowerCase()));
-
- for (const processName of processNames) {
- const executable = processName.toLowerCase().endsWith(".exe") ? processName.toLowerCase() : `${processName.toLowerCase()}.exe`;
- if (runningNames.has(executable)) return processName;
- }
- return null;
-}
-
-function parseTasklistImageName(line) {
- const match = line.match(/^"((?:[^"]|"")*)"/);
- if (!match) return null;
- return match[1].replace(/""/g, "\"");
-}
-
-function installNativeHost(browser) {
- const source = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../native-host.cjs");
- const dir = path.join(openBrowserHome(), "native-host");
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
-
- const hostScript = path.join(dir, "openbrowser-native-host.cjs");
- fs.copyFileSync(source, hostScript);
- if (process.platform !== "win32") fs.chmodSync(hostScript, 0o755);
-
- if (process.platform === "win32") {
- const cmd = path.join(dir, `openbrowser-native-host-${browser}.cmd`);
- fs.writeFileSync(cmd, `@echo off\r\nset OPENBROWSER_BROWSER=${browser}\r\n"${process.execPath}" "${hostScript}"\r\n`, "utf8");
- return cmd;
- }
-
- const launcher = path.join(dir, `openbrowser-native-host-${browser}`);
- fs.writeFileSync(launcher, `#!/bin/sh\nOPENBROWSER_BROWSER=${shellQuote(browser)} exec ${shellQuote(process.execPath)} ${shellQuote(hostScript)}\n`, "utf8");
- fs.chmodSync(launcher, 0o755);
- return launcher;
-}
-
function installNativeMessagingManifests(roots, hostPath) {
const manifest = {
name: NATIVE_HOST_NAME,
@@ -205,15 +137,6 @@ function installNativeMessagingManifests(roots, hostPath) {
return written;
}
-function shellQuote(value) {
- return `'${String(value).replace(/'/g, `'\\''`)}'`;
-}
-
-function expandHome(value) {
- if (!value.startsWith("~")) return value;
- return path.join(os.homedir(), value.slice(2));
-}
-
function readProfilesIni(file, root) {
const text = fs.readFileSync(file, "utf8");
const sections = [];
@@ -241,16 +164,3 @@ function readProfilesIni(file, root) {
return section.Path;
});
}
-
-function uniqueExistingDirectories(paths) {
- const seen = new Set();
- const result = [];
- for (const candidate of paths) {
- const resolved = path.resolve(candidate);
- if (seen.has(resolved)) continue;
- if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) continue;
- seen.add(resolved);
- result.push(resolved);
- }
- return result;
-}
diff --git a/src/browsers/registry.js b/src/browsers/registry.js
index 26d5aa3..22486fc 100644
--- a/src/browsers/registry.js
+++ b/src/browsers/registry.js
@@ -1,8 +1,10 @@
import { ZenBrowserAdapter } from "./zen.js";
+import { ChromeBrowserAdapter } from "./chrome.js";
import { DEFAULT_BROWSER } from "../constants.js";
const adapters = new Map([
["zen", new ZenBrowserAdapter()],
+ ["chrome", new ChromeBrowserAdapter()],
]);
export function supportedBrowsers() {
diff --git a/src/browsers/shared.js b/src/browsers/shared.js
new file mode 100644
index 0000000..1f15724
--- /dev/null
+++ b/src/browsers/shared.js
@@ -0,0 +1,97 @@
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { execFileSync } from "node:child_process";
+import { fileURLToPath } from "node:url";
+import { openBrowserHome } from "../util/paths.js";
+
+// Installs the shared, browser-agnostic native messaging host launcher used by
+// every OpenBrowser adapter and returns the path to the per-browser launcher.
+export function installNativeHost(browser) {
+ const source = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../native-host.cjs");
+ const dir = path.join(openBrowserHome(), "native-host");
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
+
+ const hostScript = path.join(dir, "openbrowser-native-host.cjs");
+ fs.copyFileSync(source, hostScript);
+ if (process.platform !== "win32") fs.chmodSync(hostScript, 0o755);
+
+ if (process.platform === "win32") {
+ const cmd = path.join(dir, `openbrowser-native-host-${browser}.cmd`);
+ fs.writeFileSync(cmd, `@echo off\r\nset OPENBROWSER_BROWSER=${browser}\r\n"${process.execPath}" "${hostScript}"\r\n`, "utf8");
+ return cmd;
+ }
+
+ const launcher = path.join(dir, `openbrowser-native-host-${browser}`);
+ fs.writeFileSync(launcher, `#!/bin/sh\nOPENBROWSER_BROWSER=${shellQuote(browser)} exec ${shellQuote(process.execPath)} ${shellQuote(hostScript)}\n`, "utf8");
+ fs.chmodSync(launcher, 0o755);
+ return launcher;
+}
+
+export function findRunningProcess(processNames) {
+ if (processNames.length === 0) return null;
+
+ if (process.platform === "win32") return findRunningWindowsProcess(processNames);
+ return findRunningUnixProcess(processNames);
+}
+
+function findRunningUnixProcess(processNames) {
+ for (const processName of processNames) {
+ try {
+ execFileSync("pgrep", ["-x", processName], { stdio: "ignore" });
+ return processName;
+ } catch {
+ // Process is not running, or pgrep is unavailable. Try the next known name.
+ }
+ }
+ return null;
+}
+
+function findRunningWindowsProcess(processNames) {
+ let output;
+ try {
+ output = execFileSync("tasklist", ["/fo", "csv", "/nh"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
+ } catch {
+ return null;
+ }
+
+ const runningNames = new Set(output
+ .split(/\r?\n/)
+ .map((line) => parseTasklistImageName(line))
+ .filter(Boolean)
+ .map((name) => name.toLowerCase()));
+
+ for (const processName of processNames) {
+ const executable = processName.toLowerCase().endsWith(".exe") ? processName.toLowerCase() : `${processName.toLowerCase()}.exe`;
+ if (runningNames.has(executable)) return processName;
+ }
+ return null;
+}
+
+function parseTasklistImageName(line) {
+ const match = line.match(/^"((?:[^"]|"")*)"/);
+ if (!match) return null;
+ return match[1].replace(/""/g, "\"");
+}
+
+export function shellQuote(value) {
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
+}
+
+export function expandHome(value) {
+ if (!value.startsWith("~")) return value;
+ return path.join(os.homedir(), value.slice(2));
+}
+
+export function uniqueExistingDirectories(paths) {
+ const seen = new Set();
+ const result = [];
+ for (const candidate of paths) {
+ const resolved = path.resolve(candidate);
+ if (seen.has(resolved)) continue;
+ if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) continue;
+ seen.add(resolved);
+ result.push(resolved);
+ }
+ return result;
+}
diff --git a/src/cli.js b/src/cli.js
index e4ce28f..95cba7e 100644
--- a/src/cli.js
+++ b/src/cli.js
@@ -4,6 +4,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { resolveBrowser, supportedBrowsers } from "./browsers/registry.js";
import { BridgeUnavailableError, sendBridgeCommandWithRetry } from "./bridge/client.js";
+import { getConfiguredBrowser, readConfig, setConfiguredBrowser } from "./util/config.js";
import { screenshotsDir } from "./util/paths.js";
export async function runCli(argv) {
@@ -14,7 +15,11 @@ export async function runCli(argv) {
const parsed = parseGlobalOptions(argv);
const [command, ...positionals] = parsed.positionals;
- const adapter = resolveBrowser(parsed.browser);
+
+ if (command === "config") {
+ runConfigCommand(positionals);
+ return;
+ }
if (command === "install") {
const target = positionals[0];
@@ -25,7 +30,7 @@ export async function runCli(argv) {
return;
}
- const browserName = target || parsed.browser;
+ const browserName = target || parsed.browser || getConfiguredBrowser();
if (!browserName) throw new Error(`Usage: OpenBrowser install `);
const installAdapter = resolveBrowser(browserName);
const result = await installAdapter.install();
@@ -34,6 +39,7 @@ export async function runCli(argv) {
return;
}
+ const adapter = resolveBrowser(parsed.browser || getConfiguredBrowser());
const request = toBridgeRequest(command, positionals, parsed.flags);
if (!request) throw new Error(`Unknown command: ${command}`);
@@ -41,6 +47,30 @@ export async function runCli(argv) {
await printResult(request, result);
}
+function runConfigCommand(args) {
+ const [key, value] = args;
+
+ if (!key) {
+ console.log(JSON.stringify(readConfig(), null, 2));
+ return;
+ }
+
+ if (key !== "browser") {
+ throw new Error(`Unknown config key: ${key}. Supported keys: browser.`);
+ }
+
+ if (value === undefined) {
+ console.log(JSON.stringify({ browser: getConfiguredBrowser() ?? null }, null, 2));
+ return;
+ }
+
+ const name = value.toLowerCase();
+ resolveBrowser(name); // Validates against supported browsers before persisting.
+ const config = setConfiguredBrowser(name);
+ console.error(`Default browser set to ${name}.`);
+ console.log(JSON.stringify(config, null, 2));
+}
+
async function sendCommandEnsuringBridge(adapter, command, args, timeoutMs) {
try {
return await sendBridgeCommandWithRetry(adapter.name, command, args, { timeoutMs });
@@ -179,5 +209,5 @@ function normalizeBase64(value) {
}
function printHelp() {
- console.log(`OpenBrowser\n\nUsage:\n OpenBrowser install \n OpenBrowser install skills --to \n OpenBrowser open [--browser zen]\n OpenBrowser close [--browser zen]\n OpenBrowser status [--browser zen]\n OpenBrowser navigate [--browser zen]\n OpenBrowser reload|back|forward [--browser zen]\n OpenBrowser state [--browser zen]\n OpenBrowser screenshot [--base64] [--browser zen]\n OpenBrowser click [ [--browser zen]\n OpenBrowser keys [--browser zen]\n OpenBrowser press [--browser zen]\n OpenBrowser select ][ ]