@browserbridge/bbx 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +6 -4
  2. package/package.json +53 -53
  3. package/packages/agent-client/src/cli-helpers.js +43 -5
  4. package/packages/agent-client/src/cli.js +176 -171
  5. package/packages/agent-client/src/client.js +66 -21
  6. package/packages/agent-client/src/command-registry.js +104 -69
  7. package/packages/agent-client/src/detect.js +162 -54
  8. package/packages/agent-client/src/install.js +34 -28
  9. package/packages/agent-client/src/mcp-config.js +40 -40
  10. package/packages/agent-client/src/runtime.js +41 -20
  11. package/packages/agent-client/src/setup-status.js +23 -30
  12. package/packages/mcp-server/src/bin.js +57 -5
  13. package/packages/mcp-server/src/handlers.js +573 -256
  14. package/packages/mcp-server/src/server.js +568 -257
  15. package/packages/native-host/bin/bridge-daemon.js +39 -6
  16. package/packages/native-host/bin/install-manifest.js +26 -4
  17. package/packages/native-host/bin/postinstall.js +4 -2
  18. package/packages/native-host/src/config.js +142 -13
  19. package/packages/native-host/src/daemon-process.js +396 -0
  20. package/packages/native-host/src/daemon.js +350 -150
  21. package/packages/native-host/src/framing.js +131 -11
  22. package/packages/native-host/src/install-manifest.js +194 -29
  23. package/packages/native-host/src/native-host.js +154 -102
  24. package/packages/protocol/src/budget.js +3 -7
  25. package/packages/protocol/src/capabilities.js +6 -3
  26. package/packages/protocol/src/defaults.js +1 -0
  27. package/packages/protocol/src/errors.js +15 -11
  28. package/packages/protocol/src/payload-cost.js +19 -6
  29. package/packages/protocol/src/protocol.js +242 -73
  30. package/packages/protocol/src/registry.js +311 -45
  31. package/packages/protocol/src/summary.js +260 -109
  32. package/packages/protocol/src/types.js +29 -4
  33. package/skills/browser-bridge/SKILL.md +3 -2
  34. package/skills/browser-bridge/agents/openai.yaml +3 -3
  35. package/skills/browser-bridge/references/interaction.md +34 -11
  36. package/skills/browser-bridge/references/patch-workflow.md +3 -0
  37. package/skills/browser-bridge/references/protocol.md +127 -71
  38. package/skills/browser-bridge/references/tailwind.md +12 -11
  39. package/skills/browser-bridge/references/token-efficiency.md +23 -22
  40. package/skills/browser-bridge/references/ui-workflows.md +8 -0
  41. package/CHANGELOG.md +0 -55
  42. package/assets/banner.jpg +0 -0
  43. package/assets/logo.png +0 -0
  44. package/assets/logo.svg +0 -65
  45. package/docs/api-reference.md +0 -157
  46. package/docs/cli-guide.md +0 -128
  47. package/docs/index.md +0 -25
  48. package/docs/manual-setup.md +0 -140
  49. package/docs/mcp-vs-cli.md +0 -258
  50. package/docs/publishing.md +0 -114
  51. package/docs/quickstart.md +0 -104
  52. package/docs/troubleshooting.md +0 -59
  53. package/docs/usage-scenarios.md +0 -136
  54. package/manifest.json +0 -52
  55. package/packages/extension/assets/icon-128.png +0 -0
  56. package/packages/extension/assets/icon-16.png +0 -0
  57. package/packages/extension/assets/icon-32.png +0 -0
  58. package/packages/extension/assets/icon-48.png +0 -0
  59. package/packages/extension/src/background-helpers.js +0 -459
  60. package/packages/extension/src/background-routing.js +0 -91
  61. package/packages/extension/src/background.js +0 -3227
  62. package/packages/extension/src/content-script-helpers.js +0 -281
  63. package/packages/extension/src/content-script.js +0 -1977
  64. package/packages/extension/src/debugger-coordinator.js +0 -188
  65. package/packages/extension/src/sidepanel-helpers.js +0 -102
  66. package/packages/extension/ui/offscreen.html +0 -6
  67. package/packages/extension/ui/offscreen.js +0 -61
  68. package/packages/extension/ui/popup.html +0 -35
  69. package/packages/extension/ui/popup.js +0 -279
  70. package/packages/extension/ui/sidepanel.html +0 -102
  71. package/packages/extension/ui/sidepanel.js +0 -1854
  72. package/packages/extension/ui/ui.css +0 -1159
