Skip to content

Commit b001c88

Browse files
wolfieschclaude
andcommitted
feat(extension): complete extension bridge with protocol fixes
- Fix extension response format to use 'result' field (FGP protocol) - Add AtomicBool for sync connection state checking - Add browser.tabs.create, browser.tabs.query, browser.version methods - Add version tracking to extension (0.1.1) - Update CLI group command to chain tabs.group + tabGroups.update - Add call_daemon_raw() helper for multi-step CLI commands Extension bridge now fully working for: - Tab groups (create, update, query, collapse) - Real user cookies (any domain) - Desktop notifications - Query all user tabs Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 507e4ca commit b001c88

4 files changed

Lines changed: 207 additions & 65 deletions

File tree

extension/background.js

Lines changed: 47 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const FGP_WS_URL = 'ws://localhost:9223'; // FGP extension bridge port
1212
const RECONNECT_DELAY = 3000;
1313
const FGP_TAB_GROUP_NAME = 'FGP';
1414
const FGP_TAB_GROUP_COLOR = 'blue';
15+
const FGP_EXTENSION_VERSION = '0.1.1'; // Bump on protocol changes
1516

1617
let ws = null;
1718
let fgpTabGroupId = null;
@@ -36,7 +37,7 @@ function connect() {
3637
updateBadge('connected');
3738

3839
// Send hello message
39-
send({ type: 'hello', version: '0.1.0', capabilities: getCapabilities() });
40+
send({ type: 'hello', version: FGP_EXTENSION_VERSION, capabilities: getCapabilities() });
4041
};
4142

4243
ws.onmessage = async (event) => {
@@ -157,9 +158,11 @@ async function handleRequest(request) {
157158

158159
// === Utility ===
159160
case 'health':
160-
return { ok: true, status: 'healthy' };
161+
return { ok: true, result: { status: 'healthy' } };
161162
case 'capabilities':
162-
return { ok: true, capabilities: getCapabilities() };
163+
return { ok: true, result: getCapabilities() };
164+
case 'version':
165+
return { ok: true, result: { version: FGP_EXTENSION_VERSION } };
163166

164167
default:
165168
return { ok: false, error: `Unknown method: ${method}` };
@@ -180,7 +183,7 @@ async function handleTabCreate(params) {
180183
await addTabToFgpGroup(tab.id);
181184
}
182185

183-
return { ok: true, tab: serializeTab(tab) };
186+
return { ok: true, result: serializeTab(tab) };
184187
}
185188

186189
async function handleTabUpdate(params) {
@@ -192,31 +195,31 @@ async function handleTabUpdate(params) {
192195
if (muted !== undefined) updates.muted = muted;
193196

194197
const tab = await chrome.tabs.update(tabId, updates);
195-
return { ok: true, tab: serializeTab(tab) };
198+
return { ok: true, result: serializeTab(tab) };
196199
}
197200

198201
async function handleTabRemove(params) {
199202
const { tabId, tabIds } = params;
200203
const ids = tabIds || [tabId];
201204
await chrome.tabs.remove(ids);
202-
return { ok: true, removed: ids };
205+
return { ok: true, result: { removed: ids } };
203206
}
204207

205208
async function handleTabQuery(params) {
206209
const tabs = await chrome.tabs.query(params);
207-
return { ok: true, tabs: tabs.map(serializeTab) };
210+
return { ok: true, result: tabs.map(serializeTab) };
208211
}
209212

210213
async function handleTabGet(params) {
211214
const { tabId } = params;
212215
const tab = await chrome.tabs.get(tabId);
213-
return { ok: true, tab: serializeTab(tab) };
216+
return { ok: true, result: serializeTab(tab) };
214217
}
215218

216219
async function handleTabNavigate(params) {
217220
const { tabId, url } = params;
218221
const tab = await chrome.tabs.update(tabId, { url });
219-
return { ok: true, tab: serializeTab(tab) };
222+
return { ok: true, result: serializeTab(tab) };
220223
}
221224

222225
// ============================================================================
@@ -231,7 +234,7 @@ async function handleTabGroup(params) {
231234
if (createProperties) options.createProperties = createProperties;
232235

233236
const resultGroupId = await chrome.tabs.group(options);
234-
return { ok: true, groupId: resultGroupId };
237+
return { ok: true, result: { groupId: resultGroupId } };
235238
}
236239

237240
async function handleTabUngroup(params) {
@@ -248,12 +251,12 @@ async function handleTabGroupUpdate(params) {
248251
if (collapsed !== undefined) updates.collapsed = collapsed;
249252

250253
const group = await chrome.tabGroups.update(groupId, updates);
251-
return { ok: true, group };
254+
return { ok: true, result: group };
252255
}
253256

254257
async function handleTabGroupQuery(params) {
255258
const groups = await chrome.tabGroups.query(params);
256-
return { ok: true, groups };
259+
return { ok: true, result: groups };
257260
}
258261

259262
async function handleTabGroupCollapse(params) {
@@ -302,7 +305,7 @@ async function handleExecuteScript(params) {
302305
args
303306
});
304307

305-
return { ok: true, results: results.map(r => r.result) };
308+
return { ok: true, result: results.map(r => r.result) };
306309
}
307310

308311
async function handlePageSnapshot(params) {
@@ -314,7 +317,7 @@ async function handlePageSnapshot(params) {
314317
func: extractAriaTree
315318
});
316319

317-
return { ok: true, snapshot: results[0]?.result };
320+
return { ok: true, result: results[0]?.result };
318321
}
319322

320323
async function handlePageClick(params) {
@@ -331,7 +334,7 @@ async function handlePageClick(params) {
331334
args: [selector]
332335
});
333336

334-
return { ok: true, clicked: results[0]?.result };
337+
return { ok: true, result: { clicked: results[0]?.result } };
335338
}
336339

