Skip to main content

wipe_daemon/
lib.rs

1//! The wipe local daemon: an `axum` server that exposes the board over HTTP/WS
2//! and serves the embedded human UI. Started by `wipe serve`.
3//!
4//! Collaboration remains git-only; this daemon is a *local* convenience for the
5//! human UX. It records each served project in a machine-wide registry so the UI
6//! can list every board you have opened.
7
8mod api;
9mod assets;
10mod registry;
11mod watch;
12
13use std::net::{Ipv4Addr, SocketAddr};
14use std::path::PathBuf;
15
16use axum::routing::{get, patch, post, put};
17use axum::Router;
18use tokio::sync::broadcast;
19use tower_http::cors::CorsLayer;
20
21use wipe_core::model::Exposure;
22
23pub use api::AppState;
24pub use registry::{list as list_projects, ProjectEntry};
25
26/// Configuration for a `wipe serve` invocation.
27#[derive(Debug, Clone)]
28pub struct ServeConfig {
29    /// Project root to serve (the directory containing `.wipe`).
30    pub root: PathBuf,
31    /// TCP port to bind.
32    pub port: u16,
33    /// How the daemon is exposed beyond localhost.
34    pub expose: Exposure,
35    /// Whether to open a browser once bound (best-effort; currently a hint).
36    pub open: bool,
37}
38
39/// Build the application router for a given state.
40fn router(state: AppState) -> Router {
41    Router::new()
42        .route("/api/health", get(api::health))
43        .route("/api/projects", get(api::projects))
44        .route("/api/board", get(api::board))
45        .route("/api/history", get(api::history))
46        .route("/api/board/at", get(api::board_at))
47        .route("/api/definitions", get(api::definitions))
48        .route("/api/graph", get(api::graph))
49        .route("/api/labels", post(api::create_label))
50        .route(
51            "/api/labels/{name}",
52            patch(api::recolor_label).delete(api::delete_label),
53        )
54        .route("/api/lists", post(api::add_list))
55        .route(
56            "/api/lists/{id}",
57            patch(api::rename_list).delete(api::remove_list),
58        )
59        .route("/api/lists/{id}/move", post(api::move_list))
60        .route("/api/identities", get(api::identities))
61        .route("/api/identities/{id}", put(api::put_identity))
62        .route("/api/tickets", post(api::create_ticket))
63        .route("/api/tickets/{id}", patch(api::patch_ticket))
64        .route("/api/tickets/{id}/move", post(api::move_ticket))
65        .route("/api/tickets/{id}/comments", post(api::add_comment))
66        .route(
67            "/api/tickets/{id}/attachments",
68            post(api::upload_attachment).delete(api::delete_attachment),
69        )
70        .route("/api/media/{*path}", get(api::serve_media))
71        .route("/ws", get(api::ws_handler))
72        .fallback(assets::static_handler)
73        .layer(CorsLayer::permissive())
74        .with_state(state)
75}
76
77/// Start the daemon and serve until the process is stopped (Ctrl-C).
78pub async fn serve(cfg: ServeConfig) -> anyhow::Result<()> {
79    registry::register(&cfg.root);
80
81    let (tx, _rx) = broadcast::channel::<String>(64);
82    let state = AppState {
83        current: cfg.root.clone(),
84        tx: tx.clone(),
85    };
86
87    // Watch `.wipe` for live updates; keep the watcher alive for the whole serve.
88    let _watcher = watch::spawn(&cfg.root.join(".wipe"), tx.clone());
89    if _watcher.is_err() {
90        eprintln!("warning: file watching unavailable; live updates disabled");
91    }
92
93    let ip = match cfg.expose {
94        Exposure::None => Ipv4Addr::LOCALHOST,
95        Exposure::Tailscale | Exposure::Proxy => Ipv4Addr::UNSPECIFIED,
96    };
97    let addr = SocketAddr::from((ip, cfg.port));
98    let listener = tokio::net::TcpListener::bind(addr).await?;
99    let bound = listener.local_addr()?;
100
101    let shown = if bound.ip().is_unspecified() {
102        SocketAddr::from((Ipv4Addr::LOCALHOST, bound.port()))
103    } else {
104        bound
105    };
106    println!("wipe UI serving on http://{shown}  (Ctrl-C to stop)");
107    if cfg.open {
108        open_browser(&format!("http://{shown}"));
109    }
110
111    let app = router(state);
112    axum::serve(listener, app)
113        .with_graceful_shutdown(shutdown_signal())
114        .await?;
115    Ok(())
116}
117
118async fn shutdown_signal() {
119    let _ = tokio::signal::ctrl_c().await;
120}
121
122/// Best-effort: open `url` in the user's default browser.
123fn open_browser(url: &str) {
124    #[cfg(target_os = "windows")]
125    let _ = std::process::Command::new("cmd")
126        .args(["/C", "start", "", url])
127        .spawn();
128    #[cfg(target_os = "macos")]
129    let _ = std::process::Command::new("open").arg(url).spawn();
130    #[cfg(all(unix, not(target_os = "macos")))]
131    let _ = std::process::Command::new("xdg-open").arg(url).spawn();
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use axum::body::Body;
138    use axum::http::{Request, StatusCode};
139    use tower::ServiceExt; // for `oneshot`
140
141    fn test_state(root: PathBuf) -> AppState {
142        let (tx, _rx) = broadcast::channel(8);
143        AppState { current: root, tx }
144    }
145
146    #[tokio::test]
147    async fn health_and_board_endpoints() {
148        let dir = tempfile::tempdir().unwrap();
149        let store = Store::init(dir.path(), "Daemon Test", chrono::Utc::now()).unwrap();
150        wipe_core::ops::create_ticket(
151            &store,
152            wipe_core::ops::NewTicket {
153                title: "Hello".into(),
154                ..Default::default()
155            },
156            chrono::Utc::now(),
157        )
158        .unwrap();
159
160        let app = router(test_state(store.root().to_path_buf()));
161
162        let health = app
163            .clone()
164            .oneshot(
165                Request::builder()
166                    .uri("/api/health")
167                    .body(Body::empty())
168                    .unwrap(),
169            )
170            .await
171            .unwrap();
172        assert_eq!(health.status(), StatusCode::OK);
173
174        let board = app
175            .oneshot(
176                Request::builder()
177                    .uri("/api/board")
178                    .body(Body::empty())
179                    .unwrap(),
180            )
181            .await
182            .unwrap();
183        assert_eq!(board.status(), StatusCode::OK);
184        let bytes = axum::body::to_bytes(board.into_body(), 1 << 20)
185            .await
186            .unwrap();
187        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
188        assert_eq!(v["board"], "Daemon Test");
189        assert_eq!(v["lists"][0]["tickets"][0]["title"], "Hello");
190    }
191
192    use wipe_core::Store;
193}