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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +216 -28
  3. package/dist/chunk-JVBMT2O5.js +7173 -0
  4. package/dist/chunk-JVBMT2O5.js.map +1 -0
  5. package/dist/cli/index.js +2486 -589
  6. package/dist/cli/index.js.map +1 -1
  7. package/dist/index.js +1057 -194
  8. package/dist/index.js.map +1 -1
  9. package/dist/opendevbrowser.js +1057 -194
  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 +1194 -32
  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 +370 -25
  22. package/extension/dist/relay-settings.js +1 -0
  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/manifest.json +17 -3
  33. package/extension/popup.html +144 -0
  34. package/package.json +2 -2
  35. package/skills/AGENTS.md +34 -62
  36. package/skills/data-extraction/SKILL.md +95 -103
  37. package/skills/form-testing/SKILL.md +75 -82
  38. package/skills/login-automation/SKILL.md +76 -66
  39. package/skills/opendevbrowser-best-practices/SKILL.md +90 -49
  40. package/skills/opendevbrowser-continuity-ledger/SKILL.md +57 -23
  41. package/dist/chunk-WTFSMBVH.js +0 -2815
  42. package/dist/chunk-WTFSMBVH.js.map +0 -1
  43. package/extension/dist/popup.jsx +0 -150
@@ -1,7 +1,56 @@
1
1
  import { ConnectionManager } from "./services/ConnectionManager.js";
2
- import { DEFAULT_AUTO_CONNECT, DEFAULT_AUTO_PAIR, DEFAULT_DISCOVERY_PORT, DEFAULT_PAIRING_ENABLED, DEFAULT_RELAY_PORT } from "./relay-settings.js";
2
+ import { NativePortManager } from "./services/NativePortManager.js";
3
+ import { DEFAULT_AUTO_CONNECT, DEFAULT_AUTO_PAIR, DEFAULT_DISCOVERY_PORT, DEFAULT_NATIVE_ENABLED, DEFAULT_PAIRING_ENABLED, DEFAULT_RELAY_PORT } from "./relay-settings.js";
4
+ import { logError } from "./logging.js";
5
+ import { OpsRuntime } from "./ops/ops-runtime.js";
3
6
  const connection = new ConnectionManager();
7
+ const opsRuntime = new OpsRuntime({
8
+ send: (message) => connection.sendOpsMessage(message),
9
+ cdp: connection.getCdpRouter()
10
+ });
11
+ const nativePort = new NativePortManager({
12
+ onMessage: (payload) => {
13
+ handleNativePortMessage(payload).catch((error) => {
14
+ logError("native_port.message", error, { code: "native_message_failed" });
15
+ });
16
+ },
17
+ onDisconnect: () => {
18
+ updateBadge(getEffectiveStatus());
19
+ }
20
+ });
4
21
  let autoConnectInFlight = false;
22
+ let statusNoteOverride = null;
23
+ let retryScheduled = false;
24
+ let retryDelayMs = 5000;
25
+ let nativeEnabled = DEFAULT_NATIVE_ENABLED;
26
+ const RETRY_ALARM_NAME = "opendevbrowser-auto-connect";
27
+ const RETRY_MAX_MS = 60_000;
28
+ const ANNOTATION_CONTENT_SCRIPT = "dist/annotate-content.js";
29
+ const ANNOTATION_CONTENT_STYLE = "dist/annotate-content.css";
30
+ const ANNOTATION_MAX_PAYLOAD_BYTES = 10 * 1024 * 1024;
31
+ const ANNOTATION_REQUEST_TIMEOUT_MS = 120_000;
32
+ const LAST_ANNOTATION_META_KEY = "annotationLastMeta";
33
+ const LAST_ANNOTATION_PAYLOAD_KEY = "annotationLastPayloadSansScreenshots";
34
+ const annotationSessions = new Map();
35
+ let lastAnnotationFull = null;
36
+ connection.onAnnotationCommand((command) => {
37
+ handleRelayAnnotationCommand(command).catch((error) => {
38
+ logError("annotation.relay_command", error, { code: "annotation_command_failed" });
39
+ });
40
+ });
41
+ connection.onOpsMessage((message) => {
42
+ opsRuntime.handleMessage(message);
43
+ });
44
+ const RESTRICTED_PROTOCOLS = new Set([
45
+ "chrome:",
46
+ "chrome-extension:",
47
+ "chrome-search:",
48
+ "chrome-untrusted:",
49
+ "devtools:",
50
+ "chrome-devtools:",
51
+ "edge:",
52
+ "brave:"
53
+ ]);
5
54
  const updateBadge = (status) => {
6
55
  const isConnected = status === "connected";
7
56
  chrome.action.setBadgeText({ text: isConnected ? "ON" : "OFF" });
@@ -9,6 +58,167 @@ const updateBadge = (status) => {
9
58
  color: isConnected ? "#20d5c6" : "#5b667a"
10
59
  });
11
60
  };
