@flrande/browserctl 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.
Files changed (90) hide show
  1. package/LICENSE +21 -0
  2. package/README-CN.md +1155 -0
  3. package/README.md +1155 -0
  4. package/apps/browserctl/src/commands/a11y-snapshot.ts +20 -0
  5. package/apps/browserctl/src/commands/act.ts +20 -0
  6. package/apps/browserctl/src/commands/common.test.ts +87 -0
  7. package/apps/browserctl/src/commands/common.ts +191 -0
  8. package/apps/browserctl/src/commands/console-list.ts +20 -0
  9. package/apps/browserctl/src/commands/cookie-clear.ts +18 -0
  10. package/apps/browserctl/src/commands/cookie-get.ts +18 -0
  11. package/apps/browserctl/src/commands/cookie-set.ts +22 -0
  12. package/apps/browserctl/src/commands/dialog-arm.ts +20 -0
  13. package/apps/browserctl/src/commands/dom-query-all.ts +18 -0
  14. package/apps/browserctl/src/commands/dom-query.ts +18 -0
  15. package/apps/browserctl/src/commands/download-trigger.ts +22 -0
  16. package/apps/browserctl/src/commands/download-wait.test.ts +67 -0
  17. package/apps/browserctl/src/commands/download-wait.ts +27 -0
  18. package/apps/browserctl/src/commands/element-screenshot.ts +20 -0
  19. package/apps/browserctl/src/commands/frame-list.ts +16 -0
  20. package/apps/browserctl/src/commands/frame-snapshot.ts +18 -0
  21. package/apps/browserctl/src/commands/network-wait-for.ts +100 -0
  22. package/apps/browserctl/src/commands/profile-list.ts +16 -0
  23. package/apps/browserctl/src/commands/profile-use.ts +18 -0
  24. package/apps/browserctl/src/commands/response-body.ts +24 -0
  25. package/apps/browserctl/src/commands/screenshot.ts +16 -0
  26. package/apps/browserctl/src/commands/snapshot.ts +16 -0
  27. package/apps/browserctl/src/commands/status.ts +10 -0
  28. package/apps/browserctl/src/commands/storage-get.ts +20 -0
  29. package/apps/browserctl/src/commands/storage-set.ts +22 -0
  30. package/apps/browserctl/src/commands/tab-close.ts +20 -0
  31. package/apps/browserctl/src/commands/tab-focus.ts +20 -0
  32. package/apps/browserctl/src/commands/tab-open.ts +19 -0
  33. package/apps/browserctl/src/commands/tabs.ts +13 -0
  34. package/apps/browserctl/src/commands/upload-arm.ts +26 -0
  35. package/apps/browserctl/src/daemon-client.test.ts +253 -0
  36. package/apps/browserctl/src/daemon-client.ts +632 -0
  37. package/apps/browserctl/src/e2e.test.ts +99 -0
  38. package/apps/browserctl/src/main.test.ts +215 -0
  39. package/apps/browserctl/src/main.ts +372 -0
  40. package/apps/browserctl/src/smoke.test.ts +16 -0
  41. package/apps/browserctl/src/smoke.ts +5 -0
  42. package/apps/browserd/src/bootstrap.ts +432 -0
  43. package/apps/browserd/src/chrome-relay-extension-bridge.test.ts +275 -0
  44. package/apps/browserd/src/chrome-relay-extension-bridge.ts +506 -0
  45. package/apps/browserd/src/container.ts +1531 -0
  46. package/apps/browserd/src/main.test.ts +864 -0
  47. package/apps/browserd/src/main.ts +7 -0
  48. package/bin/browserctl.cjs +21 -0
  49. package/bin/browserd.cjs +21 -0
  50. package/extensions/chrome-relay/README.md +36 -0
  51. package/extensions/chrome-relay/background.js +1687 -0
  52. package/extensions/chrome-relay/manifest.json +15 -0
  53. package/extensions/chrome-relay/popup.html +369 -0
  54. package/extensions/chrome-relay/popup.js +972 -0
  55. package/package.json +51 -0
  56. package/packages/core/src/bootstrap.test.ts +10 -0
  57. package/packages/core/src/driver-registry.test.ts +45 -0
  58. package/packages/core/src/driver-registry.ts +22 -0
  59. package/packages/core/src/driver.ts +47 -0
  60. package/packages/core/src/index.ts +5 -0
  61. package/packages/core/src/ref-cache.test.ts +61 -0
  62. package/packages/core/src/ref-cache.ts +28 -0
  63. package/packages/core/src/session-store.test.ts +49 -0
  64. package/packages/core/src/session-store.ts +33 -0
  65. package/packages/core/src/types.ts +9 -0
  66. package/packages/driver-chrome-relay/src/chrome-relay-driver.test.ts +634 -0
  67. package/packages/driver-chrome-relay/src/chrome-relay-driver.ts +2206 -0
  68. package/packages/driver-chrome-relay/src/chrome-relay-extension-runtime.test.ts +264 -0
  69. package/packages/driver-chrome-relay/src/chrome-relay-extension-runtime.ts +521 -0
  70. package/packages/driver-chrome-relay/src/index.ts +26 -0
  71. package/packages/driver-managed/src/index.ts +22 -0
  72. package/packages/driver-managed/src/managed-driver.test.ts +59 -0
  73. package/packages/driver-managed/src/managed-driver.ts +125 -0
  74. package/packages/driver-managed/src/managed-local-driver.test.ts +506 -0
  75. package/packages/driver-managed/src/managed-local-driver.ts +2021 -0
  76. package/packages/driver-remote-cdp/src/index.ts +19 -0
  77. package/packages/driver-remote-cdp/src/remote-cdp-driver.test.ts +617 -0
  78. package/packages/driver-remote-cdp/src/remote-cdp-driver.ts +2042 -0
  79. package/packages/protocol/src/envelope.test.ts +25 -0
  80. package/packages/protocol/src/envelope.ts +31 -0
  81. package/packages/protocol/src/errors.test.ts +17 -0
  82. package/packages/protocol/src/errors.ts +11 -0
  83. package/packages/protocol/src/index.ts +3 -0
  84. package/packages/protocol/src/tools.ts +3 -0
  85. package/packages/transport-mcp-stdio/src/index.ts +3 -0
  86. package/packages/transport-mcp-stdio/src/sdk-server.ts +139 -0
  87. package/packages/transport-mcp-stdio/src/server.test.ts +281 -0
  88. package/packages/transport-mcp-stdio/src/server.ts +183 -0
  89. package/packages/transport-mcp-stdio/src/tool-map.ts +67 -0
  90. package/scripts/smoke.ps1 +127 -0
