1use std::path::PathBuf;
12
13use tail_fin_common::TailFinError;
14
15#[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 if pid <= 1 {
45 return;
46 }
47
48 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#[cfg(not(unix))]
73pub fn reap_stale_default_profile_lock() {}
74
75pub 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
87pub struct Ctx {
89 pub connect: Option<String>,
90 pub cookies: Option<String>,
91 pub headed: bool,
92}
93
94pub 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
102pub 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
110pub 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#[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#[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#[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#[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#[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#[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#[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
237pub 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#[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
280pub fn print_json(value: &(impl serde::Serialize + ?Sized)) -> Result<(), TailFinError> {
282 println!("{}", serde_json::to_string_pretty(value)?);
283 Ok(())
284}
285
286pub 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 #[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 #[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 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}