@mehmoodqureshi/chrome-mcp 0.9.5 → 0.9.7

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 CHANGED
@@ -160,23 +160,41 @@ bundled file.
160
160
 
161
161
  ### Running more than one session
162
162
 
163
- The extension dials exactly **one** bridge port, so only one chrome-mcp can drive
164
- your Chrome at a time — but every MCP host session (each Claude tab/window)
165
- spawns its own server. With a pinned `--port`, the newest session **takes the
166
- port over**: it reads the owning pid from `handshake.json`, confirms that process
167
- really is a chrome-mcp, and stops it. Newest tab wins; the older session's browser
168
- tools go quiet until it reconnects. Nothing that isn't a verified chrome-mcp is
169
- ever touched — a port held by some other program is reported, never killed.
170
-
171
- Two servers can only run side by side if each has its own port **and** its own
172
- paired extension — i.e. a separate Chrome profile running its own copy of the
173
- extension, pointed at the other port (`--port 9223`). A single Chrome pairs to one
174
- server at a time, so a second server with no extension of its own can drive
175
- nothing.
176
-
177
- One server can, however, serve **several browsers at once**: connections are
178
- routed by profile key (`--profile <name>`, matching the profile set in the
179
- extension's Options), so each paired Chrome gets its own routing slot.
163
+ Every MCP host session (each Claude terminal, tab or window) starts its own
164
+ chrome-mcp, and they all share your Chrome at once. The first one to start owns
165
+ the bridge port and the extension connections — the **hub**. Each later session
166
+ finds the port held by a live chrome-mcp and joins it as a **peer**: its tool
167
+ calls are relayed through the hub to the same browsers, so every session keeps
168
+ working side by side. Nobody is disconnected.
169
+
170
+ When the hub's session ends, its peers race for the port; one takes it over
171
+ (with the same token, so the extension re-pairs by itself within a few seconds)
172
+ and the rest join the new hub. A call that was in flight at that moment fails
173
+ once with `EXTENSION_DISCONNECTED` and is retried automatically when it is safe
174
+ to repeat.
175
+
176
+ Peers authenticate with the pairing token from the 0600 handshake file, so only
177
+ your own OS user can join. A chrome-mcp too old to share the port is replaced as
178
+ before: it is verified to be chrome-mcp, then stopped. Anything that isn't a
179
+ verified chrome-mcp is never touched — a port held by some other program is
180
+ reported, never killed.
181
+
182
+ Sessions share one browser, so they also share its tabs: two sessions driving
183
+ the same tab at the same moment will step on each other. Give each session its
184
+ own tabs (`tab_new`), or its own Chrome profile (below).
185
+
186
+ Each session can also drive **several browsers at once**: load the extension
187
+ in each Chrome profile and they all pair to the same server, each under its own
188
+ profile name. Tools act on the active profile — pick it with `--profile <name>`
189
+ at startup or the `profile_use` tool at runtime.
190
+
191
+ Naming is automatic. Chrome won't tell an extension which profile it runs in, so
192
+ each install keeps a random id and the server names it: the first browser is
193
+ `default`, the next `profile-2`, then `profile-3`, and so on. Names are stored in
194
+ `~/.chrome-mcp/profiles.json`, so a browser keeps its name across restarts.
195
+ `chrome_status` lists every paired browser (with its active tab as a hint), and
196
+ `profile_rename` gives one a friendly name (`profile-2` → `work`). To pin a name
197
+ yourself instead, type it into the extension's Options → Profile; that always wins.
180
198
 
181
199
  Without `--port`, each server binds an ephemeral port (no conflict ever), but the
182
200
  port changes every boot — so you'd re-pair the extension each time. Pin `--port`
@@ -502,6 +520,31 @@ group/other-accessible. Windows has no such bits — `chmod` there only toggles
502
520
  read-only attribute — so the check is skipped and the token's confidentiality
503
521
  rests on the per-user ACL of `%USERPROFILE%\.chrome-mcp`.
504
522
 
523
+ ## Telemetry
524
+
525
+ The chrome-mcp **server** sends anonymous usage statistics to PostHog, so the
526
+ project can see how many installs are active, which versions and platforms are
527
+ in use, and which tools fail most. A notice is printed the first time it runs.
528
+
529
+ What is sent: a random install id (kept in `~/.chrome-mcp/telemetry.json`), the
530
+ chrome-mcp version, OS, CPU architecture and Node major version, whether the
531
+ session owns the bridge port or shares it, how many browsers are paired, and
532
+ per-tool call and error **counts** with error codes — batched every 10 minutes.
533
+
534
+ What is never sent: URLs, domains, tool arguments, page content, screenshots,
535
+ cookies, profile names, tokens, file paths, or anything you type. Events are
536
+ personless and GeoIP lookup is disabled.
537
+
538
+ The **browser extension sends nothing** — it only ever talks to `127.0.0.1`.
539
+
540
+ Turn it off with any of:
541
+
542
+ ```bash
543
+ CHROME_MCP_TELEMETRY=0 # or false / off
544
+ DO_NOT_TRACK=1
545
+ --no-telemetry # server flag
546
+ ```
547
+
505
548
  ## Develop
506
549
 
507
550
  ```
@@ -64,6 +64,10 @@ export interface HelloFrame extends BaseFrame {
64
64
  * NOT a security boundary (the token is) — it selects which connection slot the
65
65
  * server routes commands to, so several browsers can stay paired at once. */
66
66
  profile?: string;
67
+ /** Random id this extension install keeps in its chrome.storage.local — one per
68
+ * Chrome profile. With no `profile` label, the server names the browser from it
69
+ * ("default", "profile-2", ...) so two blank-profile browsers never collide. */
70
+ installId?: string;
67
71
  /** Optional capability advertisements (see `WIRE_CAP_TAB_URL`). An extension
68
72
  * that sends none gets the conservative path, so old builds stay correct. */
69
73
  caps?: string[];
@@ -97,6 +101,9 @@ export interface WelcomeFrame extends BaseFrame {
97
101
  heartbeatMs: number;
98
102
  /** The active policy, so the extension can mirror the server-side gate. */
99
103
  policy: WirePolicy;
104
+ /** The profile name this browser was paired as (shown in the Options page).
105
+ * Optional so an older server's welcome still parses. */
106
+ profile?: string;
100
107
  }
101
108
  export interface UnauthFrame extends BaseFrame {
102
109
  type: 'unauthorized';
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * src/bridge/evict.ts — take a pinned port back from a stale chrome-mcp.
3
3
  *
4
- * The extension dials exactly ONE bridge port, so only one server can own a
5
- * given Chrome at a time. But every MCP host session spawns its own chrome-mcp
6
- * child, so a second session (another Claude tab/window) racing for a pinned
7
- * `--port` would otherwise just fail with EADDRINUSE and force the user to kill
8
- * the old process by hand. Newest-session-wins: we do that kill for them.
4
+ * A second session normally JOINS the chrome-mcp holding the port as a peer
5
+ * (see ./peer) and nobody is stopped. This is the fallback for when joining
6
+ * fails: an older chrome-mcp that can't share the port, or a hung one. It will
7
+ * never release the port on its own, so rather than fail with EADDRINUSE and
8
+ * make the user kill it by hand, we do that for them.
9
9
  *
10
10
  * The safety bar is high, because a pid can be recycled onto an unrelated
11
11
  * process. We evict ONLY when every check agrees:
@@ -2,11 +2,11 @@
2
2
  /**
3
3
  * src/bridge/evict.ts — take a pinned port back from a stale chrome-mcp.
4
4
  *
5
- * The extension dials exactly ONE bridge port, so only one server can own a
6
- * given Chrome at a time. But every MCP host session spawns its own chrome-mcp
7
- * child, so a second session (another Claude tab/window) racing for a pinned
8
- * `--port` would otherwise just fail with EADDRINUSE and force the user to kill
9
- * the old process by hand. Newest-session-wins: we do that kill for them.
5
+ * A second session normally JOINS the chrome-mcp holding the port as a peer
6
+ * (see ./peer) and nobody is stopped. This is the fallback for when joining
7
+ * fails: an older chrome-mcp that can't share the port, or a hung one. It will
8
+ * never release the port on its own, so rather than fail with EADDRINUSE and
9
+ * make the user kill it by hand, we do that for them.
10
10
  *
11
11
  * The safety bar is high, because a pid can be recycled onto an unrelated
12
12
  * process. We evict ONLY when every check agrees:
@@ -0,0 +1,94 @@
1
+ /**
2
+ * src/bridge/peer.ts — let several chrome-mcp servers share one Chrome.
3
+ *
4
+ * Every MCP host session (each Claude terminal/tab) spawns its own chrome-mcp,
5
+ * but a browser's extension dials exactly one port. So the first server to bind
6
+ * the port is the HUB: it owns the extension connections. A later server that
7
+ * finds the port held by a live chrome-mcp joins it as a PEER over the same
8
+ * port and relays its commands through the hub, instead of killing it. If the
9
+ * hub exits, its peers race to bind the port; the winner becomes the new hub
10
+ * (with the same token, so the extension re-pairs on its own) and the rest join
11
+ * it.
12
+ *
13
+ * Peers authenticate with the same token the extension uses — the handshake
14
+ * file is 0600, so only the same OS user can read it — and speak a small
15
+ * server-to-server protocol that the extension never sees:
16
+ * peer → hub: peer_hello, relay (a wire command), peer_rename
17
+ * hub → peer: peer_welcome, peer_state (paired browsers), relay_result
18
+ */
19
+ import { PROTOCOL_VERSION, type WireMethod } from '../../shared/protocol';
20
+ import type { PairedProfile } from './server';
21
+ /** How long a joining server waits for the hub's welcome before giving up. */
22
+ export declare const JOIN_TIMEOUT_MS = 2000;
23
+ export interface PeerHelloFrame {
24
+ type: 'peer_hello';
25
+ v: typeof PROTOCOL_VERSION;
26
+ token: string;
27
+ pid: number;
28
+ }
29
+ export interface RelayFrame {
30
+ type: 'relay';
31
+ id: string;
32
+ method: WireMethod;
33
+ params: Record<string, unknown>;
34
+ opts?: {
35
+ tabId?: string;
36
+ timeoutMs?: number;
37
+ profile?: string;
38
+ };
39
+ }
40
+ export interface PeerRenameFrame {
41
+ type: 'peer_rename';
42
+ id: string;
43
+ from: string;
44
+ to: string;
45
+ }
46
+ export type PeerToHubFrame = PeerHelloFrame | RelayFrame | PeerRenameFrame;
47
+ export interface PeerWelcomeFrame {
48
+ type: 'peer_welcome';
49
+ hubPid: number;
50
+ }
51
+ export interface PeerStateFrame {
52
+ type: 'peer_state';
53
+ profiles: PairedProfile[];
54
+ }
55
+ export interface RelayResultFrame {
56
+ type: 'relay_result';
57
+ id: string;
58
+ ok: boolean;
59
+ data?: unknown;
60
+ /** Set when the hub failed with an ExecutorError, so the peer rethrows the same code. */
61
+ code?: string;
62
+ message?: string;
63
+ }
64
+ export type HubToPeerFrame = PeerWelcomeFrame | PeerStateFrame | RelayResultFrame;
65
+ /** Encode a hub-side failure so the peer can rethrow it with its code intact. */
66
+ export declare function relayError(id: string, err: unknown): RelayResultFrame;
67
+ /**
68
+ * A peer's connection to the hub. Mirrors the hub's paired-browser list (pushed
69
+ * on every change) so the synchronous `hasConnection` checks stay synchronous,
70
+ * and relays commands as request/response pairs.
71
+ */
72
+ export declare class HubLink {
73
+ private readonly ws;
74
+ readonly hubPid: number;
75
+ private readonly onClose;
76
+ profiles: PairedProfile[];
77
+ private readonly pending;
78
+ private seq;
79
+ private closed;
80
+ private constructor();
81
+ /**
82
+ * Join the hub on `port` with `token`. Resolves null when nothing answers as a
83
+ * chrome-mcp hub in time: a wrong token, an older server that doesn't speak
84
+ * the peer protocol (it ignores the frame and times us out), or no listener.
85
+ */
86
+ static join(host: string, port: number, token: string, onClose: () => void): Promise<HubLink | null>;
87
+ isOpen(): boolean;
88
+ relay(method: WireMethod, params: Record<string, unknown>, opts?: RelayFrame['opts']): Promise<unknown>;
89
+ rename(from: string, to: string): Promise<unknown>;
90
+ close(): void;
91
+ private request;
92
+ private handleMessage;
93
+ private handleClose;
94
+ }
@@ -0,0 +1,187 @@
1
+ "use strict";
2
+ /**
3
+ * src/bridge/peer.ts — let several chrome-mcp servers share one Chrome.
4
+ *
5
+ * Every MCP host session (each Claude terminal/tab) spawns its own chrome-mcp,
6
+ * but a browser's extension dials exactly one port. So the first server to bind
7
+ * the port is the HUB: it owns the extension connections. A later server that
8
+ * finds the port held by a live chrome-mcp joins it as a PEER over the same
9
+ * port and relays its commands through the hub, instead of killing it. If the
10
+ * hub exits, its peers race to bind the port; the winner becomes the new hub
11
+ * (with the same token, so the extension re-pairs on its own) and the rest join
12
+ * it.
13
+ *
14
+ * Peers authenticate with the same token the extension uses — the handshake
15
+ * file is 0600, so only the same OS user can read it — and speak a small
16
+ * server-to-server protocol that the extension never sees:
17
+ * peer → hub: peer_hello, relay (a wire command), peer_rename
18
+ * hub → peer: peer_welcome, peer_state (paired browsers), relay_result
19
+ */
20
+ Object.defineProperty(exports, "__esModule", { value: true });
21
+ exports.HubLink = exports.JOIN_TIMEOUT_MS = void 0;
22
+ exports.relayError = relayError;
23
+ const ws_1 = require("ws");
24
+ const protocol_1 = require("../../shared/protocol");
25
+ const types_1 = require("../executor/types");
26
+ /** How long a joining server waits for the hub's welcome before giving up. */
27
+ exports.JOIN_TIMEOUT_MS = 2_000;
28
+ /** Slack on top of the hub's own command timeout, so the hub always answers first. */
29
+ const RELAY_GRACE_MS = 5_000;
30
+ /** Upper bound for a relayed command whose caller gave no timeout. */
31
+ const RELAY_DEFAULT_TIMEOUT_MS = 65_000;
32
+ /** Encode a hub-side failure so the peer can rethrow it with its code intact. */
33
+ function relayError(id, err) {
34
+ return {
35
+ type: 'relay_result',
36
+ id,
37
+ ok: false,
38
+ ...(err instanceof types_1.ExecutorError ? { code: err.code } : {}),
39
+ message: err instanceof Error ? err.message : String(err),
40
+ };
41
+ }
42
+ /**
43
+ * A peer's connection to the hub. Mirrors the hub's paired-browser list (pushed
44
+ * on every change) so the synchronous `hasConnection` checks stay synchronous,
45
+ * and relays commands as request/response pairs.
46
+ */
47
+ class HubLink {
48
+ ws;
49
+ hubPid;
50
+ onClose;
51
+ profiles = [];
52
+ pending = new Map();
53
+ seq = 0;
54
+ closed = false;
55
+ constructor(ws, hubPid, onClose) {
56
+ this.ws = ws;
57
+ this.hubPid = hubPid;
58
+ this.onClose = onClose;
59
+ ws.on('message', (raw) => this.handleMessage(raw.toString()));
60
+ ws.on('close', () => this.handleClose());
61
+ ws.on('error', () => this.handleClose());
62
+ }
63
+ /**
64
+ * Join the hub on `port` with `token`. Resolves null when nothing answers as a
65
+ * chrome-mcp hub in time: a wrong token, an older server that doesn't speak
66
+ * the peer protocol (it ignores the frame and times us out), or no listener.
67
+ */
68
+ static join(host, port, token, onClose) {
69
+ return new Promise((resolve) => {
70
+ let settled = false;
71
+ const ws = new ws_1.WebSocket(`ws://${host}:${port}`);
72
+ const done = (link) => {
73
+ if (settled)
74
+ return;
75
+ settled = true;
76
+ clearTimeout(timer);
77
+ ws.off('message', onFirst);
78
+ if (!link) {
79
+ try {
80
+ ws.terminate();
81
+ }
82
+ catch {
83
+ /* ignore */
84
+ }
85
+ }
86
+ resolve(link);
87
+ };
88
+ const timer = setTimeout(() => done(null), exports.JOIN_TIMEOUT_MS);
89
+ timer.unref?.();
90
+ const onFirst = (raw) => {
91
+ let frame;
92
+ try {
93
+ frame = JSON.parse(raw.toString());
94
+ }
95
+ catch {
96
+ return done(null);
97
+ }
98
+ if (frame.type !== 'peer_welcome')
99
+ return done(null); // 'unauthorized' or anything else
100
+ done(new HubLink(ws, typeof frame.hubPid === 'number' ? frame.hubPid : 0, onClose));
101
+ };
102
+ ws.on('message', onFirst);
103
+ ws.once('open', () => {
104
+ const hello = { type: 'peer_hello', v: protocol_1.PROTOCOL_VERSION, token, pid: process.pid };
105
+ ws.send(JSON.stringify(hello));
106
+ });
107
+ ws.once('error', () => done(null));
108
+ ws.once('close', () => done(null));
109
+ });
110
+ }
111
+ isOpen() {
112
+ return !this.closed && this.ws.readyState === ws_1.WebSocket.OPEN;
113
+ }
114
+ relay(method, params, opts) {
115
+ const timeoutMs = (opts?.timeoutMs ?? RELAY_DEFAULT_TIMEOUT_MS - RELAY_GRACE_MS) + RELAY_GRACE_MS;
116
+ return this.request({ type: 'relay', id: '', method, params, ...(opts ? { opts } : {}) }, timeoutMs);
117
+ }
118
+ rename(from, to) {
119
+ return this.request({ type: 'peer_rename', id: '', from, to }, exports.JOIN_TIMEOUT_MS * 2);
120
+ }
121
+ close() {
122
+ try {
123
+ this.ws.close(1001, 'peer stopping');
124
+ }
125
+ catch {
126
+ /* ignore */
127
+ }
128
+ this.handleClose();
129
+ }
130
+ request(frame, timeoutMs) {
131
+ if (!this.isOpen()) {
132
+ return Promise.reject(new types_1.ExecutorError('EXTENSION_DISCONNECTED', 'the shared chrome-mcp hub is not reachable'));
133
+ }
134
+ const id = String(++this.seq);
135
+ return new Promise((resolve, reject) => {
136
+ const timer = setTimeout(() => {
137
+ this.pending.delete(id);
138
+ reject(new types_1.ExecutorError('TIMEOUT', `the shared chrome-mcp hub did not answer ${frame.type} within ${timeoutMs} ms`));
139
+ }, timeoutMs);
140
+ timer.unref?.();
141
+ this.pending.set(id, { resolve, reject, timer });
142
+ this.ws.send(JSON.stringify({ ...frame, id }));
143
+ });
144
+ }
145
+ handleMessage(raw) {
146
+ let frame;
147
+ try {
148
+ frame = JSON.parse(raw);
149
+ }
150
+ catch {
151
+ return;
152
+ }
153
+ if (frame.type === 'peer_state') {
154
+ this.profiles = Array.isArray(frame.profiles) ? frame.profiles : [];
155
+ return;
156
+ }
157
+ if (frame.type !== 'relay_result')
158
+ return;
159
+ const p = this.pending.get(frame.id);
160
+ if (!p)
161
+ return;
162
+ this.pending.delete(frame.id);
163
+ clearTimeout(p.timer);
164
+ if (frame.ok)
165
+ p.resolve(frame.data);
166
+ else if (frame.code)
167
+ p.reject(new types_1.ExecutorError(frame.code, frame.message ?? 'hub error'));
168
+ else
169
+ p.reject(new Error(frame.message ?? 'hub error'));
170
+ }
171
+ handleClose() {
172
+ if (this.closed)
173
+ return;
174
+ this.closed = true;
175
+ this.profiles = [];
176
+ for (const [, p] of this.pending) {
177
+ clearTimeout(p.timer);
178
+ // Retryable: the dispatcher re-runs idempotent calls once, by which time a
179
+ // surviving server has usually taken the port over.
180
+ p.reject(new types_1.ExecutorError('EXTENSION_DISCONNECTED', 'the shared chrome-mcp hub went away mid-call'));
181
+ }
182
+ this.pending.clear();
183
+ this.onClose();
184
+ }
185
+ }
186
+ exports.HubLink = HubLink;
187
+ //# sourceMappingURL=peer.js.map
@@ -0,0 +1,44 @@
1
+ /**
2
+ * src/bridge/profiles.ts — automatic profile names for paired browsers.
3
+ *
4
+ * Chrome gives an extension no way to learn which Chrome profile it runs in, so
5
+ * before this every browser that left Profile blank paired as "default" — and a
6
+ * second one silently superseded the first. Now each extension install sends a
7
+ * stable random `installId` (kept in that profile's chrome.storage.local), and
8
+ * the server gives each install its own name: "default" for the first,
9
+ * "profile-2", "profile-3", ... after that. The mapping persists in
10
+ * `<dataDir>/profiles.json`, so a browser keeps its name across restarts and
11
+ * `profile_rename` can give it a friendly one.
12
+ *
13
+ * A Profile typed into the extension's Options still wins — that label is the
14
+ * user's explicit choice and is never renamed from here.
15
+ */
16
+ export declare const REGISTRY_FILE = "profiles.json";
17
+ /** The name the first auto-named browser gets — the pre-registry default. */
18
+ export declare const FIRST_AUTO_NAME = "default";
19
+ /** An installId is opaque, but it becomes a JSON key and a log line: keep it tame. */
20
+ export declare function isValidInstallId(id: unknown): id is string;
21
+ export declare class ProfileRegistry {
22
+ private readonly dataDir?;
23
+ private installs;
24
+ /** `dataDir` undefined → in-memory only (tests, and servers run without one). */
25
+ constructor(dataDir?: string | undefined);
26
+ /** The name already assigned to this install, if any. */
27
+ nameOf(installId: string): string | undefined;
28
+ /** The install that owns `name` in the registry, if any. */
29
+ ownerOf(name: string): string | undefined;
30
+ /**
31
+ * Name for an install with no explicit label: its remembered name, else the
32
+ * first of "default", "profile-2", ... that no other install owns and
33
+ * `isLive(name)` doesn't report as held by someone else right now.
34
+ */
35
+ assign(installId: string, isLive: (name: string) => boolean): string;
36
+ /**
37
+ * Give the install currently named `from` the name `to`. Throws when `from`
38
+ * isn't an auto-named install or `to` is already owned by another one; the
39
+ * caller checks live connections (explicitly labelled browsers aren't here).
40
+ */
41
+ rename(from: string, to: string): string;
42
+ private load;
43
+ private save;
44
+ }
@@ -0,0 +1,121 @@
1
+ "use strict";
2
+ /**
3
+ * src/bridge/profiles.ts — automatic profile names for paired browsers.
4
+ *
5
+ * Chrome gives an extension no way to learn which Chrome profile it runs in, so
6
+ * before this every browser that left Profile blank paired as "default" — and a
7
+ * second one silently superseded the first. Now each extension install sends a
8
+ * stable random `installId` (kept in that profile's chrome.storage.local), and
9
+ * the server gives each install its own name: "default" for the first,
10
+ * "profile-2", "profile-3", ... after that. The mapping persists in
11
+ * `<dataDir>/profiles.json`, so a browser keeps its name across restarts and
12
+ * `profile_rename` can give it a friendly one.
13
+ *
14
+ * A Profile typed into the extension's Options still wins — that label is the
15
+ * user's explicit choice and is never renamed from here.
16
+ */
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.ProfileRegistry = exports.FIRST_AUTO_NAME = exports.REGISTRY_FILE = void 0;
19
+ exports.isValidInstallId = isValidInstallId;
20
+ const node_fs_1 = require("node:fs");
21
+ const node_path_1 = require("node:path");
22
+ const config_1 = require("../config");
23
+ exports.REGISTRY_FILE = 'profiles.json';
24
+ /** The name the first auto-named browser gets — the pre-registry default. */
25
+ exports.FIRST_AUTO_NAME = 'default';
26
+ /** An installId is opaque, but it becomes a JSON key and a log line: keep it tame. */
27
+ function isValidInstallId(id) {
28
+ return typeof id === 'string' && /^[A-Za-z0-9-]{8,64}$/.test(id);
29
+ }
30
+ class ProfileRegistry {
31
+ dataDir;
32
+ installs = {};
33
+ /** `dataDir` undefined → in-memory only (tests, and servers run without one). */
34
+ constructor(dataDir) {
35
+ this.dataDir = dataDir;
36
+ this.load();
37
+ }
38
+ /** The name already assigned to this install, if any. */
39
+ nameOf(installId) {
40
+ return this.installs[installId]?.name;
41
+ }
42
+ /** The install that owns `name` in the registry, if any. */
43
+ ownerOf(name) {
44
+ for (const [id, rec] of Object.entries(this.installs))
45
+ if (rec.name === name)
46
+ return id;
47
+ return undefined;
48
+ }
49
+ /**
50
+ * Name for an install with no explicit label: its remembered name, else the
51
+ * first of "default", "profile-2", ... that no other install owns and
52
+ * `isLive(name)` doesn't report as held by someone else right now.
53
+ */
54
+ assign(installId, isLive) {
55
+ const now = new Date().toISOString();
56
+ const known = this.installs[installId];
57
+ if (known) {
58
+ known.lastSeen = now;
59
+ this.save();
60
+ return known.name;
61
+ }
62
+ let name = exports.FIRST_AUTO_NAME;
63
+ for (let n = 2; this.ownerOf(name) !== undefined || isLive(name); n++)
64
+ name = `profile-${n}`;
65
+ this.installs[installId] = { name, firstSeen: now, lastSeen: now };
66
+ this.save();
67
+ return name;
68
+ }
69
+ /**
70
+ * Give the install currently named `from` the name `to`. Throws when `from`
71
+ * isn't an auto-named install or `to` is already owned by another one; the
72
+ * caller checks live connections (explicitly labelled browsers aren't here).
73
+ */
74
+ rename(from, to) {
75
+ const target = (0, config_1.sanitizeName)(to, 'profile');
76
+ const id = this.ownerOf(from);
77
+ if (id === undefined) {
78
+ throw new Error(`"${from}" is not an automatically named profile. A name typed into the ` +
79
+ `extension's Options can only be changed there.`);
80
+ }
81
+ if (target === from)
82
+ return target;
83
+ if (this.ownerOf(target) !== undefined)
84
+ throw new Error(`profile "${target}" is already taken`);
85
+ this.installs[id].name = target;
86
+ this.save();
87
+ return target;
88
+ }
89
+ load() {
90
+ if (!this.dataDir)
91
+ return;
92
+ const path = (0, node_path_1.join)(this.dataDir, exports.REGISTRY_FILE);
93
+ if (!(0, node_fs_1.existsSync)(path))
94
+ return;
95
+ try {
96
+ const parsed = JSON.parse((0, node_fs_1.readFileSync)(path, 'utf8'));
97
+ for (const [id, rec] of Object.entries(parsed.installs ?? {})) {
98
+ if (isValidInstallId(id) && rec && typeof rec.name === 'string')
99
+ this.installs[id] = rec;
100
+ }
101
+ }
102
+ catch {
103
+ // A corrupt file must not stop pairing: start empty and rewrite on next save.
104
+ }
105
+ }
106
+ save() {
107
+ if (!this.dataDir)
108
+ return;
109
+ const path = (0, node_path_1.join)(this.dataDir, exports.REGISTRY_FILE);
110
+ const tmp = `${path}.tmp`;
111
+ try {
112
+ (0, node_fs_1.writeFileSync)(tmp, JSON.stringify({ installs: this.installs }, null, 2), { mode: 0o600 });
113
+ (0, node_fs_1.renameSync)(tmp, path);
114
+ }
115
+ catch {
116
+ // Best-effort: names still work for this run, they just won't persist.
117
+ }
118
+ }
119
+ }
120
+ exports.ProfileRegistry = ProfileRegistry;
121
+ //# sourceMappingURL=profiles.js.map