diff --git a/README.md b/README.md index 4f354d5..6728118 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,9 @@

Node.js - Version - Browser - Firefox Extension + Version + Browser + WebExtension

## 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