@flrande/browserctl 0.1.0 → 0.2.0-dev.9.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.
@@ -0,0 +1,256 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import * as a11ySnapshotCommand from "./commands/a11y-snapshot";
4
+ import * as consoleListCommand from "./commands/console-list";
5
+ import * as cookieClearCommand from "./commands/cookie-clear";
6
+ import * as cookieGetCommand from "./commands/cookie-get";
7
+ import * as cookieSetCommand from "./commands/cookie-set";
8
+ import * as daemonClient from "./daemon-client";
9
+ import * as dialogArmCommand from "./commands/dialog-arm";
10
+ import * as domQueryAllCommand from "./commands/dom-query-all";
11
+ import * as domQueryCommand from "./commands/dom-query";
12
+ import * as downloadTriggerCommand from "./commands/download-trigger";
13
+ import * as downloadWaitCommand from "./commands/download-wait";
14
+ import * as elementScreenshotCommand from "./commands/element-screenshot";
15
+ import * as frameListCommand from "./commands/frame-list";
16
+ import * as frameSnapshotCommand from "./commands/frame-snapshot";
17
+ import * as networkWaitForCommand from "./commands/network-wait-for";
18
+ import * as profileListCommand from "./commands/profile-list";
19
+ import * as profileUseCommand from "./commands/profile-use";
20
+ import * as responseBodyCommand from "./commands/response-body";
21
+ import * as snapshotCommand from "./commands/snapshot";
22
+ import * as storageGetCommand from "./commands/storage-get";
23
+ import * as storageSetCommand from "./commands/storage-set";
24
+ import * as tabCloseCommand from "./commands/tab-close";
25
+ import * as tabFocusCommand from "./commands/tab-focus";
26
+ import * as tabOpenCommand from "./commands/tab-open";
27
+ import * as uploadArmCommand from "./commands/upload-arm";
28
+ import { EXIT_CODES, runCli } from "./main";
29
+
30
+ function createIoCapture() {
31
+ const state = {
32
+ stdout: "",
33
+ stderr: ""
34
+ };
35
+
36
+ return {
37
+ state,
38
+ io: {
39
+ stdout: {
40
+ write(content: string) {
41
+ state.stdout += content;
42
+ }
43
+ },
44
+ stderr: {
45
+ write(content: string) {
46
+ state.stderr += content;
47
+ }
48
+ }
49
+ }
50
+ };
51
+ }
52
+
53
+ type DispatchCase = {
54
+ command: string;
55
+ commandArgs: string[];
56
+ moduleRef: Record<string, unknown>;
57
+ handlerName: string;
58
+ };
59
+
60
+ const COMMAND_DISPATCH_CASES: DispatchCase[] = [
61
+ {
62
+ command: "a11y-snapshot",
63
+ commandArgs: ["target:1"],
64
+ moduleRef: a11ySnapshotCommand as unknown as Record<string, unknown>,
65
+ handlerName: "runA11ySnapshotCommand"
66
+ },
67
+ {
68
+ command: "console-list",
69
+ commandArgs: ["target:1"],
70
+ moduleRef: consoleListCommand as unknown as Record<string, unknown>,
71
+ handlerName: "runConsoleListCommand"
72
+ },
73
+ {
74
+ command: "cookie-clear",
75
+ commandArgs: ["target:1"],
76
+ moduleRef: cookieClearCommand as unknown as Record<string, unknown>,
77
+ handlerName: "runCookieClearCommand"
78
+ },
79
+ {
80
+ command: "cookie-get",
81
+ commandArgs: ["target:1"],
82
+ moduleRef: cookieGetCommand as unknown as Record<string, unknown>,
83
+ handlerName: "runCookieGetCommand"
84
+ },
85
+ {
86
+ command: "cookie-set",
87
+ commandArgs: ["target:1", "sid", "abc"],
88
+ moduleRef: cookieSetCommand as unknown as Record<string, unknown>,
89
+ handlerName: "runCookieSetCommand"
90
+ },
91
+ {
92
+ command: "dialog-arm",
93
+ commandArgs: ["target:1"],
94
+ moduleRef: dialogArmCommand as unknown as Record<string, unknown>,
95
+ handlerName: "runDialogArmCommand"
96
+ },
97
+ {
98
+ command: "dom-query",
99
+ commandArgs: ["target:1", "#root"],
100
+ moduleRef: domQueryCommand as unknown as Record<string, unknown>,
101
+ handlerName: "runDomQueryCommand"
102
+ },
103
+ {
104
+ command: "dom-query-all",
105
+ commandArgs: ["target:1", ".item"],
106
+ moduleRef: domQueryAllCommand as unknown as Record<string, unknown>,
107
+ handlerName: "runDomQueryAllCommand"
108
+ },
109
+ {
110
+ command: "download-trigger",
111
+ commandArgs: ["target:1"],
112
+ moduleRef: downloadTriggerCommand as unknown as Record<string, unknown>,
113
+ handlerName: "runDownloadTriggerCommand"
114
+ },
115
+ {
116
+ command: "download-wait",
117
+ commandArgs: ["target:1"],
118
+ moduleRef: downloadWaitCommand as unknown as Record<string, unknown>,
119
+ handlerName: "runDownloadWaitCommand"
120
+ },
121
+ {
122
+ command: "element-screenshot",
123
+ commandArgs: ["target:1", "#hero"],
124
+ moduleRef: elementScreenshotCommand as unknown as Record<string, unknown>,
125
+ handlerName: "runElementScreenshotCommand"
126
+ },
127
+ {
128
+ command: "frame-list",
129
+ commandArgs: ["target:1"],
130
+ moduleRef: frameListCommand as unknown as Record<string, unknown>,
131
+ handlerName: "runFrameListCommand"
132
+ },
133
+ {
134
+ command: "frame-snapshot",
135
+ commandArgs: ["target:1", "frame:0"],
136
+ moduleRef: frameSnapshotCommand as unknown as Record<string, unknown>,
137
+ handlerName: "runFrameSnapshotCommand"
138
+ },
139
+ {
140
+ command: "network-wait-for",
141
+ commandArgs: ["target:1", "/api", "1000"],
142
+ moduleRef: networkWaitForCommand as unknown as Record<string, unknown>,
143
+ handlerName: "runNetworkWaitForCommand"
144
+ },
145
+ {
146
+ command: "profile-list",
147
+ commandArgs: [],
148
+ moduleRef: profileListCommand as unknown as Record<string, unknown>,
149
+ handlerName: "runProfileListCommand"
150
+ },
151
+ {
152
+ command: "profile-use",
153
+ commandArgs: ["managed"],
154
+ moduleRef: profileUseCommand as unknown as Record<string, unknown>,
155
+ handlerName: "runProfileUseCommand"
156
+ },
157
+ {
158
+ command: "response-body",
159
+ commandArgs: ["target:1", "request:1"],
160
+ moduleRef: responseBodyCommand as unknown as Record<string, unknown>,
161
+ handlerName: "runResponseBodyCommand"
162
+ },
163
+ {
164
+ command: "snapshot",
165
+ commandArgs: ["target:1"],
166
+ moduleRef: snapshotCommand as unknown as Record<string, unknown>,
167
+ handlerName: "runSnapshotCommand"
168
+ },
169
+ {
170
+ command: "storage-get",
171
+ commandArgs: ["target:1", "local", "theme"],
172
+ moduleRef: storageGetCommand as unknown as Record<string, unknown>,
173
+ handlerName: "runStorageGetCommand"
174
+ },
175
+ {
176
+ command: "storage-set",
177
+ commandArgs: ["target:1", "local", "theme", "dark"],
178
+ moduleRef: storageSetCommand as unknown as Record<string, unknown>,
179
+ handlerName: "runStorageSetCommand"
180
+ },
181
+ {
182
+ command: "tab-close",
183
+ commandArgs: ["target:1"],
184
+ moduleRef: tabCloseCommand as unknown as Record<string, unknown>,
185
+ handlerName: "runTabCloseCommand"
186
+ },
187
+ {
188
+ command: "tab-focus",
189
+ commandArgs: ["target:1"],
190
+ moduleRef: tabFocusCommand as unknown as Record<string, unknown>,
191
+ handlerName: "runTabFocusCommand"
192
+ },
193
+ {
194
+ command: "tab-open",
195
+ commandArgs: ["https://example.com"],
196
+ moduleRef: tabOpenCommand as unknown as Record<string, unknown>,
197
+ handlerName: "runTabOpenCommand"
198
+ },
199
+ {
200
+ command: "upload-arm",
201
+ commandArgs: ["target:1", "upload.txt"],
202
+ moduleRef: uploadArmCommand as unknown as Record<string, unknown>,
203
+ handlerName: "runUploadArmCommand"
204
+ }
205
+ ];
206
+
207
+ afterEach(() => {
208
+ vi.restoreAllMocks();
209
+ });
210
+
211
+ describe("cli dispatch matrix", () => {
212
+ it.each(COMMAND_DISPATCH_CASES)(
213
+ "dispatches $command to $handlerName",
214
+ async ({ command, commandArgs, moduleRef, handlerName }) => {
215
+ const handler = moduleRef[handlerName] as ((args: string[]) => Promise<unknown>) | undefined;
216
+ if (typeof handler !== "function") {
217
+ throw new Error(`Expected ${handlerName} to be a function.`);
218
+ }
219
+
220
+ const handlerSpy = vi.spyOn(moduleRef as Record<string, (...args: unknown[]) => unknown>, handlerName)
221
+ .mockResolvedValue({ ok: true });
222
+ const { io, state } = createIoCapture();
223
+
224
+ const exitCode = await runCli([command, ...commandArgs], io);
225
+
226
+ expect(exitCode).toBe(EXIT_CODES.OK);
227
+ expect(state.stderr).toBe("");
228
+ expect(state.stdout).toMatch(new RegExp(`^${command}: `));
229
+ expect(handlerSpy).toHaveBeenCalledTimes(1);
230
+ expect(handlerSpy).toHaveBeenCalledWith(commandArgs);
231
+ }
232
+ );
233
+
234
+ it("dispatches daemon-stop through daemon client", async () => {
235
+ const stopSpy = vi.spyOn(daemonClient, "stopDaemon").mockResolvedValue({
236
+ stopped: true,
237
+ port: 41337,
238
+ pid: 12345
239
+ });
240
+ const { io, state } = createIoCapture();
241
+
242
+ const exitCode = await runCli(["daemon-stop", "--json"], io);
243
+
244
+ expect(exitCode).toBe(EXIT_CODES.OK);
245
+ expect(stopSpy).toHaveBeenCalledTimes(1);
246
+ expect(JSON.parse(state.stdout)).toEqual({
247
+ ok: true,
248
+ command: "daemon-stop",
249
+ data: {
250
+ stopped: true,
251
+ port: 41337,
252
+ pid: 12345
253
+ }
254
+ });
255
+ });
256
+ });
@@ -0,0 +1,394 @@
1
+ import { PassThrough } from "node:stream";
2
+
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import { bootstrapBrowserd } from "./bootstrap";
6
+
7
+ function waitForNextJsonLine(stream: PassThrough): Promise<Record<string, unknown>> {
8
+ return new Promise((resolve, reject) => {
9
+ let buffer = "";
10
+ const timeout = setTimeout(() => {
11
+ stream.off("data", onData);
12
+ reject(new Error("Timed out waiting for JSON line response."));
13
+ }, 1000);
14
+
15
+ const onData = (chunk: string | Buffer) => {
16
+ buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
17
+ const newlineIndex = buffer.indexOf("\n");
18
+ if (newlineIndex < 0) {
19
+ return;
20
+ }
21
+
22
+ clearTimeout(timeout);
23
+ stream.off("data", onData);
24
+ const line = buffer.slice(0, newlineIndex);
25
+
26
+ resolve(JSON.parse(line) as Record<string, unknown>);
27
+ };
28
+
29
+ stream.on("data", onData);
30
+ });
31
+ }
32
+
33
+ function sendToolRequest(
34
+ input: PassThrough,
35
+ output: PassThrough,
36
+ request: Record<string, unknown>
37
+ ): Promise<Record<string, unknown>> {
38
+ const responsePromise = waitForNextJsonLine(output);
39
+ input.write(`${JSON.stringify(request)}\n`);
40
+ return responsePromise;
41
+ }
42
+
43
+ function createManagedLegacyRuntime() {
44
+ const input = new PassThrough();
45
+ const output = new PassThrough();
46
+ const runtime = bootstrapBrowserd({
47
+ env: {
48
+ BROWSERD_DEFAULT_DRIVER: "managed"
49
+ },
50
+ input,
51
+ output,
52
+ stdioProtocol: "legacy"
53
+ });
54
+
55
+ return {
56
+ input,
57
+ output,
58
+ runtime
59
+ };
60
+ }
61
+
62
+ describe("browserd tool matrix", () => {
63
+ it("returns E_INVALID_ARG for missing required arguments on routed tools", async () => {
64
+ const { input, output, runtime } = createManagedLegacyRuntime();
65
+
66
+ try {
67
+ const toolNames = [
68
+ "browser.profile.use",
69
+ "browser.tab.focus",
70
+ "browser.tab.close",
71
+ "browser.snapshot",
72
+ "browser.dom.query",
73
+ "browser.dom.queryAll",
74
+ "browser.element.screenshot",
75
+ "browser.a11y.snapshot",
76
+ "browser.cookie.get",
77
+ "browser.cookie.set",
78
+ "browser.cookie.clear",
79
+ "browser.storage.get",
80
+ "browser.storage.set",
81
+ "browser.frame.list",
82
+ "browser.frame.snapshot",
83
+ "browser.act",
84
+ "browser.dialog.arm",
85
+ "browser.download.trigger",
86
+ "browser.network.waitFor"
87
+ ] as const;
88
+
89
+ for (const [index, toolName] of toolNames.entries()) {
90
+ const response = await sendToolRequest(input, output, {
91
+ id: `request-missing-arg-${index}`,
92
+ name: toolName,
93
+ traceId: `trace:missing-arg:${index}`,
94
+ arguments: {
95
+ sessionId: "session:missing-arg"
96
+ }
97
+ });
98
+
99
+ expect(response.ok).toBe(false);
100
+ expect(response.error).toMatchObject({
101
+ code: "E_INVALID_ARG"
102
+ });
103
+ }
104
+ } finally {
105
+ runtime.close();
106
+ input.end();
107
+ output.end();
108
+ }
109
+ });
110
+
111
+ it("returns E_DRIVER_UNAVAILABLE for structured action tools on managed driver", async () => {
112
+ const { input, output, runtime } = createManagedLegacyRuntime();
113
+
114
+ try {
115
+ const openResponse = await sendToolRequest(input, output, {
116
+ id: "request-unavailable-open",
117
+ name: "browser.tab.open",
118
+ traceId: "trace:unavailable:open",
119
+ arguments: {
120
+ sessionId: "session:unavailable",
121
+ url: "https://example.com/unavailable"
122
+ }
123
+ });
124
+ const targetId = (openResponse.data as { targetId: string }).targetId;
125
+
126
+ const cases: Array<{ name: string; arguments: Record<string, unknown> }> = [
127
+ {
128
+ name: "browser.dom.query",
129
+ arguments: {
130
+ sessionId: "session:unavailable",
131
+ targetId,
132
+ selector: "#root"
133
+ }
134
+ },
135
+ {
136
+ name: "browser.dom.queryAll",
137
+ arguments: {
138
+ sessionId: "session:unavailable",
139
+ targetId,
140
+ selector: ".item"
141
+ }
142
+ },
143
+ {
144
+ name: "browser.element.screenshot",
145
+ arguments: {
146
+ sessionId: "session:unavailable",
147
+ targetId,
148
+ selector: "#hero"
149
+ }
150
+ },
151
+ {
152
+ name: "browser.a11y.snapshot",
153
+ arguments: {
154
+ sessionId: "session:unavailable",
155
+ targetId
156
+ }
157
+ },
158
+ {
159
+ name: "browser.cookie.get",
160
+ arguments: {
161
+ sessionId: "session:unavailable",
162
+ targetId
163
+ }
164
+ },
165
+ {
166
+ name: "browser.cookie.set",
167
+ arguments: {
168
+ sessionId: "session:unavailable",
169
+ targetId,
170
+ name: "sid",
171
+ value: "abc"
172
+ }
173
+ },
174
+ {
175
+ name: "browser.cookie.clear",
176
+ arguments: {
177
+ sessionId: "session:unavailable",
178
+ targetId
179
+ }
180
+ },
181
+ {
182
+ name: "browser.storage.get",
183
+ arguments: {
184
+ sessionId: "session:unavailable",
185
+ targetId,
186
+ scope: "local",
187
+ key: "theme"
188
+ }
189
+ },
190
+ {
191
+ name: "browser.storage.set",
192
+ arguments: {
193
+ sessionId: "session:unavailable",
194
+ targetId,
195
+ scope: "local",
196
+ key: "theme",
197
+ value: "dark"
198
+ }
199
+ },
200
+ {
201
+ name: "browser.frame.list",
202
+ arguments: {
203
+ sessionId: "session:unavailable",
204
+ targetId
205
+ }
206
+ },
207
+ {
208
+ name: "browser.frame.snapshot",
209
+ arguments: {
210
+ sessionId: "session:unavailable",
211
+ targetId,
212
+ frameId: "frame:0"
213
+ }
214
+ }
215
+ ];
216
+
217
+ for (const [index, testCase] of cases.entries()) {
218
+ const response = await sendToolRequest(input, output, {
219
+ id: `request-driver-unavailable-${index}`,
220
+ name: testCase.name,
221
+ traceId: `trace:driver-unavailable:${index}`,
222
+ arguments: testCase.arguments
223
+ });
224
+
225
+ expect(response.ok).toBe(false);
226
+ expect(response.error).toMatchObject({
227
+ code: "E_DRIVER_UNAVAILABLE"
228
+ });
229
+ }
230
+ } finally {
231
+ runtime.close();
232
+ input.end();
233
+ output.end();
234
+ }
235
+ });
236
+
237
+ it("routes managed-driver tools including profile.use, act, focus/close, and waitFor timeout", async () => {
238
+ const { input, output, runtime } = createManagedLegacyRuntime();
239
+
240
+ try {
241
+ const profileUseResponse = await sendToolRequest(input, output, {
242
+ id: "request-profile-use",
243
+ name: "browser.profile.use",
244
+ traceId: "trace:matrix:profile-use",
245
+ arguments: {
246
+ sessionId: "session:profile-use",
247
+ profile: "managed"
248
+ }
249
+ });
250
+ expect(profileUseResponse.ok).toBe(true);
251
+ expect(profileUseResponse.data).toEqual({
252
+ profile: "managed"
253
+ });
254
+
255
+ const openResponse = await sendToolRequest(input, output, {
256
+ id: "request-flow-open",
257
+ name: "browser.tab.open",
258
+ traceId: "trace:matrix:open",
259
+ arguments: {
260
+ sessionId: "session:flow",
261
+ url: "https://example.com/flow"
262
+ }
263
+ });
264
+ const targetId = (openResponse.data as { targetId: string }).targetId;
265
+
266
+ const snapshotResponse = await sendToolRequest(input, output, {
267
+ id: "request-flow-snapshot",
268
+ name: "browser.snapshot",
269
+ traceId: "trace:matrix:snapshot",
270
+ arguments: {
271
+ sessionId: "session:flow",
272
+ targetId
273
+ }
274
+ });
275
+ expect(snapshotResponse.ok).toBe(true);
276
+ expect(snapshotResponse.data).toMatchObject({
277
+ driver: "managed",
278
+ targetId
279
+ });
280
+
281
+ const focusResponse = await sendToolRequest(input, output, {
282
+ id: "request-flow-focus",
283
+ name: "browser.tab.focus",
284
+ traceId: "trace:matrix:focus",
285
+ arguments: {
286
+ sessionId: "session:flow",
287
+ targetId
288
+ }
289
+ });
290
+ expect(focusResponse.ok).toBe(true);
291
+ expect(focusResponse.data).toMatchObject({
292
+ driver: "managed",
293
+ targetId,
294
+ focused: true
295
+ });
296
+
297
+ const actResponse = await sendToolRequest(input, output, {
298
+ id: "request-flow-act",
299
+ name: "browser.act",
300
+ traceId: "trace:matrix:act",
301
+ arguments: {
302
+ sessionId: "session:flow",
303
+ targetId,
304
+ action: {
305
+ type: "click",
306
+ payload: {
307
+ selector: "#go"
308
+ }
309
+ }
310
+ }
311
+ });
312
+ expect(actResponse.ok).toBe(true);
313
+ expect(actResponse.data).toMatchObject({
314
+ driver: "managed",
315
+ targetId,
316
+ result: {
317
+ actionType: "click",
318
+ targetId,
319
+ targetKnown: true,
320
+ ok: true
321
+ }
322
+ });
323
+
324
+ const dialogResponse = await sendToolRequest(input, output, {
325
+ id: "request-flow-dialog",
326
+ name: "browser.dialog.arm",
327
+ traceId: "trace:matrix:dialog",
328
+ arguments: {
329
+ sessionId: "session:flow",
330
+ targetId
331
+ }
332
+ });
333
+ expect(dialogResponse.ok).toBe(true);
334
+ expect(dialogResponse.data).toMatchObject({
335
+ driver: "managed",
336
+ targetId,
337
+ armed: true
338
+ });
339
+
340
+ const triggerResponse = await sendToolRequest(input, output, {
341
+ id: "request-flow-trigger",
342
+ name: "browser.download.trigger",
343
+ traceId: "trace:matrix:trigger",
344
+ arguments: {
345
+ sessionId: "session:flow",
346
+ targetId
347
+ }
348
+ });
349
+ expect(triggerResponse.ok).toBe(true);
350
+ expect(triggerResponse.data).toMatchObject({
351
+ driver: "managed",
352
+ targetId,
353
+ triggered: true
354
+ });
355
+
356
+ const waitForResponse = await sendToolRequest(input, output, {
357
+ id: "request-flow-waitfor",
358
+ name: "browser.network.waitFor",
359
+ traceId: "trace:matrix:waitfor",
360
+ arguments: {
361
+ sessionId: "session:flow",
362
+ targetId,
363
+ urlPattern: "/never-match",
364
+ timeoutMs: 5,
365
+ pollMs: 1
366
+ }
367
+ });
368
+ expect(waitForResponse.ok).toBe(false);
369
+ expect(waitForResponse.error).toMatchObject({
370
+ code: "E_TIMEOUT"
371
+ });
372
+
373
+ const closeResponse = await sendToolRequest(input, output, {
374
+ id: "request-flow-close",
375
+ name: "browser.tab.close",
376
+ traceId: "trace:matrix:close",
377
+ arguments: {
378
+ sessionId: "session:flow",
379
+ targetId
380
+ }
381
+ });
382
+ expect(closeResponse.ok).toBe(true);
383
+ expect(closeResponse.data).toMatchObject({
384
+ driver: "managed",
385
+ targetId,
386
+ closed: true
387
+ });
388
+ } finally {
389
+ runtime.close();
390
+ input.end();
391
+ output.end();
392
+ }
393
+ });
394
+ });
@@ -0,0 +1,38 @@
1
+ # BrowserCtl Relay 扩展
2
+
3
+ [English](README.md) | 简体中文
4
+
5
+ 此扩展用于将 Chromium 系浏览器标签页层连接到本地 BrowserCtl 扩展桥接服务。
6
+
7
+ ## 加载已解压扩展
8
+
9
+ 1. 打开 `chrome://extensions`(Edge 使用 `edge://extensions`)。
10
+ 2. 开启**开发者模式**。
11
+ 3. 点击**加载已解压的扩展程序**。
12
+ 4. 选择目录:`extensions/chrome-relay`。
13
+
14
+ 该扩展使用 `debugger` 权限采集控制台/网络遥测,以供 agent 侧诊断与分析。
15
+
16
+ ## 配置
17
+
18
+ 1. 打开扩展弹窗。
19
+ 2. 将 `Bridge URL` 设置为桥接地址。
20
+ 3. 将 token 设置为与 `BROWSERD_CHROME_RELAY_EXTENSION_TOKEN` 一致(必填)。
21
+ 4. 点击 **Save**,然后点击 **Reconnect**。
22
+
23
+ 默认 Bridge URL:
24
+
25
+ - `ws://127.0.0.1:9223/bridge`
26
+
27
+ ## 弹窗故障排查
28
+
29
+ 弹窗内置了专门的排查区域:
30
+
31
+ - **Run Diagnostics**:对比扩展侧连接状态与 relay 侧 `/browserctl/relay/status`。
32
+ - **Copy Report**:复制包含状态快照与建议动作的 JSON 诊断报告。
33
+ - **Guidance list**:给出常见问题的定向建议(URL 不匹配、token 不匹配、relay 不可达、连接陈旧等)。
34
+
35
+ ## 语言切换
36
+
37
+ 使用弹窗语言选择器可切换 **English** 与 **中文**。
38
+ 所选语言会保存到扩展本地存储,并在下次打开弹窗时自动应用。
@@ -1,5 +1,7 @@
1
1
  # BrowserCtl Relay Extension
2
2
 
3
+ English | [简体中文](README-CN.md)
4
+
3
5
  This extension connects a Chromium-based browser tab layer to the local BrowserCtl extension bridge.
4
6
 
5
7
  ## Load Unpacked
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flrande/browserctl",
3
- "version": "0.1.0",
3
+ "version": "0.2.0-dev.9.1",
4
4
  "private": false,
5
5
  "bin": {
6
6
  "browserctl": "bin/browserctl.cjs",