1mod 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#[derive(Debug, Clone)]
28pub struct ServeConfig {
29 pub root: PathBuf,
31 pub port: u16,
33 pub expose: Exposure,
35 pub open: bool,
37}
38
39fn 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(
62 "/api/identities/{id}",
63 put(api::put_identity).delete(api::delete_identity),
64 )
65 .route("/api/tickets", post(api::create_ticket))
66 .route("/api/tickets/{id}", patch(api::patch_ticket))
67 .route("/api/tickets/{id}/move", post(api::move_ticket))
68 .route("/api/tickets/{id}/comments", post(api::add_comment))
69 .route(
70 "/api/tickets/{id}/attachments",
71 post(api::upload_attachment).delete(api::delete_attachment),
72 )
73 .route("/api/media/{*path}", get(api::serve_media))
74 .route("/ws", get(api::ws_handler))
75 .fallback(assets::static_handler)
76 .layer(CorsLayer::permissive())
77 .with_state(state)
78}
79
80pub async fn serve(cfg: ServeConfig) -> anyhow::Result<()> {
82 registry::register(&cfg.root);
83
84 let (tx, _rx) = broadcast::channel::<String>(64);
85 let state = AppState {
86 current: cfg.root.clone(),
87 tx: tx.clone(),
88 };
89
90 let _watcher = watch::spawn(&cfg.root.join(".wipe"), tx.clone());
92 if _watcher.is_err() {
93 eprintln!("warning: file watching unavailable; live updates disabled");
94 }
95
96 let ip = match cfg.expose {
97 Exposure::None => Ipv4Addr::LOCALHOST,
98 Exposure::Tailscale | Exposure::Proxy => Ipv4Addr::UNSPECIFIED,
99 };
100 let addr = SocketAddr::from((ip, cfg.port));
101 let listener = tokio::net::TcpListener::bind(addr).await?;
102 let bound = listener.local_addr()?;
103
104 let shown = if bound.ip().is_unspecified() {
105 SocketAddr::from((Ipv4Addr::LOCALHOST, bound.port()))
106 } else {
107 bound
108 };
109 println!("wipe UI serving on http://{shown} (Ctrl-C to stop)");
110 if cfg.open {
111 open_browser(&format!("http://{shown}"));
112 }
113
114 let app = router(state);
115 axum::serve(listener, app)
116 .with_graceful_shutdown(shutdown_signal())
117 .await?;
118 Ok(())
119}
120
121async fn shutdown_signal() {
122 let _ = tokio::signal::ctrl_c().await;
123}
124
125fn open_browser(url: &str) {
127 #[cfg(target_os = "windows")]
128 let _ = std::process::Command::new("cmd")
129 .args(["/C", "start", "", url])
130 .spawn();
131 #[cfg(target_os = "macos")]
132 let _ = std::process::Command::new("open").arg(url).spawn();
133 #[cfg(all(unix, not(target_os = "macos")))]
134 let _ = std::process::Command::new("xdg-open").arg(url).spawn();
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140 use axum::body::Body;
141 use axum::http::{Request, StatusCode};
142 use tower::ServiceExt; fn test_state(root: PathBuf) -> AppState {
145 let (tx, _rx) = broadcast::channel(8);
146 AppState { current: root, tx }
147 }
148
149 #[tokio::test]
150 async fn health_and_board_endpoints() {
151 let dir = tempfile::tempdir().unwrap();
152 let store = Store::init(dir.path(), "Daemon Test", chrono::Utc::now()).unwrap();
153 wipe_core::ops::create_ticket(
154 &store,
155 wipe_core::ops::NewTicket {
156 title: "Hello".into(),
157 ..Default::default()
158 },
159 "tester",
160 chrono::Utc::now(),
161 )
162 .unwrap();
163
164 let app = router(test_state(store.root().to_path_buf()));
165
166 let health = app
167 .clone()
168 .oneshot(
169 Request::builder()
170 .uri("/api/health")
171 .body(Body::empty())
172 .unwrap(),
173 )
174 .await
175 .unwrap();
176 assert_eq!(health.status(), StatusCode::OK);
177
178 let board = app
179 .oneshot(
180 Request::builder()
181 .uri("/api/board")
182 .body(Body::empty())
183 .unwrap(),
184 )
185 .await
186 .unwrap();
187 assert_eq!(board.status(), StatusCode::OK);
188 let bytes = axum::body::to_bytes(board.into_body(), 1 << 20)
189 .await
190 .unwrap();
191 let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
192 assert_eq!(v["board"], "Daemon Test");
193 assert_eq!(v["lists"][0]["tickets"][0]["title"], "Hello");
194 }
195
196 use wipe_core::Store;
197}