Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,078 changes: 1,078 additions & 0 deletions src-tauri/nsis/installer.nsi

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion src-tauri/src/audio_feedback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 1 addition & 5 deletions src-tauri/src/commands/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}

Expand Down
20 changes: 5 additions & 15 deletions src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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())
Expand All @@ -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");
Expand All @@ -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();
Expand All @@ -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();
Expand Down
32 changes: 30 additions & 2 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ mod input;
mod llm_client;
mod managers;
mod overlay;
pub mod portable;
mod settings;
mod shortcut;
mod signal_handle;
Expand Down Expand Up @@ -251,6 +252,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();
Expand Down Expand Up @@ -370,8 +374,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);
Expand Down Expand Up @@ -413,6 +424,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)
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/src/managers/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
4 changes: 1 addition & 3 deletions src-tauri/src/managers/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,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");

Expand Down
4 changes: 4 additions & 0 deletions src-tauri/src/overlay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
77 changes: 77 additions & 0 deletions src-tauri/src/portable.rs
Original file line number Diff line number Diff line change
@@ -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)
}
}
6 changes: 3 additions & 3 deletions src-tauri/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -742,7 +742,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") {
Expand Down Expand Up @@ -792,7 +792,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") {
Expand All @@ -816,7 +816,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());
Expand Down
19 changes: 5 additions & 14 deletions src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down