Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 64 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@

<p align="center">
<img alt="Node.js" src="https://img.shields.io/badge/Node.js-%3E%3D20-339933?style=flat-square&logo=node.js&logoColor=white">
<img alt="Version" src="https://img.shields.io/badge/version-2.0.1-blue?style=flat-square">
<img alt="Browser" src="https://img.shields.io/badge/browser-Zen-7F52FF?style=flat-square">
<img alt="Firefox Extension" src="https://img.shields.io/badge/Firefox-WebExtension-FF7139?style=flat-square&logo=firefoxbrowser&logoColor=white">
<img alt="Version" src="https://img.shields.io/badge/version-2.1.0-blue?style=flat-square">
<img alt="Browser" src="https://img.shields.io/badge/browser-Zen%20%7C%20Chrome-7F52FF?style=flat-square">
<img alt="WebExtension" src="https://img.shields.io/badge/WebExtension-Firefox%20%7C%20Chromium-FF7139?style=flat-square&logo=firefoxbrowser&logoColor=white">
</p>

## Overview
Expand Down Expand Up @@ -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/<uuid>.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 <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`.
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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 <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
```

Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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`
67 changes: 67 additions & 0 deletions coverage/chrome.test.js
Original file line number Diff line number Diff line change
@@ -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 });
}
});
52 changes: 52 additions & 0 deletions coverage/config.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
16 changes: 13 additions & 3 deletions coverage/registry.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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\./,
);
});
Binary file added dist/extensions/chromium/openbrowser.zip
Binary file not shown.
19 changes: 19 additions & 0 deletions dist/extensions/chromium/unpacked/assets/logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading