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;
15use std::time::Duration;
16
17use axum::routing::{get, patch, post, put};
18use axum::Router;
19use tokio::sync::broadcast;
20use tower_http::cors::CorsLayer;
21
22use wipe_core::model::Exposure;
23
24pub use api::AppState;
25pub use registry::{list as list_projects, ProjectEntry};
26
27/// Configuration for a `wipe serve` invocation.
28#[derive(Debug, Clone)]
29pub struct ServeConfig {
30    /// Project root to serve (the directory containing `.wipe`).
31    pub root: PathBuf,
32    /// TCP port to bind.
33    pub port: u16,
34    /// How the daemon is exposed beyond localhost.
35    pub expose: Exposure,
36    /// Whether to open a browser once bound (best-effort; currently a hint).
37    pub open: bool,
38    /// If set, the daemon shuts itself down after this long with no connected UI
39    /// clients - so auto-served daemons leave no overhead once the tab is closed.
40    pub idle_timeout: Option<std::time::Duration>,
41}
42
43/// Build the application router for a given state.
44fn router(state: AppState) -> Router {
45    Router::new()
46        .route("/api/health", get(api::health))
47        .route("/api/config", get(api::app_config))
48        .route("/api/projects", get(api::projects))
49        .route("/api/board", get(api::board))
50        .route("/api/history", get(api::history))
51        .route("/api/board/at", get(api::board_at))
52        .route("/api/definitions", get(api::definitions))
53        .route("/api/graph", get(api::graph))
54        .route("/api/labels", post(api::create_label))
55        .route(
56            "/api/labels/{name}",
57            patch(api::recolor_label).delete(api::delete_label),
58        )
59        .route("/api/lists", post(api::add_list))
60        .route(
61            "/api/lists/{id}",
62            patch(api::rename_list).delete(api::remove_list),
63        )
64        .route("/api/lists/{id}/move", post(api::move_list))
65        .route("/api/identities", get(api::identities))
66        .route(
67            "/api/identities/{id}",
68            put(api::put_identity).delete(api::delete_identity),
69        )
70        .route("/api/tickets", post(api::create_ticket))
71        .route("/api/tickets/{id}", patch(api::patch_ticket))
72        .route("/api/tickets/{id}/move", post(api::move_ticket))
73        .route("/api/tickets/{id}/comments", post(api::add_comment))
74        .route(
75            "/api/tickets/{id}/attachments",
76            post(api::upload_attachment).delete(api::delete_attachment),
77        )
78        .route("/api/media/{*path}", get(api::serve_media))
79        .route("/api/forum", get(api::forum_list).post(api::forum_create))
80        .route("/api/forum/search", get(api::forum_search))
81        .route(
82            "/api/forum/{id}",
83            get(api::forum_thread)
84                .patch(api::forum_edit)
85                .delete(api::forum_delete),
86        )
87        .route("/api/forum/{id}/reply", post(api::forum_reply))
88        .route("/ws", get(api::ws_handler))
89        .fallback(assets::static_handler)
90        .layer(CorsLayer::permissive())
91        .with_state(state)
92}
93
94/// Start the daemon and serve until the process is stopped (Ctrl-C).
95pub async fn serve(cfg: ServeConfig) -> anyhow::Result<()> {
96    registry::register(&cfg.root);
97
98    let (tx, _rx) = broadcast::channel::<String>(64);
99    let clients = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
100    let state = AppState {
101        current: cfg.root.clone(),
102        tx: tx.clone(),
103        clients: clients.clone(),
104    };
105
106    // Watch `.wipe` for live updates; keep the watcher alive for the whole serve.
107    let _watcher = watch::spawn(&cfg.root.join(".wipe"), tx.clone());
108    if _watcher.is_err() {
109        eprintln!("warning: file watching unavailable; live updates disabled");
110    }
111
112    let ip = match cfg.expose {
113        Exposure::None => Ipv4Addr::LOCALHOST,
114        Exposure::Tailscale | Exposure::Proxy => Ipv4Addr::UNSPECIFIED,
115    };
116    let addr = SocketAddr::from((ip, cfg.port));
117    let listener = tokio::net::TcpListener::bind(addr).await?;
118    let bound = listener.local_addr()?;
119
120    let shown = if bound.ip().is_unspecified() {
121        SocketAddr::from((Ipv4Addr::LOCALHOST, bound.port()))
122    } else {
123        bound
124    };
125    match cfg.idle_timeout {
126        Some(d) => println!(
127            "wipe UI serving on http://{shown}  (Ctrl-C to stop; auto-stops after {}s idle)",
128            d.as_secs()
129        ),
130        None => println!("wipe UI serving on http://{shown}  (Ctrl-C to stop)"),
131    }
132    if cfg.open {
133        open_browser(&format!("http://{shown}"));
134    }
135
136    let app = router(state);
137    let idle = cfg.idle_timeout;
138    axum::serve(listener, app)
139        .with_graceful_shutdown(async move { shutdown_signal(clients, idle).await })
140        .await?;
141    Ok(())
142}
143
144/// Resolve when the daemon should stop: on Ctrl-C, or - if an idle timeout is
145/// configured - once there have been no connected UI clients for that long.
146async fn shutdown_signal(
147    clients: std::sync::Arc<std::sync::atomic::AtomicUsize>,
148    idle: Option<Duration>,
149) {
150    tokio::select! {
151        _ = async { let _ = tokio::signal::ctrl_c().await; } => {}
152        _ = idle_watcher(clients, idle) => {
153            println!("wipe: idle with no viewers; shutting down.");
154        }
155    }
156}
157
158/// Completes once the daemon has been idle (zero clients) for `timeout`. If no
159/// timeout is set, never completes.
160async fn idle_watcher(
161    clients: std::sync::Arc<std::sync::atomic::AtomicUsize>,
162    timeout: Option<Duration>,
163) {
164    use std::sync::atomic::Ordering;
165    let Some(timeout) = timeout else {
166        std::future::pending::<()>().await;
167        return;
168    };
169    let mut idle_since = Some(std::time::Instant::now());
170    let mut tick = tokio::time::interval(Duration::from_secs(5));
171    loop {
172        tick.tick().await;
173        if clients.load(Ordering::SeqCst) > 0 {
174            idle_since = None;
175        } else {
176            let since = idle_since.get_or_insert_with(std::time::Instant::now);
177            if since.elapsed() >= timeout {
178                return;
179            }
180        }
181    }
182}
183
184/// Best-effort: open `url` in the user's default browser.
185fn open_browser(url: &str) {
186    #[cfg(target_os = "windows")]
187    let _ = std::process::Command::new("cmd")
188        .args(["/C", "start", "", url])
189        .spawn();
190    #[cfg(target_os = "macos")]
191    let _ = std::process::Command::new("open").arg(url).spawn();
192    #[cfg(all(unix, not(target_os = "macos")))]
193    let _ = std::process::Command::new("xdg-open").arg(url).spawn();
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use axum::body::Body;
200    use axum::http::{Request, StatusCode};
201    use tower::ServiceExt; // for `oneshot`
202
203    fn test_state(root: PathBuf) -> AppState {
204        let (tx, _rx) = broadcast::channel(8);
205        AppState {
206            current: root,
207            tx,
208            clients: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
209        }
210    }
211
212    #[tokio::test]
213    async fn health_and_board_endpoints() {
214        let dir = tempfile::tempdir().unwrap();
215        let store = Store::init(dir.path(), "Daemon Test", chrono::Utc::now()).unwrap();
216        wipe_core::ops::create_ticket(
217            &store,
218            wipe_core::ops::NewTicket {
219                title: "Hello".into(),
220                ..Default::default()
221            },
222            "tester",
223            chrono::Utc::now(),
224        )
225        .unwrap();
226
227        let app = router(test_state(store.root().to_path_buf()));
228
229        let health = app
230            .clone()
231            .oneshot(
232                Request::builder()
233                    .uri("/api/health")
234                    .body(Body::empty())
235                    .unwrap(),
236            )
237            .await
238            .unwrap();
239        assert_eq!(health.status(), StatusCode::OK);
240
241        let board = app
242            .oneshot(
243                Request::builder()
244                    .uri("/api/board")
245                    .body(Body::empty())
246                    .unwrap(),
247            )
248            .await
249            .unwrap();
250        assert_eq!(board.status(), StatusCode::OK);
251        let bytes = axum::body::to_bytes(board.into_body(), 1 << 20)
252            .await
253            .unwrap();
254        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
255        assert_eq!(v["board"], "Daemon Test");
256        assert_eq!(v["lists"][0]["tickets"][0]["title"], "Hello");
257    }
258
259    use wipe_core::Store;
260}