tail-fin-daemon 0.7.8

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
use std::sync::{Mutex, OnceLock};
use std::time::Instant;

use anyhow::{anyhow, Result};
use night_fury_daemon_core::cli::make_req;
use night_fury_daemon_core::{client, protocol::Response};
use serde::{Deserialize, Serialize};
use serde_json::json;

// Mutex is intentionally used so in-process integration tests can reset the
// daemon start timestamp between server instances. Production runs one daemon
// per process, so this is not for cross-thread safety.
static DAEMON_START: OnceLock<Mutex<Instant>> = OnceLock::new();

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct HandshakeReply {
    pub version: String,
    pub uptime_secs: u64,
}

#[derive(Debug, thiserror::Error)]
pub enum HandshakeError {
    #[error(
        "daemon version mismatch: daemon={daemon}, CLI={cli}; run `tfd daemon stop` then retry"
    )]
    VersionMismatch { daemon: String, cli: String },
    #[error("daemon handshake protocol error: {0}")]
    Protocol(String),
}

pub fn mark_daemon_started() {
    let start = DAEMON_START.get_or_init(|| Mutex::new(Instant::now()));
    *start.lock().expect("daemon start time mutex poisoned") = Instant::now();
}

pub fn daemon_start_time() -> Instant {
    *DAEMON_START
        .get_or_init(|| Mutex::new(Instant::now()))
        .lock()
        .expect("daemon start time mutex poisoned")
}

pub fn handshake_response(id: &str) -> Response {
    Response::ok(
        id,
        json!({
            "version": env!("CARGO_PKG_VERSION"),
            "uptime_secs": daemon_start_time().elapsed().as_secs(),
        }),
    )
}

pub async fn handshake(socket: &str) -> Result<HandshakeReply> {
    let req = make_req("daemon.handshake", None, json!({}));
    let resp = client::send(socket, &req).await?;
    if !resp.ok {
        return Err(anyhow!(
            "daemon handshake failed: {}",
            resp.error.unwrap_or_default()
        ));
    }
    let data = resp
        .data
        .ok_or_else(|| anyhow!("daemon handshake missing data"))?;
    serde_json::from_value(data).map_err(Into::into)
}

pub async fn verify_compatible(socket: &str) -> Result<HandshakeReply> {
    let cli_version = env!("CARGO_PKG_VERSION");
    let req = make_req("daemon.handshake", None, json!({}));
    let resp = client::send(socket, &req).await?;
    if !resp.ok {
        return Err(version_mismatch("pre-handshake"));
    }
    let data = resp
        .data
        .ok_or_else(|| HandshakeError::Protocol("missing handshake data".to_string()))?;
    let reply: HandshakeReply = serde_json::from_value(data)
        .map_err(|e| HandshakeError::Protocol(format!("invalid handshake data: {e}")))?;
    if reply.version != cli_version {
        return Err(version_mismatch(&reply.version));
    }
    Ok(reply)
}

fn version_mismatch(daemon_version: &str) -> anyhow::Error {
    HandshakeError::VersionMismatch {
        daemon: daemon_version.to_string(),
        cli: env!("CARGO_PKG_VERSION").to_string(),
    }
    .into()
}

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

    #[test]
    fn handshake_response_contains_version_and_uptime() {
        mark_daemon_started();
        let resp = handshake_response("h1");
        assert!(resp.ok);
        let data = resp.data.unwrap();
        assert_eq!(data["version"], env!("CARGO_PKG_VERSION"));
        assert!(data["uptime_secs"].as_u64().is_some());
    }
}