61
+ const getEffectiveStatus = () => {
62
+ if (connection.getStatus() === "connected") {
63
+ return "connected";
64
+ }
65
+ if (nativeEnabled && nativePort.isConnected()) {
66
+ return "connected";
67
+ }
68
+ return "disconnected";
69
+ };
70
+ const buildStatusMessage = async () => {
71
+ const error = connection.getLastError();
72
+ const relayStatus = connection.getStatus();
73
+ const status = getEffectiveStatus();
74
+ let note = error?.message;
75
+ let relayHealth = null;
76
+ const isNativeEnabled = nativeEnabled;
77
+ let nativeHealth = isNativeEnabled ? nativePort.getHealth() : null;
78
+ if (isNativeEnabled && nativePort.isConnected()) {
79
+ try {
80
+ await nativePort.ping(1000);
81
+ }
82
+ catch (error) {
83
+ logError("native_port.ping", error, { code: "native_ping_failed" });
84
+ }
85
+ nativeHealth = nativePort.getHealth();
86
+ }
87
+ if (!error) {
88
+ if (relayStatus === "connected") {
89
+ const identity = connection.getRelayIdentity();
90
+ if (identity.relayPort && identity.instanceId) {
91
+ note = `Connected to 127.0.0.1:${identity.relayPort} (relay ${identity.instanceId.slice(0, 8)})`;
92
+ }
93
+ else if (identity.relayPort) {
94
+ note = `Connected to 127.0.0.1:${identity.relayPort}`;
95
+ }
96
+ relayHealth = await connection.relayHealthCheck();
97
+ }
98
+ else if (nativeHealth?.status === "connected") {
99
+ note = "Connected via native host.";
100
+ }
101
+ else {
102
+ const stored = await new Promise((resolve) => {
103
+ chrome.storage.local.get(["relayPort"], (items) => resolve(items));
104
+ });
105
+ const port = parsePort(stored.relayPort) ?? DEFAULT_RELAY_PORT;
106
+ relayHealth = await fetchRelayHealth(port);
107
+ note = statusNoteOverride ?? buildRelayHealthNote(relayHealth);
108
+ if (!statusNoteOverride && isNativeEnabled && nativeHealth?.status === "error") {
109
+ note = buildNativeHealthNote(nativeHealth);
110
+ }
111
+ }
112
+ }
113
+ if (!error) {
114
+ const relayNotice = connection.getRelayNotice();
115
+ if (relayNotice) {
116
+ note = relayNotice;
117
+ }
118
+ }
119
+ return {
120
+ type: "status",
121
+ status,
122
+ note,
123
+ relayHealth,
124
+ nativeHealth,
125
+ nativeEnabled: isNativeEnabled
126
+ };
127
+ };
128
+ const setStorage = (items) => {
129
+ return new Promise((resolve) => {
130
+ chrome.storage.local.set(items, () => resolve());
131
+ });
132
+ };
133
+ const setStatusNoteOverride = (note) => {
134
+ statusNoteOverride = note;
135
+ };
136
+ const buildRelayHealthNote = (health) => {
137
+ if (!health) {
138
+ return "Relay unreachable. Start the daemon and retry.";
139
+ }
140
+ switch (health.reason) {
141
+ case "pairing_invalid":
142
+ return "Pairing token mismatch. Update the token and reconnect.";
143
+ case "pairing_required":
144
+ return "Pairing required. Enable auto-pair or set the token.";
145
+ case "handshake_incomplete":
146
+ return "Extension handshake pending. Keep the relay running and retry.";
147
+ case "extension_disconnected":
148
+ return "Extension not connected to relay. Click Connect.";
149
+ case "annotation_disconnected":
150
+ return "Annotation channel disconnected. Keep the extension open and retry.";
151
+ case "ops_disconnected":
152
+ return "Ops channel disconnected. Start a new session and retry.";
153
+ case "cdp_disconnected":
154
+ return "No CDP clients connected. Start a session and retry.";
155
+ case "relay_down":
156
+ return "Relay down. Start the daemon and retry.";
157
+ default:
158
+ return "Local relay only. Tokens stay on-device.";
159
+ }
160
+ };
161
+ const buildNativeHealthNote = (health) => {
162
+ if (health.status === "connected") {
163
+ return "Native host connected.";
164
+ }
165
+ switch (health.error) {
166
+ case "host_not_installed":
167
+ return "Native host not installed. Run `opendevbrowser native install <extension-id>`.";
168
+ case "host_forbidden":
169
+ return "Native host forbidden. Verify the extension ID matches the manifest.";
170
+ case "host_disconnect":
171
+ return "Native host disconnected. Restart the host.";
172
+ case "host_timeout":
173
+ return "Native host ping timed out.";
174
+ case "host_message_too_large":
175
+ return "Native host rejected message size.";
176
+ default:
177
+ return "Native host unavailable.";
178
+ }
179
+ };
180
+ const clearRetry = () => {
181
+ retryScheduled = false;
182
+ retryDelayMs = 5000;
183
+ if (chrome.alarms?.clear) {
184
+ chrome.alarms.clear(RETRY_ALARM_NAME);
185
+ }
186
+ };
187
+ const scheduleRetry = () => {
188
+ if (retryScheduled) {
189
+ return;
190
+ }
191
+ retryScheduled = true;
192
+ const delayMs = retryDelayMs;
193
+ retryDelayMs = Math.min(retryDelayMs * 2, RETRY_MAX_MS);
194
+ if (chrome.alarms?.create) {
195
+ chrome.alarms.create(RETRY_ALARM_NAME, { when: Date.now() + delayMs });
196
+ return;
197
+ }
198
+ setTimeout(() => {
199
+ retryScheduled = false;
200
+ autoConnect().catch((error) => {
201
+ logError("auto_connect.retry", error, { code: "auto_connect_failed" });
202
+ });
203
+ }, delayMs);
204
+ };
205
+ const attemptNativeConnect = async () => {
206
+ if (!nativeEnabled) {
207
+ return false;
208
+ }
209
+ const connected = await nativePort.connect();
210
+ if (!connected) {
211
+ return false;
212
+ }
213
+ try {
214
+ await nativePort.ping(1500);
215
+ }
216
+ catch (error) {
217
+ logError("native_port.ping", error, { code: "native_ping_failed" });
218
+ }
219
+ updateBadge(getEffectiveStatus());
220
+ return nativePort.isConnected();
221
+ };
12
222
  const parsePort = (value) => {
13
223
  if (typeof value === "number" && Number.isInteger(value) && value > 0 && value <= 65535) {
14
224
  return value;
@@ -21,6 +231,30 @@ const parsePort = (value) => {
21
231
  }
22
232
  return null;
23
233
  };
234
+ const parseEpoch = (value) => {
235
+ if (typeof value === "number" && Number.isFinite(value)) {
236
+ return value;
237
+ }
238
+ return null;
239
+ };
240
+ const isWebStoreUrl = (url) => {
241
+ if (url.hostname === "chromewebstore.google.com") {
242
+ return true;
243
+ }
244
+ if (url.hostname === "chrome.google.com" && url.pathname.startsWith("/webstore")) {
245
+ return true;
246
+ }
247
+ return false;
248
+ };
249
+ const getRestrictionMessage = (url) => {
250
+ if (RESTRICTED_PROTOCOLS.has(url.protocol)) {
251
+ return "Active tab uses a restricted URL scheme. Open a normal http(s) page and retry.";
252
+ }
253
+ if (isWebStoreUrl(url)) {
254
+ return "Chrome Web Store tabs cannot be annotated. Open a normal tab and retry.";
255
+ }
256
+ return null;
257
+ };
24
258
  const fetchRelayConfig = async (port) => {
25
259
  try {
26
260
  const response = await fetch(`http://127.0.0.1:${port}/config`, {
@@ -36,11 +270,590 @@ const fetchRelayConfig = async (port) => {
36
270
  return null;
37
271
  }
38
272
  const pairingRequired = typeof data.pairingRequired === "boolean" ? data.pairingRequired : true;
39
- return { relayPort, pairingRequired };
273
+ const instanceId = typeof data.instanceId === "string" ? data.instanceId : null;
274
+ const epoch = parseEpoch(data.epoch);
275
+ return { relayPort, pairingRequired, instanceId, epoch };
276
+ }
277
+ catch (error) {
278
+ logError("relay.config_fetch", error, { code: "relay_config_fetch_failed", extra: { port } });
279
+ return null;
280
+ }
281
+ };
282
+ const fetchRelayHealth = async (port) => {
283
+ try {
284
+ const response = await fetch(`http://127.0.0.1:${port}/status`, {
285
+ method: "GET",
286
+ headers: { "Accept": "application/json" }
287
+ });
288
+ if (!response.ok) {
289
+ return null;
290
+ }
291
+ const data = await response.json();
292
+ if (data.health && typeof data.health === "object") {
293
+ return data.health;
294
+ }
295
+ const extensionConnected = data.extensionConnected === true;
296
+ const handshake = data.extensionHandshakeComplete === true;
297
+ const cdpConnected = data.cdpConnected === true;
298
+ const annotationConnected = data.annotationConnected === true;
299
+ const opsConnected = data.opsConnected === true;
300
+ const pairingRequired = data.pairingRequired === true;
301
+ const ok = extensionConnected && handshake;
302
+ return {
303
+ ok,
304
+ reason: ok ? "ok" : (extensionConnected ? "handshake_incomplete" : "extension_disconnected"),
305
+ extensionConnected,
306
+ extensionHandshakeComplete: handshake,
307
+ cdpConnected,
308
+ annotationConnected,
309
+ opsConnected,
310
+ pairingRequired
311
+ };
312
+ }
313
+ catch (error) {
314
+ logError("relay.health_fetch", error, { code: "relay_health_fetch_failed", extra: { port } });
315
+ return null;
316
+ }
317
+ };
318
+ const sendAnnotationResponse = (payload, transport = "relay") => {
319
+ if (transport === "popup") {
320
+ return;
321
+ }
322
+ const response = { type: "annotationResponse", payload };
323
+ if (transport === "native") {
324
+ nativePort.send(response);
325
+ return;
326
+ }
327
+ connection.sendAnnotationResponse(response);
328
+ };
329
+ const sendAnnotationEvent = (payload, transport = "relay") => {
330
+ if (transport === "popup") {
331
+ return;
332
+ }
333
+ const event = { type: "annotationEvent", payload };
334
+ if (transport === "native") {
335
+ nativePort.send(event);
336
+ return;
337
+ }
338
+ connection.sendAnnotationEvent(event);
339
+ };
340
+ const startAnnotationTimeout = (requestId, transport) => {
341
+ return setTimeout(() => {
342
+ const session = annotationSessions.get(requestId);
343
+ if (!session)
344
+ return;
345
+ annotationSessions.delete(requestId);
346
+ const response = {
347
+ version: 1,
348
+ requestId,
349
+ status: "error",
350
+ error: { code: "timeout", message: "Annotation request timed out." }
351
+ };
352
+ const meta = buildLastAnnotationMeta(requestId, response, false);
353
+ lastAnnotationFull = null;
354
+ persistLastAnnotation(meta, null).catch((error) => {
355
+ logError("annotation.persist_timeout_meta", error, { code: "annotation_persist_failed" });
356
+ });
357
+ sendAnnotationResponse(response, transport);
358
+ }, ANNOTATION_REQUEST_TIMEOUT_MS);
359
+ };
360
+ const getTab = async (tabId) => {
361
+ try {
362
+ return await chrome.tabs.get(tabId);
363
+ }
364
+ catch (error) {
365
+ logError("tabs.get", error, { code: "tab_lookup_failed", extra: { tabId } });
366
+ return null;
367
+ }
368
+ };
369
+ const createTab = async (url) => {
370
+ return await new Promise((resolve, reject) => {
371
+ chrome.tabs.create({ url, active: true }, (tab) => {
372
+ const lastError = chrome.runtime.lastError;
373
+ if (lastError) {
374
+ reject(new Error(lastError.message));
375
+ return;
376
+ }
377
+ if (!tab) {
378
+ reject(new Error("Tab creation failed"));
379
+ return;
380
+ }
381
+ resolve(tab);
382
+ });
383
+ });
384
+ };
385
+ const updateTabUrl = async (tabId, url) => {
386
+ return await new Promise((resolve, reject) => {
387
+ chrome.tabs.update(tabId, { url, active: true }, (tab) => {
388
+ const lastError = chrome.runtime.lastError;
389
+ if (lastError) {
390
+ reject(new Error(lastError.message));
391
+ return;
392
+ }
393
+ if (!tab) {
394
+ reject(new Error("Tab update failed"));
395
+ return;
396
+ }
397
+ resolve(tab);
398
+ });
399
+ });
400
+ };
401
+ const waitForTabComplete = async (tabId, timeoutMs = 10000) => {
402
+ const tab = await getTab(tabId);
403
+ if (tab?.status === "complete")
404
+ return;
405
+ await new Promise((resolve, reject) => {
406
+ let settled = false;
407
+ const timeout = setTimeout(() => {
408
+ if (settled)
409
+ return;
410
+ settled = true;
411
+ chrome.tabs.onUpdated.removeListener(listener);
412
+ reject(new Error("Tab load timeout"));
413
+ }, timeoutMs);
414
+ const listener = (updatedId, changeInfo) => {
415
+ if (updatedId !== tabId)
416
+ return;
417
+ if (changeInfo.status === "complete") {
418
+ if (settled)
419
+ return;
420
+ settled = true;
421
+ clearTimeout(timeout);
422
+ chrome.tabs.onUpdated.removeListener(listener);
423
+ resolve();
424
+ }
425
+ };
426
+ chrome.tabs.onUpdated.addListener(listener);
427
+ });
428
+ };
429
+ const getActiveTab = async () => {
430
+ const tabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
431
+ return tabs[0] ?? null;
432
+ };
433
+ const resolveAnnotationTab = async (command) => {
434
+ if (typeof command.tabId === "number") {
435
+ if (command.url) {
436
+ const updated = await updateTabUrl(command.tabId, command.url);
437
+ await waitForTabComplete(command.tabId);
438
+ return updated;
439
+ }
440
+ const existing = await getTab(command.tabId);
441
+ if (!existing) {
442
+ throw new Error("Target tab unavailable");
443
+ }
444
+ return existing;
445
+ }
446
+ if (command.url) {
447
+ const created = await createTab(command.url);
448
+ if (typeof created.id === "number") {
449
+ await waitForTabComplete(created.id);
450
+ }
451
+ return created;
452
+ }
453
+ const active = await getActiveTab();
454
+ if (!active) {
455
+ throw new Error("No active tab available");
456
+ }
457
+ return active;
458
+ };
459
+ const isRestrictedTab = (tab) => {
460
+ const rawUrl = tab.url ?? tab.pendingUrl ?? "";
461
+ if (!rawUrl)
462
+ return "Active tab URL unavailable.";
463
+ let parsed = null;
464
+ try {
465
+ parsed = new URL(rawUrl);
466
+ }
467
+ catch (error) {
468
+ logError("annotation.parse_tab_url", error, { code: "tab_url_parse_failed" });
469
+ return "Active tab URL is invalid.";
470
+ }
471
+ return getRestrictionMessage(parsed);
472
+ };
473
+ const injectAnnotationAssets = async (tabId) => {
474
+ await new Promise((resolve, reject) => {
475
+ chrome.scripting.insertCSS({ target: { tabId }, files: [ANNOTATION_CONTENT_STYLE] }, () => {
476
+ const lastError = chrome.runtime.lastError;
477
+ if (lastError) {
478
+ reject(new Error(lastError.message));
479
+ return;
480
+ }
481
+ resolve();
482
+ });
483
+ });
484
+ await new Promise((resolve, reject) => {
485
+ chrome.scripting.executeScript({ target: { tabId }, files: [ANNOTATION_CONTENT_SCRIPT] }, () => {
486
+ const lastError = chrome.runtime.lastError;
487
+ if (lastError) {
488
+ reject(new Error(lastError.message));
489
+ return;
490
+ }
491
+ resolve();
492
+ });
493
+ });
494
+ };
495
+ const sendMessageToTab = async (tabId, message) => {
496
+ return await new Promise((resolve, reject) => {
497
+ chrome.tabs.sendMessage(tabId, message, (response) => {
498
+ const lastError = chrome.runtime.lastError;
499
+ if (lastError) {
500
+ reject(new Error(lastError.message));
501
+ return;
502
+ }
503
+ resolve(response);
504
+ });
505
+ });
506
+ };
507
+ const isMissingAnnotationReceiverError = (error) => {
508
+ if (!(error instanceof Error)) {
509
+ return false;
510
+ }
511
+ return error.message.includes("Receiving end does not exist");
512
+ };
513
+ const sleep = async (ms) => {
514
+ await new Promise((resolve) => setTimeout(resolve, ms));
515
+ };
516
+ const pingAnnotation = async (tabId) => {
517
+ const response = await sendMessageToTab(tabId, { type: "annotation:ping" });
518
+ const ok = typeof response === "object" && response !== null && response.ok === true;
519
+ if (!ok) {
520
+ throw new Error("Annotation ping failed");
521
+ }
522
+ };
523
+ const ensureAnnotationInjected = async (tabId) => {
524
+ try {
525
+ await pingAnnotation(tabId);
526
+ return;
527
+ }
528
+ catch (error) {
529
+ // Initial ping can fail before content script injection; avoid noisy logs for missing receivers.
530
+ if (!isMissingAnnotationReceiverError(error)) {
531
+ logError("annotation.ping", error, { code: "annotation_ping_failed", extra: { tabId } });
532
+ }
533
+ }
534
+ const backoff = [150, 400];
535
+ let lastError = null;
536
+ for (let attempt = 0; attempt <= backoff.length; attempt += 1) {
537
+ try {
538
+ await injectAnnotationAssets(tabId);
539
+ await pingAnnotation(tabId);
540
+ return;
541
+ }
542
+ catch (error) {
543
+ lastError = error;
544
+ logError("annotation.inject", error, { code: "annotation_injection_failed", extra: { tabId, attempt } });
545
+ if (attempt < backoff.length) {
546
+ const delay = backoff[attempt] ?? 0;
547
+ await sleep(delay);
548
+ }
549
+ }
40
550
  }
41
- catch {
551
+ if (lastError instanceof Error) {
552
+ throw lastError;
553
+ }
554
+ throw new Error("Annotation injection failed");
555
+ };
556
+ const probeAnnotationInjected = async () => {
557
+ const active = await getActiveTab();
558
+ if (!active || typeof active.id !== "number") {
559
+ return { injected: false, detail: "No active tab available." };
560
+ }
561
+ const restricted = isRestrictedTab(active);
562
+ if (restricted) {
563
+ return { injected: false, detail: restricted };
564
+ }
565
+ try {
566
+ await pingAnnotation(active.id);
567
+ return { injected: true };
568
+ }
569
+ catch (error) {
570
+ const message = error instanceof Error ? error.message : "Annotation not injected.";
571
+ return { injected: false, detail: message };
572
+ }
573
+ };
574
+ const toggleAnnotationUi = async () => {
575
+ const tab = await getActiveTab();
576
+ if (!tab || typeof tab.id !== "number") {
577
+ return;
578
+ }
579
+ const restricted = isRestrictedTab(tab);
580
+ if (restricted) {
581
+ return;
582
+ }
583
+ await ensureAnnotationInjected(tab.id);
584
+ await sendMessageToTab(tab.id, { type: "annotation:toggle" });
585
+ };
586
+ const startAnnotationSession = async (command, transport) => {
587
+ const requestId = command.requestId;
588
+ if (annotationSessions.has(requestId)) {
589
+ sendAnnotationResponse({
590
+ version: 1,
591
+ requestId,
592
+ status: "error",
593
+ error: { code: "invalid_request", message: "Duplicate annotation requestId." }
594
+ }, transport);
595
+ return;
596
+ }
597
+ const tab = await resolveAnnotationTab(command);
598
+ const restricted = isRestrictedTab(tab);
599
+ if (restricted) {
600
+ sendAnnotationResponse({
601
+ version: 1,
602
+ requestId,
603
+ status: "error",
604
+ error: { code: "restricted_url", message: restricted }
605
+ }, transport);
606
+ return;
607
+ }
608
+ if (typeof tab.id !== "number") {
609
+ sendAnnotationResponse({
610
+ version: 1,
611
+ requestId,
612
+ status: "error",
613
+ error: { code: "invalid_request", message: "Target tab missing id." }
614
+ }, transport);
615
+ return;
616
+ }
617
+ let timeoutId = null;
618
+ try {
619
+ await ensureAnnotationInjected(tab.id);
620
+ timeoutId = startAnnotationTimeout(requestId, transport);
621
+ annotationSessions.set(requestId, {
622
+ requestId,
623
+ tabId: tab.id,
624
+ options: command.options,
625
+ createdAt: Date.now(),
626
+ timeoutId,
627
+ transport
628
+ });
629
+ await sendMessageToTab(tab.id, {
630
+ type: "annotation:start",
631
+ requestId,
632
+ options: command.options ?? {},
633
+ url: command.url
634
+ });
635
+ }
636
+ catch (error) {
637
+ if (timeoutId !== null) {
638
+ clearTimeout(timeoutId);
639
+ }
640
+ annotationSessions.delete(requestId);
641
+ throw error instanceof Error ? error : new Error("Annotation injection failed");
642
+ }
643
+ };
644
+ const cancelAnnotationSession = async (requestId, transport) => {
645
+ const session = annotationSessions.get(requestId);
646
+ if (!session) {
647
+ sendAnnotationResponse({
648
+ version: 1,
649
+ requestId,
650
+ status: "cancelled",
651
+ error: { code: "cancelled", message: "Annotation session not active." }
652
+ }, transport);
653
+ return;
654
+ }
655
+ clearTimeout(session.timeoutId);
656
+ annotationSessions.delete(requestId);
657
+ await sendMessageToTab(session.tabId, { type: "annotation:cancel", requestId });
658
+ sendAnnotationResponse({
659
+ version: 1,
660
+ requestId,
661
+ status: "cancelled",
662
+ error: { code: "cancelled", message: "Annotation cancelled." }
663
+ }, session.transport);
664
+ };
665
+ const validatePayloadSize = (payload) => {
666
+ const size = new TextEncoder().encode(JSON.stringify(payload)).length;
667
+ return size <= ANNOTATION_MAX_PAYLOAD_BYTES;
668
+ };
669
+ const generateAnnotationRequestId = () => {
670
+ return crypto.randomUUID();
671
+ };
672
+ const stripScreenshots = (payload) => {
673
+ const { screenshots, annotations, ...rest } = payload;
674
+ void screenshots;
675
+ return {
676
+ ...rest,
677
+ annotations: annotations.map((item) => {
678
+ const { screenshotId, ...restItem } = item;
679
+ void screenshotId;
680
+ return restItem;
681
+ })
682
+ };
683
+ };
684
+ const buildLastAnnotationMeta = (requestId, response, hasFullPayloadInMemory) => {
685
+ const payload = response.payload;
686
+ const annotationCount = payload ? payload.annotations.length : undefined;
687
+ const screenshotCount = payload?.screenshots?.length ?? 0;
688
+ return {
689
+ requestId,
690
+ status: response.status,
691
+ error: response.error,
692
+ url: payload?.url,
693
+ title: payload?.title,
694
+ timestamp: payload?.timestamp,
695
+ annotationCount,
696
+ screenshotCount: payload ? screenshotCount : undefined,
697
+ screenshotMode: payload?.screenshotMode,
698
+ storedAt: Date.now(),
699
+ hasScreenshots: screenshotCount > 0,
700
+ hasFullPayloadInMemory
701
+ };
702
+ };
703
+ const persistLastAnnotation = async (meta, payload) => {
704
+ await setStorage({
705
+ [LAST_ANNOTATION_META_KEY]: meta,
706
+ [LAST_ANNOTATION_PAYLOAD_KEY]: payload
707
+ });
708
+ };
709
+ const loadPersistedLastAnnotation = async () => {
710
+ const data = await new Promise((resolve) => {
711
+ chrome.storage.local.get([LAST_ANNOTATION_META_KEY, LAST_ANNOTATION_PAYLOAD_KEY], (items) => resolve(items));
712
+ });
713
+ const metaRecord = data[LAST_ANNOTATION_META_KEY];
714
+ const payloadRecord = data[LAST_ANNOTATION_PAYLOAD_KEY];
715
+ const meta = metaRecord && typeof metaRecord === "object" ? metaRecord : null;
716
+ const payload = payloadRecord && typeof payloadRecord === "object" ? payloadRecord : null;
717
+ return { meta, payload };
718
+ };
719
+ async function handleNativePortMessage(payload) {
720
+ if (!payload || typeof payload !== "object") {
721
+ return;
722
+ }
723
+ const record = payload;
724
+ if (record.type === "annotationCommand") {
725
+ await handleNativeAnnotationCommand(record);
726
+ }
727
+ }
728
+ const handleRelayAnnotationCommand = async (command, transport = "relay") => {
729
+ const payload = command.payload;
730
+ if (!payload || payload.version !== 1 || typeof payload.requestId !== "string") {
731
+ sendAnnotationResponse({
732
+ version: 1,
733
+ requestId: payload?.requestId ?? "unknown",
734
+ status: "error",
735
+ error: { code: "invalid_request", message: "Invalid annotation command." }
736
+ }, transport);
737
+ return;
738
+ }
739
+ if (payload.command === "cancel") {
740
+ await cancelAnnotationSession(payload.requestId, transport);
741
+ return;
742
+ }
743
+ try {
744
+ await startAnnotationSession(payload, transport);
745
+ sendAnnotationEvent({
746
+ version: 1,
747
+ requestId: payload.requestId,
748
+ event: "ready",
749
+ message: "Annotation session started."
750
+ }, transport);
751
+ }
752
+ catch (error) {
753
+ const detail = error instanceof Error ? error.message : "Annotation start failed.";
754
+ sendAnnotationResponse({
755
+ version: 1,
756
+ requestId: payload.requestId,
757
+ status: "error",
758
+ error: { code: "injection_failed", message: detail }
759
+ }, transport);
760
+ }
761
+ };
762
+ const handleNativeAnnotationCommand = async (command) => {
763
+ await handleRelayAnnotationCommand(command, "native");
764
+ };
765
+ const finalizeAnnotationSession = (requestId) => {
766
+ const session = annotationSessions.get(requestId);
767
+ if (!session)
42
768
  return null;
769
+ clearTimeout(session.timeoutId);
770
+ annotationSessions.delete(requestId);
771
+ return session;
772
+ };
773
+ const handleAnnotationComplete = (requestId, payload) => {
774
+ const session = finalizeAnnotationSession(requestId);
775
+ if (!session) {
776
+ return;
43
777
  }
778
+ if (!validatePayloadSize(payload)) {
779
+ const response = {
780
+ version: 1,
781
+ requestId,
782
+ status: "error",
783
+ error: { code: "payload_too_large", message: "Annotation payload exceeded size limits." }
784
+ };
785
+ const meta = buildLastAnnotationMeta(requestId, response, false);
786
+ lastAnnotationFull = null;
787
+ persistLastAnnotation(meta, null).catch((error) => {
788
+ logError("annotation.persist_payload_too_large", error, { code: "annotation_persist_failed" });
789
+ });
790
+ sendAnnotationResponse(response, session.transport);
791
+ return;
792
+ }
793
+ const response = {
794
+ version: 1,
795
+ requestId,
796
+ status: "ok",
797
+ payload
798
+ };
799
+ const meta = buildLastAnnotationMeta(requestId, response, true);
800
+ lastAnnotationFull = { meta, payload };
801
+ const storageMeta = { ...meta, hasFullPayloadInMemory: false };
802
+ const sanitizedPayload = stripScreenshots(payload);
803
+ persistLastAnnotation(storageMeta, sanitizedPayload).catch((error) => {
804
+ logError("annotation.persist_sanitized_payload", error, { code: "annotation_persist_failed" });
805
+ });
806
+ sendAnnotationResponse(response, session.transport);
807
+ };
808
+ const handleAnnotationError = (requestId, error) => {
809
+ const session = finalizeAnnotationSession(requestId);
810
+ if (!session)
811
+ return;
812
+ const response = {
813
+ version: 1,
814
+ requestId,
815
+ status: "error",
816
+ error
817
+ };
818
+ const meta = buildLastAnnotationMeta(requestId, response, false);
819
+ lastAnnotationFull = null;
820
+ persistLastAnnotation(meta, null).catch((error) => {
821
+ logError("annotation.persist_error_meta", error, { code: "annotation_persist_failed" });
822
+ });
823
+ sendAnnotationResponse(response, session.transport);
824
+ };
825
+ const handleAnnotationCancelled = (requestId) => {
826
+ const session = finalizeAnnotationSession(requestId);
827
+ if (!session)
828
+ return;
829
+ const response = {
830
+ version: 1,
831
+ requestId,
832
+ status: "cancelled",
833
+ error: { code: "cancelled", message: "Annotation cancelled." }
834
+ };
835
+ const meta = buildLastAnnotationMeta(requestId, response, false);
836
+ lastAnnotationFull = null;
837
+ persistLastAnnotation(meta, null).catch((error) => {
838
+ logError("annotation.persist_cancel_meta", error, { code: "annotation_persist_failed" });
839
+ });
840
+ sendAnnotationResponse(response, session.transport);
841
+ };
842
+ const captureVisibleTab = async (tab) => {
843
+ return await new Promise((resolve, reject) => {
844
+ chrome.tabs.captureVisibleTab(tab.windowId, { format: "png" }, (dataUrl) => {
845
+ const lastError = chrome.runtime.lastError;
846
+ if (lastError) {
847
+ reject(new Error(lastError.message));
848
+ return;
849
+ }
850
+ if (!dataUrl) {
851
+ reject(new Error("Capture failed"));
852
+ return;
853
+ }
854
+ resolve(dataUrl);
855
+ });
856
+ });
44
857
  };
45
858
  const fetchTokenFromPlugin = async (port) => {
46
859
  try {
@@ -52,42 +865,135 @@ const fetchTokenFromPlugin = async (port) => {
52
865
  return null;
53
866
  }
54
867
  const data = await response.json();
55
- return typeof data.token === "string" ? data.token : null;
868
+ if (typeof data.token !== "string") {
869
+ return null;
870
+ }
871
+ return {
872
+ token: data.token,
873
+ instanceId: typeof data.instanceId === "string" ? data.instanceId : null,
874
+ epoch: parseEpoch(data.epoch)
875
+ };
56
876
  }
57
- catch {
877
+ catch (error) {
878
+ logError("relay.token_fetch", error, { code: "relay_pair_fetch_failed", extra: { port } });
58
879
  return null;
59
880
  }
60
881
  };
882
+ const clearStoredRelayState = async () => {
883
+ await setStorage({
884
+ relayPort: null,
885
+ relayInstanceId: null,
886
+ relayEpoch: null,
887
+ pairingToken: null,
888
+ tokenEpoch: null
889
+ });
890
+ };
61
891
  const attemptAutoConnect = async () => {
62
892
  const data = await new Promise((resolve) => {
63
- chrome.storage.local.get(["autoConnect", "autoPair", "pairingEnabled", "pairingToken", "relayPort"], (items) => {
893
+ chrome.storage.local.get([
894
+ "autoConnect",
895
+ "autoPair",
896
+ "pairingEnabled",
897
+ "pairingToken",
898
+ "relayPort",
899
+ "relayInstanceId",
900
+ "relayEpoch",
901
+ "tokenEpoch",
902
+ "nativeEnabled"
903
+ ], (items) => {
64
904
  resolve(items);
65
905
  });
66
906
  });
67
907
  const autoConnect = typeof data.autoConnect === "boolean" ? data.autoConnect : DEFAULT_AUTO_CONNECT;
68
908
  if (!autoConnect || connection.getStatus() === "connected") {
909
+ clearRetry();
69
910
  return;
70
911
  }
71
912
  const autoPair = typeof data.autoPair === "boolean" ? data.autoPair : DEFAULT_AUTO_PAIR;
72
913
  const pairingEnabled = typeof data.pairingEnabled === "boolean" ? data.pairingEnabled : DEFAULT_PAIRING_ENABLED;
914
+ const storedRelayPort = parsePort(data.relayPort) ?? DEFAULT_RELAY_PORT;
915
+ let storedPairingToken = typeof data.pairingToken === "string" ? data.pairingToken : null;
916
+ nativeEnabled = typeof data.nativeEnabled === "boolean" ? data.nativeEnabled : DEFAULT_NATIVE_ENABLED;
917
+ if (typeof data.nativeEnabled !== "boolean") {
918
+ await setStorage({ nativeEnabled });
919
+ }
73
920
  if (autoPair && pairingEnabled) {
74
- const config = await fetchRelayConfig(DEFAULT_DISCOVERY_PORT);
75
- const relayPort = config?.relayPort ?? parsePort(data.relayPort) ?? DEFAULT_RELAY_PORT;
76
- if (config?.relayPort) {
77
- chrome.storage.local.set({ relayPort: config.relayPort });
921
+ let config = await fetchRelayConfig(DEFAULT_DISCOVERY_PORT);
922
+ if (!config && storedRelayPort !== DEFAULT_DISCOVERY_PORT) {
923
+ config = await fetchRelayConfig(storedRelayPort);
924
+ }
925
+ const storedRelayEpoch = parseEpoch(data.relayEpoch);
926
+ const storedRelayInstanceId = typeof data.relayInstanceId === "string" ? data.relayInstanceId : null;
927
+ const storedTokenEpoch = parseEpoch(data.tokenEpoch);
928
+ if (!config) {
929
+ setStatusNoteOverride("Relay config unreachable. Start the daemon and retry.");
930
+ scheduleRetry();
931
+ return;
932
+ }
933
+ const relayPort = config.relayPort ?? storedRelayPort;
934
+ const configEpoch = config.epoch ?? null;
935
+ const hasEpoch = config.epoch !== null;
936
+ if (config.relayPort) {
937
+ await setStorage({
938
+ relayPort: config.relayPort,
939
+ relayInstanceId: config.instanceId,
940
+ relayEpoch: config.epoch
941
+ });
942
+ }
943
+ if (hasEpoch && storedRelayEpoch !== null && storedRelayEpoch !== configEpoch) {
944
+ await clearStoredRelayState();
945
+ storedPairingToken = null;
946
+ setStatusNoteOverride("Relay restarted. Refresh the connection.");
78
947
  }
79
- const pairingRequired = config?.pairingRequired ?? true;
948
+ if (config.instanceId && storedRelayInstanceId && config.instanceId !== storedRelayInstanceId) {
949
+ await clearStoredRelayState();
950
+ storedPairingToken = null;
951
+ setStatusNoteOverride("Relay instance mismatch. Open the popup and click Connect.");
952
+ }
953
+ if (hasEpoch && storedTokenEpoch !== null && storedTokenEpoch !== configEpoch) {
954
+ await setStorage({ pairingToken: null, tokenEpoch: null });
955
+ storedPairingToken = null;
956
+ }
957
+ if (hasEpoch && storedTokenEpoch === null && storedPairingToken) {
958
+ await setStorage({ pairingToken: null, tokenEpoch: null });
959
+ storedPairingToken = null;
960
+ }
961
+ const pairingRequired = config.pairingRequired ?? true;
80
962
  if (pairingRequired) {
81
- const fetchedToken = await fetchTokenFromPlugin(relayPort);
82
- if (fetchedToken) {
83
- chrome.storage.local.set({ pairingToken: fetchedToken });
84
- }
85
- else {
86
- return;
963
+ if (!storedPairingToken) {
964
+ const fetched = await fetchTokenFromPlugin(relayPort);
965
+ if (!fetched) {
966
+ setStatusNoteOverride("Auto-pair failed. Start the daemon and retry.");
967
+ scheduleRetry();
968
+ return;
969
+ }
970
+ if (config.instanceId && fetched.instanceId && config.instanceId !== fetched.instanceId) {
971
+ console.warn("[opendevbrowser] Relay instance mismatch during auto-pair. Retrying later.");
972
+ setStatusNoteOverride("Relay instance mismatch. Open the popup and click Connect.");
973
+ return;
974
+ }
975
+ const tokenEpoch = fetched.epoch ?? configEpoch;
976
+ await setStorage({ pairingToken: fetched.token, tokenEpoch });
87
977
  }
88
978
  }
89
979
  }
90
980
  await connection.connect();
981
+ if (connection.getStatus() !== "connected") {
982
+ const nativeConnected = await attemptNativeConnect();
983
+ if (nativeConnected) {
984
+ setStatusNoteOverride("Connected via native host.");
985
+ clearRetry();
986
+ return;
987
+ }
988
+ if (nativeEnabled) {
989
+ setStatusNoteOverride(buildNativeHealthNote(nativePort.getHealth()));
990
+ }
991
+ scheduleRetry();
992
+ return;
993
+ }
994
+ nativePort.disconnect();
995
+ setStatusNoteOverride(null);
996
+ clearRetry();
91
997
  };
92
998
  const autoConnect = async () => {
93
999
  if (autoConnectInFlight) {
@@ -97,57 +1003,313 @@ const autoConnect = async () => {
97
1003
  try {
98
1004
  await attemptAutoConnect();
99
1005
  }
100
- catch {
1006
+ catch (error) {
1007
+ logError("auto_connect.attempt", error, { code: "auto_connect_failed" });
101
1008
  connection.disconnect();
1009
+ nativePort.disconnect();
102
1010
  }
103
1011
  finally {
104
1012
  autoConnectInFlight = false;
105
1013
  }
106
1014
  };
107
- connection.onStatus(updateBadge);
108
- updateBadge(connection.getStatus());
1015
+ connection.onStatus((status) => {
1016
+ const effectiveStatus = status === "connected" ? "connected" : (nativeEnabled && nativePort.isConnected()) ? "connected" : "disconnected";
1017
+ updateBadge(effectiveStatus);
1018
+ if (status === "connected") {
1019
+ nativePort.disconnect();
1020
+ setStatusNoteOverride(null);
1021
+ clearRetry();
1022
+ }
1023
+ if (status === "disconnected" && !nativePort.isConnected()) {
1024
+ for (const session of annotationSessions.values()) {
1025
+ clearTimeout(session.timeoutId);
1026
+ }
1027
+ annotationSessions.clear();
1028
+ }
1029
+ });
1030
+ updateBadge(getEffectiveStatus());
109
1031
  chrome.runtime.onStartup.addListener(() => {
110
- autoConnect().catch(() => { });
1032
+ autoConnect().catch((error) => {
1033
+ logError("auto_connect.startup", error, { code: "auto_connect_failed" });
1034
+ });
111
1035
  });
112
1036
  chrome.runtime.onInstalled.addListener(() => {
113
- autoConnect().catch(() => { });
1037
+ autoConnect().catch((error) => {
1038
+ logError("auto_connect.installed", error, { code: "auto_connect_failed" });
1039
+ });
1040
+ });
1041
+ if (chrome.alarms?.onAlarm) {
1042
+ chrome.alarms.onAlarm.addListener((alarm) => {
1043
+ if (alarm.name === RETRY_ALARM_NAME) {
1044
+ retryScheduled = false;
1045
+ autoConnect().catch((error) => {
1046
+ logError("auto_connect.alarm", error, { code: "auto_connect_failed" });
1047
+ });
1048
+ }
1049
+ });
1050
+ }
1051
+ if (chrome.commands?.onCommand) {
1052
+ chrome.commands.onCommand.addListener((command) => {
1053
+ if (command === "toggle-annotation") {
1054
+ toggleAnnotationUi().catch((error) => {
1055
+ logError("annotation.toggle", error, { code: "annotation_toggle_failed" });
1056
+ });
1057
+ }
1058
+ });
1059
+ }
1060
+ autoConnect().catch((error) => {
1061
+ logError("auto_connect.startup", error, { code: "auto_connect_failed" });
114
1062
  });
115
- autoConnect().catch(() => { });
116
1063
  chrome.storage.onChanged.addListener((changes, area) => {
117
1064
  if (area !== "local") {
118
1065
  return;
119
1066
  }
1067
+ if (changes.nativeEnabled) {
1068
+ nativeEnabled = changes.nativeEnabled.newValue === true;
1069
+ if (!nativeEnabled) {
1070
+ nativePort.disconnect();
1071
+ }
1072
+ updateBadge(getEffectiveStatus());
1073
+ }
120
1074
  if (changes.autoConnect?.newValue === true) {
121
- autoConnect().catch(() => { });
1075
+ autoConnect().catch((error) => {
1076
+ logError("auto_connect.setting", error, { code: "auto_connect_failed" });
1077
+ });
1078
+ }
1079
+ if (changes.pairingToken) {
1080
+ autoConnect().catch((error) => {
1081
+ logError("auto_connect.pairing_token", error, { code: "auto_connect_failed" });
1082
+ });
122
1083
  }
123
1084
  });
124
- chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
1085
+ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
125
1086
  const respond = (status) => {
126
1087
  sendResponse(status);
127
1088
  };
128
1089
  if (message.type === "status") {
129
- respond({ type: "status", status: connection.getStatus() });
1090
+ (async () => {
1091
+ respond(await buildStatusMessage());
1092
+ })().catch((error) => {
1093
+ logError("popup.status", error, { code: "status_failed" });
1094
+ respond({
1095
+ type: "status",
1096
+ status: connection.getStatus(),
1097
+ note: "Background unavailable"
1098
+ });
1099
+ });
130
1100
  return true;
131
1101
  }
132
1102
  if (message.type === "connect") {
133
1103
  (async () => {
134
1104
  await connection.connect();
135
- respond({ type: "status", status: connection.getStatus() });
136
- })().catch(() => {
1105
+ if (connection.getStatus() !== "connected") {
1106
+ await attemptNativeConnect();
1107
+ }
1108
+ respond(await buildStatusMessage());
1109
+ })().catch((error) => {
1110
+ logError("popup.connect", error, { code: "connect_failed" });
137
1111
  connection.disconnect();
138
- respond({ type: "status", status: connection.getStatus() });
1112
+ nativePort.disconnect();
1113
+ respond({
1114
+ type: "status",
1115
+ status: "disconnected",
1116
+ note: "Connect failed"
1117
+ });
139
1118
  });
140
1119
  return true;
141
1120
  }
142
1121
  if (message.type === "disconnect") {
143
1122
  (async () => {
144
1123
  await connection.disconnect();
145
- respond({ type: "status", status: connection.getStatus() });
146
- })().catch(() => {
1124
+ nativePort.disconnect();
1125
+ connection.clearLastError();
1126
+ respond(await buildStatusMessage());
1127
+ })().catch((error) => {
1128
+ logError("popup.disconnect", error, { code: "disconnect_failed" });
147
1129
  connection.disconnect();
148
- respond({ type: "status", status: connection.getStatus() });
1130
+ nativePort.disconnect();
1131
+ connection.clearLastError();
1132
+ respond({
1133
+ type: "status",
1134
+ status: "disconnected",
1135
+ note: "Disconnect failed"
1136
+ });
149
1137
  });
150
1138
  return true;
151
1139
  }
1140
+ if (message.type === "annotation:start") {
1141
+ (async () => {
1142
+ const requestId = generateAnnotationRequestId();
1143
+ const response = {
1144
+ type: "annotation:startResult",
1145
+ requestId,
1146
+ ok: false
1147
+ };
1148
+ try {
1149
+ const active = await getActiveTab();
1150
+ if (!active) {
1151
+ response.error = { code: "invalid_request", message: "No active tab available." };
1152
+ sendResponse(response);
1153
+ return;
1154
+ }
1155
+ const restricted = isRestrictedTab(active);
1156
+ if (restricted) {
1157
+ response.error = { code: "restricted_url", message: restricted };
1158
+ sendResponse(response);
1159
+ return;
1160
+ }
1161
+ if (typeof active.id !== "number") {
1162
+ response.error = { code: "invalid_request", message: "Target tab missing id." };
1163
+ sendResponse(response);
1164
+ return;
1165
+ }
1166
+ await startAnnotationSession({
1167
+ version: 1,
1168
+ requestId,
1169
+ command: "start",
1170
+ tabId: active.id,
1171
+ options: message.options
1172
+ }, "popup");
1173
+ if (!annotationSessions.has(requestId)) {
1174
+ response.error = { code: "injection_failed", message: "Annotation session failed to start." };
1175
+ sendResponse(response);
1176
+ return;
1177
+ }
1178
+ response.ok = true;
1179
+ sendResponse(response);
1180
+ }
1181
+ catch (error) {
1182
+ const detail = error instanceof Error ? error.message : "Annotation start failed.";
1183
+ response.error = { code: "injection_failed", message: detail };
1184
+ sendResponse(response);
1185
+ }
1186
+ })();
1187
+ return true;
1188
+ }
1189
+ if (message.type === "annotation:lastMeta") {
1190
+ (async () => {
1191
+ if (lastAnnotationFull) {
1192
+ const meta = { ...lastAnnotationFull.meta, hasFullPayloadInMemory: true };
1193
+ const response = { type: "annotation:lastMetaResult", meta };
1194
+ sendResponse(response);
1195
+ return;
1196
+ }
1197
+ const stored = await loadPersistedLastAnnotation();
1198
+ const meta = stored.meta ? { ...stored.meta, hasFullPayloadInMemory: false } : null;
1199
+ const response = { type: "annotation:lastMetaResult", meta };
1200
+ sendResponse(response);
1201
+ })().catch(() => {
1202
+ const response = { type: "annotation:lastMetaResult", meta: null };
1203
+ sendResponse(response);
1204
+ });
1205
+ return true;
1206
+ }
1207
+ if (message.type === "annotation:probe") {
1208
+ (async () => {
1209
+ const result = await probeAnnotationInjected();
1210
+ sendResponse({ type: "annotation:probeResult", ...result });
1211
+ })().catch((error) => {
1212
+ logError("annotation.probe", error, { code: "annotation_probe_failed" });
1213
+ sendResponse({ type: "annotation:probeResult", injected: false, detail: "Probe failed." });
1214
+ });
1215
+ return true;
1216
+ }
1217
+ if (message.type === "annotation:getPayload") {
1218
+ (async () => {
1219
+ if (message.includeScreenshots && lastAnnotationFull) {
1220
+ const response = {
1221
+ type: "annotation:payloadResult",
1222
+ payload: lastAnnotationFull.payload,
1223
+ meta: { ...lastAnnotationFull.meta, hasFullPayloadInMemory: true },
1224
+ source: "memory"
1225
+ };
1226
+ sendResponse(response);
1227
+ return;
1228
+ }
1229
+ const stored = await loadPersistedLastAnnotation();
1230
+ const storedMeta = stored.meta ? { ...stored.meta, hasFullPayloadInMemory: false } : null;
1231
+ if (message.includeScreenshots) {
1232
+ const response = {
1233
+ type: "annotation:payloadResult",
1234
+ payload: null,
1235
+ meta: storedMeta,
1236
+ source: "none",
1237
+ warning: "Full payload not available; screenshots may have been dropped."
1238
+ };
1239
+ sendResponse(response);
1240
+ return;
1241
+ }
1242
+ if (lastAnnotationFull) {
1243
+ const response = {
1244
+ type: "annotation:payloadResult",
1245
+ payload: stripScreenshots(lastAnnotationFull.payload),
1246
+ meta: { ...lastAnnotationFull.meta, hasFullPayloadInMemory: true },
1247
+ source: "memory"
1248
+ };
1249
+ sendResponse(response);
1250
+ return;
1251
+ }
1252
+ if (stored.payload) {
1253
+ const response = {
1254
+ type: "annotation:payloadResult",
1255
+ payload: stored.payload,
1256
+ meta: storedMeta,
1257
+ source: "storage"
1258
+ };
1259
+ sendResponse(response);
1260
+ return;
1261
+ }
1262
+ const response = {
1263
+ type: "annotation:payloadResult",
1264
+ payload: null,
1265
+ meta: storedMeta,
1266
+ source: "none",
1267
+ warning: "No stored annotation payload."
1268
+ };
1269
+ sendResponse(response);
1270
+ })().catch(() => {
1271
+ const response = {
1272
+ type: "annotation:payloadResult",
1273
+ payload: null,
1274
+ meta: null,
1275
+ source: "none",
1276
+ warning: "Background unavailable."
1277
+ };
1278
+ sendResponse(response);
1279
+ });
1280
+ return true;
1281
+ }
1282
+ if (message.type === "annotation:capture") {
1283
+ (async () => {
1284
+ const tab = sender.tab;
1285
+ if (!tab) {
1286
+ sendResponse({ ok: false, error: "No tab for capture" });
1287
+ return;
1288
+ }
1289
+ try {
1290
+ const dataUrl = await captureVisibleTab(tab);
1291
+ sendResponse({ ok: true, dataUrl });
1292
+ }
1293
+ catch (error) {
1294
+ sendResponse({ ok: false, error: error instanceof Error ? error.message : "Capture failed" });
1295
+ }
1296
+ })();
1297
+ return true;
1298
+ }
1299
+ if (message.type === "annotation:complete") {
1300
+ handleAnnotationComplete(message.requestId, message.payload);
1301
+ sendResponse({ ok: true });
1302
+ return true;
1303
+ }
1304
+ if (message.type === "annotation:cancelled") {
1305
+ handleAnnotationCancelled(message.requestId);
1306
+ sendResponse({ ok: true });
1307
+ return true;
1308
+ }
1309
+ if (message.type === "annotation:error") {
1310
+ handleAnnotationError(message.requestId, message.error);
1311
+ sendResponse({ ok: true });
1312
+ return true;
1313
+ }
152
1314
  return false;
153
1315
  });