1pub mod bidi_lock;
4mod db;
5pub mod foreground;
6pub mod naming;
7pub mod schema;
8pub mod scratches;
9pub mod tabs;
10pub mod words;
11
12pub use bidi_lock::{BidiLockBusy, BidiLockGuard, BidiLockRow};
13pub use foreground::ForegroundRow;
14pub use scratches::ScratchRow;
15pub use tabs::TabRow;
16
17use anyhow::{anyhow, bail, Context, Result};
18use fs2::FileExt;
19use rusqlite::{params, OpenFlags};
20use serde::{Deserialize, Serialize};
21use std::fs::OpenOptions;
22use std::net::{SocketAddr, TcpStream};
23use std::path::{Path, PathBuf};
24use std::time::Duration;
25
26use crate::detect::{Engine, Kind};
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub struct BrowserRow {
31 pub name: String,
32 pub kind: Kind,
33 pub engine: Engine,
34 pub pid: u32,
35 pub endpoint: String,
36 pub port: u16,
37 pub profile_dir: PathBuf,
38 pub executable: PathBuf,
39 pub headless: bool,
40 pub started_at: String,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum BrowserLiveness {
46 Alive,
48 DeadPid,
51 EndpointUnreachable,
55}
56
57pub struct Registry {
64 conn: rusqlite::Connection,
65 db_path: PathBuf,
69}
70
71impl Registry {
72 pub fn open() -> Result<Self> {
74 let p = crate::paths::registry_db_path()?;
75 Self::open_at(&p)
76 }
77
78 pub fn open_at(path: &Path) -> Result<Self> {
80 if let Some(parent) = path.parent() {
81 if !parent.as_os_str().is_empty() {
82 std::fs::create_dir_all(parent)
83 .with_context(|| format!("creating registry dir {}", parent.display()))?;
84 }
85 }
86
87 let lock_path = lock_path_for(path);
95 let lock_file = OpenOptions::new()
96 .create(true)
97 .read(true)
98 .write(true)
99 .truncate(false)
100 .open(&lock_path)
101 .with_context(|| format!("opening lock file {}", lock_path.display()))?;
102 FileExt::lock_exclusive(&lock_file)
103 .with_context(|| format!("acquiring exclusive lock on {}", lock_path.display()))?;
104
105 let conn = rusqlite::Connection::open_with_flags(
106 path,
107 OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE,
108 )
109 .with_context(|| format!("opening registry db {}", path.display()))?;
110
111 configure_conn(&conn)?;
112 schema::apply(&conn)?;
113
114 let _ = FileExt::unlock(&lock_file);
119 drop(lock_file);
120
121 Ok(Self {
122 conn,
123 db_path: path.to_path_buf(),
124 })
125 }
126
127 pub fn open_in_memory() -> Result<Self> {
129 let conn = rusqlite::Connection::open_in_memory().context("opening in-memory registry")?;
130 let _ = conn.pragma_update(None, "synchronous", "NORMAL");
132 schema::apply(&conn)?;
133 Ok(Self {
134 conn,
135 db_path: PathBuf::from(":memory:"),
136 })
137 }
138
139 pub fn insert(&self, row: &BrowserRow) -> Result<()> {
141 db::execute(
142 &self.conn,
143 "INSERT OR REPLACE INTO browsers
144 (name, kind, engine, pid, endpoint, port, profile_dir, executable, headless, started_at)
145 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
146 params![
147 row.name,
148 kind_to_str(row.kind),
149 engine_to_str(row.engine),
150 row.pid as i64,
151 row.endpoint,
152 row.port as i64,
153 row.profile_dir.to_string_lossy(),
154 row.executable.to_string_lossy(),
155 row.headless as i64,
156 row.started_at,
157 ],
158 || format!("inserting registry row {}", row.name),
159 )
160 }
161
162 pub fn delete(&self, name: &str) -> Result<()> {
164 db::execute(
165 &self.conn,
166 "DELETE FROM browsers WHERE name = ?1",
167 params![name],
168 || format!("deleting registry row {name}"),
169 )
170 }
171
172 pub fn get_by_name(&self, name: &str) -> Result<Option<BrowserRow>> {
174 db::query_optional(
175 &self.conn,
176 "SELECT name, kind, engine, pid, endpoint, port, profile_dir, executable, headless, started_at FROM browsers WHERE name = ?1",
177 params![name],
178 row_from_sqlite,
179 )
180 }
181
182 pub fn list_all(&self) -> Result<Vec<BrowserRow>> {
184 db::query_vec(
185 &self.conn,
186 "SELECT name, kind, engine, pid, endpoint, port, profile_dir, executable, headless, started_at
187 FROM browsers ORDER BY started_at DESC",
188 [],
189 row_from_sqlite,
190 )
191 }
192
193 pub(crate) fn list_by_kind_all(&self, kind: Kind) -> Result<Vec<BrowserRow>> {
195 db::query_vec(
196 &self.conn,
197 "SELECT name, kind, engine, pid, endpoint, port, profile_dir, executable, headless, started_at
198 FROM browsers WHERE kind = ?1 ORDER BY started_at DESC",
199 params![kind_to_str(kind)],
200 row_from_sqlite,
201 )
202 }
203
204 pub fn list_alive(&self) -> Result<Vec<BrowserRow>> {
207 let all = self.list_all()?;
208 let mut alive = Vec::with_capacity(all.len());
209 for row in all {
210 match liveness(&row) {
211 BrowserLiveness::Alive => alive.push(row),
212 BrowserLiveness::DeadPid => {
213 self.delete(&row.name)?;
214 }
215 BrowserLiveness::EndpointUnreachable => {}
216 }
217 }
218 Ok(alive)
219 }
220
221 pub fn first_alive_by_kind(&self, kind: Kind) -> Result<Option<BrowserRow>> {
224 for row in self.list_by_kind_all(kind)? {
225 match liveness(&row) {
226 BrowserLiveness::Alive => return Ok(Some(row)),
227 BrowserLiveness::DeadPid => {
228 self.delete(&row.name)?;
229 }
230 BrowserLiveness::EndpointUnreachable => {}
231 }
232 }
233 Ok(None)
234 }
235
236 pub fn most_recent_alive(&self) -> Result<Option<BrowserRow>> {
238 for row in self.list_all()? {
239 match liveness(&row) {
240 BrowserLiveness::Alive => return Ok(Some(row)),
241 BrowserLiveness::DeadPid => {
242 self.delete(&row.name)?;
243 }
244 BrowserLiveness::EndpointUnreachable => {}
245 }
246 }
247 Ok(None)
248 }
249}
250
251fn configure_conn(conn: &rusqlite::Connection) -> Result<()> {
252 conn.pragma_update(None, "journal_mode", "WAL")
254 .context("setting journal_mode = WAL")?;
255 conn.pragma_update(None, "synchronous", "NORMAL")
256 .context("setting synchronous = NORMAL")?;
257 conn.busy_timeout(Duration::from_secs(5))
264 .context("setting busy_timeout")?;
265 Ok(())
266}
267
268fn lock_path_for(db: &Path) -> PathBuf {
269 let mut name = db
270 .file_name()
271 .map(|n| n.to_os_string())
272 .unwrap_or_else(|| std::ffi::OsString::from("registry.db"));
273 name.push(".lock");
274 match db.parent() {
275 Some(p) if !p.as_os_str().is_empty() => p.join(name),
276 _ => PathBuf::from(name),
277 }
278}
279
280fn row_from_sqlite(r: &rusqlite::Row<'_>) -> Result<BrowserRow> {
281 let name: String = r.get(0)?;
282 let kind_s: String = r.get(1)?;
283 let engine_s: String = r.get(2)?;
284 let pid: i64 = r.get(3)?;
285 let endpoint: String = r.get(4)?;
286 let port: i64 = r.get(5)?;
287 let profile_dir: String = r.get(6)?;
288 let executable: String = r.get(7)?;
289 let headless: i64 = r.get(8)?;
290 let started_at: String = r.get(9)?;
291
292 Ok(BrowserRow {
293 name,
294 kind: parse_kind(&kind_s)?,
295 engine: parse_engine(&engine_s)?,
296 pid: pid as u32,
297 endpoint,
298 port: port as u16,
299 profile_dir: PathBuf::from(profile_dir),
300 executable: PathBuf::from(executable),
301 headless: headless != 0,
302 started_at,
303 })
304}
305
306fn kind_to_str(k: Kind) -> &'static str {
307 k.as_str()
308}
309
310fn parse_kind(s: &str) -> Result<Kind> {
311 Kind::parse(s).ok_or_else(|| anyhow!("invalid kind {s}"))
312}
313
314fn engine_to_str(e: Engine) -> &'static str {
315 match e {
316 Engine::Cdp => "cdp",
317 Engine::Bidi => "bidi",
318 }
319}
320
321fn parse_engine(s: &str) -> Result<Engine> {
322 match s {
323 "cdp" => Ok(Engine::Cdp),
324 "bidi" => Ok(Engine::Bidi),
325 _ => bail!("invalid engine {s}"),
326 }
327}
328
329pub fn is_alive(row: &BrowserRow) -> bool {
331 liveness(row) == BrowserLiveness::Alive
332}
333
334pub fn liveness(row: &BrowserRow) -> BrowserLiveness {
336 let pid = sysinfo::Pid::from_u32(row.pid);
337 let mut sys = sysinfo::System::new();
338 sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true);
342 if sys.process(pid).is_none() {
343 return BrowserLiveness::DeadPid;
344 }
345 let addr = SocketAddr::from(([127, 0, 0, 1], row.port));
346 if TcpStream::connect_timeout(&addr, Duration::from_millis(200)).is_ok() {
347 BrowserLiveness::Alive
348 } else {
349 BrowserLiveness::EndpointUnreachable
350 }
351}
352
353pub fn pid_alive(pid: u32) -> bool {
357 let pid = sysinfo::Pid::from_u32(pid);
358 let mut sys = sysinfo::System::new();
359 sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true);
362 sys.process(pid)
365 .is_some_and(|p| !matches!(p.status(), sysinfo::ProcessStatus::Zombie))
366}
367
368pub fn now_epoch_s() -> i64 {
370 std::time::SystemTime::now()
371 .duration_since(std::time::UNIX_EPOCH)
372 .map(|d| d.as_secs() as i64)
373 .unwrap_or(0)
374}
375
376pub fn now_iso8601() -> String {
380 let secs = std::time::SystemTime::now()
381 .duration_since(std::time::UNIX_EPOCH)
382 .map(|d| d.as_secs() as i64)
383 .unwrap_or(0);
384 format_unix_seconds_as_iso8601(secs)
385}
386
387pub fn format_unix_seconds_as_iso8601(secs: i64) -> String {
391 let days = secs.div_euclid(86_400);
393 let tod = secs.rem_euclid(86_400);
394 let hour = (tod / 3600) as u32;
395 let minute = ((tod % 3600) / 60) as u32;
396 let second = (tod % 60) as u32;
397
398 let (y, m, d) = civil_from_days(days);
399 format!(
400 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
401 y, m, d, hour, minute, second
402 )
403}
404
405fn civil_from_days(z: i64) -> (i64, u32, u32) {
407 let z = z + 719_468;
408 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
409 let doe = (z - era * 146_097) as u64; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; let y = yoe as i64 + era * 400;
412 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as u32; let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; let y = if m <= 2 { y + 1 } else { y };
417 (y, m, d)
418}
419
420#[cfg(test)]
423mod tests {
424 use super::*;
425
426 fn sample_row(name: &str, kind: Kind, port: u16, started_at: &str) -> BrowserRow {
427 BrowserRow {
428 name: name.to_string(),
429 kind,
430 engine: kind.engine(),
431 pid: 99_999_999, endpoint: format!("http://127.0.0.1:{port}"),
433 port,
434 profile_dir: PathBuf::from(format!("/tmp/profiles/{name}")),
435 executable: PathBuf::from("/usr/bin/example"),
436 headless: false,
437 started_at: started_at.to_string(),
438 }
439 }
440
441 #[test]
442 fn insert_then_get_round_trip() {
443 let reg = Registry::open_in_memory().unwrap();
444 let row = sample_row("alpha-bravo", Kind::Chrome, 9222, "2024-01-02T03:04:05Z");
445 reg.insert(&row).unwrap();
446 let got = reg.get_by_name("alpha-bravo").unwrap().unwrap();
447 assert_eq!(got, row);
448 assert!(reg.get_by_name("missing").unwrap().is_none());
449 }
450
451 #[test]
452 fn list_all_returns_all_rows() {
453 let reg = Registry::open_in_memory().unwrap();
454 reg.insert(&sample_row("a", Kind::Chrome, 9001, "2024-01-01T00:00:00Z"))
455 .unwrap();
456 reg.insert(&sample_row(
457 "b",
458 Kind::Firefox,
459 9002,
460 "2024-01-02T00:00:00Z",
461 ))
462 .unwrap();
463 reg.insert(&sample_row("c", Kind::Edge, 9003, "2024-01-03T00:00:00Z"))
464 .unwrap();
465 let all = reg.list_all().unwrap();
466 assert_eq!(all.len(), 3);
467 assert_eq!(all[0].name, "c");
469 assert_eq!(all[2].name, "a");
470 }
471
472 #[test]
473 fn delete_removes_row() {
474 let reg = Registry::open_in_memory().unwrap();
475 let row = sample_row("x", Kind::Brave, 9010, "2024-05-05T05:05:05Z");
476 reg.insert(&row).unwrap();
477 reg.delete("x").unwrap();
478 assert!(reg.get_by_name("x").unwrap().is_none());
479 reg.delete("ghost").unwrap();
481 }
482
483 #[test]
484 fn first_alive_by_kind_returns_most_recent() {
485 let reg = Registry::open_in_memory().unwrap();
488 reg.insert(&sample_row(
489 "older",
490 Kind::Chrome,
491 9101,
492 "2024-01-01T00:00:00Z",
493 ))
494 .unwrap();
495 reg.insert(&sample_row(
496 "newer",
497 Kind::Chrome,
498 9102,
499 "2024-06-01T00:00:00Z",
500 ))
501 .unwrap();
502 reg.insert(&sample_row(
503 "ff",
504 Kind::Firefox,
505 9103,
506 "2024-07-01T00:00:00Z",
507 ))
508 .unwrap();
509 let chromes = reg.list_by_kind_all(Kind::Chrome).unwrap();
510 assert_eq!(chromes.len(), 2);
511 assert_eq!(chromes[0].name, "newer");
512 assert_eq!(chromes[1].name, "older");
513
514 assert!(reg.first_alive_by_kind(Kind::Chrome).unwrap().is_none());
517 assert!(reg.list_by_kind_all(Kind::Chrome).unwrap().is_empty());
518 }
519
520 #[test]
521 fn list_alive_prunes_stale() {
522 let reg = Registry::open_in_memory().unwrap();
523 reg.insert(&sample_row("a", Kind::Chrome, 9201, "2024-01-01T00:00:00Z"))
524 .unwrap();
525 reg.insert(&sample_row("b", Kind::Chrome, 9202, "2024-01-02T00:00:00Z"))
526 .unwrap();
527 let alive = reg.list_alive().unwrap();
528 assert!(alive.is_empty());
529 assert!(reg.list_all().unwrap().is_empty());
530 }
531
532 #[test]
533 fn list_alive_retains_live_pid_with_unreachable_endpoint() {
534 let reg = Registry::open_in_memory().unwrap();
535 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
536 let port = listener.local_addr().unwrap().port();
537 drop(listener);
538 let mut row = sample_row(
539 "temporarily-unreachable",
540 Kind::Chrome,
541 port,
542 "2024-01-01T00:00:00Z",
543 );
544 row.pid = std::process::id();
545 reg.insert(&row).unwrap();
546
547 let alive = reg.list_alive().unwrap();
548 assert!(alive.is_empty());
549 assert!(reg
550 .get_by_name("temporarily-unreachable")
551 .unwrap()
552 .is_some());
553 }
554
555 #[test]
556 fn most_recent_alive_with_no_live_rows_is_none() {
557 let reg = Registry::open_in_memory().unwrap();
558 reg.insert(&sample_row("a", Kind::Chrome, 9301, "2024-01-01T00:00:00Z"))
559 .unwrap();
560 assert!(reg.most_recent_alive().unwrap().is_none());
561 }
562
563 #[test]
564 fn now_iso8601_format() {
565 let s = now_iso8601();
566 assert_eq!(s.len(), 20, "got {s}");
567 assert!(s.ends_with('Z'));
568 assert_eq!(&s[4..5], "-");
569 assert_eq!(&s[7..8], "-");
570 assert_eq!(&s[10..11], "T");
571 assert_eq!(&s[13..14], ":");
572 assert_eq!(&s[16..17], ":");
573
574 assert_eq!(format_unix_seconds_as_iso8601(0), "1970-01-01T00:00:00Z");
576 }
577
578 #[test]
579 fn iso8601_known_dates() {
580 let cases = [
581 (0_i64, "1970-01-01T00:00:00Z"),
582 (951_782_400, "2000-02-29T00:00:00Z"), (1_700_000_000, "2023-11-14T22:13:20Z"),
584 (1_583_020_799, "2020-02-29T23:59:59Z"), (1_583_020_800, "2020-03-01T00:00:00Z"),
586 (1_577_836_799, "2019-12-31T23:59:59Z"), ];
588 for (secs, want) in cases {
589 assert_eq!(format_unix_seconds_as_iso8601(secs), want, "epoch {secs}");
590 }
591 }
592
593 #[test]
594 fn concurrent_file_lock_serializes() {
595 use std::thread;
596
597 let tmp = tempfile::TempDir::new().unwrap();
598 let db_path = tmp.path().join("registry.db");
599
600 let p1 = db_path.clone();
601 let p2 = db_path.clone();
602 let t1 = thread::spawn(move || {
603 let reg = Registry::open_at(&p1).unwrap();
604 reg.insert(&BrowserRow {
605 name: "one".to_string(),
606 kind: Kind::Chrome,
607 engine: Engine::Cdp,
608 pid: 1,
609 endpoint: "http://127.0.0.1:9001".to_string(),
610 port: 9001,
611 profile_dir: PathBuf::from("/tmp/p1"),
612 executable: PathBuf::from("/usr/bin/chrome"),
613 headless: false,
614 started_at: "2024-01-01T00:00:00Z".to_string(),
615 })
616 .unwrap();
617 });
618 let t2 = thread::spawn(move || {
619 let reg = Registry::open_at(&p2).unwrap();
620 reg.insert(&BrowserRow {
621 name: "two".to_string(),
622 kind: Kind::Firefox,
623 engine: Engine::Bidi,
624 pid: 2,
625 endpoint: "ws://127.0.0.1:9002".to_string(),
626 port: 9002,
627 profile_dir: PathBuf::from("/tmp/p2"),
628 executable: PathBuf::from("/usr/bin/firefox"),
629 headless: false,
630 started_at: "2024-01-02T00:00:00Z".to_string(),
631 })
632 .unwrap();
633 });
634 t1.join().unwrap();
635 t2.join().unwrap();
636
637 let reg = Registry::open_at(&db_path).unwrap();
638 let all = reg.list_all().unwrap();
639 assert_eq!(all.len(), 2);
640 let names: Vec<&str> = all.iter().map(|r| r.name.as_str()).collect();
641 assert!(names.contains(&"one"));
642 assert!(names.contains(&"two"));
643 }
644
645 #[test]
646 fn open_at_creates_parent_dir_and_db() {
647 let tmp = tempfile::TempDir::new().unwrap();
648 let nested = tmp.path().join("a/b/c/registry.db");
649 let reg = Registry::open_at(&nested).unwrap();
650 reg.insert(&sample_row("x", Kind::Chrome, 9999, "2024-01-01T00:00:00Z"))
651 .unwrap();
652 assert!(nested.exists());
653 let lock = nested.parent().unwrap().join("registry.db.lock");
654 assert!(lock.exists());
655 }
656}