@@ -0,0 +1,972 @@
1
+ const UI_CONFIG_KEY = "browserctlRelayUiConfig";
2
+ const DEFAULT_BRIDGE_URL = "ws://127.0.0.1:9223/bridge";
3
+ const STATUS_POLL_INTERVAL_MS = 2_000;
4
+ const DIAGNOSTIC_TIMEOUT_MS = 2_500;
5
+ const FEEDBACK_HIDE_MS = 3_000;
6
+
7
+ const TRANSLATIONS = {
8
+ en: {
9
+ headerTitle: "BrowserCtl Relay",
10
+ headerSubtitle: "Local browser bridge console",
11
+ languageLabel: "Language",
12
+ bridgeUrlLabel: "Bridge URL",
13
+ bridgeUrlPlaceholder: "ws://127.0.0.1:9223/bridge",
14
+ tokenLabel: "Token (required)",
15
+ tokenPlaceholder: "BROWSERD_CHROME_RELAY_EXTENSION_TOKEN",
16
+ save: "Save",
17
+ reconnect: "Reconnect",
18
+ connectionSectionTitle: "Connection",
19
+ statusSectionTitle: "Live Status",
20
+ statusDetailsLabel: "Status details",
21
+ troubleshootingSectionTitle: "Troubleshooting",
22
+ diagnosticsDetailsLabel: "Diagnostics details",
23
+ runDiagnostics: "Run Diagnostics",
24
+ copyReport: "Copy Report",
25
+ statusLoading: "Loading relay status...",
26
+ diagnosticsEmpty: "No diagnostics yet.",
27
+ hint:
28
+ "Keep this extension enabled while using browserctl chrome-relay extension mode.",
29
+ yes: "yes",
30
+ no: "no",
31
+ unknown: "unknown",
32
+ tokenConfigured: "configured",
33
+ tokenNone: "none",
34
+ headerPillConnected: "Connected",
35
+ headerPillDisconnected: "Disconnected",
36
+ headerPillUnknown: "Unknown",
37
+ metricConnected: "Connected",
38
+ metricToken: "Token",
39
+ metricDebugTabs: "Debugger tabs",
40
+ metricLastConnected: "Last connected",
41
+ metricRelayReachable: "Relay reachable",
42
+ metricRelayConnected: "Relay connected",
43
+ metricRelayHost: "Relay host",
44
+ metricExtensionConnected: "Extension connected",
45
+ statusConnected: "Connected",
46
+ statusBridge: "Bridge",
47
+ statusToken: "Token",
48
+ statusDebuggerTabs: "Debugger tabs",
49
+ statusLastConnected: "Last connected",
50
+ statusLastError: "Last error",
51
+ diagnosticsGeneratedAt: "Generated at",
52
+ diagnosticsExtensionConnected: "Extension connected",
53
+ diagnosticsExtensionBridgeUrl: "Extension bridge URL",
54
+ diagnosticsRelayStatusUrl: "Relay status URL",
55
+ diagnosticsRelayReachable: "Relay reachable",
56
+ diagnosticsRelayConnected: "Relay connected",
57
+ diagnosticsRelayWebSocketUrl: "Relay websocket URL",
58
+ diagnosticsRelayExtensionId: "Relay extension ID",
59
+ diagnosticsRelayError: "Relay error",
60
+ guidanceDefault:
61
+ "Run diagnostics when status and CLI result look inconsistent.",
62
+ guidanceRelayUnreachable:
63
+ "Relay status endpoint is unreachable. Ensure browserd is running and listening on the configured host/port.",
64
+ guidanceBridgeMismatch:
65
+ "Bridge URL mismatch. Update Bridge URL to {bridgeUrl}, then click Save and Reconnect.",
66
+ guidanceExtensionRelayMismatch:
67
+ "Extension reports connected, but relay reports disconnected. Reload the extension in edge://extensions and reconnect.",
68
+ guidanceReconnectWait:
69
+ "Click Reconnect and keep this popup open for a few seconds until both sides show connected.",
70
+ guidanceTokenMismatch:
71
+ "Token is missing or mismatched. Set it to BROWSERD_CHROME_RELAY_EXTENSION_TOKEN, then Save.",
72
+ guidanceHealthy:
73
+ "Connection path looks healthy. You can run browserctl commands now.",
74
+ diagnosticsFailed: "Diagnostics failed: {message}",
75
+ diagnosticsFallback1: "Verify Bridge URL format (ws://.../bridge).",
76
+ diagnosticsFallback2: "Reload extension and retry diagnostics.",
77
+ copySuccess: "Report copied to clipboard.",
78
+ copyFailed: "Copy failed: {message}",
79
+ feedbackSaved: "Config saved.",
80
+ feedbackReconnected: "Reconnect request sent.",
81
+ feedbackDiagnosticsReady: "Diagnostics refreshed.",
82
+ initFailure1: "Failed to load initial extension status.",
83
+ initFailure2: "Check extension permissions and click Run Diagnostics.",
84
+ errorBridgeProtocol:
85
+ "Bridge URL must use ws:// or wss:// (received {protocol}).",
86
+ errorReadStatus: "Failed to read extension status.",
87
+ errorSaveConfig: "Failed to save config.",
88
+ errorReconnect: "Failed to reconnect.",
89
+ errorTokenRequired: "Token is required for extension bridge mode."
90
+ },
91
+ "zh-CN": {
92
+ headerTitle: "BrowserCtl Relay",
93
+ headerSubtitle: "本地浏览器桥接控制台",
94
+ languageLabel: "语言",
95
+ bridgeUrlLabel: "桥接地址",
96
+ bridgeUrlPlaceholder: "ws://127.0.0.1:9223/bridge",
97
+ tokenLabel: "令牌(必填)",
98
+ tokenPlaceholder: "BROWSERD_CHROME_RELAY_EXTENSION_TOKEN",
99
+ save: "保存",
100
+ reconnect: "重连",
101
+ connectionSectionTitle: "连接配置",
102
+ statusSectionTitle: "实时状态",
103
+ statusDetailsLabel: "状态详情",
104
+ troubleshootingSectionTitle: "排障",
105
+ diagnosticsDetailsLabel: "诊断详情",
106
+ runDiagnostics: "运行诊断",
107
+ copyReport: "复制报告",
108
+ statusLoading: "正在加载 relay 状态...",
109
+ diagnosticsEmpty: "暂无诊断结果。",
110
+ hint: "使用 browserctl 的 chrome-relay extension 模式时,请保持该扩展启用。",
111
+ yes: "是",
112
+ no: "否",
113
+ unknown: "未知",
114
+ tokenConfigured: "已配置",
115
+ tokenNone: "无",
116
+ headerPillConnected: "已连接",
117
+ headerPillDisconnected: "未连接",
118
+ headerPillUnknown: "未知",
119
+ metricConnected: "连接状态",
120
+ metricToken: "令牌",
121
+ metricDebugTabs: "调试器标签页",
122
+ metricLastConnected: "上次连接",
123
+ metricRelayReachable: "Relay 可达",
124
+ metricRelayConnected: "Relay 连接",
125
+ metricRelayHost: "Relay 主机",
126
+ metricExtensionConnected: "扩展连接",
127
+ statusConnected: "连接",
128
+ statusBridge: "桥接地址",
129
+ statusToken: "令牌",
130
+ statusDebuggerTabs: "调试器标签页数",
131
+ statusLastConnected: "上次连接",
132
+ statusLastError: "最近错误",
133
+ diagnosticsGeneratedAt: "生成时间",
134
+ diagnosticsExtensionConnected: "扩展连接",
135
+ diagnosticsExtensionBridgeUrl: "扩展桥接地址",
136
+ diagnosticsRelayStatusUrl: "Relay 状态地址",
137
+ diagnosticsRelayReachable: "Relay 可达",
138
+ diagnosticsRelayConnected: "Relay 连接",
139
+ diagnosticsRelayWebSocketUrl: "Relay WebSocket 地址",
140
+ diagnosticsRelayExtensionId: "Relay 扩展 ID",
141
+ diagnosticsRelayError: "Relay 错误",
142
+ guidanceDefault: "当弹窗状态与 CLI 结果不一致时,请运行诊断。",
143
+ guidanceRelayUnreachable:
144
+ "Relay 状态接口不可达。请确认 browserd 正在运行,并监听了配置的主机和端口。",
145
+ guidanceBridgeMismatch:
146
+ "Bridge URL 不一致。请将 Bridge URL 更新为 {bridgeUrl},然后点击“保存”和“重连”。",
147
+ guidanceExtensionRelayMismatch:
148
+ "扩展显示已连接,但 relay 显示未连接。请在 edge://extensions 里重载扩展并重新连接。",
149
+ guidanceReconnectWait:
150
+ "点击“重连”,并保持弹窗打开数秒,直到扩展和 relay 都显示已连接。",
151
+ guidanceTokenMismatch:
152
+ "令牌缺失或不匹配。请将 Token 设置为 BROWSERD_CHROME_RELAY_EXTENSION_TOKEN 后再保存。",
153
+ guidanceHealthy: "连接链路看起来正常,现在可以执行 browserctl 命令。",
154
+ diagnosticsFailed: "诊断失败:{message}",
155
+ diagnosticsFallback1: "请确认 Bridge URL 格式正确(ws://.../bridge)。",
156
+ diagnosticsFallback2: "请重载扩展后再次运行诊断。",
157
+ copySuccess: "诊断报告已复制到剪贴板。",
158
+ copyFailed: "复制失败:{message}",
159
+ feedbackSaved: "配置已保存。",
160
+ feedbackReconnected: "已发送重连请求。",
161
+ feedbackDiagnosticsReady: "诊断已刷新。",
162
+ initFailure1: "初始化扩展状态失败。",
163
+ initFailure2: "请检查扩展权限,然后点击“运行诊断”。",
164
+ errorBridgeProtocol: "Bridge URL 协议必须是 ws:// 或 wss://(当前为 {protocol})。",
165
+ errorReadStatus: "读取扩展状态失败。",
166
+ errorSaveConfig: "保存配置失败。",
167
+ errorReconnect: "重连失败。",
168
+ errorTokenRequired: "扩展桥接模式必须填写 Token。"
169
+ }
170
+ };
171
+
172
+ const bridgeUrlInput = document.getElementById("bridgeUrl");
173
+ const tokenInput = document.getElementById("token");
174
+ const languageSelect = document.getElementById("languageSelect");
175
+ const saveButton = document.getElementById("saveButton");
176
+ const reconnectButton = document.getElementById("reconnectButton");
177
+ const diagnoseButton = document.getElementById("diagnoseButton");
178
+ const copyReportButton = document.getElementById("copyReportButton");
179
+ const statusSummary = document.getElementById("statusSummary");
180
+ const diagnosticsHighlights = document.getElementById("diagnosticsHighlights");
181
+ const statusBox = document.getElementById("statusBox");
182
+ const diagnosticsBox = document.getElementById("diagnosticsBox");
183
+ const guidanceList = document.getElementById("guidanceList");
184
+ const actionFeedback = document.getElementById("actionFeedback");
185
+ const headerConnectionPill = document.getElementById("headerConnectionPill");
186
+
187
+ let currentLanguage = "en";
188
+ let latestStatus = undefined;
189
+ let latestDiagnostics = undefined;
190
+ let latestGuidance = [];
191
+ let feedbackTimer = undefined;
192
+
193
+ function normalizeString(value) {
194
+ return typeof value === "string" ? value.trim() : "";
195
+ }
196
+
197
+ function isObject(value) {
198
+ return value !== null && typeof value === "object";
199
+ }
200
+
201
+ function resolveLanguage(value) {
202
+ const normalized = normalizeString(value).toLowerCase();
203
+ if (normalized === "zh-cn" || normalized.startsWith("zh")) {
204
+ return "zh-CN";
205
+ }
206
+
207
+ return "en";
208
+ }
209
+
210
+ function t(key) {
211
+ const localized = TRANSLATIONS[currentLanguage] ?? TRANSLATIONS.en;
212
+ return localized[key] ?? TRANSLATIONS.en[key] ?? key;
213
+ }
214
+
215
+ function formatMessage(template, variables = {}) {
216
+ return template.replace(/\{([a-zA-Z0-9_]+)\}/g, (_match, key) => {
217
+ const value = variables[key];
218
+ return value === undefined || value === null ? "" : String(value);
219
+ });
220
+ }
221
+
222
+ function setTextById(id, text) {
223
+ const element = document.getElementById(id);
224
+ if (element !== null) {
225
+ element.textContent = text;
226
+ }
227
+ }
228
+
229
+ function applyStaticTranslations() {
230
+ setTextById("headerTitle", t("headerTitle"));
231
+ setTextById("headerSubtitle", t("headerSubtitle"));
232
+ setTextById("languageLabel", t("languageLabel"));
233
+ setTextById("connectionSectionTitle", t("connectionSectionTitle"));
234
+ setTextById("bridgeUrlLabel", t("bridgeUrlLabel"));
235
+ setTextById("tokenLabel", t("tokenLabel"));
236
+ setTextById("saveButton", t("save"));
237
+ setTextById("reconnectButton", t("reconnect"));
238
+ setTextById("statusSectionTitle", t("statusSectionTitle"));
239
+ setTextById("statusDetailsLabel", t("statusDetailsLabel"));
240
+ setTextById("troubleshootingSectionTitle", t("troubleshootingSectionTitle"));
241
+ setTextById("diagnosticsDetailsLabel", t("diagnosticsDetailsLabel"));
242
+ setTextById("diagnoseButton", t("runDiagnostics"));
243
+ setTextById("copyReportButton", t("copyReport"));
244
+ setTextById("hintText", t("hint"));
245
+
246
+ if (bridgeUrlInput !== null) {
247
+ bridgeUrlInput.placeholder = t("bridgeUrlPlaceholder");
248
+ }
249
+ if (tokenInput !== null) {
250
+ tokenInput.placeholder = t("tokenPlaceholder");
251
+ }
252
+ if (languageSelect !== null) {
253
+ languageSelect.value = currentLanguage;
254
+ }
255
+ }
256
+
257
+ function toBridgeHost(bridgeUrl) {
258
+ try {
259
+ return new URL(bridgeUrl).host;
260
+ } catch {
261
+ return t("unknown");
262
+ }
263
+ }
264
+
265
+ function toShortTime(isoTime) {
266
+ const value = normalizeString(isoTime);
267
+ if (value.length === 0) {
268
+ return "--";
269
+ }
270
+
271
+ const parsed = new Date(value);
272
+ if (Number.isNaN(parsed.getTime())) {
273
+ return value;
274
+ }
275
+
276
+ return parsed.toLocaleTimeString([], {
277
+ hour: "2-digit",
278
+ minute: "2-digit",
279
+ second: "2-digit",
280
+ hour12: false
281
+ });
282
+ }
283
+
284
+ function createMetricCard(metric) {
285
+ const item = document.createElement("div");
286
+ item.className = "metric";
287
+
288
+ const label = document.createElement("div");
289
+ label.className = "metric-label";
290
+ label.textContent = metric.label;
291
+
292
+ const value = document.createElement("div");
293
+ value.className = `metric-value${metric.tone !== undefined ? ` ${metric.tone}` : ""}`;
294
+ value.textContent = metric.value;
295
+
296
+ item.appendChild(label);
297
+ item.appendChild(value);
298
+ return item;
299
+ }
300
+
301
+ function renderMetrics(container, metrics) {
302
+ if (container === null) {
303
+ return;
304
+ }
305
+
306
+ container.textContent = "";
307
+ for (const metric of metrics) {
308
+ container.appendChild(createMetricCard(metric));
309
+ }
310
+ }
311
+
312
+ function setHeaderConnection(connected) {
313
+ if (headerConnectionPill === null) {
314
+ return;
315
+ }
316
+
317
+ headerConnectionPill.classList.remove("ok", "error");
318
+ if (connected === true) {
319
+ headerConnectionPill.classList.add("ok");
320
+ headerConnectionPill.textContent = t("headerPillConnected");
321
+ return;
322
+ }
323
+
324
+ if (connected === false) {
325
+ headerConnectionPill.classList.add("error");
326
+ headerConnectionPill.textContent = t("headerPillDisconnected");
327
+ return;
328
+ }
329
+
330
+ headerConnectionPill.textContent = t("headerPillUnknown");
331
+ }
332
+
333
+ function normalizeStatus(state) {
334
+ const safeState = isObject(state) ? state : {};
335
+ return {
336
+ connected: safeState.connected === true,
337
+ bridgeUrl: normalizeString(safeState.bridgeUrl) || DEFAULT_BRIDGE_URL,
338
+ tokenConfigured: safeState.tokenConfigured === true,
339
+ lastConnectedAt: normalizeString(safeState.lastConnectedAt),
340
+ lastError: normalizeString(safeState.lastError),
341
+ debuggerAttachedTabs:
342
+ typeof safeState.debuggerAttachedTabs === "number" &&
343
+ Number.isFinite(safeState.debuggerAttachedTabs)
344
+ ? safeState.debuggerAttachedTabs
345
+ : 0
346
+ };
347
+ }
348
+
349
+ function renderStatusSummary(status) {
350
+ renderMetrics(statusSummary, [
351
+ {
352
+ label: t("metricConnected"),
353
+ value: status.connected ? t("yes") : t("no"),
354
+ tone: status.connected ? "ok" : "error"
355
+ },
356
+ {
357
+ label: t("metricToken"),
358
+ value: status.tokenConfigured ? t("tokenConfigured") : t("tokenNone")
359
+ },
360
+ {
361
+ label: t("metricDebugTabs"),
362
+ value: String(status.debuggerAttachedTabs)
363
+ },
364
+ {
365
+ label: t("metricLastConnected"),
366
+ value: toShortTime(status.lastConnectedAt)
367
+ }
368
+ ]);
369
+ }
370
+
371
+ function setStatus(state) {
372
+ const normalizedState = normalizeStatus(state);
373
+ latestStatus = normalizedState;
374
+ setHeaderConnection(normalizedState.connected);
375
+ renderStatusSummary(normalizedState);
376
+
377
+ if (statusBox === null) {
378
+ return;
379
+ }
380
+
381
+ statusBox.classList.remove("ok", "error");
382
+ statusBox.classList.add(normalizedState.connected ? "ok" : "error");
383
+
384
+ const lines = [];
385
+ lines.push(
386
+ `${t("statusConnected")}: ${normalizedState.connected ? t("yes") : t("no")}`
387
+ );
388
+ lines.push(`${t("statusBridge")}: ${normalizedState.bridgeUrl || t("unknown")}`);
389
+ lines.push(
390
+ `${t("statusToken")}: ${
391
+ normalizedState.tokenConfigured ? t("tokenConfigured") : t("tokenNone")
392
+ }`
393
+ );
394
+ lines.push(`${t("statusDebuggerTabs")}: ${normalizedState.debuggerAttachedTabs}`);
395
+ if (normalizedState.lastConnectedAt.length > 0) {
396
+ lines.push(`${t("statusLastConnected")}: ${normalizedState.lastConnectedAt}`);
397
+ }
398
+ if (normalizedState.lastError.length > 0) {
399
+ lines.push(`${t("statusLastError")}: ${normalizedState.lastError}`);
400
+ }
401
+
402
+ statusBox.textContent = lines.join("\n");
403
+ }
404
+
405
+ function renderDiagnosticsHighlights(diagnostics) {
406
+ if (diagnostics === undefined) {
407
+ renderMetrics(diagnosticsHighlights, [
408
+ {
409
+ label: t("metricRelayReachable"),
410
+ value: "--"
411
+ },
412
+ {
413
+ label: t("metricRelayConnected"),
414
+ value: "--"
415
+ },
416
+ {
417
+ label: t("metricExtensionConnected"),
418
+ value: "--"
419
+ },
420
+ {
421
+ label: t("metricRelayHost"),
422
+ value: "--"
423
+ }
424
+ ]);
425
+ return;
426
+ }
427
+
428
+ renderMetrics(diagnosticsHighlights, [
429
+ {
430
+ label: t("metricRelayReachable"),
431
+ value: diagnostics.relay.reachable ? t("yes") : t("no"),
432
+ tone: diagnostics.relay.reachable ? "ok" : "error"
433
+ },
434
+ {
435
+ label: t("metricRelayConnected"),
436
+ value: diagnostics.relay.connected ? t("yes") : t("no"),
437
+ tone: diagnostics.relay.connected ? "ok" : "error"
438
+ },
439
+ {
440
+ label: t("metricExtensionConnected"),
441
+ value: diagnostics.extension.connected ? t("yes") : t("no"),
442
+ tone: diagnostics.extension.connected ? "ok" : "error"
443
+ },
444
+ {
445
+ label: t("metricRelayHost"),
446
+ value: toBridgeHost(diagnostics.extension.bridgeUrl)
447
+ }
448
+ ]);
449
+ }
450
+
451
+ function setDiagnosticsState(diagnostics) {
452
+ renderDiagnosticsHighlights(diagnostics);
453
+ if (diagnosticsBox === null) {
454
+ return;
455
+ }
456
+
457
+ diagnosticsBox.classList.remove("ok", "error");
458
+ const healthy =
459
+ diagnostics.relay.reachable &&
460
+ diagnostics.relay.connected &&
461
+ diagnostics.extension.connected;
462
+ diagnosticsBox.classList.add(healthy ? "ok" : "error");
463
+
464
+ const lines = [];
465
+ lines.push(`${t("diagnosticsGeneratedAt")}: ${diagnostics.generatedAt}`);
466
+ lines.push(
467
+ `${t("diagnosticsExtensionConnected")}: ${
468
+ diagnostics.extension.connected ? t("yes") : t("no")
469
+ }`
470
+ );
471
+ lines.push(
472
+ `${t("diagnosticsExtensionBridgeUrl")}: ${diagnostics.extension.bridgeUrl}`
473
+ );
474
+ lines.push(`${t("diagnosticsRelayStatusUrl")}: ${diagnostics.relay.statusUrl}`);
475
+ lines.push(
476
+ `${t("diagnosticsRelayReachable")}: ${
477
+ diagnostics.relay.reachable ? t("yes") : t("no")
478
+ }`
479
+ );
480
+ if (diagnostics.relay.reachable) {
481
+ lines.push(
482
+ `${t("diagnosticsRelayConnected")}: ${
483
+ diagnostics.relay.connected ? t("yes") : t("no")
484
+ }`
485
+ );
486
+ if (diagnostics.relay.websocketUrl.length > 0) {
487
+ lines.push(
488
+ `${t("diagnosticsRelayWebSocketUrl")}: ${diagnostics.relay.websocketUrl}`
489
+ );
490
+ }
491
+ if (diagnostics.relay.extensionId.length > 0) {
492
+ lines.push(
493
+ `${t("diagnosticsRelayExtensionId")}: ${diagnostics.relay.extensionId}`
494
+ );
495
+ }
496
+ } else if (diagnostics.relay.error.length > 0) {
497
+ lines.push(`${t("diagnosticsRelayError")}: ${diagnostics.relay.error}`);
498
+ }
499
+
500
+ diagnosticsBox.textContent = lines.join("\n");
501
+ }
502
+
503
+ function renderGuidance(items) {
504
+ latestGuidance = [...items];
505
+ if (guidanceList === null) {
506
+ return;
507
+ }
508
+
509
+ guidanceList.textContent = "";
510
+ for (const item of items) {
511
+ const entry = document.createElement("li");
512
+ entry.textContent = item;
513
+ guidanceList.appendChild(entry);
514
+ }
515
+ }
516
+
517
+ function showFeedback(message, tone = "ok") {
518
+ if (actionFeedback === null) {
519
+ return;
520
+ }
521
+
522
+ if (feedbackTimer !== undefined) {
523
+ clearTimeout(feedbackTimer);
524
+ feedbackTimer = undefined;
525
+ }
526
+
527
+ actionFeedback.hidden = false;
528
+ actionFeedback.classList.remove("ok", "error");
529
+ actionFeedback.classList.add(tone === "error" ? "error" : "ok");
530
+ actionFeedback.textContent = message;
531
+
532
+ feedbackTimer = setTimeout(() => {
533
+ if (actionFeedback !== null) {
534
+ actionFeedback.hidden = true;
535
+ }
536
+ feedbackTimer = undefined;
537
+ }, FEEDBACK_HIDE_MS);
538
+ }
539
+
540
+ function rerenderLocalizedState() {
541
+ applyStaticTranslations();
542
+
543
+ if (latestStatus !== undefined) {
544
+ setStatus(latestStatus);
545
+ } else {
546
+ setHeaderConnection(undefined);
547
+ renderMetrics(statusSummary, [
548
+ { label: t("metricConnected"), value: "--" },
549
+ { label: t("metricToken"), value: "--" },
550
+ { label: t("metricDebugTabs"), value: "--" },
551
+ { label: t("metricLastConnected"), value: "--" }
552
+ ]);
553
+ if (statusBox !== null) {
554
+ statusBox.classList.remove("ok", "error");
555
+ statusBox.textContent = t("statusLoading");
556
+ }
557
+ }
558
+
559
+ if (latestDiagnostics !== undefined) {
560
+ setDiagnosticsState(latestDiagnostics);
561
+ renderGuidance(buildGuidance(latestDiagnostics));
562
+ } else {
563
+ renderDiagnosticsHighlights(undefined);
564
+ if (diagnosticsBox !== null) {
565
+ diagnosticsBox.classList.remove("ok", "error");
566
+ diagnosticsBox.textContent = t("diagnosticsEmpty");
567
+ }
568
+ renderGuidance([t("guidanceDefault")]);
569
+ }
570
+
571
+ setCopyAvailability(latestDiagnostics !== undefined);
572
+ }
573
+
574
+ function setBusy(busy) {
575
+ if (saveButton !== null) {
576
+ saveButton.disabled = busy;
577
+ }
578
+ if (reconnectButton !== null) {
579
+ reconnectButton.disabled = busy;
580
+ }
581
+ if (diagnoseButton !== null) {
582
+ diagnoseButton.disabled = busy;
583
+ }
584
+ if (copyReportButton !== null) {
585
+ copyReportButton.disabled = busy;
586
+ }
587
+ if (languageSelect !== null) {
588
+ languageSelect.disabled = busy;
589
+ }
590
+ }
591
+
592
+ function setCopyAvailability(available) {
593
+ if (copyReportButton !== null) {
594
+ copyReportButton.disabled = !available;
595
+ }
596
+ }
597
+
598
+ function sendMessage(type, payload = {}) {
599
+ return new Promise((resolve, reject) => {
600
+ chrome.runtime.sendMessage(
601
+ {
602
+ type,
603
+ ...payload
604
+ },
605
+ (response) => {
606
+ if (chrome.runtime.lastError) {
607
+ reject(new Error(chrome.runtime.lastError.message));
608
+ return;
609
+ }
610
+
611
+ resolve(response);
612
+ }
613
+ );
614
+ });
615
+ }
616
+
617
+ function buildRelayStatusUrl(bridgeUrl) {
618
+ const parsedBridgeUrl = new URL(bridgeUrl);
619
+ if (parsedBridgeUrl.protocol !== "ws:" && parsedBridgeUrl.protocol !== "wss:") {
620
+ throw new Error(
621
+ formatMessage(t("errorBridgeProtocol"), {
622
+ protocol: parsedBridgeUrl.protocol
623
+ })
624
+ );
625
+ }
626
+
627
+ parsedBridgeUrl.protocol =
628
+ parsedBridgeUrl.protocol === "wss:" ? "https:" : "http:";
629
+ parsedBridgeUrl.pathname = "/browserctl/relay/status";
630
+ parsedBridgeUrl.search = "";
631
+ return parsedBridgeUrl.toString();
632
+ }
633
+
634
+ async function fetchRelayStatus(statusUrl) {
635
+ const controller = new AbortController();
636
+ const timeoutId = setTimeout(() => {
637
+ controller.abort();
638
+ }, DIAGNOSTIC_TIMEOUT_MS);
639
+
640
+ try {
641
+ const response = await fetch(statusUrl, {
642
+ method: "GET",
643
+ cache: "no-store",
644
+ signal: controller.signal
645
+ });
646
+
647
+ if (!response.ok) {
648
+ throw new Error(`HTTP ${response.status}`);
649
+ }
650
+
651
+ const payload = await response.json();
652
+ return {
653
+ statusUrl,
654
+ reachable: payload?.connected === true || payload?.connected === false,
655
+ connected: payload?.connected === true,
656
+ websocketUrl: normalizeString(payload?.websocketUrl),
657
+ relayUrl: normalizeString(payload?.relayUrl),
658
+ extensionId: normalizeString(payload?.extensionId),
659
+ error: ""
660
+ };
661
+ } catch (error) {
662
+ return {
663
+ statusUrl,
664
+ reachable: false,
665
+ connected: false,
666
+ websocketUrl: "",
667
+ relayUrl: "",
668
+ extensionId: "",
669
+ error: error instanceof Error ? error.message : String(error)
670
+ };
671
+ } finally {
672
+ clearTimeout(timeoutId);
673
+ }
674
+ }
675
+
676
+ function buildGuidance(diagnostics) {
677
+ const steps = [];
678
+ const extensionState = diagnostics.extension;
679
+ const relayState = diagnostics.relay;
680
+ const bridgeUrlMismatch =
681
+ relayState.websocketUrl.length > 0 &&
682
+ normalizeString(extensionState.bridgeUrl) !==
683
+ normalizeString(relayState.websocketUrl);
684
+
685
+ if (!relayState.reachable) {
686
+ steps.push(t("guidanceRelayUnreachable"));
687
+ }
688
+
689
+ if (bridgeUrlMismatch) {
690
+ steps.push(
691
+ formatMessage(t("guidanceBridgeMismatch"), {
692
+ bridgeUrl: relayState.websocketUrl
693
+ })
694
+ );
695
+ }
696
+
697
+ if (extensionState.connected && relayState.reachable && !relayState.connected) {
698
+ steps.push(t("guidanceExtensionRelayMismatch"));
699
+ }
700
+
701
+ if (!extensionState.connected && relayState.reachable && !relayState.connected) {
702
+ steps.push(t("guidanceReconnectWait"));
703
+ }
704
+
705
+ if (
706
+ extensionState.lastError.includes("1008") ||
707
+ extensionState.lastError.toLowerCase().includes("token")
708
+ ) {
709
+ steps.push(t("guidanceTokenMismatch"));
710
+ }
711
+
712
+ if (steps.length === 0) {
713
+ steps.push(t("guidanceHealthy"));
714
+ }
715
+
716
+ return steps;
717
+ }
718
+
719
+ async function readStoredLanguage() {
720
+ const stored = await chrome.storage.local.get(UI_CONFIG_KEY);
721
+ const uiConfig = stored?.[UI_CONFIG_KEY];
722
+ if (!isObject(uiConfig)) {
723
+ return undefined;
724
+ }
725
+
726
+ const language = normalizeString(uiConfig.language);
727
+ return language.length === 0 ? undefined : language;
728
+ }
729
+
730
+ async function persistLanguage(language) {
731
+ const normalized = resolveLanguage(language);
732
+ const stored = await chrome.storage.local.get(UI_CONFIG_KEY);
733
+ const currentConfig = isObject(stored?.[UI_CONFIG_KEY]) ? stored[UI_CONFIG_KEY] : {};
734
+ await chrome.storage.local.set({
735
+ [UI_CONFIG_KEY]: {
736
+ ...currentConfig,
737
+ language: normalized
738
+ }
739
+ });
740
+ }
741
+
742
+ async function initializeLanguage() {
743
+ const storedLanguage = await readStoredLanguage();
744
+ currentLanguage = resolveLanguage(storedLanguage ?? navigator.language);
745
+ rerenderLocalizedState();
746
+ }
747
+
748
+ async function loadConfigAndStatus() {
749
+ const configResponse = await sendMessage("relay.getConfig");
750
+ if (configResponse?.ok === true && isObject(configResponse.config)) {
751
+ if (bridgeUrlInput !== null) {
752
+ bridgeUrlInput.value = normalizeString(configResponse.config.bridgeUrl);
753
+ }
754
+ if (tokenInput !== null) {
755
+ tokenInput.value = "";
756
+ }
757
+ }
758
+
759
+ const statusResponse = await sendMessage("relay.getStatus");
760
+ if (statusResponse?.ok === true) {
761
+ setStatus(statusResponse.status ?? {});
762
+ }
763
+ }
764
+
765
+ async function refreshStatusSilently() {
766
+ try {
767
+ const statusResponse = await sendMessage("relay.getStatus");
768
+ if (statusResponse?.ok === true) {
769
+ setStatus(statusResponse.status ?? {});
770
+ }
771
+ } catch {
772
+ // Ignore polling failures and keep current state visible.
773
+ }
774
+ }
775
+
776
+ async function saveConfig() {
777
+ setBusy(true);
778
+ try {
779
+ const tokenValue = normalizeString(tokenInput?.value);
780
+ if (tokenValue.length === 0) {
781
+ throw new Error(t("errorTokenRequired"));
782
+ }
783
+
784
+ const response = await sendMessage("relay.saveConfig", {
785
+ config: {
786
+ bridgeUrl: bridgeUrlInput?.value ?? "",
787
+ token: tokenValue
788
+ }
789
+ });
790
+
791
+ if (response?.ok !== true) {
792
+ throw new Error(
793
+ typeof response?.error === "string"
794
+ ? response.error
795
+ : t("errorSaveConfig")
796
+ );
797
+ }
798
+
799
+ setStatus(response.status ?? {});
800
+ if (tokenInput !== null) {
801
+ tokenInput.value = "";
802
+ }
803
+ showFeedback(t("feedbackSaved"), "ok");
804
+ } catch (error) {
805
+ setStatus({
806
+ connected: false,
807
+ bridgeUrl: bridgeUrlInput?.value ?? "",
808
+ tokenConfigured: normalizeString(tokenInput?.value).length > 0,
809
+ lastError: error instanceof Error ? error.message : String(error)
810
+ });
811
+ showFeedback(error instanceof Error ? error.message : String(error), "error");
812
+ } finally {
813
+ setBusy(false);
814
+ setCopyAvailability(latestDiagnostics !== undefined);
815
+ }
816
+ }
817
+
818
+ async function reconnect() {
819
+ setBusy(true);
820
+ try {
821
+ const response = await sendMessage("relay.reconnect");
822
+ if (response?.ok !== true) {
823
+ throw new Error(
824
+ typeof response?.error === "string" ? response.error : t("errorReconnect")
825
+ );
826
+ }
827
+
828
+ setStatus(response.status ?? {});
829
+ showFeedback(t("feedbackReconnected"), "ok");
830
+ } catch (error) {
831
+ setStatus({
832
+ connected: false,
833
+ bridgeUrl: bridgeUrlInput?.value ?? "",
834
+ tokenConfigured: normalizeString(tokenInput?.value).length > 0,
835
+ lastError: error instanceof Error ? error.message : String(error)
836
+ });
837
+ showFeedback(error instanceof Error ? error.message : String(error), "error");
838
+ } finally {
839
+ setBusy(false);
840
+ setCopyAvailability(latestDiagnostics !== undefined);
841
+ }
842
+ }
843
+
844
+ async function runDiagnostics() {
845
+ setBusy(true);
846
+ try {
847
+ const statusResponse = await sendMessage("relay.getStatus");
848
+ if (statusResponse?.ok !== true) {
849
+ throw new Error(
850
+ typeof statusResponse?.error === "string"
851
+ ? statusResponse.error
852
+ : t("errorReadStatus")
853
+ );
854
+ }
855
+
856
+ const extensionStatus = normalizeStatus(statusResponse.status ?? {});
857
+ setStatus(extensionStatus);
858
+
859
+ const bridgeUrlFromInput = normalizeString(bridgeUrlInput?.value);
860
+ const bridgeUrl =
861
+ bridgeUrlFromInput.length > 0 ? bridgeUrlFromInput : extensionStatus.bridgeUrl;
862
+ const relayStatusUrl = buildRelayStatusUrl(bridgeUrl);
863
+ const relayStatus = await fetchRelayStatus(relayStatusUrl);
864
+
865
+ latestDiagnostics = {
866
+ generatedAt: new Date().toISOString(),
867
+ extension: extensionStatus,
868
+ relay: relayStatus
869
+ };
870
+
871
+ setDiagnosticsState(latestDiagnostics);
872
+ renderGuidance(buildGuidance(latestDiagnostics));
873
+ setCopyAvailability(true);
874
+ showFeedback(t("feedbackDiagnosticsReady"), "ok");
875
+ } catch (error) {
876
+ const message = error instanceof Error ? error.message : String(error);
877
+ renderDiagnosticsHighlights(undefined);
878
+ if (diagnosticsBox !== null) {
879
+ diagnosticsBox.classList.remove("ok");
880
+ diagnosticsBox.classList.add("error");
881
+ diagnosticsBox.textContent = formatMessage(t("diagnosticsFailed"), { message });
882
+ }
883
+ renderGuidance([t("diagnosticsFallback1"), t("diagnosticsFallback2")]);
884
+ setCopyAvailability(false);
885
+ showFeedback(message, "error");
886
+ } finally {
887
+ setBusy(false);
888
+ }
889
+ }
890
+
891
+ async function copyDiagnosticsReport() {
892
+ if (latestDiagnostics === undefined) {
893
+ await runDiagnostics();
894
+ if (latestDiagnostics === undefined) {
895
+ return;
896
+ }
897
+ }
898
+
899
+ const payload = {
900
+ generatedAt: latestDiagnostics.generatedAt,
901
+ language: currentLanguage,
902
+ extension: latestDiagnostics.extension,
903
+ relay: latestDiagnostics.relay,
904
+ suggestions: latestGuidance
905
+ };
906
+
907
+ try {
908
+ await navigator.clipboard.writeText(JSON.stringify(payload, null, 2));
909
+ showFeedback(t("copySuccess"), "ok");
910
+ } catch (error) {
911
+ showFeedback(
912
+ formatMessage(t("copyFailed"), {
913
+ message: error instanceof Error ? error.message : String(error)
914
+ }),
915
+ "error"
916
+ );
917
+ }
918
+ }
919
+
920
+ async function updateLanguage(nextLanguage) {
921
+ const normalized = resolveLanguage(nextLanguage);
922
+ if (normalized === currentLanguage) {
923
+ return;
924
+ }
925
+
926
+ currentLanguage = normalized;
927
+ await persistLanguage(normalized);
928
+ rerenderLocalizedState();
929
+ }
930
+
931
+ saveButton?.addEventListener("click", () => {
932
+ void saveConfig();
933
+ });
934
+
935
+ reconnectButton?.addEventListener("click", () => {
936
+ void reconnect();
937
+ });
938
+
939
+ diagnoseButton?.addEventListener("click", () => {
940
+ void runDiagnostics();
941
+ });
942
+
943
+ copyReportButton?.addEventListener("click", () => {
944
+ void copyDiagnosticsReport();
945
+ });
946
+
947
+ languageSelect?.addEventListener("change", () => {
948
+ void updateLanguage(languageSelect.value);
949
+ });
950
+
951
+ async function initialize() {
952
+ await initializeLanguage();
953
+ await loadConfigAndStatus();
954
+ await runDiagnostics();
955
+ }
956
+
957
+ void initialize().catch((error) => {
958
+ setStatus({
959
+ connected: false,
960
+ bridgeUrl: normalizeString(bridgeUrlInput?.value) || DEFAULT_BRIDGE_URL,
961
+ tokenConfigured: false,
962
+ lastError: error instanceof Error ? error.message : String(error)
963
+ });
964
+ renderDiagnosticsHighlights(undefined);
965
+ renderGuidance([t("initFailure1"), t("initFailure2")]);
966
+ showFeedback(error instanceof Error ? error.message : String(error), "error");
967
+ });
968
+
969
+ setCopyAvailability(false);
970
+ setInterval(() => {
971
+ void refreshStatusSilently();
972
+ }, STATUS_POLL_INTERVAL_MS);