use std::path::PathBuf; use std::sync::OnceLock; use tauri::Manager; /// Portable mode support for Handy. /// /// When a file named `portable` exists next to the executable, all user data /// (settings, models, recordings, database, logs) is stored in a `Data/` /// directory alongside the executable instead of `%APPDATA%`. static PORTABLE_DATA_DIR: OnceLock> = OnceLock::new(); /// Detect portable mode by looking for a `portable` marker file next to the exe. /// Must be called once at startup before Tauri initializes. pub fn init() { PORTABLE_DATA_DIR.get_or_init(|| { let exe_path = std::env::current_exe().ok()?; let exe_dir = exe_path.parent()?; let marker_path = exe_dir.join("portable"); let data_dir = exe_dir.join("Data"); let is_portable = if is_valid_portable_marker(&marker_path) { true } else if marker_path.exists() && data_dir.exists() { // Migration: v0.8.0 created an empty marker file. If we find an // empty/invalid marker alongside an existing Data/ dir, this is a // real portable install — upgrade the marker in place. eprintln!("[portable] upgrading legacy empty marker to magic string"); let _ = std::fs::write(&marker_path, "Handy Portable Mode"); true } else { false }; if is_portable { if !data_dir.exists() { std::fs::create_dir_all(&data_dir).ok()?; } eprintln!("[portable] data dir: {}", data_dir.display()); Some(data_dir) } else { None } }); } /// Returns `true` if running in portable mode. pub fn is_portable() -> bool { PORTABLE_DATA_DIR.get().and_then(|v| v.as_ref()).is_some() } /// Get the portable data dir (if active). Does not require an AppHandle. /// Returns `None` when not in portable mode. pub fn data_dir() -> Option<&'static PathBuf> { PORTABLE_DATA_DIR.get().and_then(|v| v.as_ref()) } /// Portable-aware replacement for `app.path().app_data_dir()`. pub fn app_data_dir(app: &tauri::AppHandle) -> Result { if let Some(dir) = data_dir() { Ok(dir.clone()) } else { app.path().app_data_dir() } } /// Portable-aware replacement for `app.path().app_log_dir()`. pub fn app_log_dir(app: &tauri::AppHandle) -> Result { if let Some(dir) = data_dir() { Ok(dir.join("logs")) } else { app.path().app_log_dir() } } /// Resolve a relative path against the app data directory (portable-aware). /// Replaces `app.path().resolve(path, BaseDirectory::AppData)`. pub fn resolve_app_data(app: &tauri::AppHandle, relative: &str) -> Result { Ok(app_data_dir(app)?.join(relative)) } /// Get the path to use with `tauri-plugin-store`. /// Returns an absolute path in portable mode (so the store plugin writes to /// the portable Data dir) or the original relative path otherwise. pub fn store_path(relative: &str) -> PathBuf { if let Some(dir) = data_dir() { dir.join(relative) } else { PathBuf::from(relative) } } /// Check if a marker file path contains the portable magic string. /// Extracted for testability. fn is_valid_portable_marker(path: &std::path::Path) -> bool { std::fs::read_to_string(path) .map(|s| s.trim().starts_with("Handy Portable Mode")) .unwrap_or(false) } #[cfg(test)] mod tests { use super::*; use std::io::Write; #[test] fn test_valid_magic_string_enables_portable() { let dir = std::env::temp_dir().join("handy_test_valid"); std::fs::create_dir_all(&dir).unwrap(); let marker = dir.join("portable"); let mut f = std::fs::File::create(&marker).unwrap(); write!(f, "Handy Portable Mode").unwrap(); assert!(is_valid_portable_marker(&marker)); std::fs::remove_dir_all(dir).unwrap(); } #[test] fn test_empty_file_does_not_enable_portable() { let dir = std::env::temp_dir().join("handy_test_empty"); std::fs::create_dir_all(&dir).unwrap(); let marker = dir.join("portable"); std::fs::File::create(&marker).unwrap(); assert!(!is_valid_portable_marker(&marker)); std::fs::remove_dir_all(dir).unwrap(); } #[test] fn test_wrong_content_does_not_enable_portable() { let dir = std::env::temp_dir().join("handy_test_wrong"); std::fs::create_dir_all(&dir).unwrap(); let marker = dir.join("portable"); let mut f = std::fs::File::create(&marker).unwrap(); write!(f, "some other content").unwrap(); assert!(!is_valid_portable_marker(&marker)); std::fs::remove_dir_all(dir).unwrap(); } #[test] fn test_missing_file_does_not_enable_portable() { let path = std::path::Path::new("/nonexistent/portable"); assert!(!is_valid_portable_marker(path)); } #[test] fn test_legacy_empty_marker_without_data_dir_does_not_enable_portable() { // Empty marker alone (scoop scenario) — no Data/ dir → not portable let dir = std::env::temp_dir().join("handy_test_legacy_no_data"); std::fs::create_dir_all(&dir).unwrap(); let marker = dir.join("portable"); std::fs::File::create(&marker).unwrap(); assert!(!is_valid_portable_marker(&marker)); std::fs::remove_dir_all(dir).unwrap(); } #[test] fn test_magic_string_with_whitespace_enables_portable() { let dir = std::env::temp_dir().join("handy_test_ws"); std::fs::create_dir_all(&dir).unwrap(); let marker = dir.join("portable"); let mut f = std::fs::File::create(&marker).unwrap(); write!(f, " Handy Portable Mode\n").unwrap(); assert!(is_valid_portable_marker(&marker)); std::fs::remove_dir_all(dir).unwrap(); } }