From f5bdf461badbfb44c0cc612a1c2b217ee4df6c5e Mon Sep 17 00:00:00 2001 From: skepsismusic Date: Fri, 6 Feb 2026 00:40:45 +0100 Subject: [PATCH] Fix production mode: resolve static/data paths relative to exe location, include static/ in release packages Co-authored-by: Cursor --- .github/workflows/release.yml | 2 ++ backend/src/config.rs | 26 +++++++++++++++++++++++--- backend/src/main.rs | 18 +++++++++++------- 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 037688c..75a617f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -73,6 +73,7 @@ jobs: RELEASE_DIR="${{ matrix.asset_name }}-${{ github.ref_name }}" mkdir -p "$RELEASE_DIR" cp "backend/target/${{ matrix.target }}/release/ironpad" "$RELEASE_DIR/" + cp -r backend/static "$RELEASE_DIR/static" cp README.md LICENSE "$RELEASE_DIR/" tar czf "$RELEASE_DIR.tar.gz" "$RELEASE_DIR" echo "ASSET=$RELEASE_DIR.tar.gz" >> $GITHUB_ENV @@ -84,6 +85,7 @@ jobs: RELEASE_DIR="${{ matrix.asset_name }}-${{ github.ref_name }}" mkdir -p "$RELEASE_DIR" cp "backend/target/${{ matrix.target }}/release/ironpad.exe" "$RELEASE_DIR/" + cp -r backend/static "$RELEASE_DIR/static" cp README.md LICENSE "$RELEASE_DIR/" 7z a "$RELEASE_DIR.zip" "$RELEASE_DIR" echo "ASSET=$RELEASE_DIR.zip" >> $GITHUB_ENV diff --git a/backend/src/config.rs b/backend/src/config.rs index 4ce2974..e92bedc 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -5,19 +5,39 @@ use std::sync::OnceLock; /// Priority: IRONPAD_DATA_DIR env var > auto-detect (production vs development). static DATA_DIR: OnceLock = OnceLock::new(); +/// Directory where the executable lives. +/// Used to resolve `static/` and `data/` in production mode. +static EXE_DIR: OnceLock = OnceLock::new(); + +/// Get the directory containing the executable. +/// Falls back to "." if detection fails. +pub fn exe_dir() -> &'static Path { + EXE_DIR.get_or_init(|| { + std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|p| p.to_path_buf())) + .unwrap_or_else(|| PathBuf::from(".")) + }) +} + +/// Check if we're in production mode (static/index.html exists next to the binary). +pub fn is_production() -> bool { + exe_dir().join("static").join("index.html").exists() +} + /// Initialize the data directory path. Call once at startup. /// /// Resolution order: /// 1. `IRONPAD_DATA_DIR` environment variable (if set) -/// 2. `./data` if `static/index.html` exists (production mode) +/// 2. `{exe_dir}/data` if `{exe_dir}/static/index.html` exists (production mode) /// 3. `../data` (development mode, binary runs from backend/) pub fn init_data_dir() { let path = if let Ok(custom) = std::env::var("IRONPAD_DATA_DIR") { tracing::info!("Using custom data directory from IRONPAD_DATA_DIR"); PathBuf::from(custom) - } else if Path::new("static/index.html").exists() { + } else if is_production() { // Production mode: data/ is next to the binary - PathBuf::from("data") + exe_dir().join("data") } else { // Development mode: binary runs from backend/, data/ is one level up PathBuf::from("../data") diff --git a/backend/src/main.rs b/backend/src/main.rs index ceaf176..e07f8bd 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,5 +1,4 @@ use std::net::SocketAddr; -use std::path::Path; use std::sync::Arc; use axum::{routing::get, Router}; @@ -95,14 +94,19 @@ async fn main() { .layer(cors); // Check for embedded frontend (production mode) - let static_dir = Path::new("static"); - let has_frontend = static_dir.join("index.html").exists(); + // Resolve relative to the executable's directory, not the working directory + let has_frontend = config::is_production(); if has_frontend { - // Production mode: serve frontend from static/ and use SPA fallback - info!("Production mode: serving frontend from static/"); - let serve_dir = ServeDir::new("static") - .fallback(tower_http::services::ServeFile::new("static/index.html")); + // Production mode: serve frontend from static/ next to the exe + let static_path = config::exe_dir().join("static"); + let index_path = static_path.join("index.html"); + info!( + "Production mode: serving frontend from {}", + static_path.display() + ); + let serve_dir = + ServeDir::new(&static_path).fallback(tower_http::services::ServeFile::new(index_path)); app = app.fallback_service(serve_dir); } else { // Development mode: API-only