@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/README.md +182 -0
- package/bin/OpenBrowser.js +8 -0
- package/dist/extensions/firefox/openbrowser-dev.xpi +0 -0
- package/dist/extensions/firefox/openbrowser.xpi +0 -0
- package/dist/extensions/firefox/signed/8cfe45915b2d499ab5d0-0.1.0.xpi +0 -0
- package/extensions/background.js +194 -0
- package/extensions/content.js +275 -0
- package/extensions/manifest.json +26 -0
- package/package.json +37 -0
- package/scripts/build.js +60 -0
- package/scripts/sign-firefox.js +74 -0
- package/src/bridge/client.js +87 -0
- package/src/browsers/firefox-family.js +204 -0
- package/src/browsers/registry.js +19 -0
- package/src/browsers/zen.js +70 -0
- package/src/cli.js +155 -0
- package/src/constants.js +7 -0
- package/src/native-host.cjs +162 -0
- package/src/util/paths.js +24 -0
package/README.md
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# OpenBrowser
|
|
2
|
+
|
|
3
|
+
OpenBrowser is a minimal browser-control skill and CLI. It controls one browser tab that it creates and owns, using a browser extension plus a local, user-scoped native bridge.
|
|
4
|
+
|
|
5
|
+
The npm package is intended to be published as `@pxlarified/browser` and used with:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @pxlarified/browser <command>
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The OpenBrowser skill lives in `skills/OpenBrowser/` and is not included as an npm-published skill package.
|
|
12
|
+
|
|
13
|
+
## Status
|
|
14
|
+
|
|
15
|
+
- Initial browser support: Zen, a Firefox-based browser.
|
|
16
|
+
- Extension artifact for Firefox-based browsers: `.xpi`.
|
|
17
|
+
- Chromium support is intentionally adapter-ready but not implemented yet.
|
|
18
|
+
- The default build creates an unsigned development `.xpi`.
|
|
19
|
+
- Firefox release signing is implemented as a separate `web-ext sign --channel unlisted` step.
|
|
20
|
+
|
|
21
|
+
## Security model
|
|
22
|
+
|
|
23
|
+
The CLI does not expose browser control through a public network service. The extension talks to a native messaging host, and the CLI talks to that host through a user-scoped local bridge:
|
|
24
|
+
|
|
25
|
+
- Unix/macOS: `~/OpenBrowser/bridge/<browser>.sock` with user-only permissions.
|
|
26
|
+
- Windows: a user-local named pipe.
|
|
27
|
+
|
|
28
|
+
## Session model
|
|
29
|
+
|
|
30
|
+
Each supported browser may have at most one active OpenBrowser session.
|
|
31
|
+
|
|
32
|
+
- A session is exactly one browser tab created by OpenBrowser.
|
|
33
|
+
- `open` fails if a session already exists for that browser.
|
|
34
|
+
- `close` closes only the OpenBrowser-owned tab.
|
|
35
|
+
- OpenBrowser never closes, reuses, inspects, navigates, or modifies tabs opened manually by the user.
|
|
36
|
+
- If the user manually closes the owned tab, the session is automatically considered closed.
|
|
37
|
+
|
|
38
|
+
## Install and build
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npm install
|
|
42
|
+
npm run build
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
This builds:
|
|
46
|
+
|
|
47
|
+
```text
|
|
48
|
+
dist/extensions/firefox/openbrowser-dev.xpi
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The Firefox development artifact is unsigned. It is useful for local development and for browser builds/profiles that allow unsigned extensions.
|
|
52
|
+
|
|
53
|
+
## Install the extension and native bridge
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
npx @pxlarified/browser install zen
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The installer:
|
|
60
|
+
|
|
61
|
+
1. Copies the bundled Firefox `.xpi` into detected Zen profiles as `openbrowser@mizius.com.xpi`.
|
|
62
|
+
2. Installs the user-scoped native messaging manifest.
|
|
63
|
+
3. Installs the native host launcher under `~/OpenBrowser/native-host/`.
|
|
64
|
+
|
|
65
|
+
If an OpenBrowser extension is already staged in a Zen profile, it is overwritten with the bundled version. Restart Zen if the update does not load immediately.
|
|
66
|
+
|
|
67
|
+
## CLI commands
|
|
68
|
+
|
|
69
|
+
`--browser zen` is optional while Zen is the only supported browser.
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
# Installation and session lifecycle
|
|
73
|
+
npx @pxlarified/browser install zen
|
|
74
|
+
npx @pxlarified/browser open <url> --browser zen
|
|
75
|
+
npx @pxlarified/browser close --browser zen
|
|
76
|
+
npx @pxlarified/browser status --browser zen
|
|
77
|
+
|
|
78
|
+
# Navigation
|
|
79
|
+
npx @pxlarified/browser navigate <url> --browser zen
|
|
80
|
+
npx @pxlarified/browser reload --browser zen
|
|
81
|
+
npx @pxlarified/browser back --browser zen
|
|
82
|
+
npx @pxlarified/browser forward --browser zen
|
|
83
|
+
|
|
84
|
+
# Page state
|
|
85
|
+
npx @pxlarified/browser state --browser zen
|
|
86
|
+
|
|
87
|
+
# Screenshots
|
|
88
|
+
npx @pxlarified/browser screenshot --browser zen
|
|
89
|
+
npx @pxlarified/browser screenshot --base64 --browser zen
|
|
90
|
+
|
|
91
|
+
# Interaction
|
|
92
|
+
npx @pxlarified/browser click <ref> --browser zen
|
|
93
|
+
npx @pxlarified/browser keys <text> --browser zen
|
|
94
|
+
npx @pxlarified/browser press <key> --browser zen
|
|
95
|
+
npx @pxlarified/browser select <ref> <option> --browser zen
|
|
96
|
+
|
|
97
|
+
# Content inspection
|
|
98
|
+
npx @pxlarified/browser get --html --browser zen
|
|
99
|
+
npx @pxlarified/browser get --html --ref <ref> --browser zen
|
|
100
|
+
|
|
101
|
+
# Scrolling
|
|
102
|
+
npx @pxlarified/browser scroll up [pixels] --browser zen
|
|
103
|
+
npx @pxlarified/browser scroll down [pixels] --browser zen
|
|
104
|
+
npx @pxlarified/browser scroll --to <ref> --browser zen
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Most commands print JSON to stdout. Screenshot commands are special:
|
|
108
|
+
|
|
109
|
+
- `screenshot` saves a PNG to `~/OpenBrowser/screenshots/<8-char-uuid>.png` and prints only the absolute file path to stdout.
|
|
110
|
+
- `screenshot --base64` prints only the Base64 PNG data to stdout.
|
|
111
|
+
- Diagnostics are written to stderr.
|
|
112
|
+
|
|
113
|
+
## Page state and references
|
|
114
|
+
|
|
115
|
+
`state` returns the current URL, title, viewport, and actionable elements. Element references are generated by OpenBrowser and do not depend on raw webpage IDs.
|
|
116
|
+
|
|
117
|
+
```json
|
|
118
|
+
{"url":"https://example.com","title":"Example Domain","elements":[{"ref":"e_1","role":"link","name":"More information"}]}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
References become invalid after navigation or relevant DOM updates. Commands that use invalid references return a clear `STALE_REFERENCE` error. Run `state` again to get fresh references.
|
|
122
|
+
|
|
123
|
+
## Firefox signing and release pipeline
|
|
124
|
+
|
|
125
|
+
The development build is unsigned:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
npm run build:firefox
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
When Mozilla Add-ons credentials are available, save them in a local uncommitted `.env` file:
|
|
132
|
+
|
|
133
|
+
```env
|
|
134
|
+
AMO_JWT_ISSUER="..."
|
|
135
|
+
AMO_JWT_SECRET="..."
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Then create the signed unlisted release artifact with:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
npm run release:firefox
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
You can also override the `.env` values with shell environment variables.
|
|
145
|
+
|
|
146
|
+
The release script runs the equivalent of:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
npx web-ext sign \
|
|
150
|
+
--source-dir ./extensions/ \
|
|
151
|
+
--channel unlisted \
|
|
152
|
+
--api-key "$AMO_JWT_ISSUER" \
|
|
153
|
+
--api-secret "$AMO_JWT_SECRET"
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
It then bundles the resulting signed XPI at:
|
|
157
|
+
|
|
158
|
+
```text
|
|
159
|
+
dist/extensions/firefox/openbrowser.xpi
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
That signed `.xpi` is the artifact that should be included in the npm package for Firefox-based browser installs.
|
|
163
|
+
|
|
164
|
+
### Signing information you need to provide
|
|
165
|
+
|
|
166
|
+
Before publishing a signed Firefox release, provide:
|
|
167
|
+
|
|
168
|
+
1. `AMO_JWT_ISSUER` - the Mozilla Add-ons API key / JWT issuer.
|
|
169
|
+
2. `AMO_JWT_SECRET` - the Mozilla Add-ons API secret / JWT secret.
|
|
170
|
+
3. Confirmation of the final extension ID: `openbrowser@mizius.com`.
|
|
171
|
+
4. Confirmation of the package/version to submit to AMO for unlisted signing.
|
|
172
|
+
|
|
173
|
+
## Project layout
|
|
174
|
+
|
|
175
|
+
```text
|
|
176
|
+
bin/OpenBrowser.js CLI entry point
|
|
177
|
+
src/ CLI, browser adapters, installer, native host
|
|
178
|
+
extensions/ Browser extension source
|
|
179
|
+
scripts/build.js Development XPI build
|
|
180
|
+
scripts/sign-firefox.js Unlisted Firefox signing pipeline
|
|
181
|
+
skills/OpenBrowser/SKILL.md Local agent skill, not npm-published
|
|
182
|
+
```
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
const NATIVE_HOST = "openbrowser";
|
|
2
|
+
const SESSION_KEY = "session";
|
|
3
|
+
let port;
|
|
4
|
+
let reconnectTimer;
|
|
5
|
+
|
|
6
|
+
connectNativeHost();
|
|
7
|
+
browser.tabs.onRemoved.addListener(handleTabRemoved);
|
|
8
|
+
|
|
9
|
+
function connectNativeHost() {
|
|
10
|
+
try {
|
|
11
|
+
port = browser.runtime.connectNative(NATIVE_HOST);
|
|
12
|
+
port.onMessage.addListener(handleNativeMessage);
|
|
13
|
+
port.onDisconnect.addListener(() => {
|
|
14
|
+
port = null;
|
|
15
|
+
scheduleReconnect();
|
|
16
|
+
});
|
|
17
|
+
} catch (error) {
|
|
18
|
+
scheduleReconnect();
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function scheduleReconnect() {
|
|
23
|
+
if (reconnectTimer) return;
|
|
24
|
+
reconnectTimer = setTimeout(() => {
|
|
25
|
+
reconnectTimer = null;
|
|
26
|
+
connectNativeHost();
|
|
27
|
+
}, 1000);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function handleNativeMessage(message) {
|
|
31
|
+
const id = message && message.id;
|
|
32
|
+
if (!id) return;
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const result = await dispatchCommand(message.command, message.args || {});
|
|
36
|
+
postNative({ replyTo: id, ok: true, result });
|
|
37
|
+
} catch (error) {
|
|
38
|
+
postNative({
|
|
39
|
+
replyTo: id,
|
|
40
|
+
ok: false,
|
|
41
|
+
error: {
|
|
42
|
+
code: error.code || "COMMAND_FAILED",
|
|
43
|
+
message: error.message || String(error),
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function postNative(message) {
|
|
50
|
+
if (!port) return;
|
|
51
|
+
port.postMessage(message);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function dispatchCommand(command, args) {
|
|
55
|
+
switch (command) {
|
|
56
|
+
case "open": return openSession(args.url);
|
|
57
|
+
case "close": return closeSession();
|
|
58
|
+
case "status": return status();
|
|
59
|
+
case "navigate": return navigate(args.url);
|
|
60
|
+
case "reload": return tabAction((tabId) => browser.tabs.reload(tabId));
|
|
61
|
+
case "back": return contentCommand("historyBack", {});
|
|
62
|
+
case "forward": return contentCommand("historyForward", {});
|
|
63
|
+
case "state": return contentCommand("state", {});
|
|
64
|
+
case "screenshot": return screenshot();
|
|
65
|
+
case "click": return contentCommand("click", { ref: args.ref });
|
|
66
|
+
case "keys": return contentCommand("keys", { text: args.text });
|
|
67
|
+
case "press": return contentCommand("press", { key: args.key });
|
|
68
|
+
case "select": return contentCommand("select", { ref: args.ref, option: args.option });
|
|
69
|
+
case "getHtml": return contentCommand("getHtml", { ref: args.ref });
|
|
70
|
+
case "scroll": return contentCommand("scroll", args);
|
|
71
|
+
default: throw codedError("UNKNOWN_COMMAND", `Unknown OpenBrowser command: ${command}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function openSession(url) {
|
|
76
|
+
const existing = await getLiveSession();
|
|
77
|
+
if (existing) throw codedError("SESSION_EXISTS", "An OpenBrowser session already exists for this browser. Close it first.");
|
|
78
|
+
|
|
79
|
+
const tab = await browser.tabs.create({ url });
|
|
80
|
+
const session = { tabId: tab.id, createdAt: new Date().toISOString() };
|
|
81
|
+
await browser.storage.local.set({ [SESSION_KEY]: session });
|
|
82
|
+
return { ok: true, session, url: tab.url || url };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function closeSession() {
|
|
86
|
+
const session = await getStoredSession();
|
|
87
|
+
if (!session) return { ok: true, open: false };
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
await browser.tabs.remove(session.tabId);
|
|
91
|
+
} catch {
|
|
92
|
+
// The owned tab may already have been manually closed.
|
|
93
|
+
}
|
|
94
|
+
await clearSession();
|
|
95
|
+
return { ok: true, open: false };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function status() {
|
|
99
|
+
const session = await getLiveSession();
|
|
100
|
+
if (!session) return { open: false };
|
|
101
|
+
const tab = await browser.tabs.get(session.tabId);
|
|
102
|
+
return {
|
|
103
|
+
open: true,
|
|
104
|
+
session,
|
|
105
|
+
tab: {
|
|
106
|
+
id: tab.id,
|
|
107
|
+
url: tab.url,
|
|
108
|
+
title: tab.title,
|
|
109
|
+
status: tab.status,
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function navigate(url) {
|
|
115
|
+
const session = await requireLiveSession();
|
|
116
|
+
await browser.tabs.update(session.tabId, { url });
|
|
117
|
+
return { ok: true };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function tabAction(action) {
|
|
121
|
+
const session = await requireLiveSession();
|
|
122
|
+
await action(session.tabId);
|
|
123
|
+
return { ok: true };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function screenshot() {
|
|
127
|
+
const session = await requireLiveSession();
|
|
128
|
+
await browser.tabs.update(session.tabId, { active: true });
|
|
129
|
+
const dataUrl = await browser.tabs.captureTab(session.tabId, { format: "png" });
|
|
130
|
+
return { dataUrl };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function contentCommand(type, payload) {
|
|
134
|
+
const session = await requireLiveSession();
|
|
135
|
+
await ensureContentScript(session.tabId);
|
|
136
|
+
try {
|
|
137
|
+
return await browser.tabs.sendMessage(session.tabId, { type, ...payload });
|
|
138
|
+
} catch (error) {
|
|
139
|
+
throw codedError("CONTENT_UNAVAILABLE", error.message || "OpenBrowser content script is unavailable on this page.");
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function ensureContentScript(tabId) {
|
|
144
|
+
try {
|
|
145
|
+
await browser.tabs.sendMessage(tabId, { type: "ping" });
|
|
146
|
+
return;
|
|
147
|
+
} catch {
|
|
148
|
+
// Inject below.
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
await browser.tabs.executeScript(tabId, { file: "content.js", runAt: "document_idle" });
|
|
153
|
+
} catch (error) {
|
|
154
|
+
throw codedError("INJECTION_FAILED", `Cannot control this page: ${error.message || error}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function getStoredSession() {
|
|
159
|
+
const stored = await browser.storage.local.get(SESSION_KEY);
|
|
160
|
+
return stored[SESSION_KEY] || null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function getLiveSession() {
|
|
164
|
+
const session = await getStoredSession();
|
|
165
|
+
if (!session) return null;
|
|
166
|
+
try {
|
|
167
|
+
await browser.tabs.get(session.tabId);
|
|
168
|
+
return session;
|
|
169
|
+
} catch {
|
|
170
|
+
await clearSession();
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function requireLiveSession() {
|
|
176
|
+
const session = await getLiveSession();
|
|
177
|
+
if (!session) throw codedError("NO_SESSION", "No active OpenBrowser session exists for this browser.");
|
|
178
|
+
return session;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function clearSession() {
|
|
182
|
+
await browser.storage.local.remove(SESSION_KEY);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function handleTabRemoved(tabId) {
|
|
186
|
+
const session = await getStoredSession();
|
|
187
|
+
if (session && session.tabId === tabId) await clearSession();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function codedError(code, message) {
|
|
191
|
+
const error = new Error(message);
|
|
192
|
+
error.code = code;
|
|
193
|
+
return error;
|
|
194
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
(function installOpenBrowserContentScript() {
|
|
2
|
+
if (window.__openbrowserContentInstalled) return;
|
|
3
|
+
window.__openbrowserContentInstalled = true;
|
|
4
|
+
|
|
5
|
+
var refs = new Map();
|
|
6
|
+
var generation = 0;
|
|
7
|
+
|
|
8
|
+
var observer = new MutationObserver(function () {
|
|
9
|
+
invalidateReferences();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
observer.observe(document.documentElement || document, {
|
|
13
|
+
childList: true,
|
|
14
|
+
subtree: true,
|
|
15
|
+
attributes: true,
|
|
16
|
+
characterData: true,
|
|
17
|
+
attributeFilter: ["href", "disabled", "aria-label", "aria-hidden", "role", "tabindex", "value"],
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
window.addEventListener("pageshow", invalidateReferences, true);
|
|
21
|
+
window.addEventListener("hashchange", invalidateReferences, true);
|
|
22
|
+
|
|
23
|
+
browser.runtime.onMessage.addListener(function (message) {
|
|
24
|
+
if (!message || !message.type) return undefined;
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
switch (message.type) {
|
|
28
|
+
case "ping": return Promise.resolve({ ok: true });
|
|
29
|
+
case "state": return Promise.resolve(getState());
|
|
30
|
+
case "click": return Promise.resolve(clickRef(message.ref));
|
|
31
|
+
case "keys": return Promise.resolve(typeKeys(message.text || ""));
|
|
32
|
+
case "press": return Promise.resolve(pressKey(message.key));
|
|
33
|
+
case "select": return Promise.resolve(selectOption(message.ref, message.option));
|
|
34
|
+
case "getHtml": return Promise.resolve(getHtml(message.ref));
|
|
35
|
+
case "scroll": return Promise.resolve(scrollPage(message));
|
|
36
|
+
case "historyBack": history.back(); return Promise.resolve({ ok: true });
|
|
37
|
+
case "historyForward": history.forward(); return Promise.resolve({ ok: true });
|
|
38
|
+
default: return Promise.reject(codedError("UNKNOWN_CONTENT_COMMAND", "Unknown content command."));
|
|
39
|
+
}
|
|
40
|
+
} catch (error) {
|
|
41
|
+
return Promise.reject(error);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function invalidateReferences() {
|
|
46
|
+
generation += 1;
|
|
47
|
+
refs.clear();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getState() {
|
|
51
|
+
refs.clear();
|
|
52
|
+
var elements = collectActionableElements();
|
|
53
|
+
var stateGeneration = generation;
|
|
54
|
+
var result = [];
|
|
55
|
+
|
|
56
|
+
elements.forEach(function (element, index) {
|
|
57
|
+
var ref = "e_" + (index + 1);
|
|
58
|
+
refs.set(ref, { element: element, generation: stateGeneration });
|
|
59
|
+
result.push({
|
|
60
|
+
ref: ref,
|
|
61
|
+
role: roleOf(element),
|
|
62
|
+
name: accessibleName(element),
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
url: location.href,
|
|
68
|
+
title: document.title,
|
|
69
|
+
viewport: {
|
|
70
|
+
width: window.innerWidth,
|
|
71
|
+
height: window.innerHeight,
|
|
72
|
+
scrollX: window.scrollX,
|
|
73
|
+
scrollY: window.scrollY,
|
|
74
|
+
devicePixelRatio: window.devicePixelRatio,
|
|
75
|
+
},
|
|
76
|
+
elements: result,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function collectActionableElements() {
|
|
81
|
+
var selector = [
|
|
82
|
+
"a[href]",
|
|
83
|
+
"button",
|
|
84
|
+
"input:not([type=hidden])",
|
|
85
|
+
"select",
|
|
86
|
+
"textarea",
|
|
87
|
+
"summary",
|
|
88
|
+
"[role=button]",
|
|
89
|
+
"[role=link]",
|
|
90
|
+
"[role=menuitem]",
|
|
91
|
+
"[role=checkbox]",
|
|
92
|
+
"[role=radio]",
|
|
93
|
+
"[tabindex]",
|
|
94
|
+
"[contenteditable=true]",
|
|
95
|
+
].join(",");
|
|
96
|
+
|
|
97
|
+
return Array.prototype.slice.call(document.querySelectorAll(selector))
|
|
98
|
+
.filter(function (element) {
|
|
99
|
+
return isVisible(element) && !isDisabled(element) && accessibleName(element);
|
|
100
|
+
})
|
|
101
|
+
.slice(0, 200);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function getRef(ref) {
|
|
105
|
+
var entry = refs.get(ref);
|
|
106
|
+
if (!entry || entry.generation !== generation || !entry.element.isConnected) {
|
|
107
|
+
throw codedError("STALE_REFERENCE", "Stale OpenBrowser reference. Run state again and use a fresh ref.");
|
|
108
|
+
}
|
|
109
|
+
return entry.element;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function clickRef(ref) {
|
|
113
|
+
var element = getRef(ref);
|
|
114
|
+
element.scrollIntoView({ block: "center", inline: "center" });
|
|
115
|
+
element.focus({ preventScroll: true });
|
|
116
|
+
dispatchMouse(element, "mouseover");
|
|
117
|
+
dispatchMouse(element, "mousedown");
|
|
118
|
+
dispatchMouse(element, "mouseup");
|
|
119
|
+
if (typeof element.click === "function") element.click();
|
|
120
|
+
else dispatchMouse(element, "click");
|
|
121
|
+
return { ok: true };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function typeKeys(text) {
|
|
125
|
+
var element = document.activeElement;
|
|
126
|
+
if (!element || element === document.body) throw codedError("NO_FOCUS", "No focused element is available for text input.");
|
|
127
|
+
|
|
128
|
+
if (isTextInput(element)) {
|
|
129
|
+
insertText(element, text);
|
|
130
|
+
return { ok: true };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (element.isContentEditable) {
|
|
134
|
+
document.execCommand("insertText", false, text);
|
|
135
|
+
element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: text }));
|
|
136
|
+
return { ok: true };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
throw codedError("NOT_EDITABLE", "The focused element does not accept text input.");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function pressKey(key) {
|
|
143
|
+
var element = document.activeElement || document.body;
|
|
144
|
+
var options = { key: key, bubbles: true, cancelable: true };
|
|
145
|
+
element.dispatchEvent(new KeyboardEvent("keydown", options));
|
|
146
|
+
element.dispatchEvent(new KeyboardEvent("keypress", options));
|
|
147
|
+
|
|
148
|
+
if (key === "Enter" && typeof element.click === "function" && /^(BUTTON|A)$/.test(element.tagName)) element.click();
|
|
149
|
+
if (key === "Tab") focusNextElement();
|
|
150
|
+
|
|
151
|
+
element.dispatchEvent(new KeyboardEvent("keyup", options));
|
|
152
|
+
return { ok: true };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function selectOption(ref, option) {
|
|
156
|
+
var element = getRef(ref);
|
|
157
|
+
if (element.tagName !== "SELECT") throw codedError("NOT_SELECT", "Referenced element is not a select control.");
|
|
158
|
+
|
|
159
|
+
var options = Array.prototype.slice.call(element.options);
|
|
160
|
+
var match = options.find(function (candidate, index) {
|
|
161
|
+
return candidate.value === option || candidate.text.trim() === option || String(index) === option;
|
|
162
|
+
});
|
|
163
|
+
if (!match) throw codedError("OPTION_NOT_FOUND", "Select option was not found.");
|
|
164
|
+
|
|
165
|
+
element.value = match.value;
|
|
166
|
+
element.dispatchEvent(new Event("input", { bubbles: true }));
|
|
167
|
+
element.dispatchEvent(new Event("change", { bubbles: true }));
|
|
168
|
+
return { ok: true };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function getHtml(ref) {
|
|
172
|
+
if (!ref) return { html: document.documentElement.outerHTML };
|
|
173
|
+
return { html: getRef(ref).outerHTML };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function scrollPage(message) {
|
|
177
|
+
if (message.to) {
|
|
178
|
+
getRef(message.to).scrollIntoView({ block: "center", inline: "nearest" });
|
|
179
|
+
return { ok: true, scrollX: window.scrollX, scrollY: window.scrollY };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
var pixels = Number(message.pixels || 600);
|
|
183
|
+
if (!Number.isFinite(pixels) || pixels <= 0) pixels = 600;
|
|
184
|
+
window.scrollBy({ top: message.direction === "up" ? -pixels : pixels, left: 0, behavior: "auto" });
|
|
185
|
+
return { ok: true, scrollX: window.scrollX, scrollY: window.scrollY };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function accessibleName(element) {
|
|
189
|
+
return (
|
|
190
|
+
element.getAttribute("aria-label") ||
|
|
191
|
+
element.getAttribute("title") ||
|
|
192
|
+
element.getAttribute("alt") ||
|
|
193
|
+
associatedLabel(element) ||
|
|
194
|
+
element.value ||
|
|
195
|
+
element.textContent ||
|
|
196
|
+
element.getAttribute("placeholder") ||
|
|
197
|
+
""
|
|
198
|
+
).replace(/\s+/g, " ").trim().slice(0, 160);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function associatedLabel(element) {
|
|
202
|
+
if (element.id) {
|
|
203
|
+
var label = document.querySelector('label[for="' + cssEscape(element.id) + '"]');
|
|
204
|
+
if (label) return label.textContent;
|
|
205
|
+
}
|
|
206
|
+
var parent = element.closest("label");
|
|
207
|
+
return parent ? parent.textContent : "";
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function roleOf(element) {
|
|
211
|
+
var explicit = element.getAttribute("role");
|
|
212
|
+
if (explicit) return explicit;
|
|
213
|
+
var tag = element.tagName.toLowerCase();
|
|
214
|
+
if (tag === "a") return "link";
|
|
215
|
+
if (tag === "button") return "button";
|
|
216
|
+
if (tag === "select") return "select";
|
|
217
|
+
if (tag === "textarea") return "textbox";
|
|
218
|
+
if (tag === "summary") return "button";
|
|
219
|
+
if (tag === "input") {
|
|
220
|
+
var type = (element.getAttribute("type") || "text").toLowerCase();
|
|
221
|
+
if (["button", "submit", "reset"].includes(type)) return "button";
|
|
222
|
+
if (["checkbox", "radio"].includes(type)) return type;
|
|
223
|
+
return "textbox";
|
|
224
|
+
}
|
|
225
|
+
return "control";
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function isVisible(element) {
|
|
229
|
+
var style = getComputedStyle(element);
|
|
230
|
+
if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity) === 0) return false;
|
|
231
|
+
var rect = element.getBoundingClientRect();
|
|
232
|
+
return rect.width > 0 && rect.height > 0;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function isDisabled(element) {
|
|
236
|
+
return Boolean(element.disabled || element.getAttribute("aria-disabled") === "true");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function dispatchMouse(element, type) {
|
|
240
|
+
element.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window }));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function isTextInput(element) {
|
|
244
|
+
return element.tagName === "TEXTAREA" || (element.tagName === "INPUT" && !["button", "checkbox", "file", "radio", "reset", "submit"].includes((element.type || "text").toLowerCase()));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function insertText(element, text) {
|
|
248
|
+
var start = element.selectionStart || 0;
|
|
249
|
+
var end = element.selectionEnd || start;
|
|
250
|
+
var value = element.value || "";
|
|
251
|
+
element.value = value.slice(0, start) + text + value.slice(end);
|
|
252
|
+
var cursor = start + text.length;
|
|
253
|
+
element.setSelectionRange(cursor, cursor);
|
|
254
|
+
element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: text }));
|
|
255
|
+
element.dispatchEvent(new Event("change", { bubbles: true }));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function focusNextElement() {
|
|
259
|
+
var focusable = collectActionableElements();
|
|
260
|
+
var index = focusable.indexOf(document.activeElement);
|
|
261
|
+
var next = focusable[(index + 1) % focusable.length];
|
|
262
|
+
if (next) next.focus();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function cssEscape(value) {
|
|
266
|
+
if (window.CSS && CSS.escape) return CSS.escape(value);
|
|
267
|
+
return String(value).replace(/"/g, '\\"');
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function codedError(code, message) {
|
|
271
|
+
var error = new Error(message);
|
|
272
|
+
error.code = code;
|
|
273
|
+
return error;
|
|
274
|
+
}
|
|
275
|
+
})();
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"manifest_version": 2,
|
|
3
|
+
"name": "OpenBrowser",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "Local user-scoped browser control bridge for the OpenBrowser CLI.",
|
|
6
|
+
"permissions": [
|
|
7
|
+
"nativeMessaging",
|
|
8
|
+
"storage",
|
|
9
|
+
"tabs",
|
|
10
|
+
"activeTab",
|
|
11
|
+
"<all_urls>"
|
|
12
|
+
],
|
|
13
|
+
"background": {
|
|
14
|
+
"scripts": ["background.js"],
|
|
15
|
+
"persistent": true
|
|
16
|
+
},
|
|
17
|
+
"browser_specific_settings": {
|
|
18
|
+
"gecko": {
|
|
19
|
+
"id": "openbrowser@mizius.com",
|
|
20
|
+
"strict_min_version": "109.0",
|
|
21
|
+
"data_collection_permissions": {
|
|
22
|
+
"required": ["none"]
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|