feat: add portable mode to NSIS installer (#807)
* ci: add temporary test workflow for portable ZIP validation * ci: fix mock setup in test workflow * ci: remove temporary test workflow * feat: add portable mode option to NSIS installer Custom NSIS template based on tauri-v2.9.1 upstream with 6 targeted modifications for portable mode support: - New PortableMode variable and /PORTABLE CLI flag for silent installs - Install type selection page with Normal/Portable radio buttons - Conditional default directory (Desktop for portable, LocalAppData for normal) - Skip registry writes, file associations, deep links, and uninstaller for portable - Skip Start Menu and desktop shortcuts for portable installs - Create portable marker file and Data/ directory on portable install The existing portable.rs runtime detection is unchanged — it looks for the same marker file this installer creates. Usage: GUI: select "Portable Installation" on the install type page Silent: Handy_setup.exe /S /PORTABLE /D=C:\path\to\Handy Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: implement true portable mode via source code path override Detect a `portable` marker file next to the executable at startup. When present, redirect all data paths (settings, models, recordings, database, logs) to a local `Data/` directory instead of %APPDATA%. This approach works because it intercepts path resolution at the Rust level, bypassing Tauri/Windows Shell API limitations that made the previous environment-variable and junction approaches unreliable. Changes: - Add src-tauri/src/portable.rs with OnceLock-based detection - Replace all app.path().app_data_dir() calls with portable helper - Configure tauri-plugin-log to use Folder target when portable - Configure tauri-plugin-store with absolute path when portable - Simplify build.yml portable ZIP (exe + resources + marker file) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: add missing Manager trait import in portable.rs The .path() method on AppHandle requires `tauri::Manager` in scope. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: redirect WebView2 cache to portable Data dir Move main window creation from tauri.conf.json to setup closure so we can set data_directory when in portable mode. This prevents WebView2 from creating %LOCALAPPDATA%\com.pais.handy. Also set data_directory on the recording overlay window. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: auto-detect portable mode during updates When the Tauri updater runs the installer with /UPDATE, the install type page is skipped and /PORTABLE is not passed. Detect the existing portable marker file in .onInit so updates preserve portable mode automatically. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style: fix prettier formatting in portable.rs and lib.rs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: CJ Pais <cj@cjpais.com>
This commit is contained in:
parent
5ff3c896b2
commit
10beb60d63
11 changed files with 1211 additions and 44 deletions
1078
src-tauri/nsis/installer.nsi
Normal file
1078
src-tauri/nsis/installer.nsi
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -21,7 +21,12 @@ fn resolve_sound_path(
|
|||
) -> Option<PathBuf> {
|
||||
let sound_file = get_sound_path(settings, sound_type);
|
||||
let base_dir = get_sound_base_dir(settings);
|
||||
app.path().resolve(&sound_file, base_dir).ok()
|
||||
match base_dir {
|
||||
tauri::path::BaseDirectory::AppData => {
|
||||
crate::portable::resolve_app_data(app, &sound_file).ok()
|
||||
}
|
||||
_ => app.path().resolve(&sound_file, base_dir).ok(),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_sound_path(settings: &AppSettings, sound_type: SoundType) -> String {
|
||||
|
|
|
|||
|
|
@ -15,11 +15,7 @@ pub struct CustomSounds {
|
|||
}
|
||||
|
||||
fn custom_sound_exists(app: &AppHandle, sound_type: &str) -> bool {
|
||||
app.path()
|
||||
.resolve(
|
||||
format!("custom_{}.wav", sound_type),
|
||||
tauri::path::BaseDirectory::AppData,
|
||||
)
|
||||
crate::portable::resolve_app_data(app, &format!("custom_{}.wav", sound_type))
|
||||
.map_or(false, |path| path.exists())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,9 +17,7 @@ pub fn cancel_operation(app: AppHandle) {
|
|||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn get_app_dir_path(app: AppHandle) -> Result<String, String> {
|
||||
let app_data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
let app_data_dir = crate::portable::app_data_dir(&app)
|
||||
.map_err(|e| format!("Failed to get app data directory: {}", e))?;
|
||||
|
||||
Ok(app_data_dir.to_string_lossy().to_string())
|
||||
|
|
@ -40,9 +38,7 @@ pub fn get_default_settings() -> Result<AppSettings, String> {
|
|||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn get_log_dir_path(app: AppHandle) -> Result<String, String> {
|
||||
let log_dir = app
|
||||
.path()
|
||||
.app_log_dir()
|
||||
let log_dir = crate::portable::app_log_dir(&app)
|
||||
.map_err(|e| format!("Failed to get log directory: {}", e))?;
|
||||
|
||||
Ok(log_dir.to_string_lossy().to_string())
|
||||
|
|
@ -69,9 +65,7 @@ pub fn set_log_level(app: AppHandle, level: LogLevel) -> Result<(), String> {
|
|||
#[specta::specta]
|
||||
#[tauri::command]
|
||||
pub fn open_recordings_folder(app: AppHandle) -> Result<(), String> {
|
||||
let app_data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
let app_data_dir = crate::portable::app_data_dir(&app)
|
||||
.map_err(|e| format!("Failed to get app data directory: {}", e))?;
|
||||
|
||||
let recordings_dir = app_data_dir.join("recordings");
|
||||
|
|
@ -87,9 +81,7 @@ pub fn open_recordings_folder(app: AppHandle) -> Result<(), String> {
|
|||
#[specta::specta]
|
||||
#[tauri::command]
|
||||
pub fn open_log_dir(app: AppHandle) -> Result<(), String> {
|
||||
let log_dir = app
|
||||
.path()
|
||||
.app_log_dir()
|
||||
let log_dir = crate::portable::app_log_dir(&app)
|
||||
.map_err(|e| format!("Failed to get log directory: {}", e))?;
|
||||
|
||||
let path = log_dir.to_string_lossy().as_ref().to_string();
|
||||
|
|
@ -103,9 +95,7 @@ pub fn open_log_dir(app: AppHandle) -> Result<(), String> {
|
|||
#[specta::specta]
|
||||
#[tauri::command]
|
||||
pub fn open_app_data_dir(app: AppHandle) -> Result<(), String> {
|
||||
let app_data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
let app_data_dir = crate::portable::app_data_dir(&app)
|
||||
.map_err(|e| format!("Failed to get app data directory: {}", e))?;
|
||||
|
||||
let path = app_data_dir.to_string_lossy().as_ref().to_string();
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ mod input;
|
|||
mod llm_client;
|
||||
mod managers;
|
||||
mod overlay;
|
||||
pub mod portable;
|
||||
mod settings;
|
||||
mod shortcut;
|
||||
mod signal_handle;
|
||||
|
|
@ -252,6 +253,9 @@ fn trigger_update_check(app: AppHandle) -> Result<(), String> {
|
|||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run(cli_args: CliArgs) {
|
||||
// Detect portable mode before anything else
|
||||
portable::init();
|
||||
|
||||
// Parse console logging directives from RUST_LOG, falling back to info-level logging
|
||||
// when the variable is unset
|
||||
let console_filter = build_console_filter();
|
||||
|
|
@ -373,8 +377,15 @@ pub fn run(cli_args: CliArgs) {
|
|||
move |metadata| console_filter.enabled(metadata)
|
||||
}),
|
||||
// File logs respect the user's settings (stored in FILE_LOG_LEVEL atomic)
|
||||
Target::new(TargetKind::LogDir {
|
||||
file_name: Some("handy".into()),
|
||||
Target::new(if let Some(data_dir) = portable::data_dir() {
|
||||
TargetKind::Folder {
|
||||
path: data_dir.join("logs"),
|
||||
file_name: Some("handy".into()),
|
||||
}
|
||||
} else {
|
||||
TargetKind::LogDir {
|
||||
file_name: Some("handy".into()),
|
||||
}
|
||||
})
|
||||
.filter(|metadata| {
|
||||
let file_level = FILE_LOG_LEVEL.load(Ordering::Relaxed);
|
||||
|
|
@ -416,6 +427,23 @@ pub fn run(cli_args: CliArgs) {
|
|||
))
|
||||
.manage(cli_args.clone())
|
||||
.setup(move |app| {
|
||||
// Create main window programmatically so we can set data_directory
|
||||
// for portable mode (redirects WebView2 cache to portable Data dir)
|
||||
let mut win_builder =
|
||||
tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::App("/".into()))
|
||||
.title("Handy")
|
||||
.inner_size(680.0, 570.0)
|
||||
.min_inner_size(680.0, 570.0)
|
||||
.resizable(true)
|
||||
.maximizable(false)
|
||||
.visible(false);
|
||||
|
||||
if let Some(data_dir) = portable::data_dir() {
|
||||
win_builder = win_builder.data_directory(data_dir.join("webview"));
|
||||
}
|
||||
|
||||
win_builder.build()?;
|
||||
|
||||
let mut settings = get_settings(&app.handle());
|
||||
|
||||
// CLI --debug flag overrides debug_mode and log level (runtime-only, not persisted)
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ pub struct HistoryManager {
|
|||
impl HistoryManager {
|
||||
pub fn new(app_handle: &AppHandle) -> Result<Self> {
|
||||
// Create recordings directory in app data dir
|
||||
let app_data_dir = app_handle.path().app_data_dir()?;
|
||||
let app_data_dir = crate::portable::app_data_dir(app_handle)?;
|
||||
let recordings_dir = app_data_dir.join("recordings");
|
||||
let db_path = app_data_dir.join("history.db");
|
||||
|
||||
|
|
|
|||
|
|
@ -66,9 +66,7 @@ pub struct ModelManager {
|
|||
impl ModelManager {
|
||||
pub fn new(app_handle: &AppHandle) -> Result<Self> {
|
||||
// Create models directory in app data
|
||||
let models_dir = app_handle
|
||||
.path()
|
||||
.app_data_dir()
|
||||
let models_dir = crate::portable::app_data_dir(app_handle)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to get app data dir: {}", e))?
|
||||
.join("models");
|
||||
|
||||
|
|
|
|||
|
|
@ -227,6 +227,10 @@ pub fn create_recording_overlay(app_handle: &AppHandle) {
|
|||
.focused(false)
|
||||
.visible(false);
|
||||
|
||||
if let Some(data_dir) = crate::portable::data_dir() {
|
||||
builder = builder.data_directory(data_dir.join("webview"));
|
||||
}
|
||||
|
||||
if let Some((x, y)) = position {
|
||||
builder = builder.position(x, y);
|
||||
}
|
||||
|
|
|
|||
77
src-tauri/src/portable.rs
Normal file
77
src-tauri/src/portable.rs
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
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()?;
|
||||
|
||||
if exe_dir.join("portable").exists() {
|
||||
let data_dir = exe_dir.join("Data");
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -753,7 +753,7 @@ impl AppSettings {
|
|||
pub fn load_or_create_app_settings(app: &AppHandle) -> AppSettings {
|
||||
// Initialize store
|
||||
let store = app
|
||||
.store(SETTINGS_STORE_PATH)
|
||||
.store(crate::portable::store_path(SETTINGS_STORE_PATH))
|
||||
.expect("Failed to initialize store");
|
||||
|
||||
let mut settings = if let Some(settings_value) = store.get("settings") {
|
||||
|
|
@ -803,7 +803,7 @@ pub fn load_or_create_app_settings(app: &AppHandle) -> AppSettings {
|
|||
|
||||
pub fn get_settings(app: &AppHandle) -> AppSettings {
|
||||
let store = app
|
||||
.store(SETTINGS_STORE_PATH)
|
||||
.store(crate::portable::store_path(SETTINGS_STORE_PATH))
|
||||
.expect("Failed to initialize store");
|
||||
|
||||
let mut settings = if let Some(settings_value) = store.get("settings") {
|
||||
|
|
@ -827,7 +827,7 @@ pub fn get_settings(app: &AppHandle) -> AppSettings {
|
|||
|
||||
pub fn write_settings(app: &AppHandle, settings: AppSettings) {
|
||||
let store = app
|
||||
.store(SETTINGS_STORE_PATH)
|
||||
.store(crate::portable::store_path(SETTINGS_STORE_PATH))
|
||||
.expect("Failed to initialize store");
|
||||
|
||||
store.set("settings", serde_json::to_value(&settings).unwrap());
|
||||
|
|
|
|||
|
|
@ -11,19 +11,7 @@
|
|||
},
|
||||
"app": {
|
||||
"macOSPrivateApi": true,
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "Handy",
|
||||
"width": 680,
|
||||
"height": 570,
|
||||
"minWidth": 680,
|
||||
"minHeight": 570,
|
||||
"resizable": true,
|
||||
"maximizable": false,
|
||||
"visible": false
|
||||
}
|
||||
],
|
||||
"windows": [],
|
||||
"security": {
|
||||
"csp": null,
|
||||
"assetProtocol": {
|
||||
|
|
@ -70,7 +58,10 @@
|
|||
}
|
||||
},
|
||||
"windows": {
|
||||
"signCommand": "trusted-signing-cli -e https://eus.codesigning.azure.net/ -a CJ-Signing -c cjpais-dev -d Handy %1"
|
||||
"signCommand": "trusted-signing-cli -e https://eus.codesigning.azure.net/ -a CJ-Signing -c cjpais-dev -d Handy %1",
|
||||
"nsis": {
|
||||
"template": "nsis/installer.nsi"
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
|
|
|
|||
Loading…
Reference in a new issue