omni_dev/daemon/services/
bridge.rs1use std::path::{Path, PathBuf};
11use std::sync::{Arc, Mutex as StdMutex, MutexGuard};
12
13use anyhow::{anyhow, bail, Context, Result};
14use async_trait::async_trait;
15use serde_json::{json, Value};
16
17use crate::browser::protocol::StatusResponse;
18use crate::browser::{auth, snippet, BridgeConfig, BridgeServer};
19use crate::daemon::paths;
20use crate::daemon::service::{DaemonService, MenuAction, MenuItem, MenuSnapshot, ServiceStatus};
21
22pub const SERVICE_NAME: &str = "browser-bridge";
24
25const TOKEN_LABEL_VISIBLE_CHARS: usize = 4;
29
30pub struct BridgeService {
33 inner: StdMutex<Option<BridgeServer>>,
35 config: BridgeConfig,
36 token: Arc<String>,
37 token_path: PathBuf,
38}
39
40impl BridgeService {
41 pub async fn start(
44 config: BridgeConfig,
45 token_file: Option<&Path>,
46 token_path: PathBuf,
47 ) -> Result<Self> {
48 let token = auth::resolve_token(token_file)?;
49 write_token(&token_path, &token)?;
50 let server = BridgeServer::start(config.clone(), token.clone()).await?;
51 Ok(Self {
52 inner: StdMutex::new(Some(server)),
53 config,
54 token: Arc::new(token),
55 token_path,
56 })
57 }
58
59 fn lock(&self) -> MutexGuard<'_, Option<BridgeServer>> {
61 self.inner
62 .lock()
63 .unwrap_or_else(std::sync::PoisonError::into_inner)
64 }
65
66 async fn restart(&self) -> Result<()> {
78 let old = self.lock().take();
79 if let Some(server) = old {
80 server.shutdown().await;
81 }
82 let server = BridgeServer::start(self.config.clone(), (*self.token).clone()).await?;
83 *self.lock() = Some(server);
84 Ok(())
85 }
86}
87
88fn write_token(path: &Path, token: &str) -> Result<()> {
90 if let Some(parent) = path.parent() {
91 paths::ensure_dir_0700(parent)?;
92 }
93 std::fs::write(path, token)
94 .with_context(|| format!("failed to write token file {}", path.display()))?;
95 paths::set_file_0600(path)?;
96 Ok(())
97}
98
99fn masked_key_label(token: &str) -> String {
105 let len = token.chars().count();
106 if len > TOKEN_LABEL_VISIBLE_CHARS * 2 {
107 let tail: String = token
108 .chars()
109 .skip(len - TOKEN_LABEL_VISIBLE_CHARS)
110 .collect();
111 format!("Key: \u{2022}\u{2022}\u{2022}\u{2022}{tail}")
112 } else {
113 "Key: \u{2022}\u{2022}\u{2022}\u{2022}".to_string()
114 }
115}
116
117fn summarize(status: &StatusResponse, control_port: u16, ws_port: u16) -> String {
119 if status.connected {
120 format!(
121 "{} tab(s), {} pending (control :{control_port}, ws :{ws_port})",
122 status.tabs.len(),
123 status.pending
124 )
125 } else {
126 format!("no tab connected (control :{control_port}, ws :{ws_port})")
127 }
128}
129
130#[async_trait]
131impl DaemonService for BridgeService {
132 fn name(&self) -> &'static str {
133 SERVICE_NAME
134 }
135
136 async fn handle(&self, op: &str, payload: Value) -> Result<Value> {
137 match op {
138 "status" => {
139 let snapshot = self.lock().as_ref().map(BridgeServer::status);
140 match snapshot {
141 Some(status) => Ok(serde_json::to_value(status)?),
142 None => Ok(json!({ "running": false })),
143 }
144 }
145 "disconnect-tab" => {
146 let id = payload
147 .get("id")
148 .and_then(Value::as_u64)
149 .ok_or_else(|| anyhow!("`disconnect-tab` requires a numeric `id`"))?;
150 let guard = self.lock();
151 let server = guard
152 .as_ref()
153 .ok_or_else(|| anyhow!("bridge is not running"))?;
154 server.disconnect_tab(id)?;
155 Ok(json!({ "disconnected": id }))
156 }
157 "restart" => {
158 self.restart().await?;
159 Ok(json!({ "restarted": true }))
160 }
161 "snippet" => {
162 let ws_port = self.lock().as_ref().map(BridgeServer::ws_port);
167 match ws_port {
168 Some(port) => Ok(json!({ "snippet": snippet::render(port, &self.token) })),
169 None => bail!("bridge is not running"),
170 }
171 }
172 "token" => {
173 Ok(json!({ "token": self.token.as_str() }))
177 }
178 "request-command" => {
179 let control_port = self.lock().as_ref().map(BridgeServer::control_port);
184 match control_port {
185 Some(port) => Ok(json!({
186 "command": format!(
187 "{}='{}' omni-dev browser bridge request --control-port {port} --url /",
188 auth::TOKEN_ENV, self.token
189 )
190 })),
191 None => bail!("bridge is not running"),
192 }
193 }
194 other => bail!("unknown browser-bridge op: {other}"),
195 }
196 }
197
198 fn menu(&self) -> MenuSnapshot {
199 let info = self
200 .lock()
201 .as_ref()
202 .map(|s| (s.status(), s.control_port(), s.ws_port()));
203 let items = match info {
204 Some((status, _control, _ws)) => {
205 let line = if status.connected {
206 let origins: Vec<&str> = status
207 .tabs
208 .iter()
209 .filter_map(|t| t.origin.as_deref())
210 .collect();
211 if origins.is_empty() {
212 format!(
213 "Connected — {} tab(s) — {} pending",
214 status.tabs.len(),
215 status.pending
216 )
217 } else {
218 format!(
219 "Connected — {} — {} pending",
220 origins.join(", "),
221 status.pending
222 )
223 }
224 } else {
225 "No tab connected".to_string()
226 };
227 let mut items = vec![
228 MenuItem::Label(line),
229 MenuItem::Label(masked_key_label(&self.token)),
230 MenuItem::Separator,
231 ];
232 items.push(MenuItem::Action(MenuAction {
233 id: "copy-key".to_string(),
234 label: "Copy bridge key".to_string(),
235 enabled: true,
236 }));
237 items.push(MenuItem::Action(MenuAction {
238 id: "copy-snippet".to_string(),
239 label: "Copy console snippet".to_string(),
240 enabled: true,
241 }));
242 items.push(MenuItem::Action(MenuAction {
243 id: "copy-request".to_string(),
244 label: "Copy request command".to_string(),
245 enabled: true,
246 }));
247 for tab in &status.tabs {
248 items.push(MenuItem::Action(MenuAction {
249 id: format!("disconnect-tab:{}", tab.id),
250 label: format!("Disconnect tab {}", tab.id),
251 enabled: true,
252 }));
253 }
254 items.push(MenuItem::Action(MenuAction {
255 id: "restart-bridge".to_string(),
256 label: "Restart bridge".to_string(),
257 enabled: true,
258 }));
259 items
260 }
261 None => vec![MenuItem::Label("Not running".to_string())],
262 };
263 MenuSnapshot {
264 title: "Browser Bridge".to_string(),
265 items,
266 }
267 }
268
269 async fn menu_action(&self, action_id: &str) -> Result<()> {
270 if action_id == "restart-bridge" {
271 return self.restart().await;
272 }
273 if let Some(id_str) = action_id.strip_prefix("disconnect-tab:") {
274 let id: u64 = id_str
275 .parse()
276 .with_context(|| format!("invalid tab id in action {action_id}"))?;
277 let guard = self.lock();
278 let server = guard
279 .as_ref()
280 .ok_or_else(|| anyhow!("bridge is not running"))?;
281 return server.disconnect_tab(id);
282 }
283 bail!("unknown browser-bridge menu action: {action_id}")
284 }
285
286 async fn status(&self) -> ServiceStatus {
287 let info = self
288 .lock()
289 .as_ref()
290 .map(|s| (s.status(), s.control_port(), s.ws_port()));
291 match info {
292 Some((status, control_port, ws_port)) => ServiceStatus {
293 name: SERVICE_NAME.to_string(),
294 healthy: true,
295 summary: summarize(&status, control_port, ws_port),
296 detail: json!({
297 "control_port": control_port,
298 "ws_port": ws_port,
299 "status": status,
300 }),
301 },
302 None => ServiceStatus {
303 name: SERVICE_NAME.to_string(),
304 healthy: false,
305 summary: "not running".to_string(),
306 detail: Value::Null,
307 },
308 }
309 }
310
311 async fn shutdown(&self) {
312 let server = self.lock().take();
313 if let Some(server) = server {
314 server.shutdown().await;
315 }
316 let _ = std::fs::remove_file(&self.token_path);
318 }
319}
320
321#[cfg(test)]
322#[allow(clippy::unwrap_used, clippy::expect_used)]
323mod tests {
324 use super::*;
325
326 async fn temp_service(dir: &Path) -> BridgeService {
328 let config = BridgeConfig {
329 ws_port: 0,
330 control_port: 0,
331 ..BridgeConfig::default()
332 };
333 let token_path = dir.join("bridge.token");
334 BridgeService::start(config, None, token_path)
335 .await
336 .unwrap()
337 }
338
339 #[tokio::test]
340 async fn start_writes_token_and_reports_status() {
341 let dir = tempfile::tempdir().unwrap();
342 let svc = temp_service(dir.path()).await;
343
344 let token_path = dir.path().join("bridge.token");
346 assert!(token_path.exists());
347 #[cfg(unix)]
348 {
349 use std::os::unix::fs::PermissionsExt;
350 let mode = std::fs::metadata(&token_path).unwrap().permissions().mode() & 0o777;
351 assert_eq!(mode, 0o600);
352 }
353
354 let status = svc.status().await;
356 assert_eq!(status.name, "browser-bridge");
357 assert!(status.healthy);
358 assert!(status.detail.get("control_port").is_some());
359
360 let payload = svc.handle("status", Value::Null).await.unwrap();
362 assert_eq!(payload.get("connected"), Some(&json!(false)));
363
364 let token = svc
366 .handle("token", Value::Null)
367 .await
368 .unwrap()
369 .get("token")
370 .and_then(Value::as_str)
371 .unwrap()
372 .to_string();
373 assert!(!token.is_empty());
374 assert_eq!(std::fs::read_to_string(&token_path).unwrap().trim(), token);
375
376 let cmd = svc
378 .handle("request-command", Value::Null)
379 .await
380 .unwrap()
381 .get("command")
382 .and_then(Value::as_str)
383 .unwrap()
384 .to_string();
385 assert!(cmd.starts_with(&format!("OMNI_BRIDGE_TOKEN='{token}'")));
386 assert!(cmd.contains("browser bridge request --control-port"));
387
388 assert!(svc.handle("frobnicate", Value::Null).await.is_err());
390
391 svc.shutdown().await;
392 assert!(!token_path.exists());
394 }
395
396 #[tokio::test]
397 async fn menu_lists_status_line_and_restart() {
398 let dir = tempfile::tempdir().unwrap();
399 let svc = temp_service(dir.path()).await;
400 let menu = svc.menu();
401 assert_eq!(menu.title, "Browser Bridge");
402 assert!(matches!(menu.items.first(), Some(MenuItem::Label(_))));
403 let token = svc.token.as_str();
406 let key_label = menu
407 .items
408 .iter()
409 .find_map(|i| match i {
410 MenuItem::Label(text) if text.starts_with("Key: ") => Some(text.as_str()),
411 _ => None,
412 })
413 .expect("menu has a masked Key label");
414 assert!(
415 !key_label.contains(token),
416 "full token leaked into tray label: {key_label}"
417 );
418 let tail: String = token
419 .chars()
420 .skip(token.chars().count() - TOKEN_LABEL_VISIBLE_CHARS)
421 .collect();
422 assert!(key_label.ends_with(&tail));
423 assert!(menu.items.iter().any(|i| matches!(
424 i,
425 MenuItem::Action(a) if a.id == "copy-key"
426 )));
427 assert!(menu.items.iter().any(|i| matches!(
428 i,
429 MenuItem::Action(a) if a.id == "copy-request"
430 )));
431 assert!(menu.items.iter().any(|i| matches!(
432 i,
433 MenuItem::Action(a) if a.id == "restart-bridge"
434 )));
435 svc.shutdown().await;
436 }
437
438 #[test]
439 fn masked_key_label_hides_all_but_the_tail() {
440 assert_eq!(
442 masked_key_label("abcdefghijklmnop"),
443 "Key: \u{2022}\u{2022}\u{2022}\u{2022}mnop"
444 );
445 assert_eq!(
447 masked_key_label("secret"),
448 "Key: \u{2022}\u{2022}\u{2022}\u{2022}"
449 );
450 assert_eq!(
451 masked_key_label(""),
452 "Key: \u{2022}\u{2022}\u{2022}\u{2022}"
453 );
454 }
455
456 #[tokio::test]
457 async fn restart_keeps_service_serving() {
458 let dir = tempfile::tempdir().unwrap();
459 let svc = temp_service(dir.path()).await;
460 svc.handle("restart", Value::Null).await.unwrap();
461 assert!(svc.status().await.healthy);
463 svc.shutdown().await;
464 }
465}