@@ -1,188 +0,0 @@
1
- // @ts-check
2
-
3
- /** @typedef {{ tabId: number }} DebuggerTarget */
4
- /** @typedef {(target: DebuggerTarget, protocolVersion: string) => Promise<void>} DebuggerAttach */
5
- /** @typedef {(target: DebuggerTarget) => Promise<void>} DebuggerDetach */
6
-
7
- /**
8
- * Serialize Chrome debugger sessions per tab so concurrent bridge requests do
9
- * not race on `chrome.debugger.attach`.
10
- */
11
- export class TabDebuggerCoordinator {
12
- /**
13
- * @param {{
14
- * attach: DebuggerAttach,
15
- * detach: DebuggerDetach,
16
- * protocolVersion?: string,
17
- * burstIdleMs?: number
18
- * }} options
19
- */
20
- constructor({ attach, detach, protocolVersion = '1.3', burstIdleMs = 5_000 }) {
21
- this.attach = attach;
22
- this.detach = detach;
23
- this.protocolVersion = protocolVersion;
24
- this.burstIdleMs = burstIdleMs;
25
- /** @type {Map<number, Promise<void>>} */
26
- this.pendingByTab = new Map();
27
- /** @type {Map<number, number>} */
28
- this.holdsByTab = new Map();
29
- /** @type {Map<number, ReturnType<typeof setTimeout>>} */
30
- this.burstTimers = new Map();
31
- }
32
-
33
- /**
34
- * Run one serialized operation for a tab.
35
- *
36
- * @template T
37
- * @param {number} tabId
38
- * @param {() => Promise<T>} task
39
- * @returns {Promise<T>}
40
- */
41
- async runExclusive(tabId, task) {
42
- const previous = this.pendingByTab.get(tabId) ?? Promise.resolve();
43
- /** @type {(value?: void | PromiseLike<void>) => void} */
44
- let releaseTurn = () => {};
45
- const turn = new Promise((resolve) => {
46
- releaseTurn = resolve;
47
- });
48
- const queuedTurn = previous.catch(() => {}).then(() => turn);
49
- this.pendingByTab.set(tabId, queuedTurn);
50
-
51
- await previous.catch(() => {});
52
-
53
- try {
54
- return await task();
55
- } finally {
56
- releaseTurn();
57
- if (this.pendingByTab.get(tabId) === queuedTurn) {
58
- this.pendingByTab.delete(tabId);
59
- }
60
- }
61
- }
62
-
63
- /**
64
- * Run one debugger-backed task for a tab once earlier tasks for that tab
65
- * have finished.
66
- *
67
- * @template T
68
- * @param {number} tabId
69
- * @param {(target: DebuggerTarget) => Promise<T>} task
70
- * @returns {Promise<T>}
71
- */
72
- async run(tabId, task) {
73
- return this.runExclusive(tabId, async () => {
74
- const target = { tabId };
75
- const held = (this.holdsByTab.get(tabId) ?? 0) > 0;
76
- const hasBurst = this.burstTimers.has(tabId);
77
- /** @type {T | undefined} */
78
- let result;
79
- /** @type {unknown} */
80
- let taskError = null;
81
-
82
- try {
83
- if (!held && !hasBurst) {
84
- await this.attach(target, this.protocolVersion);
85
- }
86
- result = await task(target);
87
- } catch (error) {
88
- taskError = error;
89
- }
90
-
91
- // Schedule a burst-idle detach instead of detaching immediately.
92
- if (!held) {
93
- this._resetBurstTimer(tabId, target);
94
- }
95
-
96
- if (taskError) {
97
- throw taskError;
98
- }
99
- return /** @type {T} */ (result);
100
- });
101
- }
102
-
103
- /**
104
- * Reset or start the burst idle timer for a tab. When it fires, detach
105
- * the debugger if no explicit hold is active.
106
- *
107
- * @param {number} tabId
108
- * @param {DebuggerTarget} target
109
- * @returns {void}
110
- */
111
- _resetBurstTimer(tabId, target) {
112
- const existing = this.burstTimers.get(tabId);
113
- if (existing) clearTimeout(existing);
114
- const timer = setTimeout(async () => {
115
- this.burstTimers.delete(tabId);
116
- if ((this.holdsByTab.get(tabId) ?? 0) > 0) return;
117
- try {
118
- await this.detach(target);
119
- } catch {
120
- // Already detached or tab closed.
121
- }
122
- }, this.burstIdleMs);
123
- timer.unref?.();
124
- this.burstTimers.set(tabId, timer);
125
- }
126
-
127
- /**
128
- * Attach and keep a debugger session alive across multiple runs for the same
129
- * tab. Nested holds are reference-counted.
130
- *
131
- * @param {number} tabId
132
- * @param {(target: DebuggerTarget) => Promise<void>} [initialize]
133
- * @returns {Promise<void>}
134
- */
135
- async acquire(tabId, initialize = async () => {}) {
136
- await this.runExclusive(tabId, async () => {
137
- const target = { tabId };
138
- const holdCount = this.holdsByTab.get(tabId) ?? 0;
139
- if (holdCount === 0) {
140
- let attached = false;
141
- try {
142
- await this.attach(target, this.protocolVersion);
143
- attached = true;
144
- await initialize(target);
145
- } catch (error) {
146
- if (attached) {
147
- await this.detach(target).catch(() => {});
148
- }
149
- throw error;
150
- }
151
- }
152
- this.holdsByTab.set(tabId, holdCount + 1);
153
- });
154
- }
155
-
156
- /**
157
- * Release one persistent debugger hold for a tab.
158
- *
159
- * @param {number} tabId
160
- * @param {(target: DebuggerTarget) => Promise<void>} [cleanup]
161
- * @returns {Promise<void>}
162
- */
163
- async release(tabId, cleanup = async () => {}) {
164
- await this.runExclusive(tabId, async () => {
165
- const target = { tabId };
166
- const holdCount = this.holdsByTab.get(tabId) ?? 0;
167
- if (holdCount === 0) {
168
- return;
169
- }
170
- if (holdCount > 1) {
171
- this.holdsByTab.set(tabId, holdCount - 1);
172
- return;
173
- }
174
-
175
- this.holdsByTab.delete(tabId);
176
- let cleanupError = null;
177
- try {
178
- await cleanup(target);
179
- } catch (error) {
180
- cleanupError = error;
181
- }
182
- await this.detach(target);
183
- if (cleanupError) {
184
- throw cleanupError;
185
- }
186
- });
187
- }
188
- }
@@ -1,102 +0,0 @@
1
- // @ts-check
2
-
3
- /**
4
- * @typedef {{
5
- * configured: boolean
6
- * }} McpClientInstallState
7
- */
8
-
9
- /**
10
- * @typedef {{
11
- * exists: boolean
12
- * }} SkillInstallState
13
- */
14
-
15
- /**
16
- * @typedef {{
17
- * skills: SkillInstallState[]
18
- * }} SkillTargetInstallState
19
- */
20
-
21
- /**
22
- * @typedef {{
23
- * mcpClients: McpClientInstallState[],
24
- * skillTargets: SkillTargetInstallState[]
25
- * }} SetupStatusInstallState
26
- */
27
-
28
- /**
29
- * @param {SetupStatusInstallState} setupStatus
30
- * @returns {{ hasConfiguredMcp: boolean, hasInstalledCliSkill: boolean }}
31
- */
32
- function getSetupInstallState(setupStatus) {
33
- const hasConfiguredMcp = setupStatus.mcpClients.some((client) => client.configured);
34
- const hasInstalledCliSkill = setupStatus.skillTargets.some((target) => target.skills.some((skill) => skill.exists));
35
- return { hasConfiguredMcp, hasInstalledCliSkill };
36
- }
37
-
38
- /**
39
- * Auto-expand Host Setup when the panel opens into a completely unconfigured
40
- * machine: no MCP clients configured and no CLI skill present anywhere.
41
- *
42
- * @param {SetupStatusInstallState | null} setupStatus
43
- * @returns {boolean}
44
- */
45
- export function shouldAutoExpandHostSetup(setupStatus) {
46
- if (!setupStatus) {
47
- return false;
48
- }
49
-
50
- const { hasConfiguredMcp, hasInstalledCliSkill } = getSetupInstallState(setupStatus);
51
- if (hasConfiguredMcp) {
52
- return false;
53
- }
54
- return !hasInstalledCliSkill;
55
- }
56
-
57
- /**
58
- * Pick which prompt-example set to show in the side panel.
59
- *
60
- * - `mcp`: MCP is installed, CLI skill is not.
61
- * - `cli`: CLI skill is installed, MCP is not.
62
- * - `grouped`: neither is installed, or both are installed.
63
- *
64
- * @param {SetupStatusInstallState | null} setupStatus
65
- * @returns {'mcp' | 'cli' | 'grouped'}
66
- */
67
- export function getPromptExamplesMode(setupStatus) {
68
- if (!setupStatus) {
69
- return 'grouped';
70
- }
71
-
72
- const { hasConfiguredMcp, hasInstalledCliSkill } = getSetupInstallState(setupStatus);
73
- if (hasConfiguredMcp && !hasInstalledCliSkill) {
74
- return 'mcp';
75
- }
76
- if (hasInstalledCliSkill && !hasConfiguredMcp) {
77
- return 'cli';
78
- }
79
- return 'grouped';
80
- }
81
-
82
- /**
83
- * Pick the activity source tag to display in the side panel. Prefer explicit
84
- * request metadata, but fall back to setup state when only one host path is
85
- * configured so older log entries stay understandable.
86
- *
87
- * @param {string | null | undefined} source
88
- * @param {SetupStatusInstallState | null} setupStatus
89
- * @returns {'' | 'cli' | 'mcp'}
90
- */
91
- export function getActivitySourceTag(source, setupStatus) {
92
- if (source === 'cli' || source === 'mcp') {
93
- return source;
94
- }
95
-
96
- const promptMode = getPromptExamplesMode(setupStatus);
97
- if (promptMode === 'cli' || promptMode === 'mcp') {
98
- return promptMode;
99
- }
100
-
101
- return '';
102
- }
@@ -1,6 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <body>
4
- <script type="module" src="./offscreen.js"></script>
5
- </body>
6
- </html>
@@ -1,61 +0,0 @@
1
- // @ts-check
2
-
3
- /**
4
- * @typedef {{
5
- * type?: string,
6
- * image?: string,
7
- * rect?: { x: number, y: number, width: number, height: number }
8
- * }} CropMessage
9
- */
10
-
11
- chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
12
- if (message?.type !== 'bridge.crop-image') {
13
- return false;
14
- }
15
-
16
- const typedMessage = /** @type {CropMessage} */ (message);
17
- crop(typedMessage.image || '', typedMessage.rect || { x: 0, y: 0, width: 1, height: 1 }).then(sendResponse);
18
- return true;
19
- });
20
-
21
- /**
22
- * @param {string} imageUrl
23
- * @param {{ x: number, y: number, width: number, height: number }} rect
24
- * @returns {Promise<string>}
25
- */
26
- async function crop(imageUrl, rect) {
27
- const response = await fetch(imageUrl);
28
- const blob = await response.blob();
29
- const bitmap = await createImageBitmap(blob);
30
-
31
- // Clamp crop rect to bitmap bounds to prevent out-of-bounds draws
32
- const x = Math.max(0, Math.min(rect.x, bitmap.width - 1));
33
- const y = Math.max(0, Math.min(rect.y, bitmap.height - 1));
34
- const w = Math.max(1, Math.min(rect.width, bitmap.width - x));
35
- const h = Math.max(1, Math.min(rect.height, bitmap.height - y));
36
-
37
- const canvas = new OffscreenCanvas(w, h);
38
- const context = canvas.getContext('2d');
39
- if (!context) {
40
- throw new Error('Failed to create 2D offscreen canvas context.');
41
- }
42
- context.drawImage(bitmap, x, y, w, h, 0, 0, w, h);
43
- bitmap.close();
44
- const croppedBlob = await canvas.convertToBlob({ type: 'image/png' });
45
- return blobToDataUrl(croppedBlob);
46
- }
47
-
48
- /**
49
- * @param {Blob} blob
50
- * @returns {Promise<string>}
51
- */
52
- async function blobToDataUrl(blob) {
53
- const arrayBuffer = await blob.arrayBuffer();
54
- const bytes = new Uint8Array(arrayBuffer);
55
- const chunks = [];
56
- for (let i = 0; i < bytes.length; i += 8192) {
57
- chunks.push(String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + 8192))));
58
- }
59
- const base64 = btoa(chunks.join(''));
60
- return `data:${blob.type};base64,${base64}`;
61
- }
@@ -1,35 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>Browser Bridge</title>
7
- <link rel="stylesheet" href="./ui.css">
8
- </head>
9
- <body>
10
- <main class="panel panel-popup">
11
- <header class="panel-header">
12
- <h1 class="popup-title">Browser Bridge</h1>
13
- <div class="control-links">
14
- <a class="setup-link" href="https://github.com/koltyakov/browser-bridge" target="_blank"
15
- rel="noopener noreferrer">GitHub ↗</a>
16
- <a class="setup-link" href="https://github.com/koltyakov/browser-bridge/blob/main/PRIVACY.md"
17
- target="_blank" rel="noopener noreferrer">Privacy policy ↗</a>
18
- </div>
19
- </header>
20
- <section class="card control-card popup-control-card">
21
- <div class="control-copy">
22
- <div id="popup-access-eyebrow" class="eyebrow">Window access</div>
23
- <p id="popup-access-detail" class="control-detail">
24
- Enable Browser Bridge to let your connected agent inspect and interact with pages in this Chrome window.
25
- </p>
26
- <p id="popup-disclosure" class="control-detail control-disclosure">
27
- When enabled, Browser Bridge may access page content, styles and layout, console output, network metadata, screenshots, and browser storage when requested.
28
- </p>
29
- </div>
30
- <button id="communication-action" class="popup-action popup-power-button" type="button">Enable Window Access</button>
31
- </section>
32
- </main>
33
- <script type="module" src="./popup.js"></script>
34
- </body>
35
- </html>
@@ -1,279 +0,0 @@
1
- // @ts-check
2
-
3
- /**
4
- * @typedef {{
5
- * tabId: number,
6
- * windowId: number,
7
- * title: string,
8
- * url: string,
9
- * enabled: boolean,
10
- * accessRequested: boolean,
11
- * restricted: boolean
12
- * }} PopupCurrentTab
13
- */
14
-
15
- /**
16
- * @typedef {{
17
- * type: 'state.sync',
18
- * state: {
19
- * nativeConnected: boolean,
20
- * currentTab: PopupCurrentTab | null
21
- * }
22
- * }} PopupStateMessage
23
- */
24
-
25
- const nativeIndicator = /** @type {HTMLSpanElement} */ (document.getElementById('native-indicator'));
26
- const button = /** @type {HTMLButtonElement} */ (document.getElementById('communication-action'));
27
- const accessEyebrow = /** @type {HTMLDivElement} */ (document.getElementById('popup-access-eyebrow'));
28
- const accessDetail = /** @type {HTMLParagraphElement} */ (document.getElementById('popup-access-detail'));
29
- const accessDisclosure = /** @type {HTMLParagraphElement} */ (document.getElementById('popup-disclosure'));
30
- const controlCard = /** @type {HTMLElement | null} */ (document.querySelector('.popup-control-card'));
31
- const windowedPopup = isWindowedPopup();
32
- /** @type {number | null} */
33
- let popupScopeTabId = null;
34
-
35
- /** @param {PopupStateMessage} message */
36
- function handlePopupMessage(message) {
37
- if (message.type === 'state.sync') {
38
- renderNativeStatus(message.state.nativeConnected);
39
- renderPopupState(message.state.currentTab);
40
- }
41
- }
42
-
43
- /** @type {chrome.runtime.Port} */
44
- let port;
45
-
46
- /**
47
- * @returns {Promise<number | null>}
48
- */
49
- async function resolveInitialScopeTabId() {
50
- const explicitScopeTabId = readScopedTabId();
51
- if (explicitScopeTabId) {
52
- return explicitScopeTabId;
53
- }
54
-
55
- try {
56
- const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
57
- return typeof activeTab?.id === 'number' ? activeTab.id : null;
58
- } catch {
59
- return null;
60
- }
61
- }
62
-
63
- /**
64
- * @returns {Promise<void>}
65
- */
66
- async function connectPopupPort() {
67
- popupScopeTabId = await resolveInitialScopeTabId();
68
- const nextPort = chrome.runtime.connect({ name: 'ui-popup' });
69
- nextPort.onMessage.addListener(handlePopupMessage);
70
- nextPort.postMessage({
71
- type: 'state.request',
72
- ...(popupScopeTabId ? { scopeTabId: popupScopeTabId } : {})
73
- });
74
- port = nextPort;
75
- }
76
-
77
- void connectPopupPort();
78
- /** @type {PopupCurrentTab | null} */
79
- let currentTabState = null;
80
- /** @type {number | null} */
81
- let resizeFrameId = null;
82
- /** @type {ReturnType<typeof setTimeout> | null} */
83
- let nativeDiagnosticTimer = null;
84
- const NATIVE_DIAGNOSTIC_DELAY_MS = 10_000;
85
- const PUBLISHED_EXTENSION_ID = 'jjjkmmcdkpcgamlopogicbnnhdgebhie';
86
-
87
- if (windowedPopup) {
88
- document.documentElement.dataset.windowed = 'true';
89
- document.body.dataset.windowed = 'true';
90
- window.addEventListener('load', queueWindowResize);
91
- }
92
-
93
- button.addEventListener('click', () => {
94
- if (!currentTabState || button.dataset.pending === 'true') {
95
- return;
96
- }
97
- button.dataset.pending = 'true';
98
- button.textContent = currentTabState.enabled ? 'Disabling\u2026' : 'Enabling\u2026';
99
- setCommunicationEnabled(!currentTabState.enabled);
100
- window.close();
101
- });
102
-
103
- /**
104
- * @param {PopupCurrentTab | null} currentTab
105
- * @returns {void}
106
- */
107
- function renderPopupState(currentTab) {
108
- currentTabState = currentTab;
109
-
110
- if (!currentTab) {
111
- accessEyebrow.textContent = 'Window access unavailable';
112
- accessDetail.textContent = 'Open a normal web page to manage Browser Bridge for this Chrome window.';
113
- accessDisclosure.hidden = false;
114
- button.textContent = 'Enable Window Access';
115
- button.disabled = true;
116
- controlCard?.classList.remove('attention');
117
- return;
118
- }
119
-
120
- accessDisclosure.hidden = currentTab.enabled;
121
-
122
- if (currentTab.enabled && currentTab.restricted) {
123
- accessEyebrow.textContent = 'Window access enabled';
124
- accessDetail.textContent = 'This page cannot be interacted with. Switch to a normal web page to use Browser Bridge.';
125
- accessDisclosure.hidden = false;
126
- } else if (currentTab.enabled) {
127
- accessEyebrow.textContent = 'Window access enabled';
128
- accessDetail.textContent = 'Your connected agent can inspect and interact with pages in this Chrome window.';
129
- } else if (currentTab.accessRequested) {
130
- accessEyebrow.textContent = 'Window access requested';
131
- accessDetail.textContent = 'An agent requested access for this Chrome window. Enable it to allow page inspection and interaction.';
132
- } else {
133
- accessEyebrow.textContent = 'Window access';
134
- accessDetail.textContent = 'Enable Browser Bridge to let your connected agent inspect and interact with pages in this Chrome window.';
135
- }
136
-
137
- button.textContent = currentTab.enabled ? 'Disable Window Access' : 'Enable Window Access';
138
- button.disabled = !currentTab.url;
139
- controlCard?.classList.toggle('attention', currentTab.accessRequested && !currentTab.enabled);
140
- queueWindowResize();
141
- }
142
-
143
- /**
144
- * @param {boolean} connected
145
- * @returns {void}
146
- */
147
- function renderNativeStatus(connected) {
148
- if (!nativeIndicator) return;
149
- const label = connected
150
- ? 'Native host connected'
151
- : 'Native host disconnected';
152
- nativeIndicator.dataset.connected = String(connected);
153
- nativeIndicator.title = label;
154
- nativeIndicator.setAttribute('aria-label', label);
155
-
156
- if (connected) {
157
- if (nativeDiagnosticTimer) {
158
- clearTimeout(nativeDiagnosticTimer);
159
- nativeDiagnosticTimer = null;
160
- }
161
- hideDiagnostic();
162
- } else if (!nativeDiagnosticTimer) {
163
- nativeDiagnosticTimer = setTimeout(() => {
164
- nativeDiagnosticTimer = null;
165
- showDiagnostic(`Native host unreachable. Run: npm install -g @browserbridge/bbx && ${getInstallCommand()}`);
166
- }, NATIVE_DIAGNOSTIC_DELAY_MS);
167
- }
168
- }
169
-
170
- /**
171
- * @param {string} message
172
- * @returns {void}
173
- */
174
- function showDiagnostic(message) {
175
- let el = document.getElementById('native-diagnostic');
176
- if (!el) {
177
- el = document.createElement('div');
178
- el.id = 'native-diagnostic';
179
- el.style.cssText = 'padding:8px 12px;margin:8px 0;background:var(--status-badge-bg,#fef3cd);color:var(--text-primary,#856404);border-radius:6px;font-size:12px;line-height:1.4';
180
- const container = document.querySelector('.popup-content') || document.body;
181
- container.prepend(el);
182
- }
183
- el.textContent = message;
184
- el.hidden = false;
185
- queueWindowResize();
186
- }
187
-
188
- function hideDiagnostic() {
189
- const el = document.getElementById('native-diagnostic');
190
- if (el) {
191
- el.hidden = true;
192
- queueWindowResize();
193
- }
194
- }
195
-
196
- /**
197
- * @param {boolean} enabled
198
- * @returns {void}
199
- */
200
- function setCommunicationEnabled(enabled) {
201
- const scopedTabId = currentTabState?.tabId ?? popupScopeTabId;
202
- port.postMessage({
203
- type: 'scope.set_enabled',
204
- enabled,
205
- ...(scopedTabId ? { tabId: scopedTabId } : {})
206
- });
207
- }
208
-
209
- /**
210
- * @returns {number | null}
211
- */
212
- function readScopedTabId() {
213
- const value = new URLSearchParams(window.location.search).get('tabId');
214
- const tabId = Number(value);
215
- return Number.isFinite(tabId) && tabId > 0 ? tabId : null;
216
- }
217
-
218
- /**
219
- * @returns {boolean}
220
- */
221
- function isWindowedPopup() {
222
- return new URLSearchParams(window.location.search).get('windowed') === '1';
223
- }
224
-
225
- /**
226
- * @returns {string}
227
- */
228
- function getInstallCommand() {
229
- return chrome.runtime.id === PUBLISHED_EXTENSION_ID
230
- ? 'bbx install'
231
- : `bbx install ${chrome.runtime.id}`;
232
- }
233
-
234
- /**
235
- * @returns {void}
236
- */
237
- function queueWindowResize() {
238
- if (!windowedPopup || resizeFrameId != null) {
239
- return;
240
- }
241
- resizeFrameId = window.requestAnimationFrame(() => {
242
- resizeFrameId = null;
243
- void resizeWindowToContent();
244
- });
245
- }
246
-
247
- /**
248
- * @returns {Promise<void>}
249
- */
250
- async function resizeWindowToContent() {
251
- if (!windowedPopup) {
252
- return;
253
- }
254
-
255
- const panel = /** @type {HTMLElement | null} */ (document.querySelector('.panel-popup'));
256
- const panelRect = panel?.getBoundingClientRect();
257
- const contentWidth = Math.ceil(panelRect?.width ?? document.body.getBoundingClientRect().width);
258
- const contentHeight = Math.ceil(panelRect?.height ?? document.body.getBoundingClientRect().height);
259
- const frameWidth = Math.max(window.outerWidth - window.innerWidth, 0);
260
- const frameHeight = Math.max(window.outerHeight - window.innerHeight, 0);
261
- const targetWidth = Math.min(Math.max(contentWidth + frameWidth + 2, 420), 560);
262
- const targetHeight = Math.min(Math.max(contentHeight + frameHeight + 2, 180), 520);
263
- const currentWindow = await chrome.windows.getCurrent();
264
- if (currentWindow.id == null) {
265
- return;
266
- }
267
-
268
- /** @type {chrome.windows.UpdateInfo} */
269
- const updateInfo = {
270
- width: targetWidth,
271
- height: targetHeight
272
- };
273
-
274
- if (typeof currentWindow.left === 'number' && typeof currentWindow.width === 'number') {
275
- updateInfo.left = currentWindow.left + currentWindow.width - targetWidth;
276
- }
277
-
278
- await chrome.windows.update(currentWindow.id, updateInfo);
279
- }