1use topcoat::{
4 asset::{AssetBundle, RouterBuilderAssetExt},
5 context::{Cx, app_context},
6 router::{Router, RouterBuilderDiscoverExt},
7 runtime::RouterBuilderRuntimeExt,
8};
9
10#[derive(Debug, Clone)]
12pub struct ServerOptions {
13 pub host: String,
15 pub port: u16,
16 pub open_browser: bool,
18}
19
20impl Default for ServerOptions {
21 fn default() -> Self {
22 Self {
23 host: "127.0.0.1".into(),
24 port: 3000,
25 open_browser: false,
26 }
27 }
28}
29
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub struct Interactive(pub bool);
33
34pub(crate) fn is_interactive(cx: &Cx) -> bool {
36 app_context::<Interactive>(cx).0
37}
38
39pub fn router(bundle: Option<AssetBundle>) -> Router {
42 let builder = Router::builder().discover();
43 match bundle {
44 Some(bundle) => builder
45 .app_context(Interactive(true))
46 .assets(bundle)
47 .runtime()
48 .build(),
49 None => builder.app_context(Interactive(false)).build(),
50 }
51}
52
53pub fn bundle_candidates(
61 exe_dir: Option<&std::path::Path>,
62 env_override: Option<std::ffi::OsString>,
63) -> Vec<std::path::PathBuf> {
64 use std::path::PathBuf;
65 let mut out: Vec<PathBuf> = Vec::new();
66 out.extend(env_override.filter(|v| !v.is_empty()).map(PathBuf::from));
67 if let Some(dir) = exe_dir {
68 out.push(dir.join("assets"));
69 out.push(dir.join("../share/tokenburn-web/assets"));
70 }
71 out.push(PathBuf::from("/usr/share/tokenburn-web/assets"));
72 out.push(PathBuf::from("/usr/local/share/tokenburn-web/assets"));
73 out
74}
75
76fn find_bundle() -> Option<AssetBundle> {
78 let exe_dir = std::env::current_exe()
79 .ok()
80 .and_then(|p| p.parent().map(std::path::Path::to_path_buf));
81 let candidates =
82 bundle_candidates(exe_dir.as_deref(), std::env::var_os("TOKENBURN_WEB_ASSETS"));
83 candidates
84 .iter()
85 .find_map(|dir| AssetBundle::load_dir(dir).ok())
86}
87
88pub async fn serve(opts: ServerOptions) -> std::io::Result<()> {
90 let bundle = find_bundle();
91 if bundle.is_none() {
92 eprintln!(
93 "tokenburn-web: no asset bundle found; serving the static dashboard.\n\
94 For the live version run `just run-web`, use a release archive/package, or set \
95 TOKENBURN_WEB_ASSETS to a `topcoat asset bundle` directory."
96 );
97 }
98 let live = bundle.is_some();
99
100 let listener = tokio::net::TcpListener::bind((opts.host.as_str(), opts.port)).await?;
101 let addr = listener.local_addr()?;
102 println!(
103 "tokenburn-web listening on http://{addr} ({})",
104 if live { "live" } else { "static" }
105 );
106 if !addr.ip().is_loopback() {
107 eprintln!(
108 "tokenburn-web: warning — bound to {addr}; there is no auth, anyone who can reach it sees your token usage."
109 );
110 }
111 if opts.open_browser {
112 let url = browser_url(addr);
114 if let Err(e) = open_in_browser(&url) {
115 eprintln!("tokenburn-web: could not open a browser ({e}); visit {url}");
116 }
117 }
118 tokio::select! {
121 r = topcoat::serve(listener, router(bundle)) => r,
122 () = crate::lifecycle::shutdown_signal() => Ok(()),
123 }
124}
125
126pub fn browser_url(addr: std::net::SocketAddr) -> String {
128 if addr.ip().is_unspecified() {
129 format!("http://localhost:{}", addr.port())
130 } else if addr.is_ipv6() {
131 format!("http://[{}]:{}", addr.ip(), addr.port())
132 } else {
133 format!("http://{addr}")
134 }
135}
136
137fn open_in_browser(url: &str) -> std::io::Result<()> {
139 use std::process::{Command, Stdio};
140
141 #[cfg(target_os = "macos")]
142 let mut cmd = Command::new("open");
143 #[cfg(target_os = "windows")]
144 let mut cmd = {
145 let mut c = Command::new("cmd");
146 c.args(["/C", "start", ""]);
147 c
148 };
149 #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
150 let mut cmd = Command::new("xdg-open");
151
152 cmd.arg(url)
153 .stdin(Stdio::null())
154 .stdout(Stdio::null())
155 .stderr(Stdio::null())
156 .spawn()
157 .map(|_| ())
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 #[test]
165 fn bundle_search_order() {
166 let exe = std::path::Path::new("/opt/tb");
167 let c = bundle_candidates(Some(exe), Some("/custom".into()));
168 assert_eq!(c[0], std::path::PathBuf::from("/custom"));
169 assert_eq!(c[1], std::path::PathBuf::from("/opt/tb/assets"));
170 assert!(
171 c.iter()
172 .any(|p| p.ends_with("usr/share/tokenburn-web/assets"))
173 );
174 }
175
176 #[test]
177 fn empty_override_and_unknown_exe_dir_are_tolerated() {
178 let c = bundle_candidates(None, Some("".into()));
179 assert!(c.iter().all(|p| p.starts_with("/usr")));
180 }
181
182 #[test]
183 fn unspecified_addresses_open_localhost() {
184 assert_eq!(
185 browser_url("0.0.0.0:3000".parse().unwrap()),
186 "http://localhost:3000"
187 );
188 assert_eq!(
189 browser_url("[::]:8080".parse().unwrap()),
190 "http://localhost:8080"
191 );
192 }
193
194 #[test]
195 fn concrete_addresses_are_used_as_is() {
196 assert_eq!(
197 browser_url("127.0.0.1:3000".parse().unwrap()),
198 "http://127.0.0.1:3000"
199 );
200 assert_eq!(
201 browser_url("[::1]:3000".parse().unwrap()),
202 "http://[::1]:3000"
203 );
204 }
205}