* 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>
142 lines
4.4 KiB
Rust
142 lines
4.4 KiB
Rust
use crate::settings::SoundTheme;
|
|
use crate::settings::{self, AppSettings};
|
|
use cpal::traits::{DeviceTrait, HostTrait};
|
|
use log::{debug, error, warn};
|
|
use rodio::OutputStreamBuilder;
|
|
use std::fs::File;
|
|
use std::io::BufReader;
|
|
use std::path::{Path, PathBuf};
|
|
use std::thread;
|
|
use tauri::{AppHandle, Manager};
|
|
|
|
pub enum SoundType {
|
|
Start,
|
|
Stop,
|
|
}
|
|
|
|
fn resolve_sound_path(
|
|
app: &AppHandle,
|
|
settings: &AppSettings,
|
|
sound_type: SoundType,
|
|
) -> Option<PathBuf> {
|
|
let sound_file = get_sound_path(settings, sound_type);
|
|
let base_dir = get_sound_base_dir(settings);
|
|
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 {
|
|
match (settings.sound_theme, sound_type) {
|
|
(SoundTheme::Custom, SoundType::Start) => "custom_start.wav".to_string(),
|
|
(SoundTheme::Custom, SoundType::Stop) => "custom_stop.wav".to_string(),
|
|
(_, SoundType::Start) => settings.sound_theme.to_start_path(),
|
|
(_, SoundType::Stop) => settings.sound_theme.to_stop_path(),
|
|
}
|
|
}
|
|
|
|
fn get_sound_base_dir(settings: &AppSettings) -> tauri::path::BaseDirectory {
|
|
match settings.sound_theme {
|
|
SoundTheme::Custom => tauri::path::BaseDirectory::AppData,
|
|
_ => tauri::path::BaseDirectory::Resource,
|
|
}
|
|
}
|
|
|
|
pub fn play_feedback_sound(app: &AppHandle, sound_type: SoundType) {
|
|
let settings = settings::get_settings(app);
|
|
if !settings.audio_feedback {
|
|
return;
|
|
}
|
|
if let Some(path) = resolve_sound_path(app, &settings, sound_type) {
|
|
play_sound_async(app, path);
|
|
}
|
|
}
|
|
|
|
pub fn play_feedback_sound_blocking(app: &AppHandle, sound_type: SoundType) {
|
|
let settings = settings::get_settings(app);
|
|
if !settings.audio_feedback {
|
|
return;
|
|
}
|
|
if let Some(path) = resolve_sound_path(app, &settings, sound_type) {
|
|
play_sound_blocking(app, &path);
|
|
}
|
|
}
|
|
|
|
pub fn play_test_sound(app: &AppHandle, sound_type: SoundType) {
|
|
let settings = settings::get_settings(app);
|
|
if let Some(path) = resolve_sound_path(app, &settings, sound_type) {
|
|
play_sound_blocking(app, &path);
|
|
}
|
|
}
|
|
|
|
fn play_sound_async(app: &AppHandle, path: PathBuf) {
|
|
let app_handle = app.clone();
|
|
thread::spawn(move || {
|
|
if let Err(e) = play_sound_at_path(&app_handle, path.as_path()) {
|
|
error!("Failed to play sound '{}': {}", path.display(), e);
|
|
}
|
|
});
|
|
}
|
|
|
|
fn play_sound_blocking(app: &AppHandle, path: &Path) {
|
|
if let Err(e) = play_sound_at_path(app, path) {
|
|
error!("Failed to play sound '{}': {}", path.display(), e);
|
|
}
|
|
}
|
|
|
|
fn play_sound_at_path(app: &AppHandle, path: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
|
let settings = settings::get_settings(app);
|
|
let volume = settings.audio_feedback_volume;
|
|
let selected_device = settings.selected_output_device.clone();
|
|
play_audio_file(path, selected_device, volume)
|
|
}
|
|
|
|
fn play_audio_file(
|
|
path: &std::path::Path,
|
|
selected_device: Option<String>,
|
|
volume: f32,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
let stream_builder = if let Some(device_name) = selected_device {
|
|
if device_name == "Default" {
|
|
debug!("Using default device");
|
|
OutputStreamBuilder::from_default_device()?
|
|
} else {
|
|
let host = crate::audio_toolkit::get_cpal_host();
|
|
let devices = host.output_devices()?;
|
|
|
|
let mut found_device = None;
|
|
for device in devices {
|
|
if device.name()? == device_name {
|
|
found_device = Some(device);
|
|
break;
|
|
}
|
|
}
|
|
|
|
match found_device {
|
|
Some(device) => OutputStreamBuilder::from_device(device)?,
|
|
None => {
|
|
warn!("Device '{}' not found, using default device", device_name);
|
|
OutputStreamBuilder::from_default_device()?
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
debug!("Using default device");
|
|
OutputStreamBuilder::from_default_device()?
|
|
};
|
|
|
|
let stream_handle = stream_builder.open_stream()?;
|
|
let mixer = stream_handle.mixer();
|
|
|
|
let file = File::open(path)?;
|
|
let buf_reader = BufReader::new(file);
|
|
|
|
let sink = rodio::play(mixer, buf_reader)?;
|
|
sink.set_volume(volume);
|
|
sink.sleep_until_end();
|
|
|
|
Ok(())
|
|
}
|