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