opendevbrowser 0.0.11 → 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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +289 -28
  3. package/dist/chunk-JVBMT2O5.js +7173 -0
  4. package/dist/chunk-JVBMT2O5.js.map +1 -0
  5. package/dist/cli/index.js +3690 -275
  6. package/dist/cli/index.js.map +1 -1
  7. package/dist/index.js +1080 -2857
  8. package/dist/index.js.map +1 -1
  9. package/dist/opendevbrowser.js +1080 -2857
  10. package/dist/opendevbrowser.js.map +1 -1
  11. package/extension/dist/annotate-content.css +237 -0
  12. package/extension/dist/annotate-content.js +934 -0
  13. package/extension/dist/background.js +1291 -8
  14. package/extension/dist/logging.js +50 -0
  15. package/extension/dist/ops/dom-bridge.js +355 -0
  16. package/extension/dist/ops/ops-runtime.js +1249 -0
  17. package/extension/dist/ops/ops-session-store.js +189 -0
  18. package/extension/dist/ops/redaction.js +52 -0
  19. package/extension/dist/ops/snapshot-builder.js +4 -0
  20. package/extension/dist/ops/snapshot-shared.js +220 -0
  21. package/extension/dist/popup.js +398 -21
  22. package/extension/dist/relay-settings.js +3 -1
  23. package/extension/dist/services/CDPRouter.js +501 -103
  24. package/extension/dist/services/ConnectionManager.js +464 -57
  25. package/extension/dist/services/NativePortManager.js +182 -0
  26. package/extension/dist/services/RelayClient.js +227 -26
  27. package/extension/dist/services/TabManager.js +81 -0
  28. package/extension/dist/services/TargetSessionMap.js +146 -0
  29. package/extension/dist/services/cdp-router-commands.js +203 -0
  30. package/extension/dist/services/url-restrictions.js +41 -0
  31. package/extension/dist/types.js +3 -1
  32. package/extension/icons/icon128.png +0 -0
  33. package/extension/icons/icon16.png +0 -0
  34. package/extension/icons/icon32.png +0 -0
  35. package/extension/icons/icon48.png +0 -0
  36. package/extension/manifest.json +17 -3
  37. package/extension/popup.html +469 -65
  38. package/package.json +2 -2
  39. package/skills/AGENTS.md +34 -61
  40. package/skills/data-extraction/SKILL.md +95 -103
  41. package/skills/form-testing/SKILL.md +75 -82
  42. package/skills/login-automation/SKILL.md +76 -66
  43. package/skills/opendevbrowser-best-practices/SKILL.md +90 -49
  44. package/skills/opendevbrowser-continuity-ledger/SKILL.md +57 -23
  45. package/dist/chunk-R5VUZEUU.js +0 -128
  46. package/dist/chunk-R5VUZEUU.js.map +0 -1
  47. 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
- maxReconnectAttempts = 5;
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
- async connect() {
32
- if (this.status === "connected") {
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.shouldReconnect = true;
37
- this.reconnectAttempts = 0;
38
- await this.loadSettings();
39
- await this.attachToActiveTab();
40
- await this.connectRelay();
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
- catch {
43
- await this.disconnect();
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
- if (this.trackedTab !== null) {
58
- await this.cdp.detach();
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
- const tab = await this.tabs.getActiveTab();
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
- throw new Error("No active tab available");
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: tab.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 Error("No tracked tab for relay connection");
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
- this.disconnect().catch(() => { });
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
- onClose: () => {
103
- this.handleRelayClose();
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) => this.relay?.sendEvent(event),
109
- onResponse: (response) => this.relay?.sendResponse(response),
110
- onDetach: () => {
111
- this.disconnect().catch(() => { });
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 error;
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 (!this.shouldReconnect || !this.trackedTab) {
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
- scheduleReconnect() {
136
- if (this.reconnectTimer !== null) {
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
- if (this.reconnectAttempts >= this.maxReconnectAttempts) {
140
- this.disconnect().catch(() => { });
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.trackedTab || !this.shouldReconnect) {
471
+ if (!this.shouldReconnect) {
154
472
  return;
155
473
  }
156
- const attachedId = this.cdp.getAttachedTabId();
157
- if (attachedId !== this.trackedTab.id) {
158
- this.disconnect().catch(() => { });
159
- return;
474
+ const primaryId = this.cdp.getPrimaryTabId();
475
+ if (!primaryId) {
476
+ await this.attachToActiveTab();
160
477
  }
161
- const tab = await this.tabs.getTab(this.trackedTab.id);
162
- if (!tab) {
163
- this.disconnect().catch(() => { });
164
- return;
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 && this.trackedTab.id === tabId) {
212
- this.disconnect().catch(() => { });
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.sendHandshake(this.buildHandshake());
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.sendHandshake(this.buildHandshake());
706
+ this.safeRelaySend(() => this.relay?.sendHandshake(this.buildHandshake()), "relay.send_handshake");
300
707
  }
301
708
  }