Skip to main content

tokenburn_web/
app.rs

1//! Server setup: options, router construction and the serve loop.
2
3use topcoat::{
4    asset::{AssetBundle, RouterBuilderAssetExt},
5    context::{Cx, app_context},
6    router::{Router, RouterBuilderDiscoverExt},
7    runtime::RouterBuilderRuntimeExt,
8};
9
10/// Where and how to serve.
11#[derive(Debug, Clone)]
12pub struct ServerOptions {
13    /// Interface to bind. Defaults to `127.0.0.1`: token/cost data is private.
14    pub host: String,
15    pub port: u16,
16    /// Open the dashboard in the default browser once the server is listening.
17    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/// Whether the browser runtime (signals/shards/WebSocket) is available.
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub struct Interactive(pub bool);
33
34/// Read the [`Interactive`] flag from the router's app context.
35pub(crate) fn is_interactive(cx: &Cx) -> bool {
36    app_context::<Interactive>(cx).0
37}
38
39/// Build the router. `Some(bundle)` enables live mode; `None` serves the
40/// static dashboard.
41pub 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
53/// Directories searched for the Topcoat asset bundle (the browser runtime),
54/// in order. `exe_dir` is the directory of the running executable.
55///
56/// 1. `$TOKENBURN_WEB_ASSETS`
57/// 2. `<exe_dir>/assets` — release archives, `cargo run`, Windows installs
58/// 3. `<exe_dir>/../share/tokenburn-web/assets` — Homebrew / prefix installs
59/// 4. `/usr/share/tokenburn-web/assets`, `/usr/local/share/tokenburn-web/assets` — .deb/.rpm/AUR
60pub 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
76/// Find and load the asset bundle, or `None` (→ static dashboard).
77fn 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
88/// Bind and serve until the process is killed.
89pub 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        // The listener is already bound, so the browser can connect right away.
113        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    // Spending limits: look every 30 s, page open or not, and raise a desktop
119    // notification when a level is crossed.
120    tokio::spawn(async {
121        loop {
122            for a in crate::data::watch_limits().await {
123                println!("tokenburn-web: {}", a.title());
124            }
125            tokio::time::sleep(crate::data::WATCH_EVERY).await;
126        }
127    });
128    // Exit immediately on Ctrl+C / SIGTERM: open live-update streams would
129    // otherwise hold graceful shutdown until its timeout.
130    tokio::select! {
131        r = topcoat::serve(listener, router(bundle)) => r,
132        () = crate::lifecycle::shutdown_signal() => Ok(()),
133    }
134}
135
136/// URL to open for a bound address (`0.0.0.0` / `::` are not browsable → localhost).
137pub fn browser_url(addr: std::net::SocketAddr) -> String {
138    if addr.ip().is_unspecified() {
139        format!("http://localhost:{}", addr.port())
140    } else if addr.is_ipv6() {
141        format!("http://[{}]:{}", addr.ip(), addr.port())
142    } else {
143        format!("http://{addr}")
144    }
145}
146
147/// Open `url` with the platform's default handler (no extra dependency).
148fn open_in_browser(url: &str) -> std::io::Result<()> {
149    use std::process::{Command, Stdio};
150
151    #[cfg(target_os = "macos")]
152    let mut cmd = Command::new("open");
153    #[cfg(target_os = "windows")]
154    let mut cmd = {
155        let mut c = Command::new("cmd");
156        c.args(["/C", "start", ""]);
157        c
158    };
159    #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
160    let mut cmd = Command::new("xdg-open");
161
162    cmd.arg(url)
163        .stdin(Stdio::null())
164        .stdout(Stdio::null())
165        .stderr(Stdio::null())
166        .spawn()
167        .map(|_| ())
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn bundle_search_order() {
176        let exe = std::path::Path::new("/opt/tb");
177        let c = bundle_candidates(Some(exe), Some("/custom".into()));
178        assert_eq!(c[0], std::path::PathBuf::from("/custom"));
179        assert_eq!(c[1], std::path::PathBuf::from("/opt/tb/assets"));
180        assert!(
181            c.iter()
182                .any(|p| p.ends_with("usr/share/tokenburn-web/assets"))
183        );
184    }
185
186    #[test]
187    fn empty_override_and_unknown_exe_dir_are_tolerated() {
188        let c = bundle_candidates(None, Some("".into()));
189        assert!(c.iter().all(|p| p.starts_with("/usr")));
190    }
191
192    #[test]
193    fn unspecified_addresses_open_localhost() {
194        assert_eq!(
195            browser_url("0.0.0.0:3000".parse().unwrap()),
196            "http://localhost:3000"
197        );
198        assert_eq!(
199            browser_url("[::]:8080".parse().unwrap()),
200            "http://localhost:8080"
201        );
202    }
203
204    #[test]
205    fn concrete_addresses_are_used_as_is() {
206        assert_eq!(
207            browser_url("127.0.0.1:3000".parse().unwrap()),
208            "http://127.0.0.1:3000"
209        );
210        assert_eq!(
211            browser_url("[::1]:3000".parse().unwrap()),
212            "http://[::1]:3000"
213        );
214    }
215}