diff --git a/README.md b/README.md
index 4c430d3..46a4f7f 100644
--- a/README.md
+++ b/README.md
@@ -8,8 +8,8 @@
-
-
+
+
@@ -41,9 +41,10 @@ This project uses the custom GREED-1 license. It grants permission only to fork
- 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 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.
+- Supports Helium and Chrome (Chromium-based) plus Zen (Firefox-based).
+- Detects supported browsers and lets you configure a local default so `--browser` is optional.
+- Reports installed, configured, and active browser targets with `browsers`.
+- Includes PNG extension icons for Firefox and Chromium-family browsers.
- Builds the Firefox extension into a bundled `.xpi` artifact.
- Includes a release-signing pipeline for Mozilla unlisted signing with `web-ext sign --channel unlisted`.
@@ -51,54 +52,41 @@ 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`.
-- 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.
+- A supported browser: Helium or Chrome (Chromium-based), or Zen (Firefox-based).
+- For Zen, a Zen profile created on disk. Open Zen once before running setup.
+- For Helium and 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 a browser.
+The recommended first-run setup detects the local browser targets and remembers the one you choose.
```sh
-npx @pxlarified/browser install zen
-npx @pxlarified/browser install chrome
+npx @pxlarified/browser browsers
+npx @pxlarified/browser setup helium
+# or: setup chrome / setup zen
```
-For Zen (Firefox-family) the installer does the following.
+`setup` installs the native bridge and extension for the selected browser. For Zen it copies the Firefox `.xpi` into detected profiles. For Chromium browsers it stages one shared MV3 extension artifact under `~/OpenBrowser/extensions//` and writes the browser-specific native-messaging manifest.
-1. Copies the bundled Firefox `.xpi` into detected Zen profiles as `openbrowser@mizius.com.xpi`.
-2. Installs the user-scoped native messaging manifest.
-3. Installs the native host launcher under `~/OpenBrowser/native-host/`.
-4. Replaces an existing staged OpenBrowser extension with the bundled version.
+Chromium browsers cannot side-load a packed extension from a profile. After setup, open the browser's extensions page, enable Developer mode, choose "Load unpacked", and select the path reported by setup. Helium and Chrome use the same extension artifact, but keep separate native-host launchers and bridge sockets.
-Restart Zen if the extension update does not load immediately.
+The setup command stores the selected browser in `~/OpenBrowser/config.json`. Each user has their own selection; no browser preference is stored in the repository.
-For Chrome (Chromium-family) the installer does the following.
+### Browser selection
-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.
+Use the friendly `use` command to change the local default:
```sh
-npx @pxlarified/browser config browser chrome
-npx @pxlarified/browser config browser zen
+npx @pxlarified/browser use helium
+npx @pxlarified/browser use chrome
+npx @pxlarified/browser use zen
+npx @pxlarified/browser use auto
```
-Show the current configuration.
-
-```sh
-npx @pxlarified/browser config browser
-npx @pxlarified/browser config
-```
+`config browser ` remains available as an equivalent configuration command. `auto` uses a configured, ready browser first, then an active browser, then the only detected browser. If the detected browser is not set up, OpenBrowser tells you to run setup. If multiple browsers are possible, OpenBrowser reports the choices instead of silently selecting Zen.
-The default is stored in `~/OpenBrowser/config.json`. Resolution order is `--browser`, then the configured default, then Zen.
+Selection precedence is `--browser`, `OPENBROWSER_BROWSER`, the configured local browser when setup is complete, an active session, then automatic discovery. Configured daily commands avoid probing unrelated browser bridges. Use `browsers --json` for machine-readable discovery information.
### Agent skill installation
@@ -114,38 +102,47 @@ If `--to` points at an agent directory such as `.pi/agent`, OpenBrowser writes `
## Usage
-`--browser ` selects the target browser. It is optional and defaults to the browser set with `config browser`, or Zen when none is configured.
+After setup, browser selection is automatic and commands do not need a browser flag.
```sh
-npx @pxlarified/browser open https://example.com --browser zen
-npx @pxlarified/browser state --browser chrome
-npx @pxlarified/browser click e_1 --browser zen
+npx @pxlarified/browser open https://example.com
+npx @pxlarified/browser state
+npx @pxlarified/browser click e_1
+npx @pxlarified/browser screenshot
+npx @pxlarified/browser close
+```
+
+Use `--browser ` when deliberately controlling a specific browser:
+
+```sh
+npx @pxlarified/browser state --browser helium
npx @pxlarified/browser screenshot --browser chrome
-npx @pxlarified/browser close --browser zen
```
-### Session lifecycle
+### Browser status and session lifecycle
```sh
-npx @pxlarified/browser install zen
-npx @pxlarified/browser open --browser zen
-npx @pxlarified/browser close --browser zen
-npx @pxlarified/browser status --browser zen
+npx @pxlarified/browser browsers
+npx @pxlarified/browser browsers --json
+npx @pxlarified/browser setup helium
+npx @pxlarified/browser open
+npx @pxlarified/browser close
+npx @pxlarified/browser status
```
### Navigation
```sh
-npx @pxlarified/browser navigate --browser zen
-npx @pxlarified/browser reload --browser zen
-npx @pxlarified/browser back --browser zen
-npx @pxlarified/browser forward --browser zen
+npx @pxlarified/browser navigate
+npx @pxlarified/browser reload
+npx @pxlarified/browser back
+npx @pxlarified/browser forward
```
### Page state
```sh
-npx @pxlarified/browser state --browser zen
+npx @pxlarified/browser state
```
`state` returns the current URL, page title, viewport information, and actionable elements.
@@ -157,8 +154,8 @@ npx @pxlarified/browser state --browser zen
### Screenshots
```sh
-npx @pxlarified/browser screenshot --browser zen
-npx @pxlarified/browser screenshot --base64 --browser zen
+npx @pxlarified/browser screenshot
+npx @pxlarified/browser screenshot --base64
```
`screenshot` saves a PNG file under the user OpenBrowser directory and prints only the absolute file path to stdout.
@@ -172,25 +169,25 @@ npx @pxlarified/browser screenshot --base64 --browser zen
### Interaction
```sh
-npx @pxlarified/browser click [ --browser zen
-npx @pxlarified/browser keys ] --browser zen
-npx @pxlarified/browser press --browser zen
-npx @pxlarified/browser select [ ] --browser zen
+npx @pxlarified/browser click [
+npx @pxlarified/browser keys ]
+npx @pxlarified/browser press
+npx @pxlarified/browser select [ ]
```
### Content inspection
```sh
-npx @pxlarified/browser get --html --browser zen
-npx @pxlarified/browser get --html --ref [ --browser zen
+npx @pxlarified/browser get --html
+npx @pxlarified/browser get --html --ref ][
```
### Scrolling
```sh
-npx @pxlarified/browser scroll up [pixels] --browser zen
-npx @pxlarified/browser scroll down [pixels] --browser zen
-npx @pxlarified/browser scroll --to ][ --browser zen
+npx @pxlarified/browser scroll up [pixels]
+npx @pxlarified/browser scroll down [pixels]
+npx @pxlarified/browser scroll --to ][
```
## Session model
@@ -213,14 +210,15 @@ References become invalid after navigation or relevant DOM updates. Commands tha
## Browser support
-OpenBrowser uses browser-specific adapters so additional browsers can be added later.
+OpenBrowser separates browser targets from browser families so browser-specific discovery and installation can reuse the same control implementation.
-Currently supported.
+Currently supported browser IDs.
-- Zen - Firefox-based browser, installed through a signed `.xpi` artifact.
-- Chrome - Chromium-based browser, loaded unpacked from a `.zip` extension artifact.
+- `zen` - Firefox-based browser, installed through a signed `.xpi` artifact.
+- `chrome` - Chromium-based browser, loaded unpacked from the MV3 artifact.
+- `helium` - Chromium-based browser, loaded unpacked from the same MV3 artifact.
-The adapter architecture also covers other Firefox-family browsers through signed `.xpi` artifacts and other Chromium-family browsers through `.zip` extension artifacts.
+Other Firefox-family and Chromium-family browsers can be added by registering their application, profile, launch, and native-messaging paths.
## Extension artifacts
@@ -328,7 +326,7 @@ Install dependencies.
npm install
```
-Build the Firefox extension artifact.
+Build both extension artifacts.
```sh
npm run build
@@ -345,9 +343,12 @@ Project entry points.
- Logo source - `assets/logo.svg`
- CLI - `bin/OpenBrowser.js`
- CLI implementation - `src/cli.js`
+- Browser catalog - `src/browsers/catalog.js`
- Browser registry - `src/browsers/registry.js`
+- Browser selection and discovery - `src/browsers/selection.js`
- Zen adapter - `src/browsers/zen.js`
- Chrome adapter - `src/browsers/chrome.js`
+- Helium adapter - `src/browsers/helium.js`
- Firefox-family installer - `src/browsers/firefox-family.js`
- Chromium-family installer - `src/browsers/chromium-family.js`
- Default-browser config - `src/util/config.js`
diff --git a/coverage/chrome.test.js b/coverage/chrome.test.js
index dd198b1..fb0c278 100644
--- a/coverage/chrome.test.js
+++ b/coverage/chrome.test.js
@@ -6,7 +6,8 @@ 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";
+import { HeliumBrowserAdapter } from "../src/browsers/helium.js";
+import { CHROMIUM_EXTENSION_ID, CHROMIUM_EXTENSION_KEY, NATIVE_HOST_NAME } from "../src/constants.js";
const originalHome = process.env.OPENBROWSER_HOME;
@@ -21,11 +22,18 @@ test("ChromeBrowserAdapter identifies as chrome", () => {
assert.equal(adapter.displayName, "Chrome");
});
-test("CHROME_EXTENSION_ID is derived from CHROME_EXTENSION_KEY", () => {
- const der = Buffer.from(CHROME_EXTENSION_KEY, "base64");
+test("HeliumBrowserAdapter reuses the Chromium family", () => {
+ const adapter = new HeliumBrowserAdapter();
+ assert.equal(adapter.name, "helium");
+ assert.equal(adapter.displayName, "Helium");
+ assert.equal(adapter.family, "chromium");
+});
+
+test("CHROMIUM_EXTENSION_ID is derived from CHROMIUM_EXTENSION_KEY", () => {
+ const der = Buffer.from(CHROMIUM_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);
+ assert.equal(id, CHROMIUM_EXTENSION_ID);
});
test("install stages the extension and writes a Chromium native-messaging manifest", { skip: process.platform === "win32" }, async () => {
@@ -51,6 +59,9 @@ test("install stages the extension and writes a Chromium native-messaging manife
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")));
+ assert.ok(fs.existsSync(path.join(result.unpackedExtension, "assets", "logo-128.png")));
+ const extensionManifest = JSON.parse(fs.readFileSync(path.join(result.unpackedExtension, "manifest.json"), "utf8"));
+ assert.equal(extensionManifest.icons["128"], "assets/logo-128.png");
// Native-messaging manifest uses the Chromium allowed_origins format.
const manifestPath = path.join(manifestRoot, "NativeMessagingHosts", `${NATIVE_HOST_NAME}.json`);
@@ -59,7 +70,7 @@ test("install stages the extension and writes a Chromium native-messaging manife
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}/`]);
+ assert.deepEqual(manifest.allowed_origins, [`chrome-extension://${CHROMIUM_EXTENSION_ID}/`]);
} finally {
fs.rmSync(home, { recursive: true, force: true });
fs.rmSync(manifestRoot, { recursive: true, force: true });
diff --git a/coverage/registry.test.js b/coverage/registry.test.js
index cad1e1a..8609394 100644
--- a/coverage/registry.test.js
+++ b/coverage/registry.test.js
@@ -1,31 +1,54 @@
import test from "node:test";
import assert from "node:assert/strict";
-import { resolveBrowser, supportedBrowsers } from "../src/browsers/registry.js";
+import { normalizeBrowserName, resolveBrowser, supportedBrowsers } from "../src/browsers/registry.js";
-test("supportedBrowsers lists zen and chrome", () => {
- assert.deepEqual(supportedBrowsers(), ["zen", "chrome"]);
+test("supportedBrowsers lists the registered browser targets", () => {
+ assert.deepEqual(supportedBrowsers(), ["zen", "chrome", "helium"]);
});
-test("resolveBrowser uses zen by default", () => {
- const adapter = resolveBrowser();
+test("resolveBrowser resolves Zen", () => {
+ const adapter = resolveBrowser("zen");
assert.equal(adapter.name, "zen");
assert.equal(adapter.displayName, "Zen");
});
-test("resolveBrowser resolves chrome", () => {
+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 resolves Helium through the Chromium family", () => {
+ const adapter = resolveBrowser("helium");
+ assert.equal(adapter.name, "helium");
+ assert.equal(adapter.family, "chromium");
+});
+
+test("resolveBrowser accepts case-insensitive aliases", () => {
+ assert.equal(resolveBrowser("Google Chrome").name, "chrome");
+ assert.equal(resolveBrowser("HELIUM-BROWSER").name, "helium");
+ assert.equal(normalizeBrowserName("Zen Browser"), "zen");
+ assert.equal(normalizeBrowserName("zen-browser"), "zen");
+});
+
+test("resolveBrowser does not silently select a default", () => {
+ assert.throws(
+ () => resolveBrowser(),
+ /Browser selection is required\. Choose one of: zen, chrome, helium\./,
+ );
+});
+
+test("resolveBrowser rejects automatic selection as a concrete browser", () => {
+ assert.throws(
+ () => resolveBrowser("auto"),
+ /Browser selection is required\. Choose one of: zen, chrome, helium\./,
+ );
});
test("resolveBrowser rejects unsupported browsers", () => {
assert.throws(
() => resolveBrowser("unknown"),
- /Unsupported browser: unknown\. Supported browsers: zen, chrome\./,
+ /Unsupported browser: unknown\. Supported browsers: zen, chrome, helium\./,
);
});
diff --git a/coverage/runtime.test.js b/coverage/runtime.test.js
new file mode 100644
index 0000000..9d14f88
--- /dev/null
+++ b/coverage/runtime.test.js
@@ -0,0 +1,101 @@
+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 { runCli } from "../src/cli.js";
+import { EXTENSION_ID } from "../src/constants.js";
+import { FirefoxFamilyAdapter } from "../src/browsers/firefox-family.js";
+import { resolveBrowser } from "../src/browsers/registry.js";
+import { selectBrowser } from "../src/browsers/selection.js";
+import { installNativeHost, launchBrowser, nativeMessagingManifestPath } from "../src/browsers/shared.js";
+import { setConfiguredBrowser } from "../src/util/config.js";
+
+const originalHome = process.env.OPENBROWSER_HOME;
+const originalBrowser = process.env.OPENBROWSER_BROWSER;
+
+test.afterEach(() => {
+ if (originalHome === undefined) delete process.env.OPENBROWSER_HOME;
+ else process.env.OPENBROWSER_HOME = originalHome;
+ if (originalBrowser === undefined) delete process.env.OPENBROWSER_BROWSER;
+ else process.env.OPENBROWSER_BROWSER = originalBrowser;
+});
+
+function useTempHome() {
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "ob-runtime-"));
+ process.env.OPENBROWSER_HOME = home;
+ return home;
+}
+
+test("browsers rejects unexpected positional arguments", async () => {
+ await assert.rejects(
+ () => runCli(["browsers", "chorme"]),
+ /Usage: OpenBrowser browsers \[--json\]/,
+ );
+});
+
+test("Windows Chromium native manifests are browser-specific", () => {
+ assert.equal(path.basename(nativeMessagingManifestPath("chrome", "win32")), "openbrowser-chrome.json");
+ assert.equal(path.basename(nativeMessagingManifestPath("helium", "win32")), "openbrowser-helium.json");
+});
+
+test("launchBrowser waits for wrapper command exit status", async () => {
+ const failed = await launchBrowser([{
+ command: process.execPath,
+ args: ["-e", "process.exit(7)"],
+ waitForExit: true,
+ }]);
+ assert.equal(failed, false);
+
+ const succeeded = await launchBrowser([{
+ command: process.execPath,
+ args: ["-e", "process.exit(0)"],
+ waitForExit: true,
+ }]);
+ assert.equal(succeeded, true);
+});
+
+test("Firefox targets are configured only with host, manifest, and extension", () => {
+ const home = useTempHome();
+ const profileRoot = path.join(home, "firefox");
+ const profile = path.join(profileRoot, "Profiles", "default");
+ const manifestRoot = path.join(home, "firefox-manifests");
+ const manifestDirectory = process.platform === "linux" ? "native-messaging-hosts" : "NativeMessagingHosts";
+ fs.mkdirSync(profile, { recursive: true });
+ fs.writeFileSync(path.join(profileRoot, "profiles.ini"), "[Profile0]\nName=Default\nIsRelative=1\nPath=Profiles/default\nDefault=1\n");
+ fs.mkdirSync(path.join(manifestRoot, manifestDirectory), { recursive: true });
+ fs.writeFileSync(path.join(manifestRoot, manifestDirectory, "openbrowser.json"), "{}\n");
+
+ const adapter = new FirefoxFamilyAdapter({
+ name: "test-firefox",
+ displayName: "Test Firefox",
+ profileRoots: [profileRoot],
+ nativeManifestRoots: [manifestRoot],
+ launchCommands: [],
+ });
+ installNativeHost(adapter.name);
+
+ assert.equal(adapter.detect().configured, false);
+ fs.mkdirSync(path.join(profile, "extensions"), { recursive: true });
+ fs.writeFileSync(path.join(profile, "extensions", `${EXTENSION_ID}.xpi`), "test");
+ assert.equal(adapter.detect().configured, true);
+});
+
+test("a configured default skips full bridge discovery", async () => {
+ useTempHome();
+ setConfiguredBrowser("helium");
+ const adapter = resolveBrowser("helium");
+ const originalDetect = adapter.detect;
+ let detectCalls = 0;
+ adapter.detect = () => {
+ detectCalls += 1;
+ return { configured: true };
+ };
+
+ try {
+ assert.equal((await selectBrowser()).name, "helium");
+ assert.equal(detectCalls, 1);
+ } finally {
+ adapter.detect = originalDetect;
+ }
+});
diff --git a/coverage/selection.test.js b/coverage/selection.test.js
new file mode 100644
index 0000000..3811876
--- /dev/null
+++ b/coverage/selection.test.js
@@ -0,0 +1,71 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import net from "node:net";
+import os from "node:os";
+import path from "node:path";
+import { selectBrowser } from "../src/browsers/selection.js";
+import { setConfiguredBrowser } from "../src/util/config.js";
+
+const originalHome = process.env.OPENBROWSER_HOME;
+const originalBrowser = process.env.OPENBROWSER_BROWSER;
+
+function useTempHome() {
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "ob-selection-"));
+ process.env.OPENBROWSER_HOME = home;
+ return home;
+}
+
+test.afterEach(() => {
+ if (originalHome === undefined) delete process.env.OPENBROWSER_HOME;
+ else process.env.OPENBROWSER_HOME = originalHome;
+ if (originalBrowser === undefined) delete process.env.OPENBROWSER_BROWSER;
+ else process.env.OPENBROWSER_BROWSER = originalBrowser;
+});
+
+test("explicit browser selection resolves aliases without discovery", async () => {
+ const adapter = await selectBrowser("Google Chrome");
+ assert.equal(adapter.name, "chrome");
+});
+
+test("an unconfigured local default produces a setup instruction", async () => {
+ useTempHome();
+ setConfiguredBrowser("helium");
+
+ await assert.rejects(
+ () => selectBrowser(),
+ /Selected browser Helium is not set up\. Run OpenBrowser setup helium\./,
+ );
+});
+
+test("an active session is used when the configured browser is not set up", async () => {
+ const home = useTempHome();
+ setConfiguredBrowser("helium");
+ const socketPath = path.join(home, "bridge", "chrome.sock");
+ fs.mkdirSync(path.dirname(socketPath), { recursive: true });
+ const server = net.createServer((socket) => {
+ socket.on("data", () => {
+ socket.end(JSON.stringify({ ok: true, result: { open: true, session: { tabId: 7 } } }) + "\n");
+ });
+ });
+ await new Promise((resolve, reject) => {
+ server.once("error", reject);
+ server.listen(socketPath, resolve);
+ });
+
+ try {
+ const adapter = await selectBrowser();
+ assert.equal(adapter.name, "chrome");
+ } finally {
+ await new Promise((resolve) => server.close(resolve));
+ }
+});
+
+test("OPENBROWSER_BROWSER overrides the configured browser", async () => {
+ useTempHome();
+ setConfiguredBrowser("zen");
+ process.env.OPENBROWSER_BROWSER = "chrome";
+
+ const adapter = await selectBrowser();
+ assert.equal(adapter.name, "chrome");
+});
diff --git a/dist/extensions/chromium/openbrowser.zip b/dist/extensions/chromium/openbrowser.zip
index 4f506ca..b5a21b3 100644
Binary files a/dist/extensions/chromium/openbrowser.zip and b/dist/extensions/chromium/openbrowser.zip differ
diff --git a/dist/extensions/chromium/unpacked/assets/logo-128.png b/dist/extensions/chromium/unpacked/assets/logo-128.png
new file mode 100644
index 0000000..eb29407
Binary files /dev/null and b/dist/extensions/chromium/unpacked/assets/logo-128.png differ
diff --git a/dist/extensions/chromium/unpacked/assets/logo-16.png b/dist/extensions/chromium/unpacked/assets/logo-16.png
new file mode 100644
index 0000000..8d60854
Binary files /dev/null and b/dist/extensions/chromium/unpacked/assets/logo-16.png differ
diff --git a/dist/extensions/chromium/unpacked/assets/logo-32.png b/dist/extensions/chromium/unpacked/assets/logo-32.png
new file mode 100644
index 0000000..0fe3ec0
Binary files /dev/null and b/dist/extensions/chromium/unpacked/assets/logo-32.png differ
diff --git a/dist/extensions/chromium/unpacked/assets/logo-48.png b/dist/extensions/chromium/unpacked/assets/logo-48.png
new file mode 100644
index 0000000..2096cb4
Binary files /dev/null and b/dist/extensions/chromium/unpacked/assets/logo-48.png differ
diff --git a/dist/extensions/chromium/unpacked/assets/logo-96.png b/dist/extensions/chromium/unpacked/assets/logo-96.png
new file mode 100644
index 0000000..88239e9
Binary files /dev/null and b/dist/extensions/chromium/unpacked/assets/logo-96.png differ
diff --git a/dist/extensions/chromium/unpacked/manifest.json b/dist/extensions/chromium/unpacked/manifest.json
index a06e9a9..7c17bdb 100644
--- a/dist/extensions/chromium/unpacked/manifest.json
+++ b/dist/extensions/chromium/unpacked/manifest.json
@@ -1,8 +1,15 @@
{
"manifest_version": 3,
"name": "OpenBrowser",
- "version": "2.1.0",
+ "version": "2.2.0",
"description": "Local user-scoped browser control bridge for the OpenBrowser CLI.",
+ "icons": {
+ "16": "assets/logo-16.png",
+ "32": "assets/logo-32.png",
+ "48": "assets/logo-48.png",
+ "96": "assets/logo-96.png",
+ "128": "assets/logo-128.png"
+ },
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyzAut1adVCcD0F/acoPkZeYvT8uijALA+77yvOFFlDRWWDwUoW/pmK4vrdVwppPPbjm7l2IBZgHvTrYjFVS++1BngM97h5O9bZRd8QgFTofRpqJWJMOB5li52c6CcIwtm9BpZTVXZE+YpifgGeeZ7QJ9voeT+Cg18dGVXLT1A0RyNVqyBRhaInIcjbDvZReMWxvI+Gpi/8eVF+pLu7uogtPFjIaOap3jK5YvK0f0JcnQaD6s61IMM3S1LBwZhfiWMwYz6K52d/sQNA9x9iw1EYBAEecohngYQw8n/VMXIWDAB9Sdhe5RoekJ3Lw7xt2U0CvgloPVRNi0p48MqnGIrwIDAQAB",
"minimum_chrome_version": "109",
"permissions": [
diff --git a/dist/extensions/firefox/openbrowser-dev.xpi b/dist/extensions/firefox/openbrowser-dev.xpi
index ee2acf2..59d3201 100644
Binary files a/dist/extensions/firefox/openbrowser-dev.xpi and b/dist/extensions/firefox/openbrowser-dev.xpi differ
diff --git a/extensions/assets/logo-128.png b/extensions/assets/logo-128.png
new file mode 100644
index 0000000..eb29407
Binary files /dev/null and b/extensions/assets/logo-128.png differ
diff --git a/extensions/assets/logo-16.png b/extensions/assets/logo-16.png
new file mode 100644
index 0000000..8d60854
Binary files /dev/null and b/extensions/assets/logo-16.png differ
diff --git a/extensions/assets/logo-32.png b/extensions/assets/logo-32.png
new file mode 100644
index 0000000..0fe3ec0
Binary files /dev/null and b/extensions/assets/logo-32.png differ
diff --git a/extensions/assets/logo-48.png b/extensions/assets/logo-48.png
new file mode 100644
index 0000000..2096cb4
Binary files /dev/null and b/extensions/assets/logo-48.png differ
diff --git a/extensions/assets/logo-96.png b/extensions/assets/logo-96.png
new file mode 100644
index 0000000..88239e9
Binary files /dev/null and b/extensions/assets/logo-96.png differ
diff --git a/extensions/manifest.json b/extensions/manifest.json
index 49b8cc2..d7847e5 100644
--- a/extensions/manifest.json
+++ b/extensions/manifest.json
@@ -1,12 +1,14 @@
{
"manifest_version": 2,
"name": "OpenBrowser",
- "version": "2.1.1",
+ "version": "2.2.0",
"description": "Local user-scoped browser control bridge for the OpenBrowser CLI.",
"icons": {
- "48": "assets/logo.svg",
- "96": "assets/logo.svg",
- "128": "assets/logo.svg"
+ "16": "assets/logo-16.png",
+ "32": "assets/logo-32.png",
+ "48": "assets/logo-48.png",
+ "96": "assets/logo-96.png",
+ "128": "assets/logo-128.png"
},
"permissions": [
"nativeMessaging",
diff --git a/package-lock.json b/package-lock.json
index c693fbc..04286a8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,13 +1,13 @@
{
"name": "@pxlarified/browser",
- "version": "2.0.1",
+ "version": "2.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@pxlarified/browser",
- "version": "2.0.1",
- "license": "SEE LICENSE IN LICENSE",
+ "version": "2.2.0",
+ "license": "GREED-1",
"bin": {
"OpenBrowser": "bin/OpenBrowser.js"
},
diff --git a/package.json b/package.json
index 279d0ce..a908d78 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@pxlarified/browser",
- "version": "2.1.1",
+ "version": "2.2.0",
"description": "Minimal local browser-control CLI for OpenBrowser.",
"repository": {
"type": "git",
diff --git a/scripts/build.js b/scripts/build.js
index 8892d71..355f9fe 100755
--- a/scripts/build.js
+++ b/scripts/build.js
@@ -62,7 +62,8 @@ function chromiumManifest() {
name: source.name,
version: source.version,
description: source.description,
- key: readChromeKey(),
+ icons: source.icons,
+ key: readChromiumKey(),
minimum_chrome_version: "109",
permissions: [...new Set([...permissions, "scripting"])],
host_permissions: hostPermissions,
@@ -72,10 +73,10 @@ function chromiumManifest() {
};
}
-function readChromeKey() {
+function readChromiumKey() {
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.");
+ const match = constants.match(/CHROMIUM_EXTENSION_KEY\s*=\s*"([^"]+)"/);
+ if (!match) throw new Error("Could not read CHROMIUM_EXTENSION_KEY from src/constants.js.");
return match[1];
}
diff --git a/skills/browser/SKILL.md b/skills/browser/SKILL.md
index c5ba398..7da48cb 100644
--- a/skills/browser/SKILL.md
+++ b/skills/browser/SKILL.md
@@ -10,50 +10,64 @@ Run commands through the npm package.
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 ` selects the browser (`zen` or `chrome`). It is optional and
-defaults to the browser set with `config browser`, or Zen when none is configured.
+OpenBrowser selects a local browser automatically. The first time, inspect or set up
+one browser on the current machine:
```bash
-# Installation
-npx @pxlarified/browser install zen
-npx @pxlarified/browser install chrome
+npx @pxlarified/browser browsers
+npx @pxlarified/browser setup helium
+# or: chrome / zen
+```
+
+`setup` installs the bridge, stages the extension, and remembers that browser as the
+local default. Chromium browsers may require one manual "Load unpacked" step, which
+setup reports. Do not hard-code a browser in normal commands. Use `--browser` only
+when deliberately working with multiple browsers.
-# Default browser (used when --browser is omitted)
-npx @pxlarified/browser config browser chrome
+```bash
+# Show or change the local default
+npx @pxlarified/browser config browser
+npx @pxlarified/browser use helium
+npx @pxlarified/browser use auto
# Session management
-npx @pxlarified/browser open --browser zen
-npx @pxlarified/browser close --browser zen
-npx @pxlarified/browser status --browser zen
+npx @pxlarified/browser open https://example.com
+npx @pxlarified/browser close
+npx @pxlarified/browser status
# Navigation
-npx @pxlarified/browser navigate --browser zen
-npx @pxlarified/browser reload --browser zen
-npx @pxlarified/browser back --browser zen
-npx @pxlarified/browser forward --browser zen
+npx @pxlarified/browser navigate https://example.com
+npx @pxlarified/browser reload
+npx @pxlarified/browser back
+npx @pxlarified/browser forward
# Page state
-npx @pxlarified/browser state --browser zen
+npx @pxlarified/browser state
# Screenshots
-npx @pxlarified/browser screenshot --browser zen
-npx @pxlarified/browser screenshot --base64 --browser zen
+npx @pxlarified/browser screenshot
+npx @pxlarified/browser screenshot --base64
# Interaction
-npx @pxlarified/browser click [ --browser zen
-npx @pxlarified/browser keys ] --browser zen
-npx @pxlarified/browser press --browser zen
-npx @pxlarified/browser select [ ] --browser zen
+npx @pxlarified/browser click e_1
+npx @pxlarified/browser keys "text to type"
+npx @pxlarified/browser press Enter
+npx @pxlarified/browser select e_1 option-value
# Content inspection
-npx @pxlarified/browser get --html --browser zen
-npx @pxlarified/browser get --html --ref [ --browser zen
+npx @pxlarified/browser get --html
+npx @pxlarified/browser get --html --ref e_1
# Scrolling
-npx @pxlarified/browser scroll up [pixels] --browser zen
-npx @pxlarified/browser scroll down [pixels] --browser zen
-npx @pxlarified/browser scroll --to ][ --browser zen
+npx @pxlarified/browser scroll up [pixels]
+npx @pxlarified/browser scroll down [pixels]
+npx @pxlarified/browser scroll --to e_1
```
+
+Supported browser IDs are `helium`, `chrome`, and `zen`. Every command also accepts
+`--browser ]` as an explicit override. If more than one browser is active or
+configured and no default is selected, OpenBrowser reports the choices instead of
+silently choosing the wrong browser.
+
+Close the session after you are done unless it contains important information or you
+want to keep it warm.
diff --git a/src/bridge/client.js b/src/bridge/client.js
index 694b470..56a163c 100644
--- a/src/bridge/client.js
+++ b/src/bridge/client.js
@@ -4,7 +4,7 @@ import { bridgeSocketPath } from "../util/paths.js";
export class BridgeUnavailableError extends Error {
constructor(browser, cause) {
super(
- `OpenBrowser bridge for ${browser} is not available. Start ${browser}, make sure the OpenBrowser extension is installed, then retry.`,
+ `OpenBrowser bridge for ${browser} is not available. Run OpenBrowser setup ${browser}, load the extension if prompted, then retry.`,
);
this.name = "BridgeUnavailableError";
this.cause = cause;
diff --git a/src/browsers/catalog.js b/src/browsers/catalog.js
new file mode 100644
index 0000000..9a2d1f2
--- /dev/null
+++ b/src/browsers/catalog.js
@@ -0,0 +1,226 @@
+import os from "node:os";
+import path from "node:path";
+
+function homePath(...segments) {
+ return path.join(os.homedir(), ...segments);
+}
+
+function envPath(name, fallback) {
+ return process.env[name] || fallback;
+}
+
+function macApplicationPaths(names) {
+ return names.flatMap((name) => [
+ path.join("/Applications", `${name}.app`),
+ homePath("Applications", `${name}.app`),
+ ]);
+}
+
+function windowsApplicationPaths(...segments) {
+ const localAppData = envPath("LOCALAPPDATA", path.join(os.homedir(), "AppData", "Local"));
+ const programFiles = envPath("ProgramFiles", "C:\\Program Files");
+ const programFilesX86 = envPath("ProgramFiles(x86)", "C:\\Program Files (x86)");
+
+ return [
+ path.join(localAppData, ...segments),
+ path.join(programFiles, ...segments),
+ path.join(programFilesX86, ...segments),
+ ];
+}
+
+function zenDefinition() {
+ if (process.platform === "darwin") {
+ return {
+ name: "zen",
+ aliases: ["zen-browser", "zen browser"],
+ displayName: "Zen",
+ family: "firefox",
+ profileRoots: [
+ homePath("Library", "Application Support", "zen"),
+ homePath("Library", "Application Support", "Zen"),
+ homePath("Library", "Application Support", "Zen Browser"),
+ ],
+ nativeManifestRoots: [
+ homePath("Library", "Application Support", "Mozilla"),
+ homePath("Library", "Application Support", "zen"),
+ homePath("Library", "Application Support", "Zen"),
+ homePath("Library", "Application Support", "Zen Browser"),
+ ],
+ applicationPaths: macApplicationPaths(["Zen Browser", "Zen"]),
+ executableCandidates: ["zen", "zen-browser"],
+ processNames: ["Zen Browser", "Zen", "zen"],
+ launchCommands: [
+ { command: "open", args: ["-a", "Zen Browser"], waitForExit: true },
+ { command: "open", args: ["-a", "Zen"], waitForExit: true },
+ ],
+ };
+ }
+
+ if (process.platform === "win32") {
+ const appData = envPath("APPDATA", path.join(os.homedir(), "AppData", "Roaming"));
+
+ return {
+ name: "zen",
+ aliases: ["zen-browser", "zen browser"],
+ displayName: "Zen",
+ family: "firefox",
+ profileRoots: [
+ path.join(appData, "zen"),
+ path.join(appData, "Zen"),
+ path.join(appData, "Zen Browser"),
+ ],
+ nativeManifestRoots: [],
+ applicationPaths: windowsApplicationPaths("Zen Browser", "zen.exe"),
+ executableCandidates: ["zen.exe", "zen-browser.exe"],
+ processNames: ["zen.exe", "zen-browser.exe"],
+ launchCommands: [
+ { command: "cmd", args: ["/c", "start", "", "zen"] },
+ { command: "cmd", args: ["/c", "start", "", "zen-browser"] },
+ ],
+ };
+ }
+
+ return {
+ name: "zen",
+ aliases: ["zen-browser", "zen browser"],
+ displayName: "Zen",
+ family: "firefox",
+ profileRoots: [homePath(".zen"), homePath(".zen-browser"), homePath(".mozilla", "zen")],
+ nativeManifestRoots: [homePath(".mozilla"), homePath(".zen"), homePath(".zen-browser")],
+ applicationPaths: ["/usr/bin/zen", "/usr/bin/zen-browser", homePath(".local", "bin", "zen")],
+ executableCandidates: ["zen", "zen-bin", "zen-browser"],
+ processNames: ["zen", "zen-bin", "zen-browser"],
+ launchCommands: [
+ { command: "zen-browser", args: [] },
+ { command: "zen", args: [] },
+ ],
+ };
+}
+
+function chromeDefinition() {
+ if (process.platform === "darwin") {
+ const profileRoot = homePath("Library", "Application Support", "Google", "Chrome");
+ return {
+ name: "chrome",
+ aliases: ["google-chrome", "google chrome"],
+ displayName: "Chrome",
+ family: "chromium",
+ profileRoots: [profileRoot],
+ nativeManifestRoots: [profileRoot],
+ applicationPaths: macApplicationPaths(["Google Chrome"]),
+ executableCandidates: ["google-chrome", "google-chrome-stable"],
+ launchCommands: [{ command: "open", args: ["-a", "Google Chrome"], waitForExit: true }],
+ };
+ }
+
+ if (process.platform === "win32") {
+ const localAppData = envPath("LOCALAPPDATA", path.join(os.homedir(), "AppData", "Local"));
+ return {
+ name: "chrome",
+ aliases: ["google-chrome", "google chrome"],
+ displayName: "Chrome",
+ family: "chromium",
+ profileRoots: [path.join(localAppData, "Google", "Chrome", "User Data")],
+ nativeManifestRoots: [],
+ registryRoots: ["HKCU\\Software\\Google\\Chrome\\NativeMessagingHosts"],
+ applicationPaths: windowsApplicationPaths("Google", "Chrome", "Application", "chrome.exe"),
+ executableCandidates: ["chrome.exe", "google-chrome.exe"],
+ launchCommands: [{ command: "cmd", args: ["/c", "start", "", "chrome"] }],
+ };
+ }
+
+ return {
+ name: "chrome",
+ aliases: ["google-chrome", "google chrome"],
+ displayName: "Chrome",
+ family: "chromium",
+ profileRoots: [homePath(".config", "google-chrome")],
+ nativeManifestRoots: [homePath(".config", "google-chrome")],
+ applicationPaths: ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable"],
+ executableCandidates: ["google-chrome", "google-chrome-stable"],
+ launchCommands: [
+ { command: "google-chrome", args: [] },
+ { command: "google-chrome-stable", args: [] },
+ ],
+ };
+}
+
+function heliumDefinition() {
+ if (process.platform === "darwin") {
+ const profileRoot = homePath("Library", "Application Support", "net.imput.helium");
+ return {
+ name: "helium",
+ aliases: ["helium-browser", "helium browser"],
+ displayName: "Helium",
+ family: "chromium",
+ profileRoots: [
+ profileRoot,
+ homePath("Library", "Application Support", "Helium"),
+ homePath("Library", "Application Support", "Helium Browser"),
+ ],
+ nativeManifestRoots: [
+ profileRoot,
+ homePath("Library", "Application Support", "Helium"),
+ homePath("Library", "Application Support", "Helium Browser"),
+ ],
+ applicationPaths: macApplicationPaths(["Helium", "Helium Browser"]),
+ executableCandidates: ["helium", "helium-browser"],
+ launchCommands: [
+ { command: "open", args: ["-a", "Helium"], waitForExit: true },
+ { command: "open", args: ["-a", "Helium Browser"], waitForExit: true },
+ ],
+ };
+ }
+
+ if (process.platform === "win32") {
+ const appData = envPath("APPDATA", path.join(os.homedir(), "AppData", "Roaming"));
+ const localAppData = envPath("LOCALAPPDATA", path.join(os.homedir(), "AppData", "Local"));
+ return {
+ name: "helium",
+ aliases: ["helium-browser", "helium browser"],
+ displayName: "Helium",
+ family: "chromium",
+ profileRoots: [
+ path.join(localAppData, "Helium", "User Data"),
+ path.join(appData, "Helium"),
+ ],
+ nativeManifestRoots: [],
+ registryRoots: ["HKCU\\Software\\Helium\\NativeMessagingHosts"],
+ applicationPaths: [
+ ...windowsApplicationPaths("Helium", "helium.exe"),
+ ...windowsApplicationPaths("Helium Browser", "helium.exe"),
+ ],
+ executableCandidates: ["helium.exe", "helium-browser.exe"],
+ launchCommands: [
+ { command: "cmd", args: ["/c", "start", "", "helium"] },
+ { command: "cmd", args: ["/c", "start", "", "helium-browser"] },
+ ],
+ };
+ }
+
+ return {
+ name: "helium",
+ aliases: ["helium-browser", "helium browser"],
+ displayName: "Helium",
+ family: "chromium",
+ profileRoots: [homePath(".config", "helium"), homePath(".config", "helium-browser")],
+ nativeManifestRoots: [homePath(".config", "helium"), homePath(".config", "helium-browser")],
+ applicationPaths: ["/usr/bin/helium", "/usr/bin/helium-browser", homePath(".local", "bin", "helium")],
+ executableCandidates: ["helium", "helium-browser"],
+ launchCommands: [
+ { command: "helium", args: [] },
+ { command: "helium-browser", args: [] },
+ ],
+ };
+}
+
+export function browserDefinitions() {
+ return [zenDefinition(), chromeDefinition(), heliumDefinition()];
+}
+
+export function getBrowserDefinition(name) {
+ const normalized = String(name || "").trim().toLowerCase();
+ return browserDefinitions().find((definition) => (
+ definition.name === normalized || definition.aliases.includes(normalized)
+ ));
+}
diff --git a/src/browsers/chrome.js b/src/browsers/chrome.js
index 5d21683..6196574 100644
--- a/src/browsers/chrome.js
+++ b/src/browsers/chrome.js
@@ -1,47 +1,8 @@
-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: [] },
- ];
-}
+import { getBrowserDefinition } from "./catalog.js";
export class ChromeBrowserAdapter extends ChromiumFamilyAdapter {
constructor() {
- super({
- name: "chrome",
- displayName: "Chrome",
- nativeManifestRoots: platformNativeManifestRoots(),
- registryRoots: platformRegistryRoots(),
- launchCommands: platformLaunchCommands(),
- });
+ super(getBrowserDefinition("chrome"));
}
}
diff --git a/src/browsers/chromium-family.js b/src/browsers/chromium-family.js
index c73b989..90e4e5e 100644
--- a/src/browsers/chromium-family.js
+++ b/src/browsers/chromium-family.js
@@ -1,16 +1,31 @@
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";
+import { CHROMIUM_EXTENSION_ID, CHROMIUM_UNPACKED_DIR, NATIVE_HOST_NAME } from "../constants.js";
+import { bridgeSocketPath, openBrowserHome, packageRoot } from "../util/paths.js";
+import {
+ availableCommands,
+ existingPaths,
+ expandHome,
+ installNativeHost,
+ launchBrowser,
+ nativeHostLauncherPath,
+ nativeMessagingManifestPath,
+ nativeMessagingManifestPaths,
+ uniqueExistingDirectories,
+} 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.family = options.family || "chromium";
+ this.aliases = options.aliases || [];
+ this.profileRoots = options.profileRoots || [];
+ this.nativeManifestRoots = options.nativeManifestRoots || [];
+ this.launchCommands = options.launchCommands || [];
+ this.applicationPaths = options.applicationPaths || [];
+ this.executableCandidates = options.executableCandidates || [];
this.registryRoots = options.registryRoots || [];
}
@@ -18,6 +33,38 @@ export class ChromiumFamilyAdapter {
return path.join(packageRoot(), CHROMIUM_UNPACKED_DIR);
}
+ detect() {
+ const profiles = uniqueExistingDirectories(this.profileRoots.map((root) => expandHome(root)));
+ const applications = existingPaths(this.applicationPaths);
+ const executables = availableCommands(this.executableCandidates);
+ const nativeManifests = existingPaths(this.nativeManifestCandidates());
+ const extension = path.join(openBrowserHome(), "extensions", this.name);
+ const stagedExtension = fs.existsSync(path.join(extension, "manifest.json"));
+ const nativeHost = fs.existsSync(nativeHostLauncherPath(this.name));
+ const bridgeSocket = fs.existsSync(bridgeSocketPath(this.name));
+ const installed = profiles.length > 0 || applications.length > 0 || executables.length > 0;
+ const configured = stagedExtension && nativeHost && (nativeManifests.length > 0 || process.platform === "win32");
+
+ return {
+ browser: this.name,
+ displayName: this.displayName,
+ family: this.family,
+ aliases: this.aliases,
+ installed,
+ configured,
+ detected: installed || configured || nativeHost || bridgeSocket,
+ ready: false,
+ sessionOpen: false,
+ profiles,
+ applications,
+ executables,
+ extension: stagedExtension ? extension : null,
+ nativeManifests,
+ nativeHost,
+ bridgeSocket,
+ };
+ }
+
async install() {
const source = this.artifactPath();
if (!fs.existsSync(source)) {
@@ -25,7 +72,7 @@ export class ChromiumFamilyAdapter {
}
const nativeHost = installNativeHost(this.name);
- const manifests = installNativeMessagingManifests(this.nativeManifestRoots, this.registryRoots, nativeHost);
+ const manifests = installNativeMessagingManifests(this.name, 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.
@@ -37,7 +84,7 @@ export class ChromiumFamilyAdapter {
return {
browser: this.name,
unpackedExtension: unpackedDir,
- extensionId: CHROME_EXTENSION_ID,
+ extensionId: CHROMIUM_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}.`,
@@ -45,29 +92,25 @@ export class ChromiumFamilyAdapter {
}
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 launchBrowser(this.launchCommands);
+ }
+
+ nativeManifestCandidates() {
+ const candidates = nativeMessagingManifestPaths(this.nativeManifestRoots, "NativeMessagingHosts");
+ if (process.platform === "win32") {
+ candidates.push(nativeMessagingManifestPath(this.name));
}
- return false;
+ return candidates;
}
}
-function installNativeMessagingManifests(roots, registryRoots, hostPath) {
+function installNativeMessagingManifests(browser, 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}/`],
+ allowed_origins: [`chrome-extension://${CHROMIUM_EXTENSION_ID}/`],
};
const written = [];
@@ -79,14 +122,14 @@ function installNativeMessagingManifests(roots, registryRoots, hostPath) {
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.
+ // Some candidate vendor directories may not be writable or useful 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`);
+ const manifestPath = nativeMessagingManifestPath(browser);
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
written.push(manifestPath);
diff --git a/src/browsers/firefox-family.js b/src/browsers/firefox-family.js
index bfe0246..9728b78 100644
--- a/src/browsers/firefox-family.js
+++ b/src/browsers/firefox-family.js
@@ -2,17 +2,31 @@ import fs from "node:fs";
import path from "node:path";
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";
+import { bridgeSocketPath, openBrowserHome, packageRoot } from "../util/paths.js";
+import {
+ availableCommands,
+ existingPaths,
+ expandHome,
+ findRunningProcess,
+ installNativeHost,
+ launchBrowser,
+ nativeHostLauncherPath,
+ nativeMessagingManifestPaths,
+ uniqueExistingDirectories,
+} from "./shared.js";
export class FirefoxFamilyAdapter {
constructor(options) {
this.name = options.name;
this.displayName = options.displayName;
- this.profileRoots = options.profileRoots;
- this.launchCommands = options.launchCommands;
- this.nativeManifestRoots = options.nativeManifestRoots;
+ this.family = options.family || "firefox";
+ this.aliases = options.aliases || [];
+ this.profileRoots = options.profileRoots || [];
+ this.launchCommands = options.launchCommands || [];
+ this.nativeManifestRoots = options.nativeManifestRoots || [];
this.processNames = options.processNames || [];
+ this.applicationPaths = options.applicationPaths || [];
+ this.executableCandidates = options.executableCandidates || [];
}
artifactPath() {
@@ -22,6 +36,44 @@ export class FirefoxFamilyAdapter {
return path.join(root, FIREFOX_DEV_ARTIFACT);
}
+ detect() {
+ let profiles = [];
+ try {
+ profiles = this.findProfiles();
+ } catch {
+ // A malformed profile configuration should not prevent other browsers
+ // from being discovered.
+ }
+
+ const extensions = existingPaths(profiles.map((profile) => path.join(profile, "extensions", `${EXTENSION_ID}.xpi`)));
+ const nativeManifests = existingPaths(this.nativeManifestCandidates());
+ const applications = existingPaths(this.applicationPaths);
+ const executables = availableCommands(this.executableCandidates);
+ const nativeHost = fs.existsSync(nativeHostLauncherPath(this.name));
+ const bridgeSocket = fs.existsSync(bridgeSocketPath(this.name));
+ const installed = profiles.length > 0 || applications.length > 0 || executables.length > 0;
+ const configured = nativeManifests.length > 0 && extensions.length > 0 && nativeHost;
+
+ return {
+ browser: this.name,
+ displayName: this.displayName,
+ family: this.family,
+ aliases: this.aliases,
+ installed,
+ configured,
+ detected: installed || configured || nativeHost || bridgeSocket,
+ ready: false,
+ sessionOpen: false,
+ profiles,
+ applications,
+ executables,
+ extensions,
+ nativeManifests,
+ nativeHost,
+ bridgeSocket,
+ };
+ }
+
async install() {
const artifact = this.artifactPath();
if (!fs.existsSync(artifact)) {
@@ -62,19 +114,7 @@ export class FirefoxFamilyAdapter {
}
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;
+ return launchBrowser(this.launchCommands);
}
findProfiles() {
@@ -89,6 +129,14 @@ export class FirefoxFamilyAdapter {
return uniqueExistingDirectories(profiles);
}
+
+ nativeManifestCandidates() {
+ const candidates = nativeMessagingManifestPaths(this.nativeManifestRoots);
+ if (process.platform === "win32") {
+ candidates.push(path.join(openBrowserHome(), "native-messaging-hosts", `${NATIVE_HOST_NAME}.json`));
+ }
+ return candidates;
+ }
}
function installNativeMessagingManifests(roots, hostPath) {
diff --git a/src/browsers/helium.js b/src/browsers/helium.js
new file mode 100644
index 0000000..351bd68
--- /dev/null
+++ b/src/browsers/helium.js
@@ -0,0 +1,8 @@
+import { ChromiumFamilyAdapter } from "./chromium-family.js";
+import { getBrowserDefinition } from "./catalog.js";
+
+export class HeliumBrowserAdapter extends ChromiumFamilyAdapter {
+ constructor() {
+ super(getBrowserDefinition("helium"));
+ }
+}
diff --git a/src/browsers/registry.js b/src/browsers/registry.js
index 22486fc..f3b75cd 100644
--- a/src/browsers/registry.js
+++ b/src/browsers/registry.js
@@ -1,18 +1,47 @@
-import { ZenBrowserAdapter } from "./zen.js";
-import { ChromeBrowserAdapter } from "./chrome.js";
-import { DEFAULT_BROWSER } from "../constants.js";
+import { browserDefinitions } from "./catalog.js";
+import { ChromiumFamilyAdapter } from "./chromium-family.js";
+import { FirefoxFamilyAdapter } from "./firefox-family.js";
-const adapters = new Map([
- ["zen", new ZenBrowserAdapter()],
- ["chrome", new ChromeBrowserAdapter()],
-]);
+const adapters = new Map(browserDefinitions().map((definition) => {
+ const Adapter = definition.family === "firefox" ? FirefoxFamilyAdapter : ChromiumFamilyAdapter;
+ return [definition.name, new Adapter(definition)];
+}));
+
+const aliases = new Map();
+for (const adapter of adapters.values()) {
+ aliases.set(adapter.name, adapter.name);
+ for (const alias of adapter.aliases) aliases.set(alias.toLowerCase(), adapter.name);
+}
export function supportedBrowsers() {
return [...adapters.keys()];
}
+export function browserAdapters() {
+ return [...adapters.values()];
+}
+
+export function browserCatalog() {
+ return browserAdapters().map((adapter) => ({
+ name: adapter.name,
+ displayName: adapter.displayName,
+ family: adapter.family,
+ aliases: [...adapter.aliases],
+ }));
+}
+
+export function normalizeBrowserName(browser) {
+ if (browser === undefined || browser === null) return undefined;
+ const normalized = String(browser).trim().toLowerCase();
+ return aliases.get(normalized) || normalized;
+}
+
export function resolveBrowser(browser) {
- const name = (browser || DEFAULT_BROWSER).toLowerCase();
+ const name = normalizeBrowserName(browser);
+ if (!name || name === "auto") {
+ throw new Error(`Browser selection is required. Choose one of: ${supportedBrowsers().join(", ")}.`);
+ }
+
const adapter = adapters.get(name);
if (!adapter) {
throw new Error(`Unsupported browser: ${browser}. Supported browsers: ${supportedBrowsers().join(", ")}.`);
diff --git a/src/browsers/selection.js b/src/browsers/selection.js
new file mode 100644
index 0000000..62805ed
--- /dev/null
+++ b/src/browsers/selection.js
@@ -0,0 +1,107 @@
+import { sendBridgeCommand } from "../bridge/client.js";
+import { DEFAULT_BROWSER } from "../constants.js";
+import { getConfiguredBrowser } from "../util/config.js";
+import { browserAdapters, normalizeBrowserName, resolveBrowser } from "./registry.js";
+
+const PROBE_TIMEOUT_MS = 800;
+
+export async function selectBrowser(requested, options = {}) {
+ const { allowUnconfigured = false } = options;
+
+ if (requested !== undefined) {
+ const normalized = normalizeBrowserName(requested);
+ if (normalized !== DEFAULT_BROWSER) return resolveBrowser(requested);
+ return selectAutomatically(undefined, allowUnconfigured);
+ }
+
+ const environmentBrowser = process.env.OPENBROWSER_BROWSER;
+ if (environmentBrowser) {
+ const normalized = normalizeBrowserName(environmentBrowser);
+ if (normalized !== DEFAULT_BROWSER) return resolveBrowser(environmentBrowser);
+ return selectAutomatically(undefined, allowUnconfigured);
+ }
+
+ const configuredBrowser = getConfiguredBrowser();
+ if (configuredBrowser) {
+ const normalized = normalizeBrowserName(configuredBrowser);
+ if (normalized !== DEFAULT_BROWSER) {
+ const configuredAdapter = resolveBrowser(configuredBrowser);
+ if (configuredAdapter.detect().configured) return configuredAdapter;
+ return selectAutomatically(configuredAdapter, allowUnconfigured);
+ }
+ }
+
+ return selectAutomatically(undefined, allowUnconfigured);
+}
+
+export async function inspectBrowsers() {
+ return Promise.all(browserAdapters().map(async (adapter) => {
+ const detected = adapter.detect();
+ let bridgeResponding = false;
+ let sessionOpen = false;
+ let session;
+
+ try {
+ const status = await sendBridgeCommand(adapter.name, "status", {}, { timeoutMs: PROBE_TIMEOUT_MS });
+ bridgeResponding = true;
+ sessionOpen = Boolean(status?.open);
+ session = status?.session;
+ } catch {
+ // A browser without a running native bridge is still useful discovery data.
+ }
+
+ return {
+ ...detected,
+ ready: bridgeResponding,
+ sessionOpen,
+ session,
+ bridgeSocket: detected.bridgeSocket || bridgeResponding,
+ };
+ }));
+}
+
+async function selectAutomatically(preferred, allowUnconfigured) {
+ const inventory = await inspectBrowsers();
+ const active = inventory.filter((browser) => browser.sessionOpen);
+ if (active.length === 1) return resolveBrowser(active[0].browser);
+ if (active.length > 1) {
+ throw new Error(`Multiple active OpenBrowser sessions found: ${formatBrowsers(active)}. Pass --browser or run OpenBrowser use .`);
+ }
+
+ if (preferred) {
+ const preferredStatus = inventory.find((browser) => browser.browser === preferred.name);
+ if (!allowUnconfigured && preferredStatus && !preferredStatus.configured && !preferredStatus.ready) {
+ throw new Error(`Selected browser ${preferred.displayName} is not set up. Run OpenBrowser setup ${preferred.name}.`);
+ }
+ return preferred;
+ }
+
+ const responsive = inventory.filter((browser) => browser.ready);
+ if (responsive.length === 1) return resolveBrowser(responsive[0].browser);
+ if (responsive.length > 1) {
+ throw new Error(`Multiple browsers are ready: ${formatBrowsers(responsive)}. Pass --browser or run OpenBrowser use .`);
+ }
+
+ const configured = inventory.filter((browser) => browser.configured);
+ if (configured.length === 1) return resolveBrowser(configured[0].browser);
+ if (configured.length > 1) {
+ throw new Error(`Multiple browsers are configured: ${formatBrowsers(configured)}. Run OpenBrowser use to choose one.`);
+ }
+
+ const installed = inventory.filter((browser) => browser.installed);
+ if (installed.length === 1) {
+ if (!installed[0].configured && !allowUnconfigured) {
+ throw new Error(`Detected ${installed[0].displayName}, but it is not set up. Run OpenBrowser setup ${installed[0].browser}.`);
+ }
+ return resolveBrowser(installed[0].browser);
+ }
+ if (installed.length > 1) {
+ throw new Error(`Multiple supported browsers were detected: ${formatBrowsers(installed)}. Run OpenBrowser setup or OpenBrowser use .`);
+ }
+
+ throw new Error("No supported browser was detected. Run OpenBrowser browsers to inspect the setup, then OpenBrowser setup .");
+}
+
+function formatBrowsers(browsers) {
+ return browsers.map((browser) => `${browser.displayName} (${browser.browser})`).join(", ");
+}
diff --git a/src/browsers/shared.js b/src/browsers/shared.js
index 1f15724..8f0eaf1 100644
--- a/src/browsers/shared.js
+++ b/src/browsers/shared.js
@@ -1,8 +1,9 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
-import { execFileSync } from "node:child_process";
+import { execFileSync, spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
+import { NATIVE_HOST_NAME } from "../constants.js";
import { openBrowserHome } from "../util/paths.js";
// Installs the shared, browser-agnostic native messaging host launcher used by
@@ -17,17 +18,99 @@ export function installNativeHost(browser) {
if (process.platform !== "win32") fs.chmodSync(hostScript, 0o755);
if (process.platform === "win32") {
- const cmd = path.join(dir, `openbrowser-native-host-${browser}.cmd`);
+ const cmd = nativeHostLauncherPath(browser);
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}`);
+ const launcher = nativeHostLauncherPath(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 nativeHostLauncherPath(browser) {
+ const filename = process.platform === "win32"
+ ? `openbrowser-native-host-${browser}.cmd`
+ : `openbrowser-native-host-${browser}`;
+ return path.join(openBrowserHome(), "native-host", filename);
+}
+
+export function nativeMessagingManifestPaths(roots, directory) {
+ const manifestDirectory = directory || (process.platform === "linux" ? "native-messaging-hosts" : "NativeMessagingHosts");
+ return roots.map((root) => path.join(expandHome(root), manifestDirectory, `${NATIVE_HOST_NAME}.json`));
+}
+
+export function nativeMessagingManifestPath(browser, platform = process.platform) {
+ const filename = platform === "win32"
+ ? `${NATIVE_HOST_NAME}-${browser}.json`
+ : `${NATIVE_HOST_NAME}.json`;
+ return path.join(openBrowserHome(), "native-messaging-hosts", filename);
+}
+
+export function existingPaths(candidates) {
+ const seen = new Set();
+ const result = [];
+ for (const candidate of candidates) {
+ const resolved = path.resolve(candidate);
+ if (seen.has(resolved) || !fs.existsSync(resolved)) continue;
+ seen.add(resolved);
+ result.push(resolved);
+ }
+ return result;
+}
+
+export function availableCommands(commands) {
+ const checker = process.platform === "win32" ? "where" : "which";
+ const result = [];
+ for (const command of commands) {
+ try {
+ execFileSync(checker, [command], { stdio: "ignore" });
+ result.push(command);
+ } catch {
+ // The command is not available on PATH.
+ }
+ }
+ return result;
+}
+
+export async function launchBrowser(commands) {
+ for (const command of commands) {
+ const launched = await new Promise((resolve) => {
+ let settled = false;
+ const finish = (value) => {
+ if (settled) return;
+ settled = true;
+ resolve(value);
+ };
+
+ let child;
+ try {
+ child = spawn(command.command, command.args, {
+ detached: true,
+ stdio: "ignore",
+ });
+ } catch {
+ finish(false);
+ return;
+ }
+
+ child.once("error", () => finish(false));
+ child.once("spawn", () => {
+ if (command.waitForExit) return;
+ child.unref();
+ finish(true);
+ });
+ if (command.waitForExit) {
+ child.once("close", (code) => finish(code === 0));
+ }
+ });
+
+ if (launched) return true;
+ }
+ return false;
+}
+
export function findRunningProcess(processNames) {
if (processNames.length === 0) return null;
diff --git a/src/browsers/zen.js b/src/browsers/zen.js
index f8d6255..4c29fc9 100644
--- a/src/browsers/zen.js
+++ b/src/browsers/zen.js
@@ -1,77 +1,8 @@
-import os from "node:os";
-import path from "node:path";
import { FirefoxFamilyAdapter } from "./firefox-family.js";
-
-function platformProfileRoots() {
- if (process.platform === "darwin") {
- return [
- "~/Library/Application Support/zen",
- "~/Library/Application Support/Zen",
- "~/Library/Application Support/Zen Browser",
- ];
- }
-
- if (process.platform === "win32") {
- const appData = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
- return [path.join(appData, "zen"), path.join(appData, "Zen"), path.join(appData, "Zen Browser")];
- }
-
- return ["~/.zen", "~/.zen-browser", "~/.mozilla/zen"];
-}
-
-function platformNativeManifestRoots() {
- if (process.platform === "darwin") {
- return [
- "~/Library/Application Support/Mozilla",
- "~/Library/Application Support/zen",
- "~/Library/Application Support/Zen",
- "~/Library/Application Support/Zen Browser",
- ];
- }
-
- if (process.platform === "win32") {
- return [];
- }
-
- return ["~/.mozilla", "~/.zen", "~/.zen-browser"];
-}
-
-function platformProcessNames() {
- if (process.platform === "darwin") return ["Zen Browser", "Zen", "zen"];
- if (process.platform === "win32") return ["zen", "zen-browser"];
- return ["zen", "zen-bin", "zen-browser"];
-}
-
-function platformLaunchCommands() {
- if (process.platform === "darwin") {
- return [
- { command: "open", args: ["-a", "Zen Browser"] },
- { command: "open", args: ["-a", "Zen"] },
- ];
- }
-
- if (process.platform === "win32") {
- return [
- { command: "cmd", args: ["/c", "start", "", "zen"] },
- { command: "cmd", args: ["/c", "start", "", "zen-browser"] },
- ];
- }
-
- return [
- { command: "zen-browser", args: [] },
- { command: "zen", args: [] },
- ];
-}
+import { getBrowserDefinition } from "./catalog.js";
export class ZenBrowserAdapter extends FirefoxFamilyAdapter {
constructor() {
- super({
- name: "zen",
- displayName: "Zen",
- profileRoots: platformProfileRoots(),
- nativeManifestRoots: platformNativeManifestRoots(),
- launchCommands: platformLaunchCommands(),
- processNames: platformProcessNames(),
- });
+ super(getBrowserDefinition("zen"));
}
}
diff --git a/src/cli.js b/src/cli.js
index 95cba7e..1ed7479 100644
--- a/src/cli.js
+++ b/src/cli.js
@@ -2,7 +2,9 @@ import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
-import { resolveBrowser, supportedBrowsers } from "./browsers/registry.js";
+import { DEFAULT_BROWSER } from "./constants.js";
+import { resolveBrowser, normalizeBrowserName, supportedBrowsers } from "./browsers/registry.js";
+import { inspectBrowsers, selectBrowser } from "./browsers/selection.js";
import { BridgeUnavailableError, sendBridgeCommandWithRetry } from "./bridge/client.js";
import { getConfiguredBrowser, readConfig, setConfiguredBrowser } from "./util/config.js";
import { screenshotsDir } from "./util/paths.js";
@@ -21,25 +23,28 @@ export async function runCli(argv) {
return;
}
- if (command === "install") {
- const target = positionals[0];
- if (target === "skill" || target === "skills") {
- const result = installBrowserSkill(parsed.flags.get("to"));
- console.error(result.note);
- console.log(JSON.stringify(result, null, 2));
- return;
- }
+ if (command === "use") {
+ runUseCommand(positionals);
+ return;
+ }
- const browserName = target || parsed.browser || getConfiguredBrowser();
- if (!browserName) throw new Error(`Usage: OpenBrowser install `);
- const installAdapter = resolveBrowser(browserName);
- const result = await installAdapter.install();
- console.error(result.note);
- console.log(JSON.stringify(result, null, 2));
+ if (command === "browsers") {
+ if (positionals.length > 0) throw new Error("Usage: OpenBrowser browsers [--json]");
+ await runBrowsersCommand(parsed.flags);
+ return;
+ }
+
+ if (command === "setup") {
+ await runSetupCommand(positionals, parsed.browser);
return;
}
- const adapter = resolveBrowser(parsed.browser || getConfiguredBrowser());
+ if (command === "install") {
+ await runInstallCommand(positionals, parsed.browser, parsed.flags);
+ return;
+ }
+
+ const adapter = await selectBrowser(parsed.browser);
const request = toBridgeRequest(command, positionals, parsed.flags);
if (!request) throw new Error(`Unknown command: ${command}`);
@@ -48,29 +53,101 @@ export async function runCli(argv) {
}
function runConfigCommand(args) {
- const [key, value] = args;
+ const [key, value, ...extra] = args;
if (!key) {
- console.log(JSON.stringify(readConfig(), null, 2));
+ console.log(JSON.stringify({ ...readConfig(), browser: getConfiguredBrowser() ?? DEFAULT_BROWSER }, null, 2));
return;
}
if (key !== "browser") {
throw new Error(`Unknown config key: ${key}. Supported keys: browser.`);
}
+ if (extra.length > 0) throw new Error("Usage: OpenBrowser config browser [browser|auto]");
if (value === undefined) {
- console.log(JSON.stringify({ browser: getConfiguredBrowser() ?? null }, null, 2));
+ console.log(JSON.stringify({ browser: getConfiguredBrowser() ?? DEFAULT_BROWSER }, null, 2));
return;
}
- const name = value.toLowerCase();
- resolveBrowser(name); // Validates against supported browsers before persisting.
+ setDefaultBrowser(value);
+}
+
+function runUseCommand(args) {
+ if (args.length !== 1) throw new Error("Usage: OpenBrowser use ");
+ setDefaultBrowser(args[0]);
+}
+
+function setDefaultBrowser(value) {
+ const normalized = normalizeBrowserName(value);
+ const name = normalized === DEFAULT_BROWSER ? DEFAULT_BROWSER : resolveBrowser(value).name;
const config = setConfiguredBrowser(name);
console.error(`Default browser set to ${name}.`);
console.log(JSON.stringify(config, null, 2));
}
+async function runBrowsersCommand(flags) {
+ const inventory = await inspectBrowsers();
+ const configured = process.env.OPENBROWSER_BROWSER || getConfiguredBrowser() || DEFAULT_BROWSER;
+ const defaultBrowser = normalizeBrowserName(configured);
+ const reported = inventory.map((browser) => ({
+ ...browser,
+ default: defaultBrowser !== DEFAULT_BROWSER && defaultBrowser === browser.browser,
+ }));
+
+ if (flags.get("json")) {
+ console.log(JSON.stringify({ defaultBrowser, browsers: reported }, null, 2));
+ return;
+ }
+
+ for (const browser of reported) {
+ const state = browser.sessionOpen
+ ? "active"
+ : browser.ready
+ ? "ready"
+ : browser.configured
+ ? "configured"
+ : browser.installed
+ ? "installed"
+ : "not detected";
+ const marker = browser.sessionOpen || browser.ready ? "✓" : browser.detected ? "·" : "-";
+ const defaultLabel = browser.default ? " [default]" : "";
+ console.log(`${marker} ${browser.displayName} (${browser.browser}) — ${browser.family}, ${state}${defaultLabel}`);
+ }
+ console.log(`\nDefault: ${defaultBrowser === DEFAULT_BROWSER ? "automatic discovery" : defaultBrowser}`);
+ console.log("Choose a default with: OpenBrowser use ");
+ console.log("Set up a browser with: OpenBrowser setup ");
+}
+
+async function runSetupCommand(positionals, requested) {
+ if (positionals.length > 1) throw new Error("Usage: OpenBrowser setup [browser]");
+ if (positionals.length === 1 && requested) throw new Error("Choose the browser either as an argument or with --browser, not both.");
+
+ const adapter = await selectBrowser(positionals[0] || requested, { allowUnconfigured: true });
+ const result = await adapter.install();
+ const config = setConfiguredBrowser(adapter.name);
+ console.error(result.note);
+ console.log(JSON.stringify({ ...result, defaultBrowser: config.browser }, null, 2));
+}
+
+async function runInstallCommand(positionals, requested, flags) {
+ const target = positionals[0];
+ if (target === "skill" || target === "skills") {
+ const result = installBrowserSkill(flags.get("to"));
+ console.error(result.note);
+ console.log(JSON.stringify(result, null, 2));
+ return;
+ }
+
+ if (positionals.length > 1) throw new Error("Usage: OpenBrowser install [browser]");
+ if (target && requested) throw new Error("Choose the browser either as an argument or with --browser, not both.");
+
+ const adapter = await selectBrowser(target || requested, { allowUnconfigured: true });
+ const result = await adapter.install();
+ console.error(result.note);
+ console.log(JSON.stringify(result, null, 2));
+}
+
async function sendCommandEnsuringBridge(adapter, command, args, timeoutMs) {
try {
return await sendBridgeCommandWithRetry(adapter.name, command, args, { timeoutMs });
@@ -209,5 +286,5 @@ function normalizeBase64(value) {
}
function printHelp() {
- console.log(`OpenBrowser\n\nUsage:\n OpenBrowser install \n OpenBrowser install skills --to \n OpenBrowser config browser \n OpenBrowser open [--browser ]\n OpenBrowser close [--browser ]\n OpenBrowser status [--browser ]\n OpenBrowser navigate [--browser ]\n OpenBrowser reload|back|forward [--browser ]\n OpenBrowser state [--browser ]\n OpenBrowser screenshot [--base64] [--browser ]\n OpenBrowser click [ [--browser ]]\n OpenBrowser keys [--browser ]\n OpenBrowser press [--browser ]\n OpenBrowser select [ ] [--browser ]\n OpenBrowser get --html [--ref [] [--browser ]]\n OpenBrowser scroll up|down [pixels] [--browser ]\n OpenBrowser scroll --to [ [--browser ]]\n\nSupported browsers: ${supportedBrowsers().join(", ")}\nSet a default browser with: OpenBrowser config browser `);
+ console.log(`OpenBrowser\n\nUsage:\n OpenBrowser setup [browser]\n OpenBrowser browsers [--json]\n OpenBrowser use \n OpenBrowser install [browser]\n OpenBrowser install skills --to \n OpenBrowser config browser [browser|auto]\n OpenBrowser open [--browser ]\n OpenBrowser close [--browser ]\n OpenBrowser status [--browser ]\n OpenBrowser navigate [--browser ]\n OpenBrowser reload|back|forward [--browser ]\n OpenBrowser state [--browser ]\n OpenBrowser screenshot [--base64] [--browser ]\n OpenBrowser click [ [--browser ]]\n OpenBrowser keys [--browser ]\n OpenBrowser press [--browser ]\n OpenBrowser select [ ] [--browser ]\n OpenBrowser get --html [--ref [] [--browser ]]\n OpenBrowser scroll up|down [pixels] [--browser ]\n OpenBrowser scroll --to [ [--browser ]]\n\nSupported browsers: ${supportedBrowsers().join(", ")}\nBrowser selection defaults to automatic discovery. Use OpenBrowser use to choose a local default.`);
}
diff --git a/src/constants.js b/src/constants.js
index b3f6708..44854c6 100644
--- a/src/constants.js
+++ b/src/constants.js
@@ -2,11 +2,15 @@ export const APP_NAME = "OpenBrowser";
export const PACKAGE_NAME = "@pxlarified/browser";
export const NATIVE_HOST_NAME = "openbrowser";
export const EXTENSION_ID = "openbrowser@mizius.com";
-export const DEFAULT_BROWSER = "zen";
+export const DEFAULT_BROWSER = "auto";
export const FIREFOX_DEV_ARTIFACT = "dist/extensions/firefox/openbrowser-dev.xpi";
export const FIREFOX_SIGNED_ARTIFACT = "dist/extensions/firefox/openbrowser.xpi";
export const CHROMIUM_UNPACKED_DIR = "dist/extensions/chromium/unpacked";
// Stable Chromium extension id derived from the public key bundled in the built
// Chromium manifest (see scripts/build.js). Used for native-messaging allowed_origins.
-export const CHROME_EXTENSION_ID = "dhjpahmmeaiiplllalcnbhjaincfgmfk";
-export const CHROME_EXTENSION_KEY = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyzAut1adVCcD0F/acoPkZeYvT8uijALA+77yvOFFlDRWWDwUoW/pmK4vrdVwppPPbjm7l2IBZgHvTrYjFVS++1BngM97h5O9bZRd8QgFTofRpqJWJMOB5li52c6CcIwtm9BpZTVXZE+YpifgGeeZ7QJ9voeT+Cg18dGVXLT1A0RyNVqyBRhaInIcjbDvZReMWxvI+Gpi/8eVF+pLu7uogtPFjIaOap3jK5YvK0f0JcnQaD6s61IMM3S1LBwZhfiWMwYz6K52d/sQNA9x9iw1EYBAEecohngYQw8n/VMXIWDAB9Sdhe5RoekJ3Lw7xt2U0CvgloPVRNi0p48MqnGIrwIDAQAB";
+export const CHROMIUM_EXTENSION_ID = "dhjpahmmeaiiplllalcnbhjaincfgmfk";
+export const CHROMIUM_EXTENSION_KEY = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyzAut1adVCcD0F/acoPkZeYvT8uijALA+77yvOFFlDRWWDwUoW/pmK4vrdVwppPPbjm7l2IBZgHvTrYjFVS++1BngM97h5O9bZRd8QgFTofRpqJWJMOB5li52c6CcIwtm9BpZTVXZE+YpifgGeeZ7QJ9voeT+Cg18dGVXLT1A0RyNVqyBRhaInIcjbDvZReMWxvI+Gpi/8eVF+pLu7uogtPFjIaOap3jK5YvK0f0JcnQaD6s61IMM3S1LBwZhfiWMwYz6K52d/sQNA9x9iw1EYBAEecohngYQw8n/VMXIWDAB9Sdhe5RoekJ3Lw7xt2U0CvgloPVRNi0p48MqnGIrwIDAQAB";
+
+// Legacy names retained for consumers that imported the pre-2.2 constants.
+export const CHROME_EXTENSION_ID = CHROMIUM_EXTENSION_ID;
+export const CHROME_EXTENSION_KEY = CHROMIUM_EXTENSION_KEY;
diff --git a/src/native-host.cjs b/src/native-host.cjs
index 17b0f0b..09d3e27 100755
--- a/src/native-host.cjs
+++ b/src/native-host.cjs
@@ -5,7 +5,11 @@ const net = require("node:net");
const os = require("node:os");
const path = require("node:path");
-const browser = process.env.OPENBROWSER_BROWSER || "zen";
+const browser = process.env.OPENBROWSER_BROWSER;
+if (!browser) {
+ process.stderr.write("OpenBrowser native host was started without a browser target.\n");
+ process.exit(1);
+}
const appHome = process.env.OPENBROWSER_HOME || path.join(os.homedir(), "OpenBrowser");
const socketPath = process.platform === "win32"
? `\\\\.\\pipe\\openbrowser-${browser}`