* fix: require magic string in portable marker to prevent false portable mode Scoop's extras bucket extracts the NSIS installer via #/dl.7z, which can leave a stale empty portable file next to the exe. This caused portable::init() to falsely trigger portable mode on non-portable scoop installs, storing data in Data/ next to the exe (wiped on each update). Changes: - portable.rs: only enable portable mode when marker contains "Handy Portable Mode"; extracted is_valid_portable_marker() with tests - installer.nsi: write magic string when creating marker file - installer.nsi: validate magic string in update auto-detect block - installer.nsi: allow desktop shortcut creation from finish page checkbox in portable mode (was silently doing nothing) - commands/mod.rs + lib.rs: expose is_portable() as a Tauri command - UpdateChecker.tsx: show manual-update popup for portable installs instead of attempting auto-install (which would download MSI) - en/translation.json: add i18n strings for portable update popup Fixes #1124 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: add missing translation keys to all locales and fix prettier formatting Add portableUpdateTitle, portableUpdateMessage, portableUpdateButton to all 17 non-English locales (English fallback text) so check:translations passes. Also run prettier on the PR's modified files to fix format:check. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: migrate legacy empty portable markers from v0.8.0 v0.8.0 created an empty `portable` marker file. Users who manually update (drop new exe in place) would silently lose portable mode since the new magic-string check rejects empty files. Migration: if the marker exists but has invalid/empty content AND a `Data/` directory is present, treat it as a real portable install and rewrite the marker with the magic string. This handles the v0.8.0 → new upgrade path without regressing the scoop false-positive fix (scoop does not create a Data/ directory). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
166 lines
5.8 KiB
Rust
166 lines
5.8 KiB
Rust
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<Option<PathBuf>> = 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<PathBuf, tauri::Error> {
|
|
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<PathBuf, tauri::Error> {
|
|
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<PathBuf, tauri::Error> {
|
|
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();
|
|
}
|
|
}
|