* 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>
181 lines
5.4 KiB
Rust
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(())
|
|
}
|