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<()> {
91 if let Some(parent) = path.parent() {
92 paths::ensure_dir_0700(parent)?;
93 }
94 paths::write_file_0600(path, token.as_bytes())
95 .with_context(|| format!("failed to write token file {}", path.display()))?;
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 #[cfg(unix)]
340 #[test]
341 fn write_token_retightens_preexisting_loose_file() {
342 use std::os::unix::fs::PermissionsExt;
343 let dir = tempfile::tempdir().unwrap();
344 let path = dir.path().join("bridge.token");
345 std::fs::write(&path, "stale-token").unwrap();
346 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
347 write_token(&path, "fresh-token").unwrap();
348 assert_eq!(std::fs::read_to_string(&path).unwrap(), "fresh-token");
349 assert_eq!(
350 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
351 0o600
352 );
353 }
354
355 #[tokio::test]
356 async fn start_writes_token_and_reports_status() {
357 let dir = tempfile::tempdir().unwrap();
358 let svc = temp_service(dir.path()).await;
359
360 let token_path = dir.path().join("bridge.token");
362 assert!(token_path.exists());
363 #[cfg(unix)]
364 {
365 use std::os::unix::fs::PermissionsExt;
366 let mode = std::fs::metadata(&token_path).unwrap().permissions().mode() & 0o777;
367 assert_eq!(mode, 0o600);
368 }
369
370 let status = svc.status().await;
372 assert_eq!(status.name, "browser-bridge");
373 assert!(status.healthy);
374 assert!(status.detail.get("control_port").is_some());
375
376 let payload = svc.handle("status", Value::Null).await.unwrap();
378 assert_eq!(payload.get("connected"), Some(&json!(false)));
379
380 let token = svc
382 .handle("token", Value::Null)
383 .await
384 .unwrap()
385 .get("token")
386 .and_then(Value::as_str)
387 .unwrap()
388 .to_string();
389 assert!(!token.is_empty());
390 assert_eq!(std::fs::read_to_string(&token_path).unwrap().trim(), token);
391
392 let cmd = svc
394 .handle("request-command", Value::Null)
395 .await
396 .unwrap()
397 .get("command")
398 .and_then(Value::as_str)
399 .unwrap()
400 .to_string();
401 assert!(cmd.starts_with(&format!("OMNI_BRIDGE_TOKEN='{token}'")));
402 assert!(cmd.contains("browser bridge request --control-port"));
403
404 assert!(svc.handle("frobnicate", Value::Null).await.is_err());
406
407 svc.shutdown().await;
408 assert!(!token_path.exists());
410 }
411
412 #[tokio::test]
413 async fn menu_lists_status_line_and_restart() {
414 let dir = tempfile::tempdir().unwrap();
415 let svc = temp_service(dir.path()).await;
416 let menu = svc.menu();
417 assert_eq!(menu.title, "Browser Bridge");
418 assert!(matches!(menu.items.first(), Some(MenuItem::Label(_))));
419 let token = svc.token.as_str();
422 let key_label = menu
423 .items
424 .iter()
425 .find_map(|i| match i {
426 MenuItem::Label(text) if text.starts_with("Key: ") => Some(text.as_str()),
427 _ => None,
428 })
429 .expect("menu has a masked Key label");
430 assert!(
431 !key_label.contains(token),
432 "full token leaked into tray label: {key_label}"
433 );
434 let tail: String = token
435 .chars()
436 .skip(token.chars().count() - TOKEN_LABEL_VISIBLE_CHARS)
437 .collect();
438 assert!(key_label.ends_with(&tail));
439 assert!(menu.items.iter().any(|i| matches!(
440 i,
441 MenuItem::Action(a) if a.id == "copy-key"
442 )));
443 assert!(menu.items.iter().any(|i| matches!(
444 i,
445 MenuItem::Action(a) if a.id == "copy-request"
446 )));
447 assert!(menu.items.iter().any(|i| matches!(
448 i,
449 MenuItem::Action(a) if a.id == "restart-bridge"
450 )));
451 svc.shutdown().await;
452 }
453
454 #[test]
455 fn masked_key_label_hides_all_but_the_tail() {
456 assert_eq!(
458 masked_key_label("abcdefghijklmnop"),
459 "Key: \u{2022}\u{2022}\u{2022}\u{2022}mnop"
460 );
461 assert_eq!(
463 masked_key_label("secret"),
464 "Key: \u{2022}\u{2022}\u{2022}\u{2022}"
465 );
466 assert_eq!(
467 masked_key_label(""),
468 "Key: \u{2022}\u{2022}\u{2022}\u{2022}"
469 );
470 }
471
472 #[tokio::test]
473 async fn restart_keeps_service_serving() {
474 let dir = tempfile::tempdir().unwrap();
475 let svc = temp_service(dir.path()).await;
476 svc.handle("restart", Value::Null).await.unwrap();
477 assert!(svc.status().await.healthy);
479 svc.shutdown().await;
480 }
481}