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