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:
Shehab Tarek 2026-03-06 06:07:11 +02:00 committed by GitHub
parent 5ff3c896b2
commit 10beb60d63
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1211 additions and 44 deletions

1078
src-tauri/nsis/installer.nsi Normal file

File diff suppressed because it is too large Load diff

View file

@ -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 {

View file

@ -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())
}

View file

@ -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();

View file

@ -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)

View file

@ -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");

View file

@ -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");

View file

@ -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
View 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)
}
}

View file

@ -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());

View file

@ -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": {