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
41pub struct Registry {
48 conn: rusqlite::Connection,
49 db_path: PathBuf,
53}
54
55impl Registry {
56 pub fn open() -> Result<Self> {
58 let p = crate::paths::registry_db_path()?;
59 Self::open_at(&p)
60 }
61
62 pub fn open_at(path: &Path) -> Result<Self> {
64 if let Some(parent) = path.parent() {
65 if !parent.as_os_str().is_empty() {
66 std::fs::create_dir_all(parent)
67 .with_context(|| format!("creating registry dir {}", parent.display()))?;
68 }
69 }
70
71 let lock_path = lock_path_for(path);
79 let lock_file = OpenOptions::new()
80 .create(true)
81 .read(true)
82 .write(true)
83 .truncate(false)
84 .open(&lock_path)
85 .with_context(|| format!("opening lock file {}", lock_path.display()))?;
86 FileExt::lock_exclusive(&lock_file)
87 .with_context(|| format!("acquiring exclusive lock on {}", lock_path.display()))?;
88
89 let conn = rusqlite::Connection::open_with_flags(
90 path,
91 OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE,
92 )
93 .with_context(|| format!("opening registry db {}", path.display()))?;
94
95 configure_conn(&conn)?;
96 schema::apply(&conn)?;
97
98 let _ = FileExt::unlock(&lock_file);
103 drop(lock_file);
104
105 Ok(Self {
106 conn,
107 db_path: path.to_path_buf(),
108 })
109 }
110
111 pub fn open_in_memory() -> Result<Self> {
113 let conn = rusqlite::Connection::open_in_memory().context("opening in-memory registry")?;
114 let _ = conn.pragma_update(None, "synchronous", "NORMAL");
116 schema::apply(&conn)?;
117 Ok(Self {
118 conn,
119 db_path: PathBuf::from(":memory:"),
120 })
121 }
122
123 pub fn insert(&self, row: &BrowserRow) -> Result<()> {
125 db::execute(
126 &self.conn,
127 "INSERT OR REPLACE INTO browsers
128 (name, kind, engine, pid, endpoint, port, profile_dir, executable, headless, started_at)
129 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
130 params![
131 row.name,
132 kind_to_str(row.kind),
133 engine_to_str(row.engine),
134 row.pid as i64,
135 row.endpoint,
136 row.port as i64,
137 row.profile_dir.to_string_lossy(),
138 row.executable.to_string_lossy(),
139 row.headless as i64,
140 row.started_at,
141 ],
142 || format!("inserting registry row {}", row.name),
143 )
144 }
145
146 pub fn delete(&self, name: &str) -> Result<()> {
148 db::execute(
149 &self.conn,
150 "DELETE FROM browsers WHERE name = ?1",
151 params![name],
152 || format!("deleting registry row {name}"),
153 )
154 }
155
156 pub fn get_by_name(&self, name: &str) -> Result<Option<BrowserRow>> {
158 db::query_optional(
159 &self.conn,
160 "SELECT name, kind, engine, pid, endpoint, port, profile_dir, executable, headless, started_at FROM browsers WHERE name = ?1",
161 params![name],
162 row_from_sqlite,
163 )
164 }
165
166 pub fn list_all(&self) -> Result<Vec<BrowserRow>> {
168 db::query_vec(
169 &self.conn,
170 "SELECT name, kind, engine, pid, endpoint, port, profile_dir, executable, headless, started_at
171 FROM browsers ORDER BY started_at DESC",
172 [],
173 row_from_sqlite,
174 )
175 }
176
177 pub(crate) fn list_by_kind_all(&self, kind: Kind) -> Result<Vec<BrowserRow>> {
179 db::query_vec(
180 &self.conn,
181 "SELECT name, kind, engine, pid, endpoint, port, profile_dir, executable, headless, started_at
182 FROM browsers WHERE kind = ?1 ORDER BY started_at DESC",
183 params![kind_to_str(kind)],
184 row_from_sqlite,
185 )
186 }
187
188 pub fn list_alive(&self) -> Result<Vec<BrowserRow>> {
190 let all = self.list_all()?;
191 let mut alive = Vec::with_capacity(all.len());
192 for row in all {
193 if is_alive(&row) {
194 alive.push(row);
195 } else {
196 self.delete(&row.name)?;
197 }
198 }
199 Ok(alive)
200 }
201
202 pub fn first_alive_by_kind(&self, kind: Kind) -> Result<Option<BrowserRow>> {
204 for row in self.list_by_kind_all(kind)? {
205 if is_alive(&row) {
206 return Ok(Some(row));
207 } else {
208 self.delete(&row.name)?;
209 }
210 }
211 Ok(None)
212 }
213
214 pub fn most_recent_alive(&self) -> Result<Option<BrowserRow>> {
216 for row in self.list_all()? {
217 if is_alive(&row) {
218 return Ok(Some(row));
219 } else {
220 self.delete(&row.name)?;
221 }
222 }
223 Ok(None)
224 }
225}
226
227fn configure_conn(conn: &rusqlite::Connection) -> Result<()> {
228 conn.pragma_update(None, "journal_mode", "WAL")
230 .context("setting journal_mode = WAL")?;
231 conn.pragma_update(None, "synchronous", "NORMAL")
232 .context("setting synchronous = NORMAL")?;
233 conn.busy_timeout(Duration::from_secs(5))
240 .context("setting busy_timeout")?;
241 Ok(())
242}
243
244fn lock_path_for(db: &Path) -> PathBuf {
245 let mut name = db
246 .file_name()
247 .map(|n| n.to_os_string())
248 .unwrap_or_else(|| std::ffi::OsString::from("registry.db"));
249 name.push(".lock");
250 match db.parent() {
251 Some(p) if !p.as_os_str().is_empty() => p.join(name),
252 _ => PathBuf::from(name),
253 }
254}
255
256fn row_from_sqlite(r: &rusqlite::Row<'_>) -> Result<BrowserRow> {
257 let name: String = r.get(0)?;
258 let kind_s: String = r.get(1)?;
259 let engine_s: String = r.get(2)?;
260 let pid: i64 = r.get(3)?;
261 let endpoint: String = r.get(4)?;
262 let port: i64 = r.get(5)?;
263 let profile_dir: String = r.get(6)?;
264 let executable: String = r.get(7)?;
265 let headless: i64 = r.get(8)?;
266 let started_at: String = r.get(9)?;
267
268 Ok(BrowserRow {
269 name,
270 kind: parse_kind(&kind_s)?,
271 engine: parse_engine(&engine_s)?,
272 pid: pid as u32,
273 endpoint,
274 port: port as u16,
275 profile_dir: PathBuf::from(profile_dir),
276 executable: PathBuf::from(executable),
277 headless: headless != 0,
278 started_at,
279 })
280}
281
282fn kind_to_str(k: Kind) -> &'static str {
283 k.as_str()
284}
285
286fn parse_kind(s: &str) -> Result<Kind> {
287 Kind::parse(s).ok_or_else(|| anyhow!("invalid kind {s}"))
288}
289
290fn engine_to_str(e: Engine) -> &'static str {
291 match e {
292 Engine::Cdp => "cdp",
293 Engine::Bidi => "bidi",
294 }
295}
296
297fn parse_engine(s: &str) -> Result<Engine> {
298 match s {
299 "cdp" => Ok(Engine::Cdp),
300 "bidi" => Ok(Engine::Bidi),
301 _ => bail!("invalid engine {s}"),
302 }
303}
304
305pub fn is_alive(row: &BrowserRow) -> bool {
307 let pid = sysinfo::Pid::from_u32(row.pid);
308 let mut sys = sysinfo::System::new();
309 sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true);
313 if sys.process(pid).is_none() {
314 return false;
315 }
316 let addr = SocketAddr::from(([127, 0, 0, 1], row.port));
317 TcpStream::connect_timeout(&addr, Duration::from_millis(200)).is_ok()
318}
319
320pub fn pid_alive(pid: u32) -> bool {
324 let pid = sysinfo::Pid::from_u32(pid);
325 let mut sys = sysinfo::System::new();
326 sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true);
329 sys.process(pid).is_some()
330}
331
332pub fn now_epoch_s() -> i64 {
334 std::time::SystemTime::now()
335 .duration_since(std::time::UNIX_EPOCH)
336 .map(|d| d.as_secs() as i64)
337 .unwrap_or(0)
338}
339
340pub fn now_iso8601() -> String {
344 let secs = std::time::SystemTime::now()
345 .duration_since(std::time::UNIX_EPOCH)
346 .map(|d| d.as_secs() as i64)
347 .unwrap_or(0);
348 format_unix_seconds_as_iso8601(secs)
349}
350
351pub fn format_unix_seconds_as_iso8601(secs: i64) -> String {
355 let days = secs.div_euclid(86_400);
357 let tod = secs.rem_euclid(86_400);
358 let hour = (tod / 3600) as u32;
359 let minute = ((tod % 3600) / 60) as u32;
360 let second = (tod % 60) as u32;
361
362 let (y, m, d) = civil_from_days(days);
363 format!(
364 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
365 y, m, d, hour, minute, second
366 )
367}
368
369fn civil_from_days(z: i64) -> (i64, u32, u32) {
371 let z = z + 719_468;
372 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
373 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;
376 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 };
381 (y, m, d)
382}
383
384#[cfg(test)]
387mod tests {
388 use super::*;
389
390 fn sample_row(name: &str, kind: Kind, port: u16, started_at: &str) -> BrowserRow {
391 BrowserRow {
392 name: name.to_string(),
393 kind,
394 engine: kind.engine(),
395 pid: 99_999_999, endpoint: format!("http://127.0.0.1:{port}"),
397 port,
398 profile_dir: PathBuf::from(format!("/tmp/profiles/{name}")),
399 executable: PathBuf::from("/usr/bin/example"),
400 headless: false,
401 started_at: started_at.to_string(),
402 }
403 }
404
405 #[test]
406 fn insert_then_get_round_trip() {
407 let reg = Registry::open_in_memory().unwrap();
408 let row = sample_row("alpha-bravo", Kind::Chrome, 9222, "2024-01-02T03:04:05Z");
409 reg.insert(&row).unwrap();
410 let got = reg.get_by_name("alpha-bravo").unwrap().unwrap();
411 assert_eq!(got, row);
412 assert!(reg.get_by_name("missing").unwrap().is_none());
413 }
414
415 #[test]
416 fn list_all_returns_all_rows() {
417 let reg = Registry::open_in_memory().unwrap();
418 reg.insert(&sample_row("a", Kind::Chrome, 9001, "2024-01-01T00:00:00Z"))
419 .unwrap();
420 reg.insert(&sample_row(
421 "b",
422 Kind::Firefox,
423 9002,
424 "2024-01-02T00:00:00Z",
425 ))
426 .unwrap();
427 reg.insert(&sample_row("c", Kind::Edge, 9003, "2024-01-03T00:00:00Z"))
428 .unwrap();
429 let all = reg.list_all().unwrap();
430 assert_eq!(all.len(), 3);
431 assert_eq!(all[0].name, "c");
433 assert_eq!(all[2].name, "a");
434 }
435
436 #[test]
437 fn delete_removes_row() {
438 let reg = Registry::open_in_memory().unwrap();
439 let row = sample_row("x", Kind::Brave, 9010, "2024-05-05T05:05:05Z");
440 reg.insert(&row).unwrap();
441 reg.delete("x").unwrap();
442 assert!(reg.get_by_name("x").unwrap().is_none());
443 reg.delete("ghost").unwrap();
445 }
446
447 #[test]
448 fn first_alive_by_kind_returns_most_recent() {
449 let reg = Registry::open_in_memory().unwrap();
452 reg.insert(&sample_row(
453 "older",
454 Kind::Chrome,
455 9101,
456 "2024-01-01T00:00:00Z",
457 ))
458 .unwrap();
459 reg.insert(&sample_row(
460 "newer",
461 Kind::Chrome,
462 9102,
463 "2024-06-01T00:00:00Z",
464 ))
465 .unwrap();
466 reg.insert(&sample_row(
467 "ff",
468 Kind::Firefox,
469 9103,
470 "2024-07-01T00:00:00Z",
471 ))
472 .unwrap();
473 let chromes = reg.list_by_kind_all(Kind::Chrome).unwrap();
474 assert_eq!(chromes.len(), 2);
475 assert_eq!(chromes[0].name, "newer");
476 assert_eq!(chromes[1].name, "older");
477
478 assert!(reg.first_alive_by_kind(Kind::Chrome).unwrap().is_none());
481 assert!(reg.list_by_kind_all(Kind::Chrome).unwrap().is_empty());
482 }
483
484 #[test]
485 fn list_alive_prunes_stale() {
486 let reg = Registry::open_in_memory().unwrap();
487 reg.insert(&sample_row("a", Kind::Chrome, 9201, "2024-01-01T00:00:00Z"))
488 .unwrap();
489 reg.insert(&sample_row("b", Kind::Chrome, 9202, "2024-01-02T00:00:00Z"))
490 .unwrap();
491 let alive = reg.list_alive().unwrap();
492 assert!(alive.is_empty());
493 assert!(reg.list_all().unwrap().is_empty());
494 }
495
496 #[test]
497 fn most_recent_alive_with_no_live_rows_is_none() {
498 let reg = Registry::open_in_memory().unwrap();
499 reg.insert(&sample_row("a", Kind::Chrome, 9301, "2024-01-01T00:00:00Z"))
500 .unwrap();
501 assert!(reg.most_recent_alive().unwrap().is_none());
502 }
503
504 #[test]
505 fn now_iso8601_format() {
506 let s = now_iso8601();
507 assert_eq!(s.len(), 20, "got {s}");
508 assert!(s.ends_with('Z'));
509 assert_eq!(&s[4..5], "-");
510 assert_eq!(&s[7..8], "-");
511 assert_eq!(&s[10..11], "T");
512 assert_eq!(&s[13..14], ":");
513 assert_eq!(&s[16..17], ":");
514
515 assert_eq!(format_unix_seconds_as_iso8601(0), "1970-01-01T00:00:00Z");
517 }
518
519 #[test]
520 fn iso8601_known_dates() {
521 let cases = [
522 (0_i64, "1970-01-01T00:00:00Z"),
523 (951_782_400, "2000-02-29T00:00:00Z"), (1_700_000_000, "2023-11-14T22:13:20Z"),
525 (1_583_020_799, "2020-02-29T23:59:59Z"), (1_583_020_800, "2020-03-01T00:00:00Z"),
527 (1_577_836_799, "2019-12-31T23:59:59Z"), ];
529 for (secs, want) in cases {
530 assert_eq!(format_unix_seconds_as_iso8601(secs), want, "epoch {secs}");
531 }
532 }
533
534 #[test]
535 fn concurrent_file_lock_serializes() {
536 use std::thread;
537
538 let tmp = tempfile::TempDir::new().unwrap();
539 let db_path = tmp.path().join("registry.db");
540
541 let p1 = db_path.clone();
542 let p2 = db_path.clone();
543 let t1 = thread::spawn(move || {
544 let reg = Registry::open_at(&p1).unwrap();
545 reg.insert(&BrowserRow {
546 name: "one".to_string(),
547 kind: Kind::Chrome,
548 engine: Engine::Cdp,
549 pid: 1,
550 endpoint: "http://127.0.0.1:9001".to_string(),
551 port: 9001,
552 profile_dir: PathBuf::from("/tmp/p1"),
553 executable: PathBuf::from("/usr/bin/chrome"),
554 headless: false,
555 started_at: "2024-01-01T00:00:00Z".to_string(),
556 })
557 .unwrap();
558 });
559 let t2 = thread::spawn(move || {
560 let reg = Registry::open_at(&p2).unwrap();
561 reg.insert(&BrowserRow {
562 name: "two".to_string(),
563 kind: Kind::Firefox,
564 engine: Engine::Bidi,
565 pid: 2,
566 endpoint: "ws://127.0.0.1:9002".to_string(),
567 port: 9002,
568 profile_dir: PathBuf::from("/tmp/p2"),
569 executable: PathBuf::from("/usr/bin/firefox"),
570 headless: false,
571 started_at: "2024-01-02T00:00:00Z".to_string(),
572 })
573 .unwrap();
574 });
575 t1.join().unwrap();
576 t2.join().unwrap();
577
578 let reg = Registry::open_at(&db_path).unwrap();
579 let all = reg.list_all().unwrap();
580 assert_eq!(all.len(), 2);
581 let names: Vec<&str> = all.iter().map(|r| r.name.as_str()).collect();
582 assert!(names.contains(&"one"));
583 assert!(names.contains(&"two"));
584 }
585
586 #[test]
587 fn open_at_creates_parent_dir_and_db() {
588 let tmp = tempfile::TempDir::new().unwrap();
589 let nested = tmp.path().join("a/b/c/registry.db");
590 let reg = Registry::open_at(&nested).unwrap();
591 reg.insert(&sample_row("x", Kind::Chrome, 9999, "2024-01-01T00:00:00Z"))
592 .unwrap();
593 assert!(nested.exists());
594 let lock = nested.parent().unwrap().join("registry.db.lock");
595 assert!(lock.exists());
596 }
597}