337340
async function handlePageFill(params) {
@@ -350,7 +353,7 @@ async function handlePageFill(params) {
350353
args: [selector, value]
351354
});
352355

353-
return { ok: true, filled: results[0]?.result };
356+
return { ok: true, result: { filled: results[0]?.result } };
354357
}
355358

356359
// ============================================================================
@@ -360,17 +363,17 @@ async function handlePageFill(params) {
360363
async function handleCookiesGet(params) {
361364
const { url, name } = params;
362365
const cookie = await chrome.cookies.get({ url, name });
363-
return { ok: true, cookie };
366+
return { ok: true, result: cookie };
364367
}
365368

366369
async function handleCookiesGetAll(params) {
367370
const cookies = await chrome.cookies.getAll(params);
368-
return { ok: true, cookies };
371+
return { ok: true, result: cookies };
369372
}
370373

371374
async function handleCookiesSet(params) {
372375
const cookie = await chrome.cookies.set(params);
373-
return { ok: true, cookie };
376+
return { ok: true, result: cookie };
374377
}
375378

376379
// ============================================================================
@@ -381,7 +384,7 @@ async function handleStorageGet(params) {
381384
const { keys, area = 'local' } = params;
382385
const storage = area === 'sync' ? chrome.storage.sync : chrome.storage.local;
383386
const data = await storage.get(keys);
384-
return { ok: true, data };
387+
return { ok: true, result: data };
385388
}
386389

387390
async function handleStorageSet(params) {
@@ -403,7 +406,7 @@ async function handleNotificationCreate(params) {
403406
title,
404407
message
405408
});
406-
return { ok: true, notificationId: id };
409+
return { ok: true, result: { notificationId: id } };
407410
}
408411

409412
// ============================================================================
@@ -472,6 +475,29 @@ function extractAriaTree() {
472475
return { nodes, element_count: nodes.length };
473476
}
474477

