Handy/src-tauri/src/commands/mod.rs
Shehab Tarek 10beb60d63
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>
2026-03-06 12:07:11 +08:00

181 lines
5.4 KiB
Rust

pub mod audio;
pub mod history;
pub mod models;
pub mod transcription;
use crate::settings::{get_settings, write_settings, AppSettings, LogLevel};
use crate::utils::cancel_current_operation;
use tauri::{AppHandle, Manager};
use tauri_plugin_opener::OpenerExt;
#[tauri::command]
#[specta::specta]
pub fn cancel_operation(app: AppHandle) {
cancel_current_operation(&app);
}
#[tauri::command]
#[specta::specta]
pub fn get_app_dir_path(app: AppHandle) -> Result<String, String> {
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())
}
#[tauri::command]
#[specta::specta]
pub fn get_app_settings(app: AppHandle) -> Result<AppSettings, String> {
Ok(get_settings(&app))
}
#[tauri::command]
#[specta::specta]
pub fn get_default_settings() -> Result<AppSettings, String> {
Ok(crate::settings::get_default_settings())
}
#[tauri::command]
#[specta::specta]
pub fn get_log_dir_path(app: AppHandle) -> Result<String, String> {
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())
}
#[specta::specta]
#[tauri::command]
pub fn set_log_level(app: AppHandle, level: LogLevel) -> Result<(), String> {
let tauri_log_level: tauri_plugin_log::LogLevel = level.into();
let log_level: log::Level = tauri_log_level.into();
// Update the file log level atomic so the filter picks up the new level
crate::FILE_LOG_LEVEL.store(
log_level.to_level_filter() as u8,
std::sync::atomic::Ordering::Relaxed,
);
let mut settings = get_settings(&app);
settings.log_level = level;
write_settings(&app, settings);
Ok(())
}
#[specta::specta]
#[tauri::command]
pub fn open_recordings_folder(app: AppHandle) -> Result<(), String> {
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");
let path = recordings_dir.to_string_lossy().as_ref().to_string();
app.opener()
.open_path(path, None::<String>)
.map_err(|e| format!("Failed to open recordings folder: {}", e))?;
Ok(())
}
#[specta::specta]
#[tauri::command]
pub fn open_log_dir(app: AppHandle) -> Result<(), String> {
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();
app.opener()
.open_path(path, None::<String>)
.map_err(|e| format!("Failed to open log directory: {}", e))?;
Ok(())
}
#[specta::specta]
#[tauri::command]
pub fn open_app_data_dir(app: AppHandle) -> Result<(), String> {
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();
app.opener()
.open_path(path, None::<String>)
.map_err(|e| format!("Failed to open app data directory: {}", e))?;
Ok(())
}
/// Check if Apple Intelligence is available on this device.
/// Called by the frontend when the user selects Apple Intelligence provider.
#[specta::specta]
#[tauri::command]
pub fn check_apple_intelligence_available() -> bool {
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
{
crate::apple_intelligence::check_apple_intelligence_availability()
}
#[cfg(not(all(target_os = "macos", target_arch = "aarch64")))]
{
false
}
}
/// Try to initialize Enigo (keyboard/mouse simulation).
/// On macOS, this will return an error if accessibility permissions are not granted.
#[specta::specta]
#[tauri::command]
pub fn initialize_enigo(app: AppHandle) -> Result<(), String> {
use crate::input::EnigoState;
// Check if already initialized
if app.try_state::<EnigoState>().is_some() {
log::debug!("Enigo already initialized");
return Ok(());
}
// Try to initialize
match EnigoState::new() {
Ok(enigo_state) => {
app.manage(enigo_state);
log::info!("Enigo initialized successfully after permission grant");
Ok(())
}
Err(e) => {
if cfg!(target_os = "macos") {
log::warn!(
"Failed to initialize Enigo: {} (accessibility permissions may not be granted)",
e
);
} else {
log::warn!("Failed to initialize Enigo: {}", e);
}
Err(format!("Failed to initialize input system: {}", e))
}
}
}
/// Marker state to track if shortcuts have been initialized.
pub struct ShortcutsInitialized;
/// Initialize keyboard shortcuts.
/// On macOS, this should be called after accessibility permissions are granted.
/// This is idempotent - calling it multiple times is safe.
#[specta::specta]
#[tauri::command]
pub fn initialize_shortcuts(app: AppHandle) -> Result<(), String> {
// Check if already initialized
if app.try_state::<ShortcutsInitialized>().is_some() {
log::debug!("Shortcuts already initialized");
return Ok(());
}
// Initialize shortcuts
crate::shortcut::init_shortcuts(&app);
// Mark as initialized
app.manage(ShortcutsInitialized);
log::info!("Shortcuts initialized successfully");
Ok(())
}