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