478+
// ============================================================================
479+
// Internal Message Handler (for popup communication)
480+
// ============================================================================
481+
482+
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
483+
if (message.type === 'getStatus') {
484+
sendResponse({ status: connectionStatus });
485+
return true;
486+
}
487+
488+
if (message.type === 'reconnect') {
489+
if (ws) {
490+
ws.close();
491+
}
492+
ws = null;
493+
connect();
494+
sendResponse({ ok: true });
495+
return true;
496+
}
497+
498+
return false;
499+
});
500+
475501
// ============================================================================
476502
// Initialize
477503
// ============================================================================

src/extension_bridge.rs

Lines changed: 48 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ use anyhow::{Context, Result};
1717
use futures::{SinkExt, StreamExt};
1818
use serde::{Deserialize, Serialize};
1919
use std::collections::HashMap;
20+
use std::sync::atomic::{AtomicBool, Ordering};
2021
use std::sync::Arc;
2122
use tokio::net::{TcpListener, TcpStream};
2223
use tokio::sync::{broadcast, mpsc, RwLock};
@@ -53,24 +54,37 @@ pub enum ConnectionState {
5354
}
5455

5556
/// Methods that should be routed to the extension (not CDP)
57+
/// These use `browser.` prefix to pass FGP namespace validation,
58+
/// but the actual Chrome Extension API method is extracted when calling.
5659
pub const EXTENSION_METHODS: &[&str] = &[
60+
// Tab Management (for grouping workflow)
61+
"browser.tabs.create",
62+
"browser.tabs.query",
5763
// Tab Groups (CDP can't do this!)
58-
"tabs.group",
59-
"tabs.ungroup",
60-
"tabGroups.update",
61-
"tabGroups.query",
62-
"tabGroups.collapse",
64+
"browser.tabs.group",
65+
"browser.tabs.ungroup",
66+
"browser.tabGroups.update",
67+
"browser.tabGroups.query",
68+
"browser.tabGroups.collapse",
6369
// Cookies (cleaner API than CDP)
64-
"cookies.get",
65-
"cookies.getAll",
66-
"cookies.set",
70+
"browser.cookies.get",
71+
"browser.cookies.getAll",
72+
"browser.cookies.set",
6773
// Notifications (extension-only)
68-
"notifications.create",
74+
"browser.notifications.create",
6975
// Storage (extension-only)
70-
"storage.get",
71-
"storage.set",
76+
"browser.storage.get",
77+
"browser.storage.set",
78+
// Utility
79+
"browser.version",
7280
];
7381

