opendevbrowser 0.0.12 → 0.0.15
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/LICENSE +21 -0
- package/README.md +216 -28
- package/dist/chunk-JVBMT2O5.js +7173 -0
- package/dist/chunk-JVBMT2O5.js.map +1 -0
- package/dist/cli/index.js +2486 -589
- package/dist/cli/index.js.map +1 -1
- package/dist/index.js +1057 -194
- package/dist/index.js.map +1 -1
- package/dist/opendevbrowser.js +1057 -194
- package/dist/opendevbrowser.js.map +1 -1
- package/extension/dist/annotate-content.css +237 -0
- package/extension/dist/annotate-content.js +934 -0
- package/extension/dist/background.js +1194 -32
- package/extension/dist/logging.js +50 -0
- package/extension/dist/ops/dom-bridge.js +355 -0
- package/extension/dist/ops/ops-runtime.js +1249 -0
- package/extension/dist/ops/ops-session-store.js +189 -0
- package/extension/dist/ops/redaction.js +52 -0
- package/extension/dist/ops/snapshot-builder.js +4 -0
- package/extension/dist/ops/snapshot-shared.js +220 -0
- package/extension/dist/popup.js +370 -25
- package/extension/dist/relay-settings.js +1 -0
- package/extension/dist/services/CDPRouter.js +501 -103
- package/extension/dist/services/ConnectionManager.js +464 -57
- package/extension/dist/services/NativePortManager.js +182 -0
- package/extension/dist/services/RelayClient.js +227 -26
- package/extension/dist/services/TabManager.js +81 -0
- package/extension/dist/services/TargetSessionMap.js +146 -0
- package/extension/dist/services/cdp-router-commands.js +203 -0
- package/extension/dist/services/url-restrictions.js +41 -0
- package/extension/dist/types.js +3 -1
- package/extension/manifest.json +17 -3
- package/extension/popup.html +144 -0
- package/package.json +2 -2
- package/skills/AGENTS.md +34 -62
- package/skills/data-extraction/SKILL.md +95 -103
- package/skills/form-testing/SKILL.md +75 -82
- package/skills/login-automation/SKILL.md +76 -66
- package/skills/opendevbrowser-best-practices/SKILL.md +90 -49
- package/skills/opendevbrowser-continuity-ledger/SKILL.md +57 -23
- package/dist/chunk-WTFSMBVH.js +0 -2815
- package/dist/chunk-WTFSMBVH.js.map +0 -1
- package/extension/dist/popup.jsx +0 -150
|
@@ -2,8 +2,36 @@ import { DEFAULT_PAIRING_ENABLED, DEFAULT_PAIRING_TOKEN, DEFAULT_RELAY_PORT } fr
|
|
|
2
2
|
import { RelayClient } from "./RelayClient.js";
|
|
3
3
|
import { CDPRouter } from "./CDPRouter.js";
|
|
4
4
|
import { TabManager } from "./TabManager.js";
|
|
5
|
+
import { logError } from "../logging.js";
|
|
6
|
+
import { getRestrictionMessage } from "./url-restrictions.js";
|
|
7
|
+
class ConnectionError extends Error {
|
|
8
|
+
code;
|
|
9
|
+
constructor(code, message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.code = code;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const summarizeProtocol = (rawUrl) => {
|
|
15
|
+
if (!rawUrl) {
|
|
16
|
+
return "unknown";
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
return new URL(rawUrl).protocol.replace(":", "");
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
logError("connection.summarize_protocol", error, { code: "url_parse_failed" });
|
|
23
|
+
return "unknown";
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
const logInfo = (message) => {
|
|
27
|
+
console.info(`[opendevbrowser] ${message}`);
|
|
28
|
+
};
|
|
29
|
+
const logWarn = (message) => {
|
|
30
|
+
console.warn(`[opendevbrowser] ${message}`);
|
|
31
|
+
};
|
|
5
32
|
export class ConnectionManager {
|
|
6
33
|
status = "disconnected";
|
|
34
|
+
lastError = null;
|
|
7
35
|
listeners = new Set();
|
|
8
36
|
relay = null;
|
|
9
37
|
cdp = new CDPRouter();
|
|
@@ -17,10 +45,22 @@ export class ConnectionManager {
|
|
|
17
45
|
pairingToken = DEFAULT_PAIRING_TOKEN;
|
|
18
46
|
pairingEnabled = DEFAULT_PAIRING_ENABLED;
|
|
19
47
|
relayPort = DEFAULT_RELAY_PORT;
|
|
20
|
-
|
|
48
|
+
relayInstanceId = null;
|
|
49
|
+
relayEpoch = null;
|
|
50
|
+
relayConfirmedPort = null;
|
|
51
|
+
relayNotice = null;
|
|
21
52
|
maxReconnectDelayMs = 5000;
|
|
53
|
+
connectPromise = null;
|
|
54
|
+
annotationHandler = null;
|
|
55
|
+
opsHandler = null;
|
|
56
|
+
heartbeatTimer = null;
|
|
57
|
+
heartbeatInFlight = false;
|
|
58
|
+
heartbeatIntervalMs = 25_000;
|
|
59
|
+
heartbeatTimeoutMs = 2_000;
|
|
22
60
|
constructor() {
|
|
23
|
-
this.loadSettings().catch(() => {
|
|
61
|
+
this.loadSettings().catch((error) => {
|
|
62
|
+
logError("connection.load_settings", error, { code: "storage_load_failed" });
|
|
63
|
+
});
|
|
24
64
|
chrome.storage.onChanged.addListener(this.handleStorageChange);
|
|
25
65
|
chrome.tabs.onRemoved.addListener(this.handleTabRemoved);
|
|
26
66
|
chrome.tabs.onUpdated.addListener(this.handleTabUpdated);
|
|
@@ -28,19 +68,106 @@ export class ConnectionManager {
|
|
|
28
68
|
getStatus() {
|
|
29
69
|
return this.status;
|
|
30
70
|
}
|
|
31
|
-
|
|
32
|
-
|
|
71
|
+
getRelayIdentity() {
|
|
72
|
+
return {
|
|
73
|
+
instanceId: this.relayInstanceId,
|
|
74
|
+
relayPort: this.relayConfirmedPort
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
getRelayNotice() {
|
|
78
|
+
return this.relayNotice;
|
|
79
|
+
}
|
|
80
|
+
getLastError() {
|
|
81
|
+
return this.lastError;
|
|
82
|
+
}
|
|
83
|
+
clearLastError() {
|
|
84
|
+
this.lastError = null;
|
|
85
|
+
}
|
|
86
|
+
onAnnotationCommand(handler) {
|
|
87
|
+
this.annotationHandler = handler;
|
|
88
|
+
}
|
|
89
|
+
onOpsMessage(handler) {
|
|
90
|
+
this.opsHandler = handler;
|
|
91
|
+
}
|
|
92
|
+
sendAnnotationResponse(response) {
|
|
93
|
+
if (!this.relay)
|
|
94
|
+
return;
|
|
95
|
+
try {
|
|
96
|
+
this.relay.sendAnnotationResponse(response);
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
logError("relay.send_annotation_response", error, { code: "relay_send_failed" });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
sendAnnotationEvent(event) {
|
|
103
|
+
if (!this.relay)
|
|
33
104
|
return;
|
|
105
|
+
try {
|
|
106
|
+
this.relay.sendAnnotationEvent(event);
|
|
34
107
|
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
logError("relay.send_annotation_event", error, { code: "relay_send_failed" });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
sendOpsMessage(message) {
|
|
113
|
+
if (!this.relay)
|
|
114
|
+
return;
|
|
35
115
|
try {
|
|
36
|
-
this.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
116
|
+
this.relay.sendOpsMessage(message);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
logError("relay.send_ops_message", error, { code: "relay_send_failed" });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
getCdpRouter() {
|
|
123
|
+
return this.cdp;
|
|
124
|
+
}
|
|
125
|
+
async relayHealthCheck() {
|
|
126
|
+
if (!this.relay || !this.relay.isConnected()) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
return await this.relay.sendHealthCheck();
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
logError("relay.health_check", error, { code: "relay_health_failed" });
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
async connect() {
|
|
138
|
+
if (this.connectPromise) {
|
|
139
|
+
return await this.connectPromise;
|
|
41
140
|
}
|
|
42
|
-
|
|
43
|
-
|
|
141
|
+
const run = (async () => {
|
|
142
|
+
if (this.status === "connected") {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
this.clearLastError();
|
|
147
|
+
this.relayNotice = null;
|
|
148
|
+
this.shouldReconnect = true;
|
|
149
|
+
this.reconnectAttempts = 0;
|
|
150
|
+
await this.loadSettings();
|
|
151
|
+
await this.attachToActiveTab();
|
|
152
|
+
await this.connectRelay();
|
|
153
|
+
this.clearLastError();
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
const info = this.normalizeError(error);
|
|
157
|
+
this.setLastError(info);
|
|
158
|
+
const detail = error instanceof Error ? error.message : "Unknown error";
|
|
159
|
+
logWarn(`Connect failed (${info.code}). ${detail}`);
|
|
160
|
+
await this.disconnect();
|
|
161
|
+
}
|
|
162
|
+
})();
|
|
163
|
+
this.connectPromise = run;
|
|
164
|
+
try {
|
|
165
|
+
return await run;
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
if (this.connectPromise === run) {
|
|
169
|
+
this.connectPromise = null;
|
|
170
|
+
}
|
|
44
171
|
}
|
|
45
172
|
}
|
|
46
173
|
async disconnect() {
|
|
@@ -49,19 +176,22 @@ export class ConnectionManager {
|
|
|
49
176
|
this.disconnecting = true;
|
|
50
177
|
this.shouldReconnect = false;
|
|
51
178
|
this.clearReconnectTimer();
|
|
179
|
+
this.stopHeartbeat();
|
|
52
180
|
try {
|
|
53
181
|
if (this.relay) {
|
|
54
182
|
this.relay.disconnect();
|
|
55
183
|
this.relay = null;
|
|
56
184
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
this.trackedTab = null;
|
|
60
|
-
}
|
|
185
|
+
await this.cdp.detachAll();
|
|
186
|
+
this.trackedTab = null;
|
|
61
187
|
}
|
|
62
188
|
finally {
|
|
63
189
|
this.disconnecting = false;
|
|
64
190
|
this.setStatus("disconnected");
|
|
191
|
+
this.relayInstanceId = null;
|
|
192
|
+
this.relayConfirmedPort = null;
|
|
193
|
+
this.relayEpoch = null;
|
|
194
|
+
this.relayNotice = null;
|
|
65
195
|
}
|
|
66
196
|
}
|
|
67
197
|
onStatus(listener) {
|
|
@@ -74,16 +204,148 @@ export class ConnectionManager {
|
|
|
74
204
|
listener(status);
|
|
75
205
|
}
|
|
76
206
|
}
|
|
207
|
+
safeRelaySend(action, context) {
|
|
208
|
+
try {
|
|
209
|
+
action();
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
logError(context, error, { code: "relay_send_failed" });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
startHeartbeat() {
|
|
216
|
+
if (this.heartbeatTimer !== null) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
this.heartbeatTimer = setInterval(() => {
|
|
220
|
+
this.runHeartbeat().catch((error) => {
|
|
221
|
+
logError("relay.heartbeat", error, { code: "relay_heartbeat_failed" });
|
|
222
|
+
});
|
|
223
|
+
}, this.heartbeatIntervalMs);
|
|
224
|
+
}
|
|
225
|
+
stopHeartbeat() {
|
|
226
|
+
if (this.heartbeatTimer !== null) {
|
|
227
|
+
clearInterval(this.heartbeatTimer);
|
|
228
|
+
this.heartbeatTimer = null;
|
|
229
|
+
}
|
|
230
|
+
this.heartbeatInFlight = false;
|
|
231
|
+
}
|
|
232
|
+
async runHeartbeat() {
|
|
233
|
+
if (!this.relay || !this.relay.isConnected()) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (this.heartbeatInFlight) {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
this.heartbeatInFlight = true;
|
|
240
|
+
try {
|
|
241
|
+
await this.relay.sendPing(this.heartbeatTimeoutMs);
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
logError("relay.heartbeat", error, { code: "relay_heartbeat_failed" });
|
|
245
|
+
if (this.shouldReconnect && !this.disconnecting) {
|
|
246
|
+
this.relay.disconnect();
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
finally {
|
|
250
|
+
this.heartbeatInFlight = false;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
77
253
|
async attachToActiveTab() {
|
|
78
|
-
|
|
254
|
+
let tab = await this.tabs.getActiveTab();
|
|
79
255
|
if (!tab || typeof tab.id !== "number") {
|
|
80
256
|
this.trackedTab = null;
|
|
81
257
|
this.setStatus("disconnected");
|
|
82
|
-
|
|
258
|
+
logWarn("Active tab not found.");
|
|
259
|
+
throw new ConnectionError("no_active_tab", "No active browser tab found. Focus a normal tab (not the popup) and retry.");
|
|
260
|
+
}
|
|
261
|
+
if (!tab.url) {
|
|
262
|
+
logWarn("Active tab URL missing.");
|
|
263
|
+
const fallbackId = await this.tabs.getFirstHttpTabId();
|
|
264
|
+
if (fallbackId && fallbackId !== tab.id) {
|
|
265
|
+
const fallbackTab = await this.tabs.getTab(fallbackId);
|
|
266
|
+
if (fallbackTab && typeof fallbackTab.id === "number" && fallbackTab.url) {
|
|
267
|
+
logInfo("Falling back to first http(s) tab.");
|
|
268
|
+
tab = fallbackTab;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (!tab.url) {
|
|
272
|
+
throw new ConnectionError("tab_url_missing", "Active tab URL is unavailable. Reload the tab and retry.");
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
let parsedUrl = null;
|
|
276
|
+
try {
|
|
277
|
+
parsedUrl = new URL(tab.url);
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
logError("connection.parse_tab_url", error, { code: "tab_url_parse_failed" });
|
|
281
|
+
parsedUrl = null;
|
|
282
|
+
}
|
|
283
|
+
if (!parsedUrl) {
|
|
284
|
+
logWarn("Active tab URL is invalid.");
|
|
285
|
+
const fallbackId = await this.tabs.getFirstHttpTabId();
|
|
286
|
+
if (fallbackId && fallbackId !== tab.id) {
|
|
287
|
+
const fallbackTab = await this.tabs.getTab(fallbackId);
|
|
288
|
+
if (fallbackTab && typeof fallbackTab.id === "number" && fallbackTab.url) {
|
|
289
|
+
logInfo("Falling back to first http(s) tab.");
|
|
290
|
+
try {
|
|
291
|
+
parsedUrl = new URL(fallbackTab.url);
|
|
292
|
+
tab = fallbackTab;
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
parsedUrl = null;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (!parsedUrl) {
|
|
300
|
+
throw new ConnectionError("tab_url_restricted", "Active tab URL is unsupported. Focus a normal http(s) tab and retry.");
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const restrictionMessage = getRestrictionMessage(parsedUrl);
|
|
304
|
+
if (restrictionMessage) {
|
|
305
|
+
logWarn(`Active tab blocked: ${summarizeProtocol(tab.url)} scheme.`);
|
|
306
|
+
const fallbackId = await this.tabs.getFirstHttpTabId();
|
|
307
|
+
if (fallbackId && fallbackId !== tab.id) {
|
|
308
|
+
const fallbackTab = await this.tabs.getTab(fallbackId);
|
|
309
|
+
if (fallbackTab && typeof fallbackTab.id === "number" && fallbackTab.url) {
|
|
310
|
+
try {
|
|
311
|
+
const fallbackUrl = new URL(fallbackTab.url);
|
|
312
|
+
const fallbackRestriction = getRestrictionMessage(fallbackUrl);
|
|
313
|
+
if (!fallbackRestriction) {
|
|
314
|
+
logInfo("Falling back to first http(s) tab.");
|
|
315
|
+
tab = fallbackTab;
|
|
316
|
+
parsedUrl = fallbackUrl;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
// Ignore invalid fallback URL.
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (restrictionMessage && getRestrictionMessage(parsedUrl)) {
|
|
325
|
+
throw new ConnectionError("tab_url_restricted", restrictionMessage);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
const tabId = tab.id;
|
|
329
|
+
if (typeof tabId !== "number") {
|
|
330
|
+
this.trackedTab = null;
|
|
331
|
+
this.setStatus("disconnected");
|
|
332
|
+
throw new ConnectionError("no_active_tab", "No active browser tab found. Focus a normal tab (not the popup) and retry.");
|
|
333
|
+
}
|
|
334
|
+
logInfo("Active tab resolved.");
|
|
335
|
+
try {
|
|
336
|
+
await this.cdp.attach(tabId);
|
|
337
|
+
logInfo("Debugger attached.");
|
|
338
|
+
}
|
|
339
|
+
catch (error) {
|
|
340
|
+
const detail = error instanceof Error ? error.message : "Unknown error";
|
|
341
|
+
logWarn(`Debugger attach failed. ${detail}`);
|
|
342
|
+
const message = detail.includes("Chrome 125+")
|
|
343
|
+
? detail
|
|
344
|
+
: "Debugger attach failed. Close DevTools for the tab and retry.";
|
|
345
|
+
throw new ConnectionError("debugger_attach_failed", message);
|
|
83
346
|
}
|
|
84
|
-
await this.cdp.attach(tab.id);
|
|
85
347
|
this.trackedTab = {
|
|
86
|
-
id:
|
|
348
|
+
id: tabId,
|
|
87
349
|
url: tab.url ?? undefined,
|
|
88
350
|
title: tab.title ?? undefined,
|
|
89
351
|
groupId: typeof tab.groupId === "number" ? tab.groupId : undefined
|
|
@@ -91,84 +353,134 @@ export class ConnectionManager {
|
|
|
91
353
|
}
|
|
92
354
|
async connectRelay() {
|
|
93
355
|
if (!this.trackedTab) {
|
|
94
|
-
throw new
|
|
356
|
+
throw new ConnectionError("relay_connect_failed", "Relay connection failed. Start the daemon and retry.");
|
|
95
357
|
}
|
|
96
358
|
const relay = new RelayClient(this.buildRelayUrl(), {
|
|
97
359
|
onCommand: (command) => {
|
|
98
|
-
this.cdp.handleCommand(command).catch(() => {
|
|
99
|
-
|
|
360
|
+
this.cdp.handleCommand(command).catch((error) => {
|
|
361
|
+
logError("cdp.handle_command", error, { code: "cdp_command_failed" });
|
|
362
|
+
this.handleCdpDetach({ reason: "cdp_command_failed" });
|
|
100
363
|
});
|
|
101
364
|
},
|
|
102
|
-
|
|
103
|
-
this.
|
|
365
|
+
onAnnotationCommand: (command) => {
|
|
366
|
+
this.annotationHandler?.(command);
|
|
367
|
+
},
|
|
368
|
+
onOpsMessage: (message) => {
|
|
369
|
+
this.opsHandler?.(message);
|
|
370
|
+
},
|
|
371
|
+
onClose: (detail) => {
|
|
372
|
+
this.handleRelayClose(detail);
|
|
104
373
|
}
|
|
105
374
|
});
|
|
106
375
|
this.relay = relay;
|
|
107
376
|
this.cdp.setCallbacks({
|
|
108
|
-
onEvent: (event) =>
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
377
|
+
onEvent: (event) => {
|
|
378
|
+
this.safeRelaySend(() => this.relay?.sendEvent(event), "relay.send_event");
|
|
379
|
+
},
|
|
380
|
+
onResponse: (response) => {
|
|
381
|
+
this.safeRelaySend(() => this.relay?.sendResponse(response), "relay.send_response");
|
|
382
|
+
},
|
|
383
|
+
onDetach: (detail) => {
|
|
384
|
+
this.handleCdpDetach(detail);
|
|
385
|
+
},
|
|
386
|
+
onPrimaryTabChange: (tabId) => {
|
|
387
|
+
this.handlePrimaryTabChange(tabId).catch((error) => {
|
|
388
|
+
logError("connection.primary_tab_change", error, { code: "primary_tab_change_failed" });
|
|
389
|
+
});
|
|
112
390
|
}
|
|
113
391
|
});
|
|
114
392
|
try {
|
|
115
|
-
await relay.connect(this.buildHandshake());
|
|
393
|
+
const ack = await relay.connect(this.buildHandshake());
|
|
394
|
+
const relayEpoch = typeof ack.payload.epoch === "number" && Number.isFinite(ack.payload.epoch)
|
|
395
|
+
? ack.payload.epoch
|
|
396
|
+
: null;
|
|
397
|
+
const mismatch = await this.reconcileRelayIdentity(ack);
|
|
398
|
+
this.relayInstanceId = ack.payload.instanceId;
|
|
399
|
+
this.relayEpoch = relayEpoch;
|
|
400
|
+
this.persistRelayPort(ack.payload.relayPort);
|
|
401
|
+
if (!mismatch) {
|
|
402
|
+
this.persistRelayIdentity(ack.payload.relayPort, this.relayInstanceId, this.relayEpoch);
|
|
403
|
+
}
|
|
404
|
+
logInfo("Relay WebSocket connected.");
|
|
116
405
|
this.setStatus("connected");
|
|
406
|
+
this.startHeartbeat();
|
|
117
407
|
this.reconnectAttempts = 0;
|
|
118
408
|
this.reconnectDelayMs = 500;
|
|
119
409
|
}
|
|
120
410
|
catch (error) {
|
|
411
|
+
const detail = error instanceof Error ? error.message : "Unknown error";
|
|
412
|
+
logWarn(`Relay WebSocket connect failed. ${detail}`);
|
|
121
413
|
if (this.relay === relay) {
|
|
122
414
|
this.relay = null;
|
|
123
415
|
}
|
|
124
|
-
throw
|
|
416
|
+
throw new ConnectionError("relay_connect_failed", "Relay connection failed. Start the daemon and retry.");
|
|
125
417
|
}
|
|
126
418
|
}
|
|
127
|
-
handleRelayClose() {
|
|
419
|
+
handleRelayClose(detail) {
|
|
420
|
+
this.stopHeartbeat();
|
|
128
421
|
this.relay = null;
|
|
129
|
-
if (
|
|
422
|
+
if (detail && (detail.code === 1008 || detail.reason?.includes("Invalid pairing token"))) {
|
|
423
|
+
this.clearStoredPairingToken();
|
|
424
|
+
}
|
|
425
|
+
if (!this.shouldReconnect) {
|
|
130
426
|
return;
|
|
131
427
|
}
|
|
132
428
|
this.setStatus("disconnected");
|
|
429
|
+
this.relayInstanceId = null;
|
|
430
|
+
this.relayConfirmedPort = null;
|
|
431
|
+
this.relayEpoch = null;
|
|
133
432
|
this.scheduleReconnect();
|
|
134
433
|
}
|
|
135
|
-
|
|
136
|
-
|
|
434
|
+
handleCdpDetach(detail) {
|
|
435
|
+
const reason = detail?.reason ? ` (${detail.reason})` : "";
|
|
436
|
+
logWarn(`CDP detached${reason}.`);
|
|
437
|
+
if (this.disconnecting) {
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (!this.shouldReconnect) {
|
|
441
|
+
this.disconnect().catch((error) => {
|
|
442
|
+
logError("connection.cdp_detach_disconnect", error, { code: "disconnect_failed" });
|
|
443
|
+
});
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
if (this.cdp.getAttachedTabIds().length > 0) {
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
this.setStatus("disconnected");
|
|
450
|
+
if (this.relay?.isConnected()) {
|
|
451
|
+
this.relay.disconnect();
|
|
137
452
|
return;
|
|
138
453
|
}
|
|
139
|
-
|
|
140
|
-
|
|
454
|
+
this.scheduleReconnect();
|
|
455
|
+
}
|
|
456
|
+
scheduleReconnect() {
|
|
457
|
+
if (this.reconnectTimer !== null) {
|
|
141
458
|
return;
|
|
142
459
|
}
|
|
143
460
|
this.reconnectTimer = setTimeout(() => {
|
|
144
461
|
this.reconnectTimer = null;
|
|
145
462
|
this.reconnectAttempts += 1;
|
|
146
463
|
this.reconnectDelayMs = Math.min(this.reconnectDelayMs * 2, this.maxReconnectDelayMs);
|
|
147
|
-
this.reconnectRelay().catch(() => {
|
|
464
|
+
this.reconnectRelay().catch((error) => {
|
|
465
|
+
logError("connection.reconnect", error, { code: "relay_reconnect_failed" });
|
|
148
466
|
this.scheduleReconnect();
|
|
149
467
|
});
|
|
150
468
|
}, this.reconnectDelayMs);
|
|
151
469
|
}
|
|
152
470
|
async reconnectRelay() {
|
|
153
|
-
if (!this.
|
|
471
|
+
if (!this.shouldReconnect) {
|
|
154
472
|
return;
|
|
155
473
|
}
|
|
156
|
-
const
|
|
157
|
-
if (
|
|
158
|
-
this.
|
|
159
|
-
return;
|
|
474
|
+
const primaryId = this.cdp.getPrimaryTabId();
|
|
475
|
+
if (!primaryId) {
|
|
476
|
+
await this.attachToActiveTab();
|
|
160
477
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
478
|
+
else {
|
|
479
|
+
await this.refreshTrackedTab(primaryId);
|
|
480
|
+
}
|
|
481
|
+
if (!this.trackedTab) {
|
|
482
|
+
throw new Error("Reconnect failed: no tracked tab available");
|
|
165
483
|
}
|
|
166
|
-
this.trackedTab = {
|
|
167
|
-
id: tab.id ?? this.trackedTab.id,
|
|
168
|
-
url: tab.url ?? this.trackedTab.url,
|
|
169
|
-
title: tab.title ?? this.trackedTab.title,
|
|
170
|
-
groupId: typeof tab.groupId === "number" ? tab.groupId : this.trackedTab.groupId
|
|
171
|
-
};
|
|
172
484
|
await this.connectRelay();
|
|
173
485
|
}
|
|
174
486
|
buildHandshake() {
|
|
@@ -204,12 +516,19 @@ export class ConnectionManager {
|
|
|
204
516
|
}
|
|
205
517
|
if (changes.relayPort) {
|
|
206
518
|
this.updateRelayPort(changes.relayPort.newValue);
|
|
207
|
-
this.refreshRelay().catch(() => {
|
|
519
|
+
this.refreshRelay().catch((error) => {
|
|
520
|
+
logError("connection.refresh_relay", error, { code: "relay_refresh_failed" });
|
|
521
|
+
});
|
|
208
522
|
}
|
|
209
523
|
};
|
|
210
524
|
handleTabRemoved = (tabId) => {
|
|
211
|
-
if (this.trackedTab
|
|
212
|
-
|
|
525
|
+
if (!this.trackedTab || this.trackedTab.id !== tabId) {
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
if (this.cdp.getAttachedTabIds().length <= 1) {
|
|
529
|
+
this.disconnect().catch((error) => {
|
|
530
|
+
logError("connection.tab_removed_disconnect", error, { code: "disconnect_failed" });
|
|
531
|
+
});
|
|
213
532
|
}
|
|
214
533
|
};
|
|
215
534
|
handleTabUpdated = (_tabId, _changeInfo, tab) => {
|
|
@@ -223,7 +542,7 @@ export class ConnectionManager {
|
|
|
223
542
|
groupId: typeof tab.groupId === "number" ? tab.groupId : this.trackedTab.groupId
|
|
224
543
|
};
|
|
225
544
|
if (this.relay?.isConnected()) {
|
|
226
|
-
this.relay
|
|
545
|
+
this.safeRelaySend(() => this.relay?.sendHandshake(this.buildHandshake()), "relay.send_handshake");
|
|
227
546
|
}
|
|
228
547
|
};
|
|
229
548
|
async loadSettings() {
|
|
@@ -237,6 +556,18 @@ export class ConnectionManager {
|
|
|
237
556
|
this.updateRelayPort(data.relayPort);
|
|
238
557
|
this.ensurePairingTokenDefault();
|
|
239
558
|
}
|
|
559
|
+
setLastError(error) {
|
|
560
|
+
this.lastError = error;
|
|
561
|
+
}
|
|
562
|
+
normalizeError(error) {
|
|
563
|
+
if (error instanceof ConnectionError) {
|
|
564
|
+
return { code: error.code, message: error.message };
|
|
565
|
+
}
|
|
566
|
+
return {
|
|
567
|
+
code: "unknown",
|
|
568
|
+
message: "Connection failed. Focus a normal tab and retry."
|
|
569
|
+
};
|
|
570
|
+
}
|
|
240
571
|
updatePairingToken(value) {
|
|
241
572
|
if (typeof value === "string" && value.trim().length > 0) {
|
|
242
573
|
this.pairingToken = value.trim();
|
|
@@ -258,6 +589,12 @@ export class ConnectionManager {
|
|
|
258
589
|
this.pairingToken = DEFAULT_PAIRING_TOKEN;
|
|
259
590
|
chrome.storage.local.set({ pairingToken: DEFAULT_PAIRING_TOKEN });
|
|
260
591
|
}
|
|
592
|
+
clearStoredPairingToken(clearMemory = true) {
|
|
593
|
+
if (clearMemory) {
|
|
594
|
+
this.pairingToken = null;
|
|
595
|
+
}
|
|
596
|
+
chrome.storage.local.set({ pairingToken: null, tokenEpoch: null });
|
|
597
|
+
}
|
|
261
598
|
updateRelayPort(value) {
|
|
262
599
|
if (typeof value === "number" && Number.isInteger(value) && value > 0 && value <= 65535) {
|
|
263
600
|
this.relayPort = value;
|
|
@@ -272,6 +609,52 @@ export class ConnectionManager {
|
|
|
272
609
|
}
|
|
273
610
|
this.relayPort = DEFAULT_RELAY_PORT;
|
|
274
611
|
}
|
|
612
|
+
persistRelayPort(value) {
|
|
613
|
+
if (!Number.isInteger(value) || value <= 0 || value > 65535) {
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
this.relayPort = value;
|
|
617
|
+
this.relayConfirmedPort = value;
|
|
618
|
+
chrome.storage.local.set({ relayPort: value });
|
|
619
|
+
}
|
|
620
|
+
persistRelayIdentity(port, instanceId, epoch) {
|
|
621
|
+
this.persistRelayPort(port);
|
|
622
|
+
chrome.storage.local.set({
|
|
623
|
+
relayInstanceId: instanceId,
|
|
624
|
+
relayEpoch: epoch
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
clearStoredRelayIdentity() {
|
|
628
|
+
chrome.storage.local.set({
|
|
629
|
+
relayInstanceId: null,
|
|
630
|
+
relayEpoch: null
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
async reconcileRelayIdentity(ack) {
|
|
634
|
+
const stored = await new Promise((resolve) => {
|
|
635
|
+
chrome.storage.local.get(["relayInstanceId", "relayEpoch"], (items) => resolve(items));
|
|
636
|
+
});
|
|
637
|
+
const storedInstanceId = typeof stored.relayInstanceId === "string" ? stored.relayInstanceId : null;
|
|
638
|
+
const storedEpoch = typeof stored.relayEpoch === "number" && Number.isFinite(stored.relayEpoch)
|
|
639
|
+
? stored.relayEpoch
|
|
640
|
+
: null;
|
|
641
|
+
const ackEpoch = typeof ack.payload.epoch === "number" && Number.isFinite(ack.payload.epoch)
|
|
642
|
+
? ack.payload.epoch
|
|
643
|
+
: null;
|
|
644
|
+
const instanceMismatch = Boolean(storedInstanceId && storedInstanceId !== ack.payload.instanceId);
|
|
645
|
+
const epochMismatch = storedEpoch !== null && ackEpoch !== null && storedEpoch !== ackEpoch;
|
|
646
|
+
if (instanceMismatch || epochMismatch) {
|
|
647
|
+
this.clearStoredRelayIdentity();
|
|
648
|
+
this.clearStoredPairingToken(false);
|
|
649
|
+
this.relayNotice = instanceMismatch
|
|
650
|
+
? "Relay instance changed. Re-pair and reconnect."
|
|
651
|
+
: "Relay restarted. Re-pair and reconnect.";
|
|
652
|
+
this.safeRelaySend(() => this.relay?.sendHandshake(this.buildHandshake()), "relay.rehandshake");
|
|
653
|
+
return true;
|
|
654
|
+
}
|
|
655
|
+
this.relayNotice = null;
|
|
656
|
+
return false;
|
|
657
|
+
}
|
|
275
658
|
/**
|
|
276
659
|
* Chrome automatically sends Origin: chrome-extension://EXTENSION_ID
|
|
277
660
|
* for WebSocket connections from extensions. The relay server validates
|
|
@@ -286,6 +669,30 @@ export class ConnectionManager {
|
|
|
286
669
|
await this.disconnect();
|
|
287
670
|
await this.connect();
|
|
288
671
|
}
|
|
672
|
+
async handlePrimaryTabChange(tabId) {
|
|
673
|
+
if (!tabId) {
|
|
674
|
+
this.trackedTab = null;
|
|
675
|
+
if (this.relay?.isConnected()) {
|
|
676
|
+
this.relay.disconnect();
|
|
677
|
+
}
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
await this.refreshTrackedTab(tabId);
|
|
681
|
+
this.refreshHandshake();
|
|
682
|
+
}
|
|
683
|
+
async refreshTrackedTab(tabId) {
|
|
684
|
+
const tab = await this.tabs.getTab(tabId);
|
|
685
|
+
if (!tab || typeof tab.id !== "number") {
|
|
686
|
+
this.trackedTab = null;
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
this.trackedTab = {
|
|
690
|
+
id: tab.id,
|
|
691
|
+
url: tab.url ?? this.trackedTab?.url,
|
|
692
|
+
title: tab.title ?? this.trackedTab?.title,
|
|
693
|
+
groupId: typeof tab.groupId === "number" ? tab.groupId : this.trackedTab?.groupId
|
|
694
|
+
};
|
|
695
|
+
}
|
|
289
696
|
clearReconnectTimer() {
|
|
290
697
|
if (this.reconnectTimer !== null) {
|
|
291
698
|
clearTimeout(this.reconnectTimer);
|
|
@@ -296,6 +703,6 @@ export class ConnectionManager {
|
|
|
296
703
|
if (!this.trackedTab || !this.relay?.isConnected()) {
|
|
297
704
|
return;
|
|
298
705
|
}
|
|
299
|
-
this.relay
|
|
706
|
+
this.safeRelaySend(() => this.relay?.sendHandshake(this.buildHandshake()), "relay.send_handshake");
|
|
300
707
|
}
|
|
301
708
|
}
|