Skip to main content

tail_fin_cli_core/
lib.rs

1//! Shared helpers for the tail-fin CLI and daemon.
2//!
3//! This crate holds the CLI-adjacent plumbing that both the standalone
4//! `tail-fin` binary and the `tfd` daemon need: optionally building a browser
5//! session from a `--connect` host, resolving cookie file paths, and emitting
6//! JSON/list responses. Site-specific logic does **not** live here — each
7//! adapter crate owns its own Site impl + command handlers.
8//!
9//! Browser session helpers are gated behind the `browser` feature.
10
11use std::path::PathBuf;
12
13use tail_fin_common::TailFinError;
14
15/// Unlink a stale chromiumoxide default-profile SingletonLock if it
16/// points to a dead PID. No-op if the lock doesn't exist, points to a
17/// live process, or is on a platform we don't need to handle.
18///
19/// Call this once at CLI startup so a prior tail-fin invocation that
20/// died mid-launch can't block the current one. It's a symlink unlink,
21/// not a profile-dir removal — the dir itself is safe to keep.
22#[cfg(unix)]
23pub fn reap_stale_default_profile_lock() {
24    let temp = std::env::var_os("TMPDIR").unwrap_or_else(|| "/tmp".into());
25    let lock = std::path::Path::new(&temp)
26        .join("chromiumoxide-runner")
27        .join("SingletonLock");
28
29    let Ok(target) = std::fs::read_link(&lock) else {
30        return;
31    };
32    let target_str = target.to_string_lossy();
33    let Some(pid_str) = target_str.rsplit('-').next() else {
34        return;
35    };
36    let Ok(pid) = pid_str.parse::<i32>() else {
37        return;
38    };
39    // Reject non-positive and PID 1. `kill -0 0` targets our process
40    // group, `kill -0 -1` targets every process we can signal, `kill -0
41    // 1` targets init/launchd (non-root gets EPERM on macOS). None of
42    // those answer the question "is the process that minted this lock
43    // still around?" so we must not reap based on them.
44    if pid <= 1 {
45        return;
46    }
47
48    // `kill -0 <pid>` distinguishes:
49    //   success       → target is alive AND we can signal it
50    //   ESRCH         → no such process — definitely stale
51    //   EPERM         → process exists but we can't signal it (owned by
52    //                   another user). We treat this as "dead" too: our
53    //                   default profile is per-user on Unix, so a lock
54    //                   pointing at another user's PID is already
55    //                   nonsense, and reaping it unblocks us.
56    // We don't inspect errno (Command doesn't expose it cheaply); both
57    // non-success exits collapse to "not alive for our purposes".
58    let alive = std::process::Command::new("kill")
59        .args(["-0", &pid.to_string()])
60        .stdout(std::process::Stdio::null())
61        .stderr(std::process::Stdio::null())
62        .status()
63        .map(|s| s.success())
64        .unwrap_or(false);
65
66    if !alive {
67        let _ = std::fs::remove_file(&lock);
68    }
69}
70
71/// No-op on non-unix platforms.
72#[cfg(not(unix))]
73pub fn reap_stale_default_profile_lock() {}
74
75/// Build a user-facing error for missing connection mode.
76pub fn no_mode_error(service: &str, cmd: &str) -> TailFinError {
77    TailFinError::Api(format!(
78        "No connection mode specified for {service}.\n\
79         \x20 Use --connect to use browser mode:\n\
80         \x20   tail-fin --connect 127.0.0.1:9222 {service} {cmd}\n\
81         \x20 Or --cookies to use saved cookies:\n\
82         \x20   tail-fin --cookies auto {service} {cmd}\n\
83         \x20 Some adapters (e.g. spotify) auto-launch a stealth browser when no mode is given."
84    ))
85}
86
87/// Connection-mode context shared across CLI subcommands and the REPL.
88pub struct Ctx {
89    pub connect: Option<String>,
90    pub cookies: Option<String>,
91    pub headed: bool,
92}
93
94/// Default cookies path for a given site: `~/.tail-fin/<site>-cookies.txt`.
95pub fn default_cookies_path(site: &str) -> PathBuf {
96    let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
97    PathBuf::from(home)
98        .join(".tail-fin")
99        .join(format!("{}-cookies.txt", site))
100}
101
102/// Default JSON credentials path for a given site: `~/.tail-fin/<site>-creds.json`.
103pub fn default_creds_path(site: &str) -> PathBuf {
104    let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
105    PathBuf::from(home)
106        .join(".tail-fin")
107        .join(format!("{}-creds.json", site))
108}
109
110/// Resolve the cookies file path from the `--cookies` flag value.
111/// `"auto"` expands to [`default_cookies_path`]; anything else is verbatim.
112pub fn resolve_cookies_path(cookies_flag: &str, site: &str) -> PathBuf {
113    if cookies_flag == "auto" {
114        default_cookies_path(site)
115    } else {
116        PathBuf::from(cookies_flag)
117    }
118}
119
120/// Connect to an existing Chrome instance via CDP at `ws://{host}`.
121#[cfg(feature = "browser")]
122pub async fn browser_session(
123    host: &str,
124    headed: bool,
125) -> Result<night_fury_core::BrowserSession, TailFinError> {
126    Ok(night_fury_core::BrowserSession::builder()
127        .connect_to(format!("ws://{}", host))
128        .headed(headed)
129        .build()
130        .await?)
131}
132
133/// Launch a fresh headless (or headed) browser — no existing Chrome required.
134///
135/// Returns `(profile_dir, session)`. The `TempDir` is first in the tuple
136/// so the idiomatic `let (_profile, session) = launch_browser(...).await?`
137/// binding declares `_profile` before `session`. Rust drops locals in
138/// reverse declaration order, so `session` is dropped first (giving
139/// Chromium time to tear down) and `_profile` is dropped second
140/// (unlinking the profile dir only after Chromium is gone).
141#[cfg(feature = "browser")]
142pub async fn launch_browser(
143    headed: bool,
144) -> Result<(tempfile::TempDir, night_fury_core::BrowserSession), TailFinError> {
145    launch_with_tempdir(headed).await
146}
147
148/// Auto-launch a stealth browser session when no connection mode is
149/// specified. Adapters that support browser-only mode use this as their
150/// fallback path. Emits a stderr notice before launching.
151///
152/// Returns `(profile_dir, session)`. See [`launch_browser`] for the
153/// drop-order rationale behind the tuple shape.
154#[cfg(feature = "browser")]
155pub async fn auto_launch_stealth(
156    url: &str,
157    headed: bool,
158) -> Result<(tempfile::TempDir, night_fury_core::BrowserSession), TailFinError> {
159    eprintln!("No connection mode specified. Launching stealth browser...");
160    launch_stealth_with_tempdir(url, headed, Some(std::time::Duration::from_secs(30))).await
161}
162
163/// Launch a stealth browser navigated to `url` with anti-detection.
164/// Returns `(profile_dir, session)`; see [`launch_browser`] for the
165/// drop-order rationale.
166#[cfg(feature = "browser")]
167pub async fn launch_stealth_session(
168    url: &str,
169    headed: bool,
170) -> Result<(tempfile::TempDir, night_fury_core::BrowserSession), TailFinError> {
171    launch_stealth_with_tempdir(url, headed, None).await
172}
173
174/// Like [`launch_stealth_session`] but blocks until Cloudflare clears (or
175/// the timeout elapses). Use this from long-running services (e.g. the
176/// `tfd` daemon's `--host auto` path) where the next request hits the
177/// network *immediately* and must not race the CF interstitial.
178///
179/// Returns `(profile_dir, session)`; see [`launch_browser`] for the
180/// drop-order rationale behind the tuple shape.
181#[cfg(feature = "browser")]
182pub async fn launch_stealth_session_blocking_cf(
183    url: &str,
184    headed: bool,
185    cloudflare_timeout: std::time::Duration,
186) -> Result<(tempfile::TempDir, night_fury_core::BrowserSession), TailFinError> {
187    launch_stealth_with_tempdir(url, headed, Some(cloudflare_timeout)).await
188}
189
190#[cfg(feature = "browser")]
191fn profile_tempdir() -> Result<tempfile::TempDir, TailFinError> {
192    tempfile::Builder::new()
193        .prefix("tail-fin-cli-")
194        .tempdir()
195        .map_err(|e| TailFinError::Api(format!("failed to create chromium profile tempdir: {e}")))
196}
197
198/// Shared inner: build an isolated Chromium profile, launch a non-stealth
199/// browser, and return the tuple that keeps the profile alive.
200#[cfg(feature = "browser")]
201async fn launch_with_tempdir(
202    headed: bool,
203) -> Result<(tempfile::TempDir, night_fury_core::BrowserSession), TailFinError> {
204    let profile_dir = profile_tempdir()?;
205    let user_data_arg = format!("--user-data-dir={}", profile_dir.path().display());
206    let session = night_fury_core::BrowserSession::builder()
207        .headed(headed)
208        .arg(user_data_arg)
209        .build()
210        .await?;
211    Ok((profile_dir, session))
212}
213
214/// Shared inner: build an isolated Chromium profile, launch stealth,
215/// return the tuple that keeps the profile alive for the session's
216/// lifetime.
217#[cfg(feature = "browser")]
218async fn launch_stealth_with_tempdir(
219    url: &str,
220    headed: bool,
221    cloudflare_timeout: Option<std::time::Duration>,
222) -> Result<(tempfile::TempDir, night_fury_core::BrowserSession), TailFinError> {
223    let profile_dir = profile_tempdir()?;
224    let user_data_arg = format!("--user-data-dir={}", profile_dir.path().display());
225
226    let mut builder = night_fury_core::BrowserSession::builder()
227        .headed(headed)
228        .arg(user_data_arg);
229    if let Some(t) = cloudflare_timeout {
230        builder = builder.cloudflare_timeout(t);
231    }
232
233    let session = builder.launch_stealth(url).await?;
234    Ok((profile_dir, session))
235}
236
237/// Require `--connect` and return the host, or a friendly error pointing at
238/// the correct invocation.
239pub fn require_browser(
240    connect: &Option<String>,
241    service: &str,
242    action_name: &str,
243) -> Result<String, TailFinError> {
244    match connect {
245        Some(host) => Ok(host.clone()),
246        None => Err(TailFinError::Api(format!(
247            "`{service} {action_name}` requires browser mode (--connect).\n\
248             \x20 Use: tail-fin --connect 127.0.0.1:9222 {service} {action_name} ..."
249        ))),
250    }
251}
252
253/// Browser-only adapter: reject `--cookies`, require `--connect`, return a
254/// ready-to-use [`BrowserSession`].
255///
256/// [`BrowserSession`]: night_fury_core::BrowserSession
257#[cfg(feature = "browser")]
258pub async fn require_browser_session(
259    ctx: &Ctx,
260    service: &str,
261) -> Result<night_fury_core::BrowserSession, TailFinError> {
262    if ctx.cookies.is_some() {
263        return Err(TailFinError::Api(format!(
264            "{service} cookie mode is not supported.\n\
265             \x20 Use --connect for browser mode."
266        )));
267    }
268    let host = match ctx.connect.as_deref() {
269        Some(h) => h,
270        None => {
271            return Err(TailFinError::Api(format!(
272                "{service} requires --connect.\n\
273                 \x20 Example: tail-fin --connect 127.0.0.1:9222 {service} ..."
274            )));
275        }
276    };
277    browser_session(host, ctx.headed).await
278}
279
280/// Print a serializable value as pretty JSON to stdout.
281pub fn print_json(value: &(impl serde::Serialize + ?Sized)) -> Result<(), TailFinError> {
282    println!("{}", serde_json::to_string_pretty(value)?);
283    Ok(())
284}
285
286/// Print a list result as `{ "<key>": items, "count": N }` JSON.
287pub fn print_list(
288    key: &str,
289    items: &impl serde::Serialize,
290    count: usize,
291) -> Result<(), TailFinError> {
292    println!(
293        "{}",
294        serde_json::to_string_pretty(&serde_json::json!({
295            key: items,
296            "count": count,
297        }))?
298    );
299    Ok(())
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[cfg(unix)]
307    fn env_lock() -> &'static std::sync::Mutex<()> {
308        static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
309        LOCK.get_or_init(|| std::sync::Mutex::new(()))
310    }
311
312    // Acquire the TMPDIR env lock, recovering from poisoning so one panicking
313    // reaper test doesn't cascade-fail every later test.
314    #[cfg(unix)]
315    fn lock_env() -> std::sync::MutexGuard<'static, ()> {
316        env_lock().lock().unwrap_or_else(|e| e.into_inner())
317    }
318
319    #[cfg(unix)]
320    #[test]
321    fn reap_no_op_when_no_lock() {
322        let _guard = lock_env();
323        let td = tempfile::TempDir::new().unwrap();
324        let original = std::env::var_os("TMPDIR");
325        std::env::set_var("TMPDIR", td.path());
326
327        reap_stale_default_profile_lock();
328
329        match original {
330            Some(v) => std::env::set_var("TMPDIR", v),
331            None => std::env::remove_var("TMPDIR"),
332        }
333    }
334
335    // Check that the symlink itself exists at `p`, without following the
336    // target. `Path::exists()` follows symlinks, so it returns false for
337    // dangling symlinks even though the symlink entry is still on disk —
338    // useless for these tests which point at fake PID names.
339    #[cfg(unix)]
340    fn symlink_exists(p: &std::path::Path) -> bool {
341        std::fs::symlink_metadata(p).is_ok()
342    }
343
344    #[cfg(unix)]
345    #[test]
346    fn reap_removes_stale_symlink_pointing_at_dead_pid() {
347        use std::os::unix::fs::symlink;
348
349        let _guard = lock_env();
350        let td = tempfile::TempDir::new().unwrap();
351        let runner_dir = td.path().join("chromiumoxide-runner");
352        std::fs::create_dir_all(&runner_dir).unwrap();
353
354        let dead_pid = 999_999_999i32;
355        let lock = runner_dir.join("SingletonLock");
356        symlink(format!("fake-host-{dead_pid}"), &lock).unwrap();
357
358        let original = std::env::var_os("TMPDIR");
359        std::env::set_var("TMPDIR", td.path());
360        reap_stale_default_profile_lock();
361        match original {
362            Some(v) => std::env::set_var("TMPDIR", v),
363            None => std::env::remove_var("TMPDIR"),
364        }
365
366        assert!(
367            !symlink_exists(&lock),
368            "reaper should have unlinked dead-pid lock"
369        );
370    }
371
372    #[cfg(unix)]
373    #[test]
374    fn reap_preserves_symlink_pointing_at_live_pid() {
375        use std::os::unix::fs::symlink;
376
377        let _guard = lock_env();
378        let td = tempfile::TempDir::new().unwrap();
379        let runner_dir = td.path().join("chromiumoxide-runner");
380        std::fs::create_dir_all(&runner_dir).unwrap();
381
382        // Use our own PID as the "live" probe — it's guaranteed to be
383        // alive and signalable by us. Using PID 1 (init/launchd) works
384        // on Linux but fails on macOS where non-root can't signal
385        // launchd, so `kill -0 1` returns EPERM and the reaper would
386        // incorrectly treat it as dead.
387        let live_pid = std::process::id() as i32;
388        let lock = runner_dir.join("SingletonLock");
389        symlink(format!("fake-host-{live_pid}"), &lock).unwrap();
390
391        let original = std::env::var_os("TMPDIR");
392        std::env::set_var("TMPDIR", td.path());
393        reap_stale_default_profile_lock();
394        match original {
395            Some(v) => std::env::set_var("TMPDIR", v),
396            None => std::env::remove_var("TMPDIR"),
397        }
398
399        assert!(
400            symlink_exists(&lock),
401            "reaper must not touch a live-pid lock"
402        );
403    }
404
405    #[test]
406    fn resolve_cookies_path_auto_ends_with_site_cookies_txt() {
407        let p = resolve_cookies_path("auto", "twitter");
408
409        assert_eq!(
410            p.file_name().and_then(|n| n.to_str()),
411            Some("twitter-cookies.txt"),
412            "unexpected filename in: {}",
413            p.display()
414        );
415
416        assert_eq!(
417            p.parent()
418                .and_then(|pp| pp.file_name())
419                .and_then(|n| n.to_str()),
420            Some(".tail-fin"),
421            "unexpected parent directory in: {}",
422            p.display()
423        );
424    }
425
426    #[test]
427    fn resolve_cookies_path_explicit_is_verbatim() {
428        let p = resolve_cookies_path("/explicit/cookies.txt", "twitter");
429        assert_eq!(p.to_string_lossy(), "/explicit/cookies.txt");
430    }
431
432    #[test]
433    fn default_creds_path_uses_tail_fin_json_name() {
434        let p = default_creds_path("nansen");
435        assert!(p.to_string_lossy().contains(".tail-fin"));
436        assert!(p.ends_with("nansen-creds.json"));
437    }
438
439    #[test]
440    fn require_browser_errors_when_connect_missing() {
441        let err = require_browser(&None, "twitter", "timeline").unwrap_err();
442        let msg = err.to_string();
443        assert!(
444            msg.contains("--connect"),
445            "error should mention --connect; got: {msg}"
446        );
447        assert!(
448            msg.contains("twitter timeline"),
449            "error should mention the service/action; got: {msg}"
450        );
451    }
452
453    #[test]
454    fn require_browser_returns_host_when_present() {
455        let host = require_browser(&Some("127.0.0.1:9222".to_string()), "twitter", "timeline")
456            .expect("should succeed when --connect is provided");
457        assert_eq!(host, "127.0.0.1:9222");
458    }
459}