@pxlarified/browser 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.js ADDED
@@ -0,0 +1,155 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { resolveBrowser, supportedBrowsers } from "./browsers/registry.js";
5
+ import { BridgeUnavailableError, sendBridgeCommandWithRetry } from "./bridge/client.js";
6
+ import { screenshotsDir } from "./util/paths.js";
7
+
8
+ export async function runCli(argv) {
9
+ if (argv.length === 0 || argv.includes("--help") || argv.includes("-h")) {
10
+ printHelp();
11
+ return;
12
+ }
13
+
14
+ const parsed = parseGlobalOptions(argv);
15
+ const [command, ...positionals] = parsed.positionals;
16
+ const adapter = resolveBrowser(parsed.browser);
17
+
18
+ if (command === "install") {
19
+ const browserName = positionals[0] || parsed.browser;
20
+ if (!browserName) throw new Error(`Usage: OpenBrowser install <browser>`);
21
+ const installAdapter = resolveBrowser(browserName);
22
+ const result = await installAdapter.install();
23
+ console.error(result.note);
24
+ console.log(JSON.stringify(result, null, 2));
25
+ return;
26
+ }
27
+
28
+ const request = toBridgeRequest(command, positionals, parsed.flags);
29
+ if (!request) throw new Error(`Unknown command: ${command}`);
30
+
31
+ const result = await sendCommandEnsuringBridge(adapter, request.command, request.args, request.timeoutMs);
32
+ await printResult(request, result);
33
+ }
34
+
35
+ async function sendCommandEnsuringBridge(adapter, command, args, timeoutMs) {
36
+ try {
37
+ return await sendBridgeCommandWithRetry(adapter.name, command, args, { timeoutMs });
38
+ } catch (error) {
39
+ if (!(error instanceof BridgeUnavailableError)) throw error;
40
+ await adapter.launch();
41
+ return sendBridgeCommandWithRetry(adapter.name, command, args, {
42
+ timeoutMs,
43
+ attempts: 12,
44
+ delayMs: 500,
45
+ });
46
+ }
47
+ }
48
+
49
+ function parseGlobalOptions(argv) {
50
+ const flags = new Map();
51
+ const positionals = [];
52
+ let browser;
53
+
54
+ for (let i = 0; i < argv.length; i += 1) {
55
+ const token = argv[i];
56
+ if (token === "--browser") {
57
+ browser = requireValue(argv, ++i, "--browser");
58
+ continue;
59
+ }
60
+ if (token.startsWith("--browser=")) {
61
+ browser = token.slice("--browser=".length);
62
+ continue;
63
+ }
64
+ if (token.startsWith("--")) {
65
+ const [name, inlineValue] = token.slice(2).split("=", 2);
66
+ if (inlineValue !== undefined) flags.set(name, inlineValue);
67
+ else if (isValueFlag(name)) flags.set(name, requireValue(argv, ++i, `--${name}`));
68
+ else flags.set(name, true);
69
+ continue;
70
+ }
71
+ positionals.push(token);
72
+ }
73
+
74
+ return { browser, flags, positionals };
75
+ }
76
+
77
+ function isValueFlag(name) {
78
+ return new Set(["ref", "to"]).has(name);
79
+ }
80
+
81
+ function requireValue(argv, index, flag) {
82
+ const value = argv[index];
83
+ if (!value || value.startsWith("--")) throw new Error(`Missing value for ${flag}.`);
84
+ return value;
85
+ }
86
+
87
+ function toBridgeRequest(command, args, flags) {
88
+ switch (command) {
89
+ case "open":
90
+ return requireArgs(command, args, 1, { command, args: { url: args[0] } });
91
+ case "close":
92
+ case "status":
93
+ case "reload":
94
+ case "back":
95
+ case "forward":
96
+ case "state":
97
+ return { command, args: {} };
98
+ case "navigate":
99
+ return requireArgs(command, args, 1, { command, args: { url: args[0] } });
100
+ case "screenshot":
101
+ return { command, args: { base64: Boolean(flags.get("base64")) }, timeoutMs: 45_000 };
102
+ case "click":
103
+ return requireArgs(command, args, 1, { command, args: { ref: args[0] } });
104
+ case "keys":
105
+ return requireArgs(command, args, 1, { command, args: { text: args.join(" ") } });
106
+ case "press":
107
+ return requireArgs(command, args, 1, { command, args: { key: args[0] } });
108
+ case "select":
109
+ return requireArgs(command, args, 2, { command, args: { ref: args[0], option: args[1] } });
110
+ case "get":
111
+ if (!flags.get("html")) throw new Error("Only get --html is currently supported.");
112
+ return { command: "getHtml", args: { ref: flags.get("ref") || null } };
113
+ case "scroll":
114
+ if (flags.get("to")) return { command, args: { to: flags.get("to") } };
115
+ if (!["up", "down"].includes(args[0])) {
116
+ throw new Error("Usage: OpenBrowser scroll up|down [pixels] or OpenBrowser scroll --to <ref>");
117
+ }
118
+ return { command, args: { direction: args[0], pixels: Number(args[1] || 600) } };
119
+ default:
120
+ return null;
121
+ }
122
+ }
123
+
124
+ function requireArgs(name, args, count, request) {
125
+ if (args.length < count) throw new Error(`Missing argument for ${name}.`);
126
+ return request;
127
+ }
128
+
129
+ async function printResult(request, result) {
130
+ if (request.command === "screenshot") {
131
+ const base64 = normalizeBase64(result.dataUrl || result.base64 || "");
132
+ if (request.args.base64) {
133
+ process.stdout.write(`${base64}\n`);
134
+ return;
135
+ }
136
+
137
+ const dir = screenshotsDir();
138
+ fs.mkdirSync(dir, { recursive: true });
139
+ const file = path.join(dir, `${crypto.randomBytes(4).toString("hex")}.png`);
140
+ fs.writeFileSync(file, Buffer.from(base64, "base64"));
141
+ process.stdout.write(`${file}\n`);
142
+ return;
143
+ }
144
+
145
+ console.log(JSON.stringify(result, null, 2));
146
+ }
147
+
148
+ function normalizeBase64(value) {
149
+ const comma = value.indexOf(",");
150
+ return comma === -1 ? value : value.slice(comma + 1);
151
+ }
152
+
153
+ function printHelp() {
154
+ console.log(`OpenBrowser\n\nUsage:\n OpenBrowser install <browser>\n OpenBrowser open <url> [--browser zen]\n OpenBrowser close [--browser zen]\n OpenBrowser status [--browser zen]\n OpenBrowser navigate <url> [--browser zen]\n OpenBrowser reload|back|forward [--browser zen]\n OpenBrowser state [--browser zen]\n OpenBrowser screenshot [--base64] [--browser zen]\n OpenBrowser click <ref> [--browser zen]\n OpenBrowser keys <text> [--browser zen]\n OpenBrowser press <key> [--browser zen]\n OpenBrowser select <ref> <option> [--browser zen]\n OpenBrowser get --html [--ref <ref>] [--browser zen]\n OpenBrowser scroll up|down [pixels] [--browser zen]\n OpenBrowser scroll --to <ref> [--browser zen]\n\nSupported browsers: ${supportedBrowsers().join(", ")}`);
155
+ }
@@ -0,0 +1,7 @@
1
+ export const APP_NAME = "OpenBrowser";
2
+ export const PACKAGE_NAME = "@pxlarified/browser";
3
+ export const NATIVE_HOST_NAME = "openbrowser";
4
+ export const EXTENSION_ID = "openbrowser@mizius.com";
5
+ export const DEFAULT_BROWSER = "zen";
6
+ export const FIREFOX_DEV_ARTIFACT = "dist/extensions/firefox/openbrowser-dev.xpi";
7
+ export const FIREFOX_SIGNED_ARTIFACT = "dist/extensions/firefox/openbrowser.xpi";
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env node
2
+ /* eslint-disable no-console */
3
+ const fs = require("node:fs");
4
+ const net = require("node:net");
5
+ const os = require("node:os");
6
+ const path = require("node:path");
7
+
8
+ const browser = process.env.OPENBROWSER_BROWSER || "zen";
9
+ const appHome = process.env.OPENBROWSER_HOME || path.join(os.homedir(), "OpenBrowser");
10
+ const socketPath = process.platform === "win32"
11
+ ? `\\\\.\\pipe\\openbrowser-${browser}`
12
+ : path.join(appHome, "bridge", `${browser}.sock`);
13
+
14
+ const pending = new Map();
15
+ let nextId = 1;
16
+ let server;
17
+
18
+ function log(message) {
19
+ console.error(`[OpenBrowser native host] ${message}`);
20
+ }
21
+
22
+ function nativeWrite(message) {
23
+ const json = Buffer.from(JSON.stringify(message), "utf8");
24
+ const header = Buffer.alloc(4);
25
+ header.writeUInt32LE(json.length, 0);
26
+ process.stdout.write(Buffer.concat([header, json]));
27
+ }
28
+
29
+ function respond(socket, response) {
30
+ socket.end(`${JSON.stringify(response)}\n`);
31
+ }
32
+
33
+ function forwardToExtension(payload) {
34
+ return new Promise((resolve, reject) => {
35
+ const id = `n_${Date.now()}_${nextId++}`;
36
+ const timer = setTimeout(() => {
37
+ pending.delete(id);
38
+ reject(new Error("Timed out waiting for the OpenBrowser extension."));
39
+ }, 30_000);
40
+
41
+ pending.set(id, { resolve, reject, timer });
42
+ nativeWrite({ id, ...payload });
43
+ });
44
+ }
45
+
46
+ function handleNativeMessage(message) {
47
+ if (!message || typeof message !== "object") return;
48
+ if (!message.replyTo) return;
49
+
50
+ const request = pending.get(message.replyTo);
51
+ if (!request) return;
52
+ pending.delete(message.replyTo);
53
+ clearTimeout(request.timer);
54
+
55
+ if (message.ok) request.resolve(message.result);
56
+ else {
57
+ const error = new Error(message.error?.message || "OpenBrowser extension command failed.");
58
+ error.code = message.error?.code;
59
+ request.reject(error);
60
+ }
61
+ }
62
+
63
+ let stdinBuffer = Buffer.alloc(0);
64
+ process.stdin.on("data", (chunk) => {
65
+ stdinBuffer = Buffer.concat([stdinBuffer, chunk]);
66
+
67
+ while (stdinBuffer.length >= 4) {
68
+ const length = stdinBuffer.readUInt32LE(0);
69
+ if (stdinBuffer.length < 4 + length) break;
70
+
71
+ const body = stdinBuffer.slice(4, 4 + length);
72
+ stdinBuffer = stdinBuffer.slice(4 + length);
73
+
74
+ try {
75
+ handleNativeMessage(JSON.parse(body.toString("utf8")));
76
+ } catch (error) {
77
+ log(`failed to parse extension message: ${error.message}`);
78
+ }
79
+ }
80
+ });
81
+
82
+ process.stdin.on("end", () => {
83
+ shutdown(0);
84
+ });
85
+
86
+ function ensureSocketDirectory() {
87
+ if (process.platform === "win32") return;
88
+ const dir = path.dirname(socketPath);
89
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
90
+ try {
91
+ fs.chmodSync(appHome, 0o700);
92
+ } catch {}
93
+ try {
94
+ fs.unlinkSync(socketPath);
95
+ } catch (error) {
96
+ if (error.code !== "ENOENT") throw error;
97
+ }
98
+ }
99
+
100
+ function startServer() {
101
+ ensureSocketDirectory();
102
+
103
+ server = net.createServer((socket) => {
104
+ let buffer = "";
105
+ socket.on("data", async (chunk) => {
106
+ buffer += chunk.toString("utf8");
107
+ const newline = buffer.indexOf("\n");
108
+ if (newline === -1) return;
109
+ const line = buffer.slice(0, newline);
110
+ buffer = buffer.slice(newline + 1);
111
+
112
+ let request;
113
+ try {
114
+ request = JSON.parse(line);
115
+ } catch {
116
+ respond(socket, { ok: false, error: { code: "BAD_REQUEST", message: "Invalid OpenBrowser request." } });
117
+ return;
118
+ }
119
+
120
+ try {
121
+ const result = await forwardToExtension(request);
122
+ respond(socket, { ok: true, result });
123
+ } catch (error) {
124
+ respond(socket, {
125
+ ok: false,
126
+ error: { code: error.code || "COMMAND_FAILED", message: error.message || String(error) },
127
+ });
128
+ }
129
+ });
130
+ });
131
+
132
+ server.listen(socketPath, () => {
133
+ if (process.platform !== "win32") {
134
+ try { fs.chmodSync(socketPath, 0o600); } catch {}
135
+ }
136
+ log(`listening on ${socketPath}`);
137
+ });
138
+
139
+ server.on("error", (error) => {
140
+ log(error.message);
141
+ shutdown(1);
142
+ });
143
+ }
144
+
145
+ function shutdown(code) {
146
+ for (const [id, request] of pending) {
147
+ clearTimeout(request.timer);
148
+ request.reject(new Error("OpenBrowser native host stopped."));
149
+ pending.delete(id);
150
+ }
151
+
152
+ if (server) server.close();
153
+ if (process.platform !== "win32") {
154
+ try { fs.unlinkSync(socketPath); } catch {}
155
+ }
156
+ process.exit(code);
157
+ }
158
+
159
+ process.on("SIGINT", () => shutdown(0));
160
+ process.on("SIGTERM", () => shutdown(0));
161
+
162
+ startServer();
@@ -0,0 +1,24 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { APP_NAME } from "../constants.js";
5
+
6
+ export function packageRoot() {
7
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
8
+ }
9
+
10
+ export function openBrowserHome() {
11
+ return process.env.OPENBROWSER_HOME || path.join(os.homedir(), APP_NAME);
12
+ }
13
+
14
+ export function screenshotsDir() {
15
+ return path.join(openBrowserHome(), "screenshots");
16
+ }
17
+
18
+ export function bridgeSocketPath(browser) {
19
+ if (process.platform === "win32") {
20
+ return `\\\\.\\pipe\\openbrowser-${browser}`;
21
+ }
22
+
23
+ return path.join(openBrowserHome(), "bridge", `${browser}.sock`);
24
+ }