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 open by default (the directory containing `.wipe`). `None`
31    /// when serving purely as a global viewer from outside any board - the UI then
32    /// lists every registered project and the user picks one.
33    pub root: Option<PathBuf>,
34    /// TCP port to bind.
35    pub port: u16,
36    /// How the daemon is exposed beyond localhost.
37    pub expose: Exposure,
38    /// Whether to open a browser once bound (best-effort; currently a hint).
39    pub open: bool,
40    /// If set, the daemon shuts itself down after this long with no connected UI
41    /// clients - so auto-served daemons leave no overhead once the tab is closed.
42    pub idle_timeout: Option<std::time::Duration>,
43}
44
45/// Build the application router for a given state.
46fn router(state: AppState) -> Router {
47    Router::new()
48        .route("/api/health", get(api::health))
49        .route("/api/config", get(api::app_config).patch(api::patch_config))
50        .route("/api/scan", post(api::rescan))
51        .route("/api/projects", get(api::projects))
52        .route("/api/board", get(api::board))
53        .route("/api/history", get(api::history))
54        .route("/api/board/at", get(api::board_at))
55        .route("/api/definitions", get(api::definitions))
56        .route("/api/graph", get(api::graph))
57        .route("/api/labels", post(api::create_label))
58        .route(
59            "/api/labels/{name}",
60            patch(api::recolor_label).delete(api::delete_label),
61        )
62        .route("/api/lists", post(api::add_list))
63        .route(
64            "/api/lists/{id}",
65            patch(api::rename_list).delete(api::remove_list),
66        )
67        .route("/api/lists/{id}/move", post(api::move_list))
68        .route("/api/identities", get(api::identities))
69        .route(
70            "/api/identities/{id}",
71            put(api::put_identity).delete(api::delete_identity),
72        )
73        .route("/api/tickets", post(api::create_ticket))
74        .route("/api/tickets/{id}", patch(api::patch_ticket))
75        .route("/api/tickets/{id}/move", post(api::move_ticket))
76        .route("/api/tickets/{id}/comments", post(api::add_comment))
77        .route(
78            "/api/tickets/{id}/attachments",
79            post(api::upload_attachment).delete(api::delete_attachment),
80        )
81        .route("/api/media/{*path}", get(api::serve_media))
82        .route("/api/forum", get(api::forum_list).post(api::forum_create))
83        .route("/api/forum/search", get(api::forum_search))
84        .route(
85            "/api/forum/{id}",
86            get(api::forum_thread)
87                .patch(api::forum_edit)
88                .delete(api::forum_delete),
89        )
90        .route("/api/forum/{id}/reply", post(api::forum_reply))
91        .route("/ws", get(api::ws_handler))
92        .fallback(assets::static_handler)
93        .layer(CorsLayer::permissive())
94        .with_state(state)
95}
96
97/// Start the daemon and serve until the process is stopped (Ctrl-C).
98pub async fn serve(cfg: ServeConfig) -> anyhow::Result<()> {
99    if let Some(root) = &cfg.root {
100        registry::register(root);
101    }
102
103    let (tx, _rx) = broadcast::channel::<String>(64);
104    let clients = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
105    let state = AppState {
106        current: cfg.root.clone(),
107        tx: tx.clone(),
108        clients: clients.clone(),
109    };
110
111    // Watch the launch project's `.wipe` for live updates; keep the watcher alive
112    // for the whole serve. (Global-viewer mode has no single dir to watch; the UI
113    // still refetches on demand.)
114    let _watcher = cfg
115        .root
116        .as_ref()
117        .map(|root| watch::spawn(&root.join(".wipe"), tx.clone()));
118    if matches!(_watcher, Some(Err(_))) {
119        eprintln!("warning: file watching unavailable; live updates disabled");
120    }
121
122    let ip = match cfg.expose {
123        Exposure::None => Ipv4Addr::LOCALHOST,
124        Exposure::Tailscale | Exposure::Proxy => Ipv4Addr::UNSPECIFIED,
125    };
126    let addr = SocketAddr::from((ip, cfg.port));
127    let listener = tokio::net::TcpListener::bind(addr).await?;
128    let bound = listener.local_addr()?;
129
130    let shown = if bound.ip().is_unspecified() {
131        SocketAddr::from((Ipv4Addr::LOCALHOST, bound.port()))
132    } else {
133        bound
134    };
135    match cfg.idle_timeout {
136        Some(d) => println!(
137            "wipe UI serving on http://{shown}  (Ctrl-C to stop; auto-stops after {}s idle)",
138            d.as_secs()
139        ),
140        None => println!("wipe UI serving on http://{shown}  (Ctrl-C to stop)"),
141    }
142    if cfg.open {
143        open_browser(&format!("http://{shown}"));
144    }
145
146    let app = router(state);
147    let idle = cfg.idle_timeout;
148    axum::serve(listener, app)
149        .with_graceful_shutdown(async move { shutdown_signal(clients, idle).await })
150        .await?;
151    Ok(())
152}
153
154/// Resolve when the daemon should stop: on Ctrl-C, or - if an idle timeout is
155/// configured - once there have been no connected UI clients for that long.
156async fn shutdown_signal(
157    clients: std::sync::Arc<std::sync::atomic::AtomicUsize>,
158    idle: Option<Duration>,
159) {
160    tokio::select! {
161        _ = async { let _ = tokio::signal::ctrl_c().await; } => {}
162        _ = idle_watcher(clients, idle) => {
163            println!("wipe: idle with no viewers; shutting down.");
164        }
165    }
166}
167
168/// Completes once the daemon has been idle (zero clients) for `timeout`. If no
169/// timeout is set, never completes.
170async fn idle_watcher(
171    clients: std::sync::Arc<std::sync::atomic::AtomicUsize>,
172    timeout: Option<Duration>,
173) {
174    use std::sync::atomic::Ordering;
175    let Some(timeout) = timeout else {
176        std::future::pending::<()>().await;
177        return;
178    };
179    let mut idle_since = Some(std::time::Instant::now());
180    let mut tick = tokio::time::interval(Duration::from_secs(5));
181    loop {
182        tick.tick().await;
183        if clients.load(Ordering::SeqCst) > 0 {
184            idle_since = None;
185        } else {
186            let since = idle_since.get_or_insert_with(std::time::Instant::now);
187            if since.elapsed() >= timeout {
188                return;
189            }
190        }
191    }
192}
193
194/// Best-effort: open `url` in the user's default browser.
195fn open_browser(url: &str) {
196    #[cfg(target_os = "windows")]
197    let _ = std::process::Command::new("cmd")
198        .args(["/C", "start", "", url])
199        .spawn();
200    #[cfg(target_os = "macos")]
201    let _ = std::process::Command::new("open").arg(url).spawn();
202    #[cfg(all(unix, not(target_os = "macos")))]
203    let _ = std::process::Command::new("xdg-open").arg(url).spawn();
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use axum::body::Body;
210    use axum::http::{Request, StatusCode};
211    use tower::ServiceExt; // for `oneshot`
212
213    fn test_state(root: PathBuf) -> AppState {
214        let (tx, _rx) = broadcast::channel(8);
215        AppState {
216            current: Some(root),
217            tx,
218            clients: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
219        }
220    }
221
222    #[tokio::test]
223    async fn health_and_board_endpoints() {
224        let dir = tempfile::tempdir().unwrap();
225        let store = Store::init(dir.path(), "Daemon Test", chrono::Utc::now()).unwrap();
226        wipe_core::ops::create_ticket(
227            &store,
228            wipe_core::ops::NewTicket {
229                title: "Hello".into(),
230                ..Default::default()
231            },
232            "tester",
233            chrono::Utc::now(),
234        )
235        .unwrap();
236
237        let app = router(test_state(store.root().to_path_buf()));
238
239        let health = app
240            .clone()
241            .oneshot(
242                Request::builder()
243                    .uri("/api/health")
244                    .body(Body::empty())
245                    .unwrap(),
246            )
247            .await
248            .unwrap();
249        assert_eq!(health.status(), StatusCode::OK);
250
251        let board = app
252            .oneshot(
253                Request::builder()
254                    .uri("/api/board")
255                    .body(Body::empty())
256                    .unwrap(),
257            )
258            .await
259            .unwrap();
260        assert_eq!(board.status(), StatusCode::OK);
261        let bytes = axum::body::to_bytes(board.into_body(), 1 << 20)
262            .await
263            .unwrap();
264        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
265        assert_eq!(v["board"], "Daemon Test");
266        assert_eq!(v["lists"][0]["tickets"][0]["title"], "Hello");
267    }
268
269    use wipe_core::Store;
270
271    /// Percent-encode a string for use as a query-parameter value.
272    fn enc(s: &str) -> String {
273        s.bytes()
274            .map(|b| match b {
275                b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
276                    (b as char).to_string()
277                }
278                _ => format!("%{b:02X}"),
279            })
280            .collect()
281    }
282
283    async fn board_titles(app: &Router, project: &str) -> Vec<String> {
284        let res = app
285            .clone()
286            .oneshot(
287                Request::builder()
288                    .uri(format!("/api/board?project={}", enc(project)))
289                    .body(Body::empty())
290                    .unwrap(),
291            )
292            .await
293            .unwrap();
294        let bytes = axum::body::to_bytes(res.into_body(), 1 << 20)
295            .await
296            .unwrap();
297        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
298        v["lists"]
299            .as_array()
300            .unwrap()
301            .iter()
302            .flat_map(|l| l["tickets"].as_array().unwrap().clone())
303            .map(|t| t["title"].as_str().unwrap().to_string())
304            .collect()
305    }
306
307    /// A mutation naming a project via `?project=` must hit THAT board, never the
308    /// daemon's launch project. Guards the silent-write-to-served-board bug.
309    #[tokio::test]
310    async fn mutations_target_the_requested_project_not_the_served_one() {
311        let served = tempfile::tempdir().unwrap();
312        let other = tempfile::tempdir().unwrap();
313        let served_store = Store::init(served.path(), "Served", chrono::Utc::now()).unwrap();
314        let other_store = Store::init(other.path(), "Other", chrono::Utc::now()).unwrap();
315        let other_root = other_store.root().display().to_string();
316
317        // Daemon launched in the "Served" board.
318        let app = router(test_state(served_store.root().to_path_buf()));
319
320        // Create a ticket while viewing the OTHER board (project passed in query).
321        let res = app
322            .clone()
323            .oneshot(
324                Request::builder()
325                    .method("POST")
326                    .uri(format!("/api/tickets?project={}", enc(&other_root)))
327                    .header("content-type", "application/json")
328                    .body(Body::from(r#"{"title":"lands in other"}"#))
329                    .unwrap(),
330            )
331            .await
332            .unwrap();
333        assert_eq!(res.status(), StatusCode::OK);
334
335        // It must appear in "Other" and leave "Served" empty.
336        assert_eq!(
337            board_titles(&app, &other_root).await,
338            vec!["lands in other".to_string()]
339        );
340        assert!(
341            board_titles(&app, &served_store.root().display().to_string())
342                .await
343                .is_empty()
344        );
345    }
346}