tail-fin-daemon 0.5.1

Long-running browser-session daemon for tail-fin (tfd binary). Keeps Chrome tabs warm across invocations via a Unix-socket protocol; registers Site implementations through a runtime Arc<dyn Site> registry.
Documentation
//! `SessionDriver` impl for tail-fin daemon sessions.
//!
//! Each session is tied to one **site** (exactly one browser tab per site per
//! session) and one **Chrome host**. The site identity is resolved at launch
//! time from a globally registered `SiteRegistry` — adding a new site to the
//! daemon is now a registration step, not a code edit on this file.

use std::collections::HashMap;
use std::sync::{Arc, OnceLock};

use anyhow::{anyhow, Context};
use night_fury_core::BrowserSession;
use night_fury_daemon_core::{protocol::Response, SessionDriver};
use serde_json::Value;
use tail_fin_cli_core::browser_session;
use tail_fin_core::Site;

/// Global registry of known sites, keyed by `Site::id()`.
///
/// Populated once at daemon startup via [`register_sites`]. `launch()` uses
/// this to resolve the `"site"` param into an `Arc<dyn Site>`.
static SITE_REGISTRY: OnceLock<HashMap<&'static str, Arc<dyn Site>>> = OnceLock::new();

/// Register all sites this daemon instance can serve. Call once at startup
/// before `serve()`. Subsequent calls are no-ops.
pub fn register_sites(sites: Vec<Arc<dyn Site>>) {
    let map: HashMap<&'static str, Arc<dyn Site>> =
        sites.into_iter().map(|s| (s.id(), s)).collect();
    let _ = SITE_REGISTRY.set(map);
}

/// Look up a registered site by its identifier (e.g. `"twitter"`, `"sa"`).
pub fn lookup_site(id: &str) -> Option<Arc<dyn Site>> {
    SITE_REGISTRY.get()?.get(id).cloned()
}

/// Return all registered site ids, for CLI --help / error messages.
pub fn registered_site_ids() -> Vec<&'static str> {
    SITE_REGISTRY
        .get()
        .map(|m| m.keys().copied().collect())
        .unwrap_or_default()
}

/// One browser tab bound to a single site + host. Multiple of these can live
/// in the registry simultaneously.
pub struct TailFinSession {
    pub session: BrowserSession,
    pub site: Arc<dyn Site>,
    pub host: String,
}

impl SessionDriver for TailFinSession {
    async fn launch(params: Value) -> anyhow::Result<Self> {
        let site_id = params
            .get("site")
            .and_then(|v| v.as_str())
            .ok_or_else(|| anyhow!("launch: missing 'site'"))?;

        let site = lookup_site(site_id).ok_or_else(|| {
            let known = registered_site_ids().join(", ");
            anyhow!(
                "unknown site '{site_id}' (registered: {known}) — was it registered via register_sites?"
            )
        })?;

        let host = params
            .get("host")
            .and_then(|v| v.as_str())
            .unwrap_or(crate::cli::DEFAULT_CDP_HOST)
            .to_string();

        if host == "auto" {
            return Err(anyhow!(
                "host='auto' (stealth launch) is not supported in MVP; use --connect"
            ));
        }

        let session = browser_session(&host, false)
            .await
            .with_context(|| format!("connect to chrome at {host}"))?;

        Ok(Self {
            session,
            site,
            host,
        })
    }

    async fn handle(&self, id: &str, cmd: &str, params: &Value) -> (Response, bool) {
        let resp =
            match crate::handlers::dispatch(self.site.as_ref(), &self.session, id, cmd, params)
                .await
            {
                Some(r) => r,
                None => Response::err(
                    id,
                    format!("cmd '{cmd}' does not belong to site '{}'", self.site.id()),
                ),
            };
        (resp, false)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // A tiny site impl for testing registry mechanics without pulling in
    // real site crates (which would create circular dev-dep issues).
    struct FakeSite;

    #[async_trait::async_trait]
    impl Site for FakeSite {
        fn id(&self) -> &'static str {
            "fake"
        }
        fn display_name(&self) -> &'static str {
            "Fake"
        }
        fn cookie_domain_patterns(&self) -> &'static [&'static str] {
            &["*.fake.test"]
        }
        fn refresh_url(&self) -> &'static str {
            "https://fake.test/"
        }
        async fn validate(
            &self,
            _session: &BrowserSession,
        ) -> Result<tail_fin_core::SessionStatus, tail_fin_core::SiteError> {
            Ok(tail_fin_core::SessionStatus::Valid)
        }
    }

    // NOTE: SITE_REGISTRY is process-global. In test runs the registry is
    // populated exactly once; we verify lookup mechanics.
    #[test]
    fn register_and_lookup() {
        register_sites(vec![Arc::new(FakeSite)]);
        let s = lookup_site("fake").expect("fake site registered");
        assert_eq!(s.id(), "fake");
        assert!(registered_site_ids().contains(&"fake"));
        // Unknown lookup is None (whether or not registry has fake).
        assert!(lookup_site("definitely-not-a-real-site").is_none());
    }
}