qz-browser-agent 0.1.0 → 0.1.1
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 +150 -36
- package/package.json +1 -1
- package/src/args.mjs +18 -2
- package/src/browser-registry.mjs +101 -0
- package/src/catalog.json +29 -31
- package/src/cli.mjs +62 -15
- package/src/client.mjs +1 -1
- package/src/commands.mjs +8 -5
- package/src/daemon.mjs +18 -3
- package/src/package-info.mjs +1 -0
- package/src/server.mjs +18 -8
- package/src/state.mjs +136 -32
- package/src/terminal.mjs +35 -15
- package/src/tools.mjs +16 -27
package/README.md
CHANGED
|
@@ -5,21 +5,26 @@ Requires Node.js 24 or later and Chrome. CLI and MCP share the same browser conn
|
|
|
5
5
|
|
|
6
6
|
## Quick start
|
|
7
7
|
|
|
8
|
+
Install [qz from the Chrome Web Store](https://chromewebstore.google.com/detail/dknbdgkgoinlgjpfebapkklbmjjgdblm)
|
|
9
|
+
with **Add to Chrome**. Open the qz side panel, complete its first-use data notice, and configure
|
|
10
|
+
your provider and model in Settings.
|
|
11
|
+
|
|
8
12
|
Run the published package with npx:
|
|
9
13
|
|
|
10
14
|
```bash
|
|
11
|
-
npx qz-browser-agent@latest install
|
|
12
15
|
npx qz-browser-agent@latest connect
|
|
13
16
|
npx qz-browser-agent@latest create "Summarize the current page." --stream
|
|
14
17
|
```
|
|
15
18
|
|
|
16
|
-
`install` opens the
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
`npx qz-browser-agent@latest install` opens the same Chrome Web Store page. For a manual ZIP
|
|
20
|
+
installation, download the [latest GitHub release](https://github.com/alibaba/qz-browser-agent/releases/latest)
|
|
21
|
+
and follow the [manual installation steps](https://github.com/alibaba/qz-browser-agent/blob/main/README.md#manual-installation).
|
|
19
22
|
|
|
20
|
-
`connect` starts the local MCP service and opens
|
|
21
|
-
|
|
22
|
-
|
|
23
|
+
`connect` starts the local MCP service and opens its connection page for first pairing. Use the
|
|
24
|
+
Chrome profile where qz is installed. The first new profile is named `default`;
|
|
25
|
+
accepting the data notice alone does not enable the local connection. Repeating `connect` reuses
|
|
26
|
+
an online browser. Add further profiles with a [pairing URL](#pair-a-selected-browser-from-the-shell)
|
|
27
|
+
or from their own qz Settings.
|
|
23
28
|
|
|
24
29
|
The examples and suggested next commands use `qz`. Replace it with
|
|
25
30
|
`npx qz-browser-agent@latest`, or install the command globally:
|
|
@@ -31,35 +36,138 @@ qz --version
|
|
|
31
36
|
|
|
32
37
|
On Windows, use PowerShell 7.3 or newer with its default native argument passing. After a global
|
|
33
38
|
install, use `qz.ps1` for commands containing JSON or quoted names; Windows command suggestions
|
|
34
|
-
use that wrapper. For automation, pin
|
|
39
|
+
use that wrapper. For automation, pin a compatible release version verified with your extension.
|
|
35
40
|
|
|
36
41
|
## Connect and stop
|
|
37
42
|
|
|
38
43
|
```bash
|
|
39
44
|
qz connect
|
|
40
45
|
qz status
|
|
46
|
+
qz browsers
|
|
41
47
|
qz mcp
|
|
42
48
|
qz stop
|
|
43
49
|
```
|
|
44
50
|
|
|
45
|
-
`connect` reuses the healthy service already running for this installation
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
51
|
+
`connect` reuses the healthy service already running for this installation. It prefers port 18765;
|
|
52
|
+
if that port belongs to another program, the service chooses a free port. `status` shows the actual
|
|
53
|
+
service address, and `mcp` prints its MCP URL. Both commands are read-only. To request a custom port,
|
|
54
|
+
run `qz connect --port 18766`; an existing service on another port remains running and the command
|
|
55
|
+
reports its address. The extension connects only to its configured address.
|
|
56
|
+
|
|
57
|
+
The connection page can be closed once `qz browsers` shows the profile online. Connections start
|
|
58
|
+
only through `qz connect` or **qz Settings → Advanced → CLI → Connect CLI**. For ordinary connection
|
|
59
|
+
failures, the extension makes at most three recovery attempts, 30 seconds apart, then pauses.
|
|
60
|
+
After a browser or background restart, it may first wait once for the previous connection to expire.
|
|
61
|
+
The saved retry budget survives worker restarts. Click **Connect CLI** in that browser's settings to retry.
|
|
62
|
+
|
|
63
|
+
To disconnect one browser, click **Disconnect: NAME** in its qz Settings. `qz stop` stops the local
|
|
64
|
+
service and disconnects every browser and MCP client using it. Browser tasks already running
|
|
65
|
+
remain in the extension; stopping the service does not cancel them. Use `qz cancel TASK_ID` to stop
|
|
66
|
+
a task, adding `--client NAME|UUID` when needed.
|
|
67
|
+
|
|
68
|
+
### Pair a selected browser from the shell
|
|
69
|
+
|
|
70
|
+
Get a pairing URL without opening the default browser or waiting for a connection:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
qz connect --no-open --json
|
|
74
|
+
qz connect --no-open --name work --json
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The JSON envelope contains `result.endpoint`, `result.url`, `result.mcpUrl`, and
|
|
78
|
+
`result.opened: false`. The command starts or reuses the service even when other browsers are
|
|
79
|
+
already connected. Open the exact returned URL in the intended browser Profile with qz installed.
|
|
80
|
+
|
|
81
|
+
Set `QZ_PAIRING_URL` to that URL and `QZ_PROFILE_DIRECTORY` to the selected profile's directory
|
|
82
|
+
name, such as `Default` or `Profile 2`. These are directory names, not Chrome's display labels.
|
|
83
|
+
Use a Profile already chosen by the user. On macOS:
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
open -na "Google Chrome" --args --profile-directory="$QZ_PROFILE_DIRECTORY" "$QZ_PAIRING_URL"
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
For Chrome on Linux:
|
|
90
|
+
|
|
91
|
+
```sh
|
|
92
|
+
google-chrome --profile-directory="$QZ_PROFILE_DIRECTORY" "$QZ_PAIRING_URL"
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
For Chrome installed under Program Files on Windows, set `$QzProfileDirectory` and
|
|
96
|
+
`$QzPairingUrl` to those same values in PowerShell:
|
|
97
|
+
|
|
98
|
+
```powershell
|
|
99
|
+
& "$env:ProgramFiles\Google\Chrome\Application\chrome.exe" "--profile-directory=$QzProfileDirectory" $QzPairingUrl
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Use the installed browser's executable or app name. For an existing custom data directory, also
|
|
103
|
+
pass `--user-data-dir` with its exact path; for example on macOS:
|
|
104
|
+
|
|
105
|
+
```sh
|
|
106
|
+
open -na "Google Chrome" --args --user-data-dir="$QZ_USER_DATA_DIR" --profile-directory="$QZ_PROFILE_DIRECTORY" "$QZ_PAIRING_URL"
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The [Chrome profile documentation](https://chromium.googlesource.com/chromium/src/+/master/docs/user_data_dir.md)
|
|
110
|
+
distinguishes the data directory from the Profile inside it. A new data directory needs its own
|
|
111
|
+
extension installation and first-use setup.
|
|
112
|
+
|
|
113
|
+
`--name work` adds `?name=work` to the URL. The Profile opening it receives that identifier,
|
|
114
|
+
including when it already has a connection. Without `--name`, a registered Profile keeps its name;
|
|
115
|
+
a new Profile receives the next available name: `default`, `browser-2`, and so on. A name conflict
|
|
116
|
+
requires another name. `qz connect --name work` opens the URL automatically and returns immediately.
|
|
117
|
+
These URL-returning commands do not confirm that a browser connected. Verify it with:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
qz browsers --json
|
|
121
|
+
qz create "Summarize the current page." --client UUID --stream
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Read `result.browsers`, then use the intended online entry's `clientId` UUID. `--client` selects an
|
|
125
|
+
existing connection and cannot be combined with `--no-open` or `--name`. `--port` works with either
|
|
126
|
+
pairing mode and follows the existing-service behavior above.
|
|
127
|
+
|
|
128
|
+
### Add and select browsers
|
|
129
|
+
|
|
130
|
+
You can also pair from **qz Settings → Advanced → CLI** in the intended Profile. Enter a distinct
|
|
131
|
+
browser identifier and connect. Use the service address printed by `qz status` in **Local service address** if
|
|
132
|
+
its port differs from 18765. Names use 1–64 letters, digits, underscores, or hyphens, beginning with
|
|
133
|
+
a letter or digit; names are case-sensitive. UUID-shaped names are reserved for profile IDs.
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
qz browsers
|
|
137
|
+
qz create "Summarize the current page." --client work --stream
|
|
138
|
+
qz show TASK_ID --client work
|
|
139
|
+
qz data --client work
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`--client NAME|UUID` and `-c NAME|UUID` select a profile. With exactly one online browser, the CLI
|
|
143
|
+
selects it automatically. With several online browsers, a selection is required; `default` is a
|
|
144
|
+
name, not a routing preference. An unknown or offline selection fails without opening or using
|
|
145
|
+
another browser. Each command resolves its name once and carries the UUID through subsequent
|
|
146
|
+
requests. JSON task results and suggested next commands include that UUID.
|
|
147
|
+
|
|
148
|
+
Names remain registered while their browsers are offline and across service restarts. To release
|
|
149
|
+
an offline name, remove its registration explicitly:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
qz browsers forget work
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
An online browser must first disconnect in its settings. Forgetting a registration leaves the
|
|
156
|
+
browser's tasks and data intact. A browser can then explicitly pair using the released name.
|
|
157
|
+
|
|
158
|
+
### Update the connection
|
|
159
|
+
|
|
160
|
+
Use the current CLI and extension together. Source edits and a newer npm package leave a healthy
|
|
161
|
+
running service in place; `qz status` reports an available update. An incompatible service returns
|
|
162
|
+
an update instruction instead of being stopped automatically.
|
|
163
|
+
|
|
164
|
+
1. Use the latest CLI and let current tasks finish.
|
|
165
|
+
2. Run `npx --yes qz-browser-agent@latest stop`. This disconnects all browsers using that service.
|
|
166
|
+
3. Update qz from the [Chrome Web Store](https://chromewebstore.google.com/detail/dknbdgkgoinlgjpfebapkklbmjjgdblm).
|
|
167
|
+
For a manual ZIP installation, replace its extracted files with the
|
|
168
|
+
[latest release](https://github.com/alibaba/qz-browser-agent/releases/latest) and reload it at `chrome://extensions`.
|
|
169
|
+
4. Run `npx --yes qz-browser-agent@latest connect`, then reconnect additional profiles using their
|
|
170
|
+
pairing URL or settings.
|
|
63
171
|
|
|
64
172
|
## Connect an MCP client
|
|
65
173
|
|
|
@@ -72,7 +180,9 @@ url = "http://127.0.0.1:18765/mcp"
|
|
|
72
180
|
|
|
73
181
|
Use the URL printed by your command if its port differs. `qz mcp --json` returns the URL as JSON
|
|
74
182
|
for scripts. The `qingzhou_*` MCP tool names and extension protocol remain available. Responses
|
|
75
|
-
to pending input bind to an explicit `waitId`.
|
|
183
|
+
to pending input bind to an explicit `waitId`. Use `qingzhou_bridge_status` to list browser names
|
|
184
|
+
and UUIDs, then pass `clientId: "work"` or a UUID to browser tools. Selection is required with
|
|
185
|
+
multiple online browsers. Task results include the resolved `clientId`. MCP initialization and streaming are handled by your
|
|
76
186
|
client's MCP implementation.
|
|
77
187
|
|
|
78
188
|
### Task and resource operations
|
|
@@ -81,6 +191,7 @@ CLI and MCP use the same task observer and browser operations. The task and reso
|
|
|
81
191
|
|
|
82
192
|
| CLI | MCP tool |
|
|
83
193
|
| --- | --- |
|
|
194
|
+
| `browsers`, `browsers forget` | `qingzhou_bridge_status`, `qingzhou_forget_browser` |
|
|
84
195
|
| `create`, `send`, `cancel` | `qingzhou_create_session`, `qingzhou_send_message`, `qingzhou_stop_session` |
|
|
85
196
|
| `tasks`, `show`, `watch` | `qingzhou_list_sessions`, `qingzhou_get_session_snapshot`, `qingzhou_watch_task` |
|
|
86
197
|
| `show`, `submit` / `approve` / `reject` | `qingzhou_get_session_snapshot`, `qingzhou_respond_to_user_input` |
|
|
@@ -185,7 +296,8 @@ continues in the browser.
|
|
|
185
296
|
|
|
186
297
|
## Browser selection and MCP tools
|
|
187
298
|
|
|
188
|
-
Use `--client
|
|
299
|
+
Use `--client NAME|UUID` or `-c NAME|UUID` to select a browser. `qz browsers` lists registered names,
|
|
300
|
+
UUIDs, and online status. See [browser selection](#add-and-select-browsers) for pairing and name reuse.
|
|
189
301
|
Use `-t` for `--title`. Run `qz help COMMAND` or `qz COMMAND --help` for command-specific options.
|
|
190
302
|
|
|
191
303
|
MCP clients can inspect browser tool schemas with `qingzhou_list_tools` and `qingzhou_get_tool`,
|
|
@@ -252,8 +364,8 @@ a patch containing the supplied top-level fields. Follow the field descriptions
|
|
|
252
364
|
replace an entire object, such as `modelParameters`.
|
|
253
365
|
|
|
254
366
|
Read credential patches from stdin to keep them out of shell history. JSON is validated before
|
|
255
|
-
connecting. The extension validates field names and values before saving.
|
|
256
|
-
`--client
|
|
367
|
+
connecting. The extension validates field names and values before saving. Browser commands accept
|
|
368
|
+
`--client NAME|UUID` to select a profile. `automations run` starts an existing automation and
|
|
257
369
|
returns its task ID.
|
|
258
370
|
|
|
259
371
|
The CLI uses the same browser operations as MCP. Its commands correspond to tasks, data,
|
|
@@ -283,8 +395,10 @@ extension polling only from extension origins or local clients. Ordinary website
|
|
|
283
395
|
requests through the MCP endpoint. Local programs can control connected qz tasks while the
|
|
284
396
|
service is running. Run `qz stop` to disconnect.
|
|
285
397
|
|
|
286
|
-
Connection metadata
|
|
287
|
-
directory for isolated tests.
|
|
398
|
+
Connection metadata and browser registrations are stored in `~/.qz-browser-agent/run/`;
|
|
399
|
+
`QZ_STATE_DIR` selects another local directory for isolated tests. Registration writes are atomic;
|
|
400
|
+
a failed write leaves the previous registration active. Private pairing proofs stay in local storage
|
|
401
|
+
and are omitted from status output. Atomic startup locks prevent duplicate services. Development copies
|
|
288
402
|
and published installations use separate identities. The client verifies the service identity,
|
|
289
403
|
protocol version, instance, and process before reusing or stopping it. Editing source files or
|
|
290
404
|
running a newer package keeps the healthy service and its connections alive. `status` reports
|
|
@@ -320,7 +434,7 @@ using the public npm registry. It checks the installed command, versions, MCP to
|
|
|
320
434
|
and shutdown on an isolated port. The test owns its npm configuration, cache, connection state,
|
|
321
435
|
and service process. It requires network access to download dependencies.
|
|
322
436
|
|
|
323
|
-
Use `--output tests/results/npm/qz-browser-agent-0.1.
|
|
437
|
+
Use `--output tests/results/npm/qz-browser-agent-0.1.1.tgz` to retain the verified tarball.
|
|
324
438
|
Use `--tarball PATH` to check an existing tarball. The check also runs through
|
|
325
439
|
`npm test -- release --match npm-package`.
|
|
326
440
|
|
|
@@ -345,9 +459,9 @@ For the first manual release, log in with npm, verify the saved tarball, and pub
|
|
|
345
459
|
|
|
346
460
|
```bash
|
|
347
461
|
npm login --registry=https://registry.npmjs.org
|
|
348
|
-
node tests/release/npm-package.mjs --output tests/results/npm/qz-browser-agent-0.1.
|
|
349
|
-
npm publish tests/results/npm/qz-browser-agent-0.1.
|
|
462
|
+
node tests/release/npm-package.mjs --output tests/results/npm/qz-browser-agent-0.1.1.tgz
|
|
463
|
+
npm publish tests/results/npm/qz-browser-agent-0.1.1.tgz --access public --registry=https://registry.npmjs.org
|
|
350
464
|
```
|
|
351
465
|
|
|
352
466
|
The root package stays private. Publishing the service does not publish the Chrome extension.
|
|
353
|
-
After publication, check `npx qz-browser-agent@0.1.
|
|
467
|
+
After publication, check `npx qz-browser-agent@0.1.1 --version` from a directory outside the repository.
|
package/package.json
CHANGED
package/src/args.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { parseArgs } from "node:util";
|
|
2
2
|
import { QzError } from "./client.mjs";
|
|
3
|
+
import { browserName } from "./browser-registry.mjs";
|
|
3
4
|
import { COMMANDS, commandOptions } from "./commands.mjs";
|
|
4
5
|
|
|
5
6
|
function invalid(message) {
|
|
@@ -22,7 +23,7 @@ export function parseCommand(argv) {
|
|
|
22
23
|
title: { type: "string", short: "t" }, client: { type: "string", short: "c" },
|
|
23
24
|
"wait-id": { type: "string" }, format: { type: "string" }, query: { type: "string" },
|
|
24
25
|
start: { type: "string" }, limit: { type: "string" }, scope: { type: "string" }, filename: { type: "string" },
|
|
25
|
-
parent: { type: "string" },
|
|
26
|
+
parent: { type: "string" }, port: { type: "string" }, name: { type: "string" }, "no-open": { type: "boolean" },
|
|
26
27
|
wait: { type: "boolean" }, stream: { type: "boolean" }, steer: { type: "boolean" },
|
|
27
28
|
json: { type: "boolean" }, plain: { type: "boolean" }, "no-color": { type: "boolean" },
|
|
28
29
|
help: { type: "boolean", short: "h" }, version: { type: "boolean", short: "v" },
|
|
@@ -56,6 +57,17 @@ export function parseCommand(argv) {
|
|
|
56
57
|
for (const name of ["session", "client", "wait-id", "parent"]) {
|
|
57
58
|
if (options[name] !== undefined && !options[name].trim()) invalid(`--${name} requires a non-empty ID.`);
|
|
58
59
|
}
|
|
60
|
+
if (options.name !== undefined) {
|
|
61
|
+
try { options.name = browserName(options.name); } catch (error) { invalid(error.message); }
|
|
62
|
+
}
|
|
63
|
+
if (options.client !== undefined && (options.name !== undefined || options["no-open"])) {
|
|
64
|
+
invalid("Use --client to select an existing browser, or --name/--no-open to prepare a binding URL.");
|
|
65
|
+
}
|
|
66
|
+
if (options.port !== undefined) {
|
|
67
|
+
const port = Number(options.port);
|
|
68
|
+
if (!options.port.trim() || !Number.isInteger(port) || port < 1 || port > 65535) invalid("--port must be an integer from 1 to 65535.");
|
|
69
|
+
options.port = port;
|
|
70
|
+
}
|
|
59
71
|
if (options.filename !== undefined && !options.filename.trim()) invalid("--filename requires a non-empty name.");
|
|
60
72
|
if (options.scope !== undefined && !["all", "global", "site", "resident"].includes(options.scope)) invalid("--scope must be all, global, site, or resident.");
|
|
61
73
|
if (options.client !== undefined) {
|
|
@@ -78,7 +90,11 @@ export function parseCommand(argv) {
|
|
|
78
90
|
}
|
|
79
91
|
|
|
80
92
|
let command = originalCommand, action, id;
|
|
81
|
-
if (command === "
|
|
93
|
+
if (command === "browsers") {
|
|
94
|
+
action = words[0] || "list";
|
|
95
|
+
if (action === "list" ? words.length > 1 : action !== "forget" || words.length !== 2 || !words[1].trim()) invalid("Use qz browsers [list], or qz browsers forget NAME|UUID.");
|
|
96
|
+
id = words[1];
|
|
97
|
+
} else if (command === "send") {
|
|
82
98
|
id = words[0]; options.prompt = words.slice(1).join(" ");
|
|
83
99
|
if (!id?.trim() || !options.prompt.trim()) invalid('Use qz send TASK_ID "MESSAGE".');
|
|
84
100
|
} else if (command === "data" || command === "images" || command === "skills") {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { lstat, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
|
|
5
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
6
|
+
export const isBrowserId = value => typeof value === "string" && uuid.test(value);
|
|
7
|
+
export function pairingError(code, message) {
|
|
8
|
+
return Object.assign(new Error(`${code}: ${message}`), { code });
|
|
9
|
+
}
|
|
10
|
+
export function browserName(value) {
|
|
11
|
+
const name = typeof value === "string" ? value.trim() : "";
|
|
12
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(name) || uuid.test(name)) {
|
|
13
|
+
throw pairingError("BRIDGE_NAME_INVALID", "Use 1–64 letters, digits, underscores, or hyphens, starting with a letter or digit. A UUID cannot be used as a name.");
|
|
14
|
+
}
|
|
15
|
+
return name;
|
|
16
|
+
}
|
|
17
|
+
export function sameToken(actual, expected) {
|
|
18
|
+
if (typeof actual !== "string" || typeof expected !== "string") return false;
|
|
19
|
+
const a = Buffer.from(actual), b = Buffer.from(expected);
|
|
20
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function missingRegistry(path) {
|
|
24
|
+
// On Windows, ENOENT can also mean that a parent component is a file.
|
|
25
|
+
for (let candidate = path; ; candidate = dirname(candidate)) {
|
|
26
|
+
try { await lstat(candidate); }
|
|
27
|
+
catch (error) {
|
|
28
|
+
if (error.code !== "ENOENT" || dirname(candidate) === candidate) return false;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
return candidate !== path && await stat(candidate).then(entry => entry.isDirectory(), () => false);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The daemon owns this file; its scope lock prevents competing writers. */
|
|
36
|
+
export class BrowserRegistry {
|
|
37
|
+
constructor({ path, entries = [], persist } = {}) {
|
|
38
|
+
this.path = path;
|
|
39
|
+
this.entries = new Map(entries.map(entry => [entry.clientId, entry]));
|
|
40
|
+
this.persist = persist || (async entries => {
|
|
41
|
+
if (!path) return;
|
|
42
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
43
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
44
|
+
try {
|
|
45
|
+
await writeFile(temporary, `${JSON.stringify({ version: 1, browsers: entries })}\n`, { mode: 0o600 });
|
|
46
|
+
await rename(temporary, path);
|
|
47
|
+
} finally { await rm(temporary, { force: true }).catch(() => {}); }
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
static async open(path) {
|
|
52
|
+
if (!path) return new BrowserRegistry();
|
|
53
|
+
let document;
|
|
54
|
+
try { document = JSON.parse(await readFile(path, "utf8")); }
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (error.code === "ENOENT" && await missingRegistry(path)) return new BrowserRegistry({ path });
|
|
57
|
+
throw pairingError("BRIDGE_REGISTRY_READ_FAILED", "Cannot read the browser registry. Restore its valid contents before restarting qz.");
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
if (document.version !== 1 || !Array.isArray(document.browsers)) throw new Error();
|
|
61
|
+
const ids = new Set(), names = new Set();
|
|
62
|
+
for (const entry of document.browsers) {
|
|
63
|
+
if (!isBrowserId(entry.clientId) || browserName(entry.name) !== entry.name
|
|
64
|
+
|| !/^[A-Za-z0-9_-]{43}$/.test(entry.pairingToken) || ids.has(entry.clientId) || names.has(entry.name)) throw new Error();
|
|
65
|
+
ids.add(entry.clientId); names.add(entry.name);
|
|
66
|
+
}
|
|
67
|
+
return new BrowserRegistry({ path, entries: document.browsers });
|
|
68
|
+
} catch { throw pairingError("BRIDGE_REGISTRY_READ_FAILED", "The browser registry is invalid. Restore its valid contents before restarting qz."); }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
list() { return [...this.entries.values()].map(({ clientId, name }) => ({ clientId, name })); }
|
|
72
|
+
get(selector) { return this.entries.get(selector) || [...this.entries.values()].find(entry => entry.name === selector); }
|
|
73
|
+
async save(entries) {
|
|
74
|
+
try { await this.persist([...entries.values()]); }
|
|
75
|
+
catch { throw pairingError("BRIDGE_REGISTRY_WRITE_FAILED", "Could not save the browser registry. Check its directory permissions and retry."); }
|
|
76
|
+
this.entries = entries;
|
|
77
|
+
}
|
|
78
|
+
async pair({ clientId, name, pairingToken }) {
|
|
79
|
+
const existing = this.entries.get(clientId);
|
|
80
|
+
if (existing && !sameToken(pairingToken, existing.pairingToken)) {
|
|
81
|
+
throw pairingError("BRIDGE_PAIRING_REQUIRED", "This browser identity is already paired. Use its original profile, or forget the offline browser before pairing again.");
|
|
82
|
+
}
|
|
83
|
+
if (!name && !existing) {
|
|
84
|
+
let index = this.entries.size ? 2 : 1;
|
|
85
|
+
do { name = index === 1 ? "default" : `browser-${index}`; index++; } while (this.get(name));
|
|
86
|
+
}
|
|
87
|
+
name = browserName(name || existing?.name);
|
|
88
|
+
const occupied = this.get(name);
|
|
89
|
+
if (occupied && occupied.clientId !== clientId) throw pairingError("BRIDGE_NAME_IN_USE", `Browser name ${name} is already registered. Choose another name or forget its offline registration.`);
|
|
90
|
+
if (existing?.name === name) return existing;
|
|
91
|
+
if (pairingToken !== undefined && !/^[A-Za-z0-9_-]{43}$/.test(pairingToken)) throw pairingError("BRIDGE_PAIRING_REQUIRED", "The browser pairing proof is invalid. Reconnect from the extension settings.");
|
|
92
|
+
const entry = { clientId, name, pairingToken: existing?.pairingToken || pairingToken || randomBytes(32).toString("base64url") };
|
|
93
|
+
await this.save(new Map(this.entries).set(clientId, entry));
|
|
94
|
+
return entry;
|
|
95
|
+
}
|
|
96
|
+
async forget(clientId) {
|
|
97
|
+
const entries = new Map(this.entries);
|
|
98
|
+
entries.delete(clientId);
|
|
99
|
+
await this.save(entries);
|
|
100
|
+
}
|
|
101
|
+
}
|