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("/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
94pub 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 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
144async 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
158async 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
184fn 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; 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}