82+
/// Extract the Chrome Extension API method from a browser.* method
83+
/// e.g., "browser.tabs.group" -> "tabs.group"
84+
pub fn extension_method_name(method: &str) -> &str {
85+
method.strip_prefix("browser.").unwrap_or(method)
86+
}
87+
7488
/// Check if a method should be handled by the extension
7589
pub fn is_extension_method(method: &str) -> bool {
7690
EXTENSION_METHODS.iter().any(|m| *m == method)
@@ -80,6 +94,8 @@ pub fn is_extension_method(method: &str) -> bool {
8094
pub struct ExtensionBridge {
8195
/// Current connection state
8296
state: Arc<RwLock<ConnectionState>>,
97+
/// Atomic flag for sync access to connection state
98+
connected: Arc<AtomicBool>,
8399
/// Channel to send requests to extension
84100
request_tx: broadcast::Sender<ExtensionRequest>,
85101
/// Channel to receive responses from extension
@@ -99,6 +115,7 @@ impl ExtensionBridge {
99115

100116
Self {
101117
state: Arc::new(RwLock::new(ConnectionState::Disconnected)),
118+
connected: Arc::new(AtomicBool::new(false)),
102119
request_tx,
103120
response_tx,
104121
response_rx: Arc::new(RwLock::new(response_rx)),
@@ -117,6 +134,7 @@ impl ExtensionBridge {
117134
tracing::info!("Extension bridge listening on ws://{}", addr);
118135

119136
let state = self.state.clone();
137+
let connected = self.connected.clone();
120138
let request_tx = self.request_tx.clone();
121139
let response_tx = self.response_tx.clone();
122140
let pending = self.pending.clone();
@@ -128,13 +146,14 @@ impl ExtensionBridge {
128146
Ok((stream, peer)) => {
129147
tracing::info!("Extension connected from {}", peer);
130148
let state = state.clone();
149+
let connected = connected.clone();
131150
let request_rx = request_tx.subscribe();
132151
let response_tx = response_tx.clone();
133152
let pending = pending.clone();
134153

135154
tokio::spawn(async move {
136155
if let Err(e) =
137-
handle_connection(stream, state, request_rx, response_tx, pending)
156+
handle_connection(stream, state, connected, request_rx, response_tx, pending)
138157
.await
139158
{
140159
tracing::warn!("Extension connection error: {}", e);
@@ -170,17 +189,9 @@ impl ExtensionBridge {
170189
}
171190

172191
/// Blocking version of is_connected() for use from synchronous code
192+
/// Uses atomic bool for lock-free access
173193
pub fn is_connected_blocking(&self) -> bool {
174-
match tokio::runtime::Handle::try_current() {
175-
Ok(handle) => {
176-
tokio::task::block_in_place(|| handle.block_on(self.is_connected()))
177-
}
178-
Err(_) => {
179-
// Not in async context, check state directly (risky but okay for read)
180-
// This is a best-effort check
181-
false
182-
}
183-
}
194+
self.connected.load(Ordering::SeqCst)
184195
}
185196

186197
/// Send a request to the extension and wait for response
@@ -270,6 +281,7 @@ impl ExtensionBridge {
270281
async fn handle_connection(
271282
stream: TcpStream,
272283
state: Arc<RwLock<ConnectionState>>,
284+
connected: Arc<AtomicBool>,
273285
mut request_rx: broadcast::Receiver<ExtensionRequest>,
274286
response_tx: mpsc::Sender<ExtensionResponse>,
275287
_pending: Arc<RwLock<HashMap<String, tokio::sync::oneshot::Sender<ExtensionResponse>>>>,
@@ -280,8 +292,9 @@ async fn handle_connection(
280292

281293
let (mut ws_write, mut ws_read) = ws_stream.split();
282294

283-
// Mark as connected
295+
// Mark as connected (both async state and atomic flag)
284296
*state.write().await = ConnectionState::Connected;
297+
connected.store(true, Ordering::SeqCst);
285298
tracing::info!("Extension WebSocket connected");
286299

287300
// Handle incoming messages from extension
@@ -344,8 +357,9 @@ async fn handle_connection(
344357
_ = write_handle => {},
345358
}
346359

347-
// Mark as disconnected
360+
// Mark as disconnected (both async state and atomic flag)
348361
*state.write().await = ConnectionState::Disconnected;
362+
connected.store(false, Ordering::SeqCst);
349363
tracing::info!("Extension WebSocket disconnected");
350364

351365
Ok(())
@@ -357,10 +371,17 @@ mod tests {
357371

358372
#[test]
359373
fn test_is_extension_method() {
360-
assert!(is_extension_method("tabs.group"));
361-
assert!(is_extension_method("tabGroups.update"));
362-
assert!(is_extension_method("cookies.getAll"));
374+
assert!(is_extension_method("browser.tabs.group"));
375+
assert!(is_extension_method("browser.tabGroups.update"));
376+
assert!(is_extension_method("browser.cookies.getAll"));
363377
assert!(!is_extension_method("browser.open"));
364378
assert!(!is_extension_method("browser.snapshot"));
365379
}
380+
381+
#[test]
382+
fn test_extension_method_name() {
383+
assert_eq!(extension_method_name("browser.tabs.group"), "tabs.group");
384+
assert_eq!(extension_method_name("browser.cookies.getAll"), "cookies.getAll");
385+
assert_eq!(extension_method_name("tabs.group"), "tabs.group"); // Already stripped
386+
}
366387
}

0 commit comments

Comments
 (0)