tail-fin-daemon 0.5.0

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 night_fury_core::BrowserSession;
use night_fury_daemon_core::protocol::Response;
use serde_json::{json, Value};
use tail_fin_twitter::{TimelineType, TwitterApi, TwitterClient};

pub async fn handle(session: &BrowserSession, id: &str, cmd: &str, params: &Value) -> Response {
    let client = TwitterClient::new(session.clone());
    match cmd {
        "twitter.timeline" => {
            let kind = parse_timeline_type(params);
            let count = count_param(params, 20);
            let cursor = params
                .get("cursor")
                .and_then(|v| v.as_str())
                .map(String::from);
            match client.timeline(kind, count, cursor.as_deref()).await {
                Ok(resp) => Response::ok(
                    id,
                    json!({"tweets": resp.tweets, "next_cursor": resp.next_cursor}),
                ),
                Err(e) => Response::err(id, e.to_string()),
            }
        }
        "twitter.search" => match required_str(params, "query") {
            Ok(q) => {
                let count = count_param(params, 20);
                let cursor = params
                    .get("cursor")
                    .and_then(|v| v.as_str())
                    .map(String::from);
                match client.search(&q, count, cursor.as_deref()).await {
                    Ok(resp) => Response::ok(
                        id,
                        json!({"tweets": resp.tweets, "next_cursor": resp.next_cursor, "count": resp.tweets.len()}),
                    ),
                    Err(e) => Response::err(id, e.to_string()),
                }
            }
            Err(e) => Response::err(id, e),
        },
        other => Response::err(id, format!("unknown twitter cmd: {other}")),
    }
}

fn parse_timeline_type(params: &Value) -> TimelineType {
    match params
        .get("type")
        .and_then(|v| v.as_str())
        .unwrap_or("following")
    {
        "for-you" => TimelineType::ForYou,
        _ => TimelineType::Following,
    }
}

fn count_param(params: &Value, default: usize) -> usize {
    params
        .get("count")
        .and_then(|v| v.as_u64())
        .map(|c| c as usize)
        .unwrap_or(default)
}

fn required_str(params: &Value, key: &str) -> Result<String, String> {
    params
        .get(key)
        .and_then(|v| v.as_str())
        .map(String::from)
        .ok_or_else(|| format!("missing required param '{key}'"))
}