From 7f9156d71c3477f3363e8296e0d1192cac167026 Mon Sep 17 00:00:00 2001 From: pxlarified Date: Tue, 7 Jul 2026 21:35:45 +0000 Subject: [PATCH 1/3] Add Chrome browser support and configurable default browser Co-authored-by: Codesmith --- README.md | 81 +++-- coverage/chrome.test.js | 67 +++++ coverage/config.test.js | 52 ++++ coverage/registry.test.js | 16 +- dist/extensions/chromium/openbrowser.zip | Bin 0 -> 6605 bytes .../chromium/unpacked/assets/logo.svg | 19 ++ .../chromium/unpacked/background.js | 210 +++++++++++++ dist/extensions/chromium/unpacked/content.js | 279 ++++++++++++++++++ .../chromium/unpacked/manifest.json | 21 ++ dist/extensions/firefox/openbrowser-dev.xpi | Bin 5957 -> 6372 bytes extensions/background.js | 20 +- extensions/content.js | 4 + package.json | 3 +- scripts/build.js | 85 +++++- skills/browser/SKILL.md | 7 +- src/browsers/chrome.js | 47 +++ src/browsers/chromium-family.js | 110 +++++++ src/browsers/firefox-family.js | 94 +----- src/browsers/registry.js | 2 + src/browsers/shared.js | 97 ++++++ src/cli.js | 36 ++- src/constants.js | 5 + src/util/config.js | 34 +++ 23 files changed, 1155 insertions(+), 134 deletions(-) create mode 100644 coverage/chrome.test.js create mode 100644 coverage/config.test.js create mode 100644 dist/extensions/chromium/openbrowser.zip create mode 100644 dist/extensions/chromium/unpacked/assets/logo.svg create mode 100644 dist/extensions/chromium/unpacked/background.js create mode 100644 dist/extensions/chromium/unpacked/content.js create mode 100644 dist/extensions/chromium/unpacked/manifest.json create mode 100644 src/browsers/chrome.js create mode 100644 src/browsers/chromium-family.js create mode 100644 src/browsers/shared.js create mode 100644 src/util/config.js diff --git a/README.md b/README.md index 4f354d5..ef5a191 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,8 @@

Node.js Version - Browser - Firefox Extension + 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 0000000000000000000000000000000000000000..fb90b2fd5401b070b20bc02cedbe74599a6576c0 GIT binary patch literal 6605 zcmaJ`Ra6{I(#2f^OmKI1_h7-@onXTtL4yr$!GpW&1a~J8G)!>U;O-XmPtO0a-|jiP z-4ESQx4XJ;ovK@} zi63_NOrR%_<(X))7C(x|l+bF>I$Ed*Si}__R6xK6p%Rpvs-r+u>GO{fJ8Ac&#hlFqI`IG0ptm*0AS!9i4gdtY%b{<+OGp(dwbP$6VSh zXgCPL{*n7v^3;bAC3mxeq$zeHT@XkV-sr^j`f+3={|uVlSjpL5xuT)wJAB zZYh5##8Q``k5E4r{XD#{@j}F%cQj)hz|RPtbLnq<0ZkmnNR_r6XM$IxX(DIgl4<86 z!`x*IFb#2mpXf0y;)3QviIJypq%iH|@slXYxD0~|WEdMJV$^}(6#9saWJL7L32;?y z4RPfO^xu7*nPjJp&$0UR52DN^)96a5cvzd9i3W0w`1u&Wbc48L46qb&zHQK6O^?tW zEL9S{3+QC}QA=zI%L#$dZ$HP2o9Px16&U*XvEw$6(KMiRv}%U3-3=4UFAPwVI|vz& zX97t@yRiyhXLn>x(unh zu~lW9XaSiosiJF6{)SZuQB+OLaT3;~9lrewvbz%Y_S_Tlz2EB-i#*VItmDC%v|-yN zuHinb?ctI{T?rY*D{lax2nPc*iU0$n{$FW``G*91%7+Tfaf$am6yY2`bszSYIv#01 zXM69a^iy%LZ=mb7e&Gn@Xqzej2EuWrYcbxsQ!3>O8!GuC5_^b?uw3}lAlRyP1xKY0 zk*VI_{M5v_*Gih@p0J;HOBr4uzeIKx6kYpaWZG~^f(nO{=+5k09Zl4@8bO6x{sqy?>EApe5WS??pMVFBf#+zNO+Hfo1im7UoEsv2 z{v?*Q1@U0xK68<92$l>z-uwQX3d1Tl zuFGQmqOra-5f4kMfSvm@>@t@V<&H4JtLf-TY83Bq4BnjtD;;Ok-1#k1O$7I`Fzplz z<5g`(AhmZeNA5o+`IGrWsiViuoA5<=q8 zB~GpV&r{C=_6WYa4mQ9~7Nth5f1a+fkRK$}WV(NcX=GaFxz0CQx^)i0WEGYGdMa3` zr*C|>>u*W~zD7lc>+p-rrD%(kPIiAR0&dRoiF1$W|kuK<020Um#1u#_D33E!yCgp6&uAnjhE!LNl)h? zcROMW38!=$#g^Ct$JNf;b!R$y5{u$=?y=`EX5@UkI0D-W|HRk(Iy{ClCO4A9rglI; zkOIDTyq!KirD#Al%PFK11>>nnpCYX6FpB@?A8`=~ratB&q%Z zMNiY#FW)ozSfWCTdCaw=%?C07E-suzNmr89sF#l+4}CkgEo zbV1HTHOC?G7VEi^1;Zdq14oN6Rgq2U@lpqfRbC0eRZRgsv5Ez%xOwX0O81GHW za1NetcW2I+Z=tS6(WchsM4fGkXw$m)-B-mS?6HgzPq>O`yu=|`0W8ZNw_=vZm7r%F zuk5#PYZoYob9!fxoPXR}BY_rp`rc^trbY!@FRmG64Dv^WtkAwIB_8ao0WSyq!Vx}e z``LJQX{5E%anL3={R;wPYqCRG#aA72!(t;9ZOICPIq(1(vd z?UpR+Exm!76o$YTN_n4MyeMW^tTXa-9C}1eH9OPIre@&EwCT&coXg&yQ)I)fE{}0} zJF1dWsOI?Y!A*Vctlgqc>2N9`&cLnhVDV}oqRhjhvh@Y3z512rPlWX;MToVGQ<9bH z@vR&!S#-!Hh9AkX092O+7%TJvMh+*LbsMIw0Up| zf6cQsT)NhFkVkIuy>ISPSa#gT{)dj~9r6H;PyS~hh)D4-l+WTdeb`OWMn?-BfF3JL z^QerK0V;o%y0>p#d|%~mb*=9+}=OYy&MUz{iIvixAe{OxEwNIulb zmUS65v%tpZ!pHGnqZ%;qdqQvzcTcs7O6Kh33U&%3VhP68<2UAqBLrSd zMH?M{O^UTqkjTeUHfJw~o6qKYh6IqsKusvbBk!hf`<|qW>0o=UK-j|`KkHQ`iO96; zr&HT$p9#@s>JrI`b_B}MREOWXj`=I(wl+tip#%BMza1A^EYTstAy>vOqd`;r4bMa% zwEgW@s=doWuXE^((9NlS7tcIop8^^y`TG|_EPny9njL7**Z;<8lAfK9j>s@D3xxkGPNV&a(^k$-?$%E3e}Z%s zv5DI<|D{+D+^-e7ZGGz%-~AB3_P7&9NBX$rx@2(7ksGBeQ$aWkIgH}m!poV!JteGW z^47}D0CipT>VRd>L2J2wDT-|*X9Z`;^qYl@4I|XeY#u~qD7Ta%0mJ?aqO|i`zL$*T zw=%j&APM2Fj!OyPMdCF1U)o2BwkJ-YV>kq z3=#iLEKUg9L*x%Zp^ryO=9INLFf@hbCb@l4^t(e#{u23x=r#zsil;o60F*UJR|p+ zcv^rJSJQIumWH-P#u^e{yR82XceEA(KP8l{$U@~T>E=#5aN8aJLTt0;mOK``Y*f;R zhIM8VoM|5;xVW65@nw~K&AvBHowpF3@iuCuPnEp1K&VUd0M54MqwA#vQDV_WTr?ZNTe7j1WnEPu0!~l+Ci_ZlSUdZI z^s)}e`1|DENW{qX{0Q{7J2!>euE5@OJcSyAv&Vi3d3)j+ z`v=D*r?0W*##gBg(Bj2`h>PtU?IMLzSVF`FXKVyHE>kgZ0Cx1oeQySL-u8Wd%pfR= zu$y%@P(+OH(c^>XqICHE;7tIUR%RO13N?X1$1b$G+QhH&_x>{37Kq@x1oBi6UxY}F ze46$oimVG>0S!J0f_rGUvr`|bV({l!k=1!ncsbSK0LdxpE8)Z>T$weUULAHa9Gau@ zKyQr;+Pf<+!ysHXkohR|69Nh?mib;Sv)LQx*>Qb=GAwPJ(w zRJ(Ug5DDD!7j!-h>gUvLAm7(3VhfD#H9jLsopANW75t;)Hqfnw`*Wj^?X?b+9xog) z1ww8c6_Fpcmn6qc9q|}@{-nmU`Z9FFB+(>brR9#{>pau<^cvIwV?7hSxObs7jd3+a znbwWv$(v(6c)Gg4;nvSJ?yf|Oquat4gzWIVXTm$?u!6nia}KPY;9rU)lf(7xr|epN zPIrL$UgkpcF63d|gjM$YRdUIV>~oi<>ij;ww|kTfiV(-*ejsL#>g#ssz$E42!eIr@ zr?k{ymTH|Zii+F{JjxXu!mnCE3()L7HKtgH9fl~YTtF$~N<5-P)285?-{@T8J0DHE zq&cGejz)*)9$?>s;6Bj=;fjir=Le8d+{tU0#O*f{>*I2oIK_gl4VLz|?6MA_eH7%Z zmc%k=X+@monU_B|1yQxA$AzpCm{t>J6xCV2wJ?sEHT*spxZibsa`F?(&5rk2%Vbi9 zMO@7VjMIO?H_;uFlL$>q=L|K7V@5?Ok`6SspW#KT*DAVAbr&<$dkZqbY?HIX2cFZ8 zA+RfS+J1hOHYZsxLflzA!I8~qma{-}R3yAlp?965Ma$LU?v^hJj6HD>D#_`iWT>C9 zf1s(My*I|Ix)E6qjLZ#5zhd(RSn9|H(Y2N~rOw;^Xs&KTDk{c|&7x5h93Ih~9C`mq z!$PGoKutyCD~;BxEFU2tC90ky zL6K9IqC?S)b#Fz3I(*pi8BT!D2FO*T{<2~$^i4~aT0I^7_uk&L*Lq&=Nv1`M_m+O* z@el7Np9X`F8G*!Ax}4ldr=dJ5`5QCHh0O%w8c4H+vzZrio%|+S<@8B_&>bn%WQ)`+ zahDn@S3^e}O&$cEw?KRQ@eB2n+=@3J$Qa17Gv0)U`p>QiVraNI%rVVGs-Ai0~axW)Xx+} zyjN@L`W1Ybnaw|0`FB#_F0P)oWXZ~eN#Og>Ha(y017h${W|jL}1sLUpVf>YJ24T<> zn1M}`WL210{P7W)gQ}%dSXzg->K)6)h8&W-m+dzZ$k!n(YdJ4$ri$AleUZTn)bzyD zC%^Yl$QW|Zv4@5{eU(xhO#I^UIU=Ch>rd_?xTPfu&qy;0iUcI_(fDMB;2Q%8 z)^OOv8T(Q;O?3h?3YX|z@=xsPCByAknBJ%6s~$1=ld5hu_2qu&yNakOvZ$1%Wu+TO z(M>S3<2M}@eOmb!AF_w2Wev zyW)Aw9JX4MwhlYVxnN-hIo;d@zl4@;4zC9M&?UQ#KP~V;e}^yoNPHMG($@&q)BKW{ z;PtpR#*oK3*?|fDb%>a!OJIt$Z7iF>4F1+a`Liyx7KeLyDeU7+Ld^9H-Ql;7SCsMl z03Y>L%Hg8+F!C82x$VT8so?dSH=b8TdnguHR?FM&-obKfv?6hxEL9XnM2*sUd9PquYmR*>IYhuUqW4?bXyEZ&g%Ij|vIyMloII_fQnVXLPco+izbN5^GEk zr+>Z(R;7sk6f6rgAWhas5=%$Brc2&6a^8$F)1H{;Ay5AlYQ~BE*2^Zzf0o2&Y2V2~ zkW=eQxM6PAOpor-Dxz7n-6Zm)cCZJk#xYd3+kHn&5ON8=Jh)nE^x$|`gSJ{oc{|7m0@QlvHhb z&~C84ll&a_?djnfG<@J^w~H&!##t-6-&wDy!1LG=Q758_2CC4_`9)i9z;;t|N?_UH zwPEMiG+3+-x*E>6$<|XS?mZ@9 z7$y3|u=Xii%3EIn=}H2>@REhu+DcZ+r~t5K-WS!Z`hjoM(Z+hB19t=wURL1|SXl&> z0>|wYI0ZSeKBI(ia7mh_S9KiWnpHcc+5>Gc(ujp}3u$sHo4!iv&0Q*V!xLk2PCShz>KsMX_OJ=CSB1yt z9VAqd-w(}*UP6}6O+c;j(9%Nd@0!RO&Lt1Icvy~>poMQEY7Hl2hW1^dx583p*kkPq zI&63Rrws!3r>;JfS}|QgVp=TW0xCI-{Vs>%9QIX|YvOn4cjZETS0B&tP`jh5Z4 z`OAbHvFjXouVjT7iJ0c(K!pUO9T%M~kljnET*QpnLH|iTV{M zXC8gcwQ1^NkVaj45lmH=-olvUW1sXUlcU9zxPnsgaNJWN3<}#-Suw%4ISd9h3Vn0c z4aKf6oQ8;R7XF$|IvJU)MV+7~kL0seK<; zTay2l=%Q;=zzbJ%R+_HI+xwV#L9;vW<)>MtZ=AMH>>S(kmwnH%fvNBQ6o@(C|FRDj zP6Ga4or-@JkFjA;|1j|XM{&^K^*{O+I@*XZ@H;Ll!2d+~r?kcVH + + + + + + + + + + + + + + + + + + diff --git a/dist/extensions/chromium/unpacked/background.js b/dist/extensions/chromium/unpacked/background.js new file mode 100644 index 0000000..8c0ead2 --- /dev/null +++ b/dist/extensions/chromium/unpacked/background.js @@ -0,0 +1,210 @@ +// Firefox exposes the promise-based `browser` namespace; Chromium only exposes +// `chrome`. Alias it so the same background script runs on both browser families. +if (typeof browser === "undefined") var browser = 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..a5b8dbd --- /dev/null +++ b/dist/extensions/chromium/unpacked/content.js @@ -0,0 +1,279 @@ +(function installOpenBrowserContentScript() { + // Firefox exposes the promise-based `browser` namespace; Chromium only exposes + // `chrome`. Alias it so the same content script runs on both browser families. + if (typeof browser === "undefined") var browser = 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..16812bb --- /dev/null +++ b/dist/extensions/chromium/unpacked/manifest.json @@ -0,0 +1,21 @@ +{ + "manifest_version": 3, + "name": "OpenBrowser", + "version": "2.0.1", + "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 2e9914acec388079f79bcdbeb50ba6edf1eb09ae..0ba00f0bd245ec357ad29a1f848045983c40d1ff 100644 GIT binary patch delta 5619 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 BjLQH3 delta 5193 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 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