1mod 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#[derive(Debug, Clone)]
29pub struct ServeConfig {
30 pub root: PathBuf,
32 pub port: u16,
34 pub expose: Exposure,
36 pub open: bool,
38 pub idle_timeout: Option<std::time::Duration>,
41}
42
43fn 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("/ws", get(api::ws_handler))
80 .fallback(assets::static_handler)
81 .layer(CorsLayer::permissive())
82 .with_state(state)
83}
84
85pub async fn serve(cfg: ServeConfig) -> anyhow::Result<()> {
87 registry::register(&cfg.root);
88
89 let (tx, _rx) = broadcast::channel::<String>(64);
90 let clients = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
91 let state = AppState {
92 current: cfg.root.clone(),
93 tx: tx.clone(),
94 clients: clients.clone(),
95 };
96
97 let _watcher = watch::spawn(&cfg.root.join(".wipe"), tx.clone());
99 if _watcher.is_err() {
100 eprintln!("warning: file watching unavailable; live updates disabled");
101 }
102
103 let ip = match cfg.expose {
104 Exposure::None => Ipv4Addr::LOCALHOST,
105 Exposure::Tailscale | Exposure::Proxy => Ipv4Addr::UNSPECIFIED,
106 };
107 let addr = SocketAddr::from((ip, cfg.port));
108 let listener = tokio::net::TcpListener::bind(addr).await?;
109 let bound = listener.local_addr()?;
110
111 let shown = if bound.ip().is_unspecified() {
112 SocketAddr::from((Ipv4Addr::LOCALHOST, bound.port()))
113 } else {
114 bound
115 };
116 match cfg.idle_timeout {
117 Some(d) => println!(
118 "wipe UI serving on http://{shown} (Ctrl-C to stop; auto-stops after {}s idle)",
119 d.as_secs()
120 ),
121 None => println!("wipe UI serving on http://{shown} (Ctrl-C to stop)"),
122 }
123 if cfg.open {
124 open_browser(&format!("http://{shown}"));
125 }
126
127 let app = router(state);
128 let idle = cfg.idle_timeout;
129 axum::serve(listener, app)
130 .with_graceful_shutdown(async move { shutdown_signal(clients, idle).await })
131 .await?;
132 Ok(())
133}
134
135async fn shutdown_signal(
138 clients: std::sync::Arc<std::sync::atomic::AtomicUsize>,
139 idle: Option<Duration>,
140) {
141 tokio::select! {
142 _ = async { let _ = tokio::signal::ctrl_c().await; } => {}
143 _ = idle_watcher(clients, idle) => {
144 println!("wipe: idle with no viewers; shutting down.");
145 }
146 }
147}
148
149async fn idle_watcher(
152 clients: std::sync::Arc<std::sync::atomic::AtomicUsize>,
153 timeout: Option<Duration>,
154) {
155 use std::sync::atomic::Ordering;
156 let Some(timeout) = timeout else {
157 std::future::pending::<()>().await;
158 return;
159 };
160 let mut idle_since = Some(std::time::Instant::now());
161 let mut tick = tokio::time::interval(Duration::from_secs(5));
162 loop {
163 tick.tick().await;
164 if clients.load(Ordering::SeqCst) > 0 {
165 idle_since = None;
166 } else {
167 let since = idle_since.get_or_insert_with(std::time::Instant::now);
168 if since.elapsed() >= timeout {
169 return;
170 }
171 }
172 }
173}
174
175fn open_browser(url: &str) {
177 #[cfg(target_os = "windows")]
178 let _ = std::process::Command::new("cmd")
179 .args(["/C", "start", "", url])
180 .spawn();
181 #[cfg(target_os = "macos")]
182 let _ = std::process::Command::new("open").arg(url).spawn();
183 #[cfg(all(unix, not(target_os = "macos")))]
184 let _ = std::process::Command::new("xdg-open").arg(url).spawn();
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190 use axum::body::Body;
191 use axum::http::{Request, StatusCode};
192 use tower::ServiceExt; fn test_state(root: PathBuf) -> AppState {
195 let (tx, _rx) = broadcast::channel(8);
196 AppState {
197 current: root,
198 tx,
199 clients: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
200 }
201 }
202
203 #[tokio::test]
204 async fn health_and_board_endpoints() {
205 let dir = tempfile::tempdir().unwrap();
206 let store = Store::init(dir.path(), "Daemon Test", chrono::Utc::now()).unwrap();
207 wipe_core::ops::create_ticket(
208 &store,
209 wipe_core::ops::NewTicket {
210 title: "Hello".into(),
211 ..Default::default()
212 },
213 "tester",
214 chrono::Utc::now(),
215 )
216 .unwrap();
217
218 let app = router(test_state(store.root().to_path_buf()));
219
220 let health = app
221 .clone()
222 .oneshot(
223 Request::builder()
224 .uri("/api/health")
225 .body(Body::empty())
226 .unwrap(),
227 )
228 .await
229 .unwrap();
230 assert_eq!(health.status(), StatusCode::OK);
231
232 let board = app
233 .oneshot(
234 Request::builder()
235 .uri("/api/board")
236 .body(Body::empty())
237 .unwrap(),
238 )
239 .await
240 .unwrap();
241 assert_eq!(board.status(), StatusCode::OK);
242 let bytes = axum::body::to_bytes(board.into_body(), 1 << 20)
243 .await
244 .unwrap();
245 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
246 assert_eq!(v["board"], "Daemon Test");
247 assert_eq!(v["lists"][0]["tickets"][0]["title"], "Hello");
248 }
249
250 use wipe_core::Store;
251}