From c5a0d3f913b0cd71368b2b91d41fa7eb876bfdb8 Mon Sep 17 00:00:00 2001 From: GalaxyRuler Date: Sun, 12 Jul 2026 02:21:09 +0300 Subject: [PATCH 1/8] fix(audio): unify microphone lifecycle state locking --- src-tauri/src/managers/audio.rs | 152 ++++++++++++++++++++++++++------ 1 file changed, 127 insertions(+), 25 deletions(-) diff --git a/src-tauri/src/managers/audio.rs b/src-tauri/src/managers/audio.rs index 6ac2a2e4..a9ff6901 100644 --- a/src-tauri/src/managers/audio.rs +++ b/src-tauri/src/managers/audio.rs @@ -126,6 +126,12 @@ pub(crate) struct RecordingStopResult { pub(crate) vad_fallback: bool, } +#[derive(Default)] +struct MicrophoneLifecycleState { + is_open: bool, + did_mute: bool, +} + #[derive(Clone, Debug, PartialEq, Eq)] enum RecordingStopOutcome { Complete, @@ -313,12 +319,12 @@ pub struct AudioRecordingManager { app_handle: tauri::AppHandle, recorder: Arc>>, - is_open: Arc>, + lifecycle: Arc>, + mute_api: Arc>, is_recording: Arc>, recording_started_at: Arc>>, mic_diagnostic: Arc>, mic_diagnostic_state: Arc>, - did_mute: Arc>, close_generation: Arc, } @@ -339,12 +345,12 @@ impl AudioRecordingManager { app_handle: app.clone(), recorder: Arc::new(Mutex::new(None)), - is_open: Arc::new(Mutex::new(false)), + lifecycle: Arc::new(Mutex::new(MicrophoneLifecycleState::default())), + mute_api: Arc::new(Mutex::new(())), is_recording: Arc::new(Mutex::new(false)), recording_started_at: Arc::new(Mutex::new(None)), mic_diagnostic: Arc::new(Mutex::new(SilenceDiagnostic::default())), mic_diagnostic_state: Arc::new(Mutex::new(MicDiagnosticState::Recording)), - did_mute: Arc::new(Mutex::new(false)), close_generation: Arc::new(AtomicU64::new(0)), }; @@ -541,22 +547,35 @@ impl AudioRecordingManager { /// Applies mute if mute_while_recording is enabled and stream is open pub fn apply_mute(&self) { let settings = get_settings(&self.app_handle); - let mut did_mute_guard = self.did_mute.lock().unwrap_or_else(|e| e.into_inner()); + let _mute_api_guard = self.mute_api.lock().unwrap_or_else(|e| e.into_inner()); + let should_mute = { + let mut lifecycle = self.lifecycle.lock().unwrap_or_else(|e| e.into_inner()); + if settings.mute_while_recording && lifecycle.is_open { + lifecycle.did_mute = true; + true + } else { + false + } + }; - if settings.mute_while_recording && *self.is_open.lock().unwrap_or_else(|e| e.into_inner()) - { + if should_mute { set_mute(true); - *did_mute_guard = true; debug!("Mute applied"); } } /// Removes mute if it was applied pub fn remove_mute(&self) { - let mut did_mute_guard = self.did_mute.lock().unwrap_or_else(|e| e.into_inner()); - if *did_mute_guard { + let _mute_api_guard = self.mute_api.lock().unwrap_or_else(|e| e.into_inner()); + let should_unmute = { + let mut lifecycle = self.lifecycle.lock().unwrap_or_else(|e| e.into_inner()); + let was_muted = lifecycle.did_mute; + lifecycle.did_mute = false; + was_muted + }; + + if should_unmute { set_mute(false); - *did_mute_guard = false; debug!("Mute removed"); } } @@ -579,8 +598,8 @@ impl AudioRecordingManager { } pub fn start_microphone_stream(&self) -> Result<(), anyhow::Error> { - let mut open_flag = self.is_open.lock().unwrap_or_else(|e| e.into_inner()); - if *open_flag { + let mut lifecycle = self.lifecycle.lock().unwrap_or_else(|e| e.into_inner()); + if lifecycle.is_open { debug!("Microphone stream already active"); return Ok(()); } @@ -588,8 +607,7 @@ impl AudioRecordingManager { let start_time = Instant::now(); // Don't mute immediately - caller will handle muting after audio feedback - let mut did_mute_guard = self.did_mute.lock().unwrap_or_else(|e| e.into_inner()); - *did_mute_guard = false; + lifecycle.did_mute = false; // Get the selected device from settings, considering clamshell mode let settings = get_settings(&self.app_handle); @@ -616,7 +634,7 @@ impl AudioRecordingManager { .map_err(|e| anyhow::anyhow!("Failed to open recorder: {}", e))?; } - *open_flag = true; + lifecycle.is_open = true; // This timing covers through cpal's stream.play() returning — i.e. the // point cpal surfaces as "stream running." It does NOT guarantee the // host audio device is producing samples yet; the first input callback @@ -630,16 +648,21 @@ impl AudioRecordingManager { } pub fn stop_microphone_stream(&self) { - let mut open_flag = self.is_open.lock().unwrap_or_else(|e| e.into_inner()); - if !*open_flag { - return; - } + let _mute_api_guard = self.mute_api.lock().unwrap_or_else(|e| e.into_inner()); + let should_unmute = { + let mut lifecycle = self.lifecycle.lock().unwrap_or_else(|e| e.into_inner()); + if !lifecycle.is_open { + return; + } - let mut did_mute_guard = self.did_mute.lock().unwrap_or_else(|e| e.into_inner()); - if *did_mute_guard { + lifecycle.is_open = false; + let was_muted = lifecycle.did_mute; + lifecycle.did_mute = false; + was_muted + }; + if should_unmute { set_mute(false); } - *did_mute_guard = false; if let Some(rec) = self .recorder @@ -656,7 +679,6 @@ impl AudioRecordingManager { let _ = rec.close(); } - *open_flag = false; debug!("Microphone stream stopped"); } @@ -730,7 +752,12 @@ impl AudioRecordingManager { pub fn update_selected_device(&self) -> Result<(), anyhow::Error> { // If currently open, restart the microphone stream to use the new device - if *self.is_open.lock().unwrap_or_else(|e| e.into_inner()) { + if self + .lifecycle + .lock() + .unwrap_or_else(|e| e.into_inner()) + .is_open + { self.close_generation.fetch_add(1, Ordering::SeqCst); self.stop_microphone_stream(); self.start_microphone_stream()?; @@ -966,6 +993,16 @@ impl AudioRecordingManager { #[cfg(test)] mod tests { use super::*; + use std::sync::{mpsc, Arc, Barrier}; + use std::time::Duration; + + fn with_lifecycle_state( + lifecycle: &Mutex, + update: impl FnOnce(&mut MicrophoneLifecycleState) -> T, + ) -> T { + let mut lifecycle = lifecycle.lock().unwrap(); + update(&mut lifecycle) + } fn dev<'a>(stable_id: Option<&'a str>, name: &'a str) -> DeviceSelectionInfo<'a> { DeviceSelectionInfo { stable_id, name } @@ -1161,4 +1198,69 @@ mod tests { Some(Duration::from_millis(2_000)) ); } + + #[test] + fn microphone_lifecycle_state_removes_abba_deadlock() { + let is_open = Arc::new(Mutex::new(false)); + let did_mute = Arc::new(Mutex::new(false)); + let barrier = Arc::new(Barrier::new(2)); + let release_barrier = Arc::new(Barrier::new(2)); + + let old_apply = { + let is_open = Arc::clone(&is_open); + let did_mute = Arc::clone(&did_mute); + let barrier = Arc::clone(&barrier); + let release_barrier = Arc::clone(&release_barrier); + std::thread::spawn(move || { + let _did_mute = did_mute.lock().unwrap(); + barrier.wait(); + let blocked = + matches!(is_open.try_lock(), Err(std::sync::TryLockError::WouldBlock)); + release_barrier.wait(); + blocked + }) + }; + let old_stop = { + let is_open = Arc::clone(&is_open); + let did_mute = Arc::clone(&did_mute); + let barrier = Arc::clone(&barrier); + let release_barrier = Arc::clone(&release_barrier); + std::thread::spawn(move || { + let _is_open = is_open.lock().unwrap(); + barrier.wait(); + let blocked = matches!( + did_mute.try_lock(), + Err(std::sync::TryLockError::WouldBlock) + ); + release_barrier.wait(); + blocked + }) + }; + + assert!(old_apply.join().unwrap()); + assert!(old_stop.join().unwrap()); + + let lifecycle = Arc::new(Mutex::new(MicrophoneLifecycleState::default())); + let start_barrier = Arc::new(Barrier::new(8)); + let (done_tx, done_rx) = mpsc::channel(); + for _ in 0..8 { + let lifecycle = Arc::clone(&lifecycle); + let start_barrier = Arc::clone(&start_barrier); + let done_tx = done_tx.clone(); + std::thread::spawn(move || { + start_barrier.wait(); + with_lifecycle_state(&lifecycle, |state| { + state.is_open = true; + state.did_mute = false; + }); + done_tx.send(()).unwrap(); + }); + } + + for _ in 0..8 { + done_rx + .recv_timeout(Duration::from_secs(1)) + .expect("all lifecycle transitions must complete without deadlock"); + } + } } From aef50a41ae96ebbbc08658e7d247f0ba962fc459 Mon Sep 17 00:00:00 2001 From: GalaxyRuler Date: Sun, 12 Jul 2026 02:33:48 +0300 Subject: [PATCH 2/8] fix(insertion): bound external script execution --- src-tauri/src/actions.rs | 55 +++++++---- src-tauri/src/clipboard.rs | 185 ++++++++++++++++++++++++++----------- 2 files changed, 165 insertions(+), 75 deletions(-) diff --git a/src-tauri/src/actions.rs b/src-tauri/src/actions.rs index 193d5d03..0083ebf6 100644 --- a/src-tauri/src/actions.rs +++ b/src-tauri/src/actions.rs @@ -2170,16 +2170,25 @@ impl ShortcutAction for TranscribeAction { operation_token: operation_token.clone(), paste_started_at: Instant::now(), }; - ah.run_on_main_thread(move || { - complete_adaptive_insertion(insertion); - }) - .unwrap_or_else(|e| { - error!("Failed to run paste on main thread: {:?}", e); - finish_dictation_transaction( - &ah, - DictationTransactionTerminal::InsertionSchedulingFailed, - ); - }); + if matches!( + insertion.settings.paste_method, + crate::settings::PasteMethod::ExternalScript + ) { + std::thread::spawn(move || { + complete_adaptive_insertion(insertion) + }); + } else { + ah.run_on_main_thread(move || { + complete_adaptive_insertion(insertion); + }) + .unwrap_or_else(|e| { + error!("Failed to run paste on main thread: {:?}", e); + finish_dictation_transaction( + &ah, + DictationTransactionTerminal::InsertionSchedulingFailed, + ); + }); + } } else { let processed = process_transcription_output( &ah, @@ -2258,7 +2267,11 @@ impl ShortcutAction for TranscribeAction { let app_for_insertion = ah.clone(); let settings_for_insertion = settings.clone(); let paste_started_at = Instant::now(); - ah.run_on_main_thread(move || { + let external_script = matches!( + settings_for_insertion.paste_method, + crate::settings::PasteMethod::ExternalScript + ); + let insertion = move || { complete_classic_insertion( app_for_insertion, Arc::clone(&hm), @@ -2270,14 +2283,18 @@ impl ShortcutAction for TranscribeAction { classic_context, paste_started_at, ); - }) - .unwrap_or_else(|e| { - error!("Failed to run paste on main thread: {:?}", e); - finish_dictation_transaction( - &ah, - DictationTransactionTerminal::InsertionSchedulingFailed, - ); - }); + }; + if external_script { + std::thread::spawn(insertion); + } else { + ah.run_on_main_thread(insertion).unwrap_or_else(|e| { + error!("Failed to run paste on main thread: {:?}", e); + finish_dictation_transaction( + &ah, + DictationTransactionTerminal::InsertionSchedulingFailed, + ); + }); + } } } Err(err) => { diff --git a/src-tauri/src/clipboard.rs b/src-tauri/src/clipboard.rs index 9694862b..a2b14cc6 100644 --- a/src-tauri/src/clipboard.rs +++ b/src-tauri/src/clipboard.rs @@ -10,6 +10,7 @@ use enigo::{Direction, Enigo, Key, Keyboard}; use log::{info, warn}; use serde::Serialize; use std::process::{Child, Command, ExitStatus, Stdio}; +use std::sync::{mpsc, Arc, Mutex}; use std::time::{Duration, Instant}; use tauri::{AppHandle, Manager}; use tauri_plugin_clipboard_manager::ClipboardExt; @@ -23,6 +24,7 @@ const FOCUSED_TEXT_READ_CAP: usize = MAX_FOCUSED_TEXT_CHARS; const PASTE_VERIFY_TOTAL_MS: u64 = 600; const PASTE_VERIFY_POLL_MS: u64 = 75; const TARGET_CHANGED_BEFORE_INSERTION: &str = "target changed before insertion"; +const EXTERNAL_SCRIPT_DEADLINE: Duration = Duration::from_secs(2); #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct ClipboardPayloadMarker { @@ -1315,12 +1317,14 @@ fn send_key_combo_via_xdotool(paste_method: &PasteMethod) -> Result<(), String> Ok(()) } +#[cfg_attr(not(test), allow(dead_code))] struct ExternalScriptInvocation<'a> { program: &'a str, args: Vec<&'a str>, stdin: &'a [u8], } +#[cfg_attr(not(test), allow(dead_code))] fn build_external_script_invocation<'a>( script_path: &'a str, text: &'a str, @@ -1339,81 +1343,135 @@ fn external_script_failure_message(script_path: &str, code: Option) -> Stri ) } -fn wait_for_external_script( - child: &mut Child, - script_path: &str, - is_cancelled: CancellationCheck<'_>, -) -> Result { - loop { - if let Some(status) = child - .try_wait() - .map_err(|e| format!("Failed to poll external script '{}': {}", script_path, e))? - { - return Ok(status); - } +struct ExternalScriptChildGuard { + child: Arc>>, + script_path: String, +} - if let Err(err) = ensure_not_cancelled(is_cancelled, "external script completion") { - kill_external_script_child(child, script_path); - return Err(err); +impl ExternalScriptChildGuard { + fn kill(&self) { + let mut child_slot = self.child.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(child) = child_slot.as_mut() { + if let Err(kill_err) = child.kill() { + warn!( + "Failed to kill external script '{}': {}", + self.script_path, kill_err + ); + } + let _ = child.wait(); } - - std::thread::sleep(Duration::from_millis(25)); + *child_slot = None; } } -fn kill_external_script_child(child: &mut Child, script_path: &str) { - if let Err(kill_err) = child.kill() { - warn!( - "Failed to kill cancelled external script '{}': {}", - script_path, kill_err - ); +impl Drop for ExternalScriptChildGuard { + fn drop(&mut self) { + self.kill(); } - let _ = child.wait(); } -/// Pastes text by invoking an external script. -/// The script receives the text to paste on stdin. -fn paste_via_external_script( +fn paste_via_external_script_with_args( text: &str, - script_path: &str, + program: &str, + args: &[&str], is_cancelled: CancellationCheck<'_>, ) -> Result<(), String> { use std::io::Write; - info!("Pasting via external script: {}", script_path); + info!("Pasting via external script: {}", program); ensure_not_cancelled(is_cancelled, "external script invocation")?; - let invocation = build_external_script_invocation(script_path, text); - let mut child = Command::new(invocation.program) - .args(&invocation.args) - .stdin(Stdio::piped()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .map_err(|e| format!("Failed to execute external script '{}': {}", script_path, e))?; + let child_slot = Arc::new(Mutex::new(None)); + let guard = ExternalScriptChildGuard { + child: Arc::clone(&child_slot), + script_path: program.to_string(), + }; + let (result_tx, result_rx) = mpsc::channel(); + let program_owned = program.to_string(); + let args_owned = args + .iter() + .map(|arg| (*arg).to_string()) + .collect::>(); + let stdin_payload = text.as_bytes().to_vec(); + let worker_slot = Arc::clone(&child_slot); + std::thread::spawn(move || { + let result = (|| -> Result { + let mut child = Command::new(&program_owned) + .args(&args_owned) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| { + format!( + "Failed to execute external script '{}': {}", + program_owned, e + ) + })?; + + let mut stdin = child.stdin.take(); + *worker_slot.lock().unwrap_or_else(|e| e.into_inner()) = Some(child); + if let Some(mut stdin) = stdin.take() { + stdin.write_all(&stdin_payload).map_err(|e| { + format!( + "Failed to write to external script '{}': {}", + program_owned, e + ) + })?; + } - if let Err(err) = ensure_not_cancelled(is_cancelled, "external script stdin write") { - kill_external_script_child(&mut child, script_path); - return Err(err); - } - let mut stdin = child - .stdin - .take() - .ok_or_else(|| format!("Failed to open stdin for external script '{}'", script_path))?; - stdin.write_all(invocation.stdin).map_err(|e| { - format!( - "Failed to write to external script '{}': {}", - script_path, e - ) - })?; - drop(stdin); + loop { + let mut slot = worker_slot.lock().unwrap_or_else(|e| e.into_inner()); + let Some(child) = slot.as_mut() else { + return Err(format!("External script '{}' was killed", program_owned)); + }; + if let Some(status) = child.try_wait().map_err(|e| { + format!("Failed to poll external script '{}': {}", program_owned, e) + })? { + *slot = None; + return Ok(status); + } + drop(slot); + std::thread::sleep(Duration::from_millis(25)); + } + })(); + let _ = result_tx.send(result); + }); - let status = wait_for_external_script(&mut child, script_path, is_cancelled)?; - if !status.success() { - return Err(external_script_failure_message(script_path, status.code())); + let deadline = Instant::now() + EXTERNAL_SCRIPT_DEADLINE; + loop { + if let Err(err) = ensure_not_cancelled(is_cancelled, "external script completion") { + guard.kill(); + return Err(err); + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + guard.kill(); + return Err(format!( + "External script '{}' exceeded completion deadline", + program + )); + } + match result_rx.recv_timeout(remaining.min(Duration::from_millis(25))) { + Ok(Ok(status)) if status.success() => return Ok(()), + Ok(Ok(status)) => return Err(external_script_failure_message(program, status.code())), + Ok(Err(error)) => return Err(error), + Err(mpsc::RecvTimeoutError::Timeout) => continue, + Err(mpsc::RecvTimeoutError::Disconnected) => { + return Err(format!("External script '{}' worker stopped", program)) + } + } } +} - Ok(()) +/// Pastes text by invoking an external script. +/// The script receives the text to paste on stdin. +fn paste_via_external_script( + text: &str, + script_path: &str, + is_cancelled: CancellationCheck<'_>, +) -> Result<(), String> { + paste_via_external_script_with_args(text, script_path, &[], is_cancelled) } /// Types text directly by simulating individual key presses. @@ -1993,6 +2051,21 @@ mod tests { assert!(!message.contains("sensitive text")); } + #[cfg(target_os = "windows")] + #[test] + fn hanging_external_script_is_killed_by_completion_deadline() { + let started = Instant::now(); + let result = paste_via_external_script_with_args( + "payload", + "cmd.exe", + &["/C", "ping", "127.0.0.1", "-n", "20", ">", "NUL"], + None, + ); + + assert!(result.is_err()); + assert!(started.elapsed() < Duration::from_secs(3)); + } + #[test] fn clipboard_restore_requires_own_payload() { assert!(clipboard_payload_owned_by_verbatim( From ad374edca67cffa1b52ff0cce27840433a41ade1 Mon Sep 17 00:00:00 2001 From: GalaxyRuler Date: Sun, 12 Jul 2026 02:35:00 +0300 Subject: [PATCH 3/8] fix(input): release modifiers on injection failure --- src-tauri/src/input.rs | 210 +++++++++++++++++++++++++++++------------ 1 file changed, 151 insertions(+), 59 deletions(-) diff --git a/src-tauri/src/input.rs b/src-tauri/src/input.rs index 86697de1..f82ee62c 100644 --- a/src-tauri/src/input.rs +++ b/src-tauri/src/input.rs @@ -8,6 +8,84 @@ use tauri::{AppHandle, Manager}; /// Enigo is wrapped in a Mutex since it requires mutable access. pub struct EnigoState(pub Mutex); +#[cfg(not(any(target_os = "android", target_os = "ios")))] +trait KeySink { + fn key(&mut self, key: Key, direction: enigo::Direction) -> Result<(), String>; +} + +#[cfg(not(any(target_os = "android", target_os = "ios")))] +impl KeySink for Enigo { + fn key(&mut self, key: Key, direction: enigo::Direction) -> Result<(), String> { + Keyboard::key(self, key, direction).map_err(|e| e.to_string()) + } +} + +#[cfg(not(any(target_os = "android", target_os = "ios")))] +struct PressedKeyGuard<'a, S: KeySink> { + sink: &'a mut S, + pressed: Vec, +} + +#[cfg(not(any(target_os = "android", target_os = "ios")))] +impl<'a, S: KeySink> PressedKeyGuard<'a, S> { + fn new(sink: &'a mut S) -> Self { + Self { + sink, + pressed: Vec::new(), + } + } + + fn press(&mut self, key: Key) -> Result<(), String> { + self.sink + .key(key, enigo::Direction::Press) + .map_err(|error| format!("Failed to press key: {error}"))?; + self.pressed.push(key); + Ok(()) + } + + fn click(&mut self, key: Key) -> Result<(), String> { + self.sink + .key(key, enigo::Direction::Click) + .map_err(|error| format!("Failed to click key: {error}")) + } + + fn release_all(&mut self) -> Result<(), String> { + let mut first_error = None; + for key in self.pressed.drain(..).rev() { + if let Err(error) = self.sink.key(key, enigo::Direction::Release) { + first_error.get_or_insert_with(|| format!("Failed to release key: {error}")); + } + } + first_error.map_or(Ok(()), Err) + } +} + +#[cfg(not(any(target_os = "android", target_os = "ios")))] +impl Drop for PressedKeyGuard<'_, S> { + fn drop(&mut self) { + for key in self.pressed.drain(..).rev() { + let _ = self.sink.key(key, enigo::Direction::Release); + } + } +} + +#[cfg(not(any(target_os = "android", target_os = "ios")))] +fn send_paste_chord( + sink: &mut S, + modifier_key: Key, + paste_key: Key, + with_shift: bool, +) -> Result<(), String> { + let mut guard = PressedKeyGuard::new(sink); + guard.press(modifier_key)?; + if with_shift { + guard.press(Key::Shift)?; + } + guard.click(paste_key)?; + std::thread::sleep(std::time::Duration::from_millis(100)); + guard.release_all() +} + impl EnigoState { pub fn new() -> Result { let enigo = Enigo::new(&Settings::default()) @@ -37,21 +115,7 @@ pub fn send_paste_ctrl_v(enigo: &mut Enigo) -> Result<(), String> { #[cfg(target_os = "linux")] let (modifier_key, v_key_code) = (Key::Control, Key::Unicode('v')); - // Press modifier + V - enigo - .key(modifier_key, enigo::Direction::Press) - .map_err(|e| format!("Failed to press modifier key: {}", e))?; - enigo - .key(v_key_code, enigo::Direction::Click) - .map_err(|e| format!("Failed to click V key: {}", e))?; - - std::thread::sleep(std::time::Duration::from_millis(100)); - - enigo - .key(modifier_key, enigo::Direction::Release) - .map_err(|e| format!("Failed to release modifier key: {}", e))?; - - Ok(()) + send_paste_chord(enigo, modifier_key, v_key_code, false) } #[cfg(any(target_os = "android", target_os = "ios"))] @@ -72,27 +136,7 @@ pub fn send_paste_ctrl_shift_v(enigo: &mut Enigo) -> Result<(), String> { #[cfg(target_os = "linux")] let (modifier_key, v_key_code) = (Key::Control, Key::Unicode('v')); - // Press Ctrl/Cmd + Shift + V - enigo - .key(modifier_key, enigo::Direction::Press) - .map_err(|e| format!("Failed to press modifier key: {}", e))?; - enigo - .key(Key::Shift, enigo::Direction::Press) - .map_err(|e| format!("Failed to press Shift key: {}", e))?; - enigo - .key(v_key_code, enigo::Direction::Click) - .map_err(|e| format!("Failed to click V key: {}", e))?; - - std::thread::sleep(std::time::Duration::from_millis(100)); - - enigo - .key(Key::Shift, enigo::Direction::Release) - .map_err(|e| format!("Failed to release Shift key: {}", e))?; - enigo - .key(modifier_key, enigo::Direction::Release) - .map_err(|e| format!("Failed to release modifier key: {}", e))?; - - Ok(()) + send_paste_chord(enigo, modifier_key, v_key_code, true) } #[cfg(any(target_os = "android", target_os = "ios"))] @@ -110,21 +154,7 @@ pub fn send_paste_shift_insert(enigo: &mut Enigo) -> Result<(), String> { #[cfg(not(target_os = "windows"))] let insert_key_code = Key::Other(0x76); // XK_Insert (keycode 118 / 0x76, also used as fallback) - // Press Shift + Insert - enigo - .key(Key::Shift, enigo::Direction::Press) - .map_err(|e| format!("Failed to press Shift key: {}", e))?; - enigo - .key(insert_key_code, enigo::Direction::Click) - .map_err(|e| format!("Failed to click Insert key: {}", e))?; - - std::thread::sleep(std::time::Duration::from_millis(100)); - - enigo - .key(Key::Shift, enigo::Direction::Release) - .map_err(|e| format!("Failed to release Shift key: {}", e))?; - - Ok(()) + send_paste_chord(enigo, Key::Shift, insert_key_code, false) } #[cfg(any(target_os = "android", target_os = "ios"))] @@ -137,20 +167,16 @@ pub fn send_paste_shift_insert(_enigo: &mut Enigo) -> Result<(), String> { pub fn send_ltr_reading_order(enigo: &mut Enigo) -> Result<(), String> { const VK_LSHIFT: u32 = 0xA0; - enigo - .key(Key::Control, enigo::Direction::Press) + Keyboard::key(enigo, Key::Control, enigo::Direction::Press) .map_err(|e| format!("Failed to press Control key: {}", e))?; - enigo - .key(Key::Other(VK_LSHIFT), enigo::Direction::Press) + Keyboard::key(enigo, Key::Other(VK_LSHIFT), enigo::Direction::Press) .map_err(|e| format!("Failed to press Left Shift key: {}", e))?; std::thread::sleep(std::time::Duration::from_millis(20)); - enigo - .key(Key::Other(VK_LSHIFT), enigo::Direction::Release) + Keyboard::key(enigo, Key::Other(VK_LSHIFT), enigo::Direction::Release) .map_err(|e| format!("Failed to release Left Shift key: {}", e))?; - enigo - .key(Key::Control, enigo::Direction::Release) + Keyboard::key(enigo, Key::Control, enigo::Direction::Release) .map_err(|e| format!("Failed to release Control key: {}", e))?; Ok(()) @@ -171,3 +197,69 @@ pub fn paste_text_direct(enigo: &mut Enigo, text: &str) -> Result<(), String> { pub fn paste_text_direct(_enigo: &mut Enigo, _text: &str) -> Result<(), String> { Err("Desktop direct text injection is not supported on mobile".to_string()) } + +#[cfg(all(test, not(any(target_os = "android", target_os = "ios"))))] +mod tests { + use super::*; + + struct FaultInjectingSink { + calls: Vec<(Key, enigo::Direction)>, + fail_at: usize, + next_call: usize, + } + + impl KeySink for FaultInjectingSink { + fn key(&mut self, key: Key, direction: enigo::Direction) -> Result<(), String> { + let call = self.next_call; + self.next_call += 1; + if call == self.fail_at { + return Err("injected enigo failure".to_string()); + } + self.calls.push((key, direction)); + Ok(()) + } + } + + fn assert_failed_chord_releases_pressed_modifiers( + modifier: Key, + paste_key: Key, + with_shift: bool, + fail_at: usize, + ) { + let mut sink = FaultInjectingSink { + calls: Vec::new(), + fail_at, + next_call: 0, + }; + + assert!(send_paste_chord(&mut sink, modifier, paste_key, with_shift).is_err()); + assert!(sink + .calls + .iter() + .any(|(key, direction)| *key == modifier && *direction == enigo::Direction::Release)); + if with_shift { + assert!(sink + .calls + .iter() + .any(|(key, direction)| *key == Key::Shift + && *direction == enigo::Direction::Release)); + } + } + + #[test] + fn ctrl_or_cmd_v_failure_releases_modifier() { + assert_failed_chord_releases_pressed_modifiers(Key::Control, Key::Unicode('v'), false, 1); + assert_failed_chord_releases_pressed_modifiers(Key::Meta, Key::Unicode('v'), false, 1); + } + + #[test] + fn ctrl_or_cmd_shift_v_failure_releases_both_modifiers() { + assert_failed_chord_releases_pressed_modifiers(Key::Control, Key::Unicode('v'), true, 2); + assert_failed_chord_releases_pressed_modifiers(Key::Meta, Key::Unicode('v'), true, 2); + } + + #[test] + fn shift_insert_failure_releases_shift() { + assert_failed_chord_releases_pressed_modifiers(Key::Shift, Key::Other(0x2D), false, 1); + } +} From 03cbfe18ffd7b697dbb257ed4243204a2e8f8806 Mon Sep 17 00:00:00 2001 From: GalaxyRuler Date: Sun, 12 Jul 2026 03:07:57 +0300 Subject: [PATCH 4/8] fix(downloads): bound model transfer cancellation --- src-tauri/src/asr/llm_models.rs | 35 ++--- src-tauri/src/asr/models.rs | 35 ++--- src-tauri/src/download.rs | 211 ++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 2 + src-tauri/src/local_llm/download.rs | 23 ++- src-tauri/src/managers/model.rs | 27 ++-- 6 files changed, 253 insertions(+), 80 deletions(-) create mode 100644 src-tauri/src/download.rs diff --git a/src-tauri/src/asr/llm_models.rs b/src-tauri/src/asr/llm_models.rs index 92e43902..7b2a4976 100644 --- a/src-tauri/src/asr/llm_models.rs +++ b/src-tauri/src/asr/llm_models.rs @@ -1,7 +1,6 @@ //! Android on-device LLM cleanup model-pack metadata and filesystem helpers. use anyhow::Result; -use futures_util::StreamExt; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use specta::Type; @@ -425,18 +424,21 @@ async fn download_component( .metadata() .map(|metadata| metadata.len()) .unwrap_or(0); - let client = reqwest::Client::new(); - let mut request = client.get(&file.url); + let downloader = crate::download::DownloadClient::default(); + let deadline = Instant::now() + downloader.total_timeout(); + let mut request = downloader.get(&file.url); if resume_from > 0 { request = request.header("Range", format!("bytes={resume_from}-")); } - let mut response = request.send().await?; + let mut response = downloader.send(request, cancel_flag).await?; if resume_from > 0 && response.status() == reqwest::StatusCode::OK { drop(response); let _ = fs::remove_file(&partial_path); resume_from = 0; - response = client.get(&file.url).send().await?; + response = downloader + .send(downloader.get(&file.url), cancel_flag) + .await?; } if !response.status().is_success() && response.status() != reqwest::StatusCode::PARTIAL_CONTENT @@ -473,26 +475,11 @@ async fn download_component( let mut last_emit = Instant::now(); let throttle = Duration::from_millis(100); - let mut stream = response.bytes_stream(); - - while let Some(chunk) = stream.next().await { - if cancel_flag.load(Ordering::Relaxed) { - drop(output); - emit_progress( - app, - model_id, - "cancelled", - Some(file.target_path.clone()), - downloaded, - total_size, - ); - return Err(anyhow::anyhow!( - "Android LLM model download cancelled for {}", - model_id - )); - } - let chunk = chunk?; + while let Some(chunk) = downloader + .next_chunk(&mut response, cancel_flag, deadline) + .await? + { output.write_all(&chunk)?; downloaded += chunk.len() as u64; if last_emit.elapsed() >= throttle { diff --git a/src-tauri/src/asr/models.rs b/src-tauri/src/asr/models.rs index 0bae6a72..7bc23dc0 100644 --- a/src-tauri/src/asr/models.rs +++ b/src-tauri/src/asr/models.rs @@ -1,7 +1,6 @@ //! Model pack metadata and filesystem helpers for Android ASR. use anyhow::Result; -use futures_util::StreamExt; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use specta::Type; @@ -720,18 +719,21 @@ async fn download_component( .metadata() .map(|metadata| metadata.len()) .unwrap_or(0); - let client = reqwest::Client::new(); - let mut request = client.get(&file.url); + let downloader = crate::download::DownloadClient::default(); + let deadline = Instant::now() + downloader.total_timeout(); + let mut request = downloader.get(&file.url); if resume_from > 0 { request = request.header("Range", format!("bytes={resume_from}-")); } - let mut response = request.send().await?; + let mut response = downloader.send(request, cancel_flag).await?; if resume_from > 0 && response.status() == reqwest::StatusCode::OK { drop(response); let _ = fs::remove_file(&partial_path); resume_from = 0; - response = client.get(&file.url).send().await?; + response = downloader + .send(downloader.get(&file.url), cancel_flag) + .await?; } if !response.status().is_success() && response.status() != reqwest::StatusCode::PARTIAL_CONTENT @@ -768,26 +770,11 @@ async fn download_component( let mut last_emit = Instant::now(); let throttle = Duration::from_millis(100); - let mut stream = response.bytes_stream(); - - while let Some(chunk) = stream.next().await { - if cancel_flag.load(Ordering::Relaxed) { - drop(output); - emit_progress( - app, - model_id, - "cancelled", - Some(file.target_path.clone()), - downloaded, - total_size, - ); - return Err(anyhow::anyhow!( - "Android ASR model download cancelled for {}", - model_id - )); - } - let chunk = chunk?; + while let Some(chunk) = downloader + .next_chunk(&mut response, cancel_flag, deadline) + .await? + { output.write_all(&chunk)?; downloaded += chunk.len() as u64; if last_emit.elapsed() >= throttle { diff --git a/src-tauri/src/download.rs b/src-tauri/src/download.rs new file mode 100644 index 00000000..af6bb9da --- /dev/null +++ b/src-tauri/src/download.rs @@ -0,0 +1,211 @@ +use anyhow::{anyhow, Result}; +use reqwest::{RequestBuilder, Response}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +#[derive(Clone, Copy, Debug)] +pub(crate) struct DownloadTimeouts { + pub connect: Duration, + pub read: Duration, + pub total: Duration, +} + +impl Default for DownloadTimeouts { + fn default() -> Self { + Self { + connect: Duration::from_secs(10), + read: Duration::from_secs(30), + total: Duration::from_secs(30 * 60), + } + } +} + +pub(crate) struct DownloadClient { + client: reqwest::Client, + timeouts: DownloadTimeouts, +} + +impl DownloadClient { + pub(crate) fn with_timeouts(timeouts: DownloadTimeouts) -> Result { + let client = reqwest::Client::builder() + .connect_timeout(timeouts.connect) + .timeout(timeouts.total) + .build()?; + Ok(Self { client, timeouts }) + } + + pub(crate) fn get(&self, url: &str) -> RequestBuilder { + self.client.get(url) + } + + pub(crate) fn total_timeout(&self) -> Duration { + self.timeouts.total + } + + pub(crate) async fn send( + &self, + request: RequestBuilder, + cancelled: &AtomicBool, + ) -> Result { + if cancelled.load(Ordering::Relaxed) { + return Err(anyhow!("download cancelled before request")); + } + + let request = request.send(); + tokio::pin!(request); + let deadline = Instant::now() + self.timeouts.total; + let mut cancellation_poll = tokio::time::interval(Duration::from_millis(20)); + + loop { + if Instant::now() >= deadline { + return Err(anyhow!("download request timed out")); + } + + tokio::select! { + response = &mut request => { + return response.map_err(|error| anyhow!("download request failed: {error:?}")); + } + _ = cancellation_poll.tick() => { + if cancelled.load(Ordering::Relaxed) { + return Err(anyhow!("download cancelled during request")); + } + } + _ = tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)) => { + return Err(anyhow!("download request timed out")); + } + } + } + } + + pub(crate) async fn next_chunk( + &self, + response: &mut Response, + cancelled: &AtomicBool, + deadline: Instant, + ) -> Result>> { + if cancelled.load(Ordering::Relaxed) { + return Err(anyhow!("download cancelled while reading")); + } + + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(anyhow!("download exceeded total deadline")); + } + + let read_deadline = self.timeouts.read.min(remaining); + let mut cancellation_poll = tokio::time::interval(Duration::from_millis(20)); + tokio::time::timeout(read_deadline, async { + loop { + tokio::select! { + chunk = response.chunk() => { + return chunk + .map(|chunk| chunk.map(|chunk| chunk.to_vec())) + .map_err(|error| anyhow!("download body read failed: {error}")); + } + _ = cancellation_poll.tick() => { + if cancelled.load(Ordering::Relaxed) { + return Err(anyhow!("download cancelled while reading")); + } + } + } + } + }) + .await + .map_err(|_| anyhow!("download body read timed out"))? + } +} + +impl Default for DownloadClient { + fn default() -> Self { + Self::with_timeouts(DownloadTimeouts::default()) + .expect("default download client configuration must be valid") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::thread; + + fn stalled_server() -> String { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0_u8; 1024]; + let _ = stream.read(&mut request); + stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\nConnection: keep-alive\r\n\r\nx", + ) + .unwrap(); + thread::sleep(Duration::from_secs(2)); + }); + format!("http://{address}/stalled") + } + + #[tokio::test] + async fn stalled_body_aborts_when_cancelled() { + let client = DownloadClient::with_timeouts(DownloadTimeouts { + connect: Duration::from_millis(100), + read: Duration::from_secs(1), + total: Duration::from_secs(5), + }) + .unwrap(); + let cancelled = AtomicBool::new(false); + let url = stalled_server(); + let response = client.send(client.get(&url), &cancelled).await.unwrap(); + let mut response = response; + cancelled.store(true, Ordering::Relaxed); + + let started = Instant::now(); + let error = client + .next_chunk( + &mut response, + &cancelled, + Instant::now() + Duration::from_secs(5), + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("cancelled")); + assert!(started.elapsed() < Duration::from_millis(500)); + } + + #[tokio::test] + async fn stalled_body_aborts_on_read_timeout() { + let client = DownloadClient::with_timeouts(DownloadTimeouts { + connect: Duration::from_millis(100), + read: Duration::from_millis(100), + total: Duration::from_secs(5), + }) + .unwrap(); + let cancelled = AtomicBool::new(false); + let url = stalled_server(); + let response = client.send(client.get(&url), &cancelled).await.unwrap(); + let mut response = response; + + assert_eq!( + client + .next_chunk( + &mut response, + &cancelled, + Instant::now() + Duration::from_secs(5) + ) + .await + .unwrap(), + Some(vec![b'x']) + ); + + let error = client + .next_chunk( + &mut response, + &cancelled, + Instant::now() + Duration::from_secs(5), + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("timed out")); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7a3633cb..b0c63386 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -12,6 +12,7 @@ mod credentials; mod dictation_transaction; mod dictionary; mod dictionary_learning; +mod download; mod elevation_watch; mod helpers; mod input; @@ -1131,6 +1132,7 @@ pub fn specta_builder() -> Builder { shortcut::change_formatting_level_setting, shortcut::change_experimental_enabled_setting, shortcut::change_post_process_base_url_setting, + shortcut::change_post_process_insecure_lan_setting, shortcut::change_post_process_api_key_setting, shortcut::change_post_process_model_setting, shortcut::set_post_process_provider, diff --git a/src-tauri/src/local_llm/download.rs b/src-tauri/src/local_llm/download.rs index 7c8b600a..8e7c0883 100644 --- a/src-tauri/src/local_llm/download.rs +++ b/src-tauri/src/local_llm/download.rs @@ -5,7 +5,6 @@ use crate::local_llm::runtime::{ }; use crate::local_llm::LocalLlmSettings; use anyhow::Result; -use futures_util::StreamExt; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use specta::Type; @@ -126,18 +125,19 @@ impl LocalLlmManager { .metadata() .map(|metadata| metadata.len()) .unwrap_or(0); - let client = reqwest::Client::new(); - let mut request = client.get(&url); + let downloader = crate::download::DownloadClient::default(); + let deadline = Instant::now() + downloader.total_timeout(); + let mut request = downloader.get(&url); if resume_from > 0 { request = request.header("Range", format!("bytes={}-", resume_from)); } - let mut response = request.send().await?; + let mut response = downloader.send(request, &cancel_flag).await?; if resume_from > 0 && response.status() == reqwest::StatusCode::OK { drop(response); let _ = fs::remove_file(&partial_path); resume_from = 0; - response = client.get(&url).send().await?; + response = downloader.send(downloader.get(&url), &cancel_flag).await?; } if !response.status().is_success() @@ -168,16 +168,11 @@ impl LocalLlmManager { self.emit_download_progress(model_id, downloaded, total_size); let mut last_emit = Instant::now(); let throttle = Duration::from_millis(100); - let mut stream = response.bytes_stream(); - while let Some(chunk) = stream.next().await { - if cancel_flag.load(Ordering::Relaxed) { - drop(file); - self.emit_download_progress(model_id, downloaded, total_size); - return Ok(()); - } - - let chunk = chunk?; + while let Some(chunk) = downloader + .next_chunk(&mut response, &cancel_flag, deadline) + .await? + { file.write_all(&chunk)?; downloaded += chunk.len() as u64; diff --git a/src-tauri/src/managers/model.rs b/src-tauri/src/managers/model.rs index fdf1b9ee..3805f95c 100644 --- a/src-tauri/src/managers/model.rs +++ b/src-tauri/src/managers/model.rs @@ -1,7 +1,6 @@ use crate::settings::{get_settings, mutate_settings_locked}; use anyhow::Result; use flate2::read::GzDecoder; -use futures_util::StreamExt; use log::{debug, info, warn}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -579,14 +578,15 @@ impl ModelManager { }; // Create HTTP client with range request for resuming - let client = reqwest::Client::new(); - let mut request = client.get(&url); + let downloader = crate::download::DownloadClient::default(); + let deadline = Instant::now() + downloader.total_timeout(); + let mut request = downloader.get(&url); if resume_from > 0 { request = request.header("Range", format!("bytes={}-", resume_from)); } - let mut response = request.send().await?; + let mut response = downloader.send(request, &cancel_flag).await?; // If we tried to resume but server returned 200 (not 206 Partial Content), // the server doesn't support range requests. Delete partial file and restart @@ -603,7 +603,7 @@ impl ModelManager { resume_from = 0; // Restart download without range header - response = client.get(&url).send().await?; + response = downloader.send(downloader.get(&url), &cancel_flag).await?; } // Check for success or partial content status @@ -624,7 +624,6 @@ impl ModelManager { }; let mut downloaded = resume_from; - let mut stream = response.bytes_stream(); // Open file for appending if resuming, or create new if starting fresh let mut file = if resume_from > 0 { @@ -656,18 +655,10 @@ impl ModelManager { let throttle_duration = Duration::from_millis(100); // Download with progress - while let Some(chunk) = stream.next().await { - // Check if download was cancelled - if cancel_flag.load(Ordering::Relaxed) { - drop(file); - info!("Download cancelled for: {}", model_id); - // Keep partial file for resume functionality. - // Guard handles is_downloading + cancel_flags cleanup on drop. - return Ok(()); - } - - let chunk = chunk?; - + while let Some(chunk) = downloader + .next_chunk(&mut response, &cancel_flag, deadline) + .await? + { file.write_all(&chunk)?; downloaded += chunk.len() as u64; From 6a73bf5fae9e154342939494eae9abc1c382b00c Mon Sep 17 00:00:00 2001 From: GalaxyRuler Date: Sun, 12 Jul 2026 03:09:28 +0300 Subject: [PATCH 5/8] fix(provider): require TLS for remote endpoints --- src-tauri/src/llm_client.rs | 15 ++++++++ src-tauri/src/runtime_settings.rs | 57 ++++++++++++++++++++++++++----- src-tauri/src/settings.rs | 47 ++++++++++++++++++++++--- src-tauri/src/shortcut/mod.rs | 40 +++++++++++++++++----- 4 files changed, 138 insertions(+), 21 deletions(-) diff --git a/src-tauri/src/llm_client.rs b/src-tauri/src/llm_client.rs index 6fb32c73..4d1b369f 100644 --- a/src-tauri/src/llm_client.rs +++ b/src-tauri/src/llm_client.rs @@ -68,6 +68,12 @@ struct ChatMessageResponse { /// Build headers for API requests based on provider type fn build_headers(provider: &PostProcessProvider, api_key: &str) -> Result { + if crate::settings::is_insecure_lan_post_process_base_url(&provider.base_url) + && !api_key.trim().is_empty() + { + return Err("API keys are forbidden for insecure LAN providers".to_string()); + } + let mut headers = HeaderMap::new(); // Common headers @@ -434,6 +440,15 @@ mod tests { } } + #[test] + fn insecure_lan_transport_rejects_api_keys() { + let provider = provider("http://192.168.1.20:8000/v1".to_string()); + let error = build_headers(&provider, "secret-key") + .expect_err("insecure LAN transport must not carry API keys"); + assert!(error.contains("forbidden")); + assert!(build_headers(&provider, "").is_ok()); + } + fn read_request_path(stream: &mut std::net::TcpStream) -> String { let mut buffer = [0u8; 2048]; let read = stream.read(&mut buffer).expect("read request"); diff --git a/src-tauri/src/runtime_settings.rs b/src-tauri/src/runtime_settings.rs index 457bef75..b5876b03 100644 --- a/src-tauri/src/runtime_settings.rs +++ b/src-tauri/src/runtime_settings.rs @@ -1,6 +1,6 @@ use crate::settings::{ - is_local_post_process_base_url, AppSettings, LLMPrompt, OverlayPosition, PostProcessProvider, - TranslationRoute, + is_insecure_lan_post_process_base_url, is_local_post_process_base_url, AppSettings, LLMPrompt, + OverlayPosition, PostProcessProvider, TranslationRoute, }; use std::time::Duration; @@ -322,12 +322,21 @@ pub fn text_processing_provider_runtime( return skipped_text_processing(TextProcessingSkipReason::MissingModel); } - let api_key = settings - .post_process_api_keys - .get(&provider.id) - .map(String::as_str) - .unwrap_or_default(); - if !is_local_post_process_base_url(&provider.base_url) && api_key.trim().is_empty() { + let insecure_lan = settings.allow_insecure_lan_post_process + && is_insecure_lan_post_process_base_url(&provider.base_url); + let api_key = if insecure_lan { + "" + } else { + settings + .post_process_api_keys + .get(&provider.id) + .map(String::as_str) + .unwrap_or_default() + }; + if !is_local_post_process_base_url(&provider.base_url) + && !insecure_lan + && api_key.trim().is_empty() + { return skipped_text_processing(TextProcessingSkipReason::RemoteMissingApiKey); } @@ -463,3 +472,35 @@ pub fn post_processing_runtime(settings: &AppSettings, requested: bool) -> PostP }, )) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn insecure_lan_opt_in_never_carries_an_api_key() { + let mut settings = crate::settings::get_default_settings(); + settings.post_process_enabled = true; + settings.allow_insecure_lan_post_process = true; + settings.post_process_provider_id = "custom".to_string(); + settings + .post_process_provider_mut("custom") + .expect("custom provider") + .base_url = "http://192.168.1.20:8000/v1".to_string(); + settings + .post_process_models + .insert("custom".to_string(), "test-model".to_string()); + settings + .post_process_api_keys + .insert("custom".to_string(), "secret-key".to_string()); + + let runtime = text_processing_provider_runtime( + &settings, + TextProcessingIntent::DictationPostProcessing { requested: true }, + ); + let provider = runtime + .api_provider() + .expect("opted-in insecure LAN provider should remain usable"); + assert!(provider.api_key.is_empty()); + } +} diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 38e9bf2c..d02340fc 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -606,6 +606,8 @@ pub struct AppSettings { pub post_process_providers: Vec, #[serde(default = "default_post_process_api_keys")] pub post_process_api_keys: SecretMap, + #[serde(default)] + pub allow_insecure_lan_post_process: bool, #[serde(default = "default_post_process_models")] pub post_process_models: HashMap, #[serde(default = "default_post_process_prompts")] @@ -974,10 +976,36 @@ pub fn is_local_post_process_base_url(base_url: &str) -> bool { return false; } - matches!( - parsed.host_str(), - Some("localhost") | Some("127.0.0.1") | Some("::1") | Some("[::1]") - ) + parsed.host_str().is_some_and(|host| { + host.eq_ignore_ascii_case("localhost") + || host + .trim_matches(['[', ']']) + .parse::() + .map(|address| address.is_loopback()) + .unwrap_or(false) + }) +} + +pub fn validate_post_process_base_url( + base_url: &str, + allow_insecure_lan: bool, +) -> Result<(), String> { + let parsed = reqwest::Url::parse(base_url.trim()) + .map_err(|error| format!("Invalid provider URL: {error}"))?; + + match parsed.scheme() { + "https" => Ok(()), + "http" if is_local_post_process_base_url(base_url) => Ok(()), + "http" if allow_insecure_lan => Ok(()), + "http" => Err("Non-loopback provider URLs must use HTTPS".to_string()), + scheme => Err(format!("Unsupported provider URL scheme: {scheme}")), + } +} + +pub fn is_insecure_lan_post_process_base_url(base_url: &str) -> bool { + reqwest::Url::parse(base_url.trim()) + .map(|parsed| parsed.scheme() == "http" && !is_local_post_process_base_url(base_url)) + .unwrap_or(false) } fn default_post_process_models() -> HashMap { @@ -1364,6 +1392,7 @@ pub fn get_default_settings() -> AppSettings { post_process_provider_id: default_post_process_provider_id(), post_process_providers: default_post_process_providers(), post_process_api_keys: default_post_process_api_keys(), + allow_insecure_lan_post_process: false, post_process_models: default_post_process_models(), post_process_prompts: default_post_process_prompts(), post_process_selected_prompt_id: None, @@ -3456,6 +3485,16 @@ mod tests { assert!(!vllm.supports_structured_output); } + #[test] + fn provider_url_policy_requires_tls_off_loopback() { + assert!(validate_post_process_base_url("https://192.168.1.20:8000/v1", false).is_ok()); + assert!(validate_post_process_base_url("http://localhost:8000/v1", false).is_ok()); + assert!(validate_post_process_base_url("http://127.0.0.1:8000/v1", false).is_ok()); + assert!(validate_post_process_base_url("http://[::1]:8000/v1", false).is_ok()); + assert!(validate_post_process_base_url("http://192.168.1.20:8000/v1", false).is_err()); + assert!(validate_post_process_base_url("http://192.168.1.20:8000/v1", true).is_ok()); + } + #[test] fn post_process_defaults_normalize_known_openai_compatible_base_urls() { let mut settings = get_default_settings(); diff --git a/src-tauri/src/shortcut/mod.rs b/src-tauri/src/shortcut/mod.rs index 2a5beb50..b6c9982e 100644 --- a/src-tauri/src/shortcut/mod.rs +++ b/src-tauri/src/shortcut/mod.rs @@ -111,10 +111,10 @@ use tauri_plugin_autostart::ManagerExt; #[cfg(all(target_os = "macos", target_arch = "aarch64"))] use crate::settings::APPLE_INTELLIGENCE_DEFAULT_MODEL_ID; use crate::settings::{ - self, get_settings, is_unbound_shortcut, AutoSubmitKey, ClipboardHandling, - DictationLanguageMode, KeyboardImplementation, LLMPrompt, OverlayPosition, PasteMethod, - ShortcutBinding, SoundTheme, TranslationRequestSettings, TranslationRoute, TypingTool, - APPLE_INTELLIGENCE_PROVIDER_ID, + self, get_settings, is_unbound_shortcut, validate_post_process_base_url, AutoSubmitKey, + ClipboardHandling, DictationLanguageMode, KeyboardImplementation, LLMPrompt, OverlayPosition, + PasteMethod, ShortcutBinding, SoundTheme, TranslationRequestSettings, TranslationRoute, + TypingTool, APPLE_INTELLIGENCE_PROVIDER_ID, }; use crate::tray; @@ -1258,16 +1258,21 @@ pub fn change_post_process_base_url_setting( .map(|provider| provider.label.clone()) .ok_or_else(|| format!("Provider '{}' not found", provider_id))?; - let provider = settings - .post_process_provider_mut(&provider_id) - .expect("Provider looked up above must exist"); - - if !provider.allow_base_url_edit { + let allow_insecure_lan = settings.allow_insecure_lan_post_process; + let allow_base_url_edit = settings + .post_process_provider(&provider_id) + .is_some_and(|provider| provider.allow_base_url_edit); + if !allow_base_url_edit { return Err(format!( "Provider '{}' does not allow editing the base URL", label )); } + validate_post_process_base_url(&base_url, allow_insecure_lan)?; + + let provider = settings + .post_process_provider_mut(&provider_id) + .expect("Provider looked up above must exist"); provider.base_url = base_url; Ok(()) @@ -1276,6 +1281,23 @@ pub fn change_post_process_base_url_setting( Ok(()) } +#[tauri::command] +#[specta::specta] +pub fn change_post_process_insecure_lan_setting( + app: AppHandle, + enabled: bool, +) -> Result<(), String> { + settings::try_write_settings_domain( + &app, + settings::SettingsWriteDomain::PostProcessing, + |settings| { + settings.allow_insecure_lan_post_process = enabled; + Ok(()) + }, + )?; + Ok(()) +} + /// Generic helper to validate provider exists fn validate_provider_exists( settings: &settings::AppSettings, From 73a4781ca8e89a2957c4f69cee2bbe9822589565 Mon Sep 17 00:00:00 2001 From: GalaxyRuler Date: Sun, 12 Jul 2026 03:10:20 +0300 Subject: [PATCH 6/8] fix(audio): bound realtime capture queue --- src-tauri/src/audio_toolkit/audio/recorder.rs | 130 +++++++++++++++--- src-tauri/src/managers/audio.rs | 8 ++ 2 files changed, 119 insertions(+), 19 deletions(-) diff --git a/src-tauri/src/audio_toolkit/audio/recorder.rs b/src-tauri/src/audio_toolkit/audio/recorder.rs index d4aa9519..548c61b7 100644 --- a/src-tauri/src/audio_toolkit/audio/recorder.rs +++ b/src-tauri/src/audio_toolkit/audio/recorder.rs @@ -2,7 +2,7 @@ use std::{ collections::VecDeque, io::Error, sync::{ - atomic::{AtomicBool, Ordering}, + atomic::{AtomicBool, AtomicUsize, Ordering}, mpsc, Arc, Mutex, }, time::{Duration, Instant}, @@ -28,6 +28,7 @@ enum Cmd { enum AudioChunk { Samples(Vec), + #[cfg_attr(not(test), allow(dead_code))] EndOfStream, } @@ -35,6 +36,7 @@ const RAW_FALLBACK_MAX_SECS: usize = 300; const RAW_FALLBACK_MAX_SAMPLES: usize = RAW_FALLBACK_MAX_SECS * constants::WHISPER_SAMPLE_RATE as usize; const WORKER_JOIN_TIMEOUT: Duration = Duration::from_secs(3); +const AUDIO_QUEUE_CAPACITY: usize = 32; fn join_worker_with_timeout(worker: std::thread::JoinHandle<()>, timeout: Duration) -> bool { let deadline = Instant::now() + timeout; @@ -60,6 +62,7 @@ pub struct RecorderStopOutput { pub raw_samples: Vec, pub device_error: bool, pub dropped_resampler_chunks: usize, + pub dropped_audio_chunks: usize, } pub struct AudioRecorder { @@ -101,7 +104,9 @@ impl AudioRecorder { return Ok(()); // already open } - let (sample_tx, sample_rx) = mpsc::channel::(); + let (sample_tx, sample_rx) = mpsc::sync_channel::(AUDIO_QUEUE_CAPACITY); + let eos_pending = Arc::new(AtomicBool::new(false)); + let audio_overruns = Arc::new(AtomicUsize::new(0)); let (cmd_tx, cmd_rx) = mpsc::channel::(); let (init_tx, init_rx) = mpsc::sync_channel::>(1); @@ -119,6 +124,8 @@ impl AudioRecorder { let vad = self.vad.clone(); // Move the optional level callback into the worker thread let level_cb = self.level_cb.clone(); + let worker_eos_pending = Arc::clone(&eos_pending); + let worker_audio_overruns = Arc::clone(&audio_overruns); let worker = std::thread::spawn(move || { let stop_flag = Arc::new(AtomicBool::new(false)); @@ -153,6 +160,8 @@ impl AudioRecorder { channels, stop_flag.clone(), device_error.clone(), + eos_pending.clone(), + audio_overruns.clone(), ) .map_err(|e| format!("Failed to build input stream: {e}"))?, cpal::SampleFormat::I8 => AudioRecorder::build_stream::( @@ -162,6 +171,8 @@ impl AudioRecorder { channels, stop_flag.clone(), device_error.clone(), + eos_pending.clone(), + audio_overruns.clone(), ) .map_err(|e| format!("Failed to build input stream: {e}"))?, cpal::SampleFormat::I16 => AudioRecorder::build_stream::( @@ -171,6 +182,8 @@ impl AudioRecorder { channels, stop_flag.clone(), device_error.clone(), + eos_pending.clone(), + audio_overruns.clone(), ) .map_err(|e| format!("Failed to build input stream: {e}"))?, cpal::SampleFormat::I32 => AudioRecorder::build_stream::( @@ -180,6 +193,8 @@ impl AudioRecorder { channels, stop_flag.clone(), device_error.clone(), + eos_pending.clone(), + audio_overruns.clone(), ) .map_err(|e| format!("Failed to build input stream: {e}"))?, cpal::SampleFormat::F32 => AudioRecorder::build_stream::( @@ -189,6 +204,8 @@ impl AudioRecorder { channels, stop_flag.clone(), device_error.clone(), + eos_pending.clone(), + audio_overruns.clone(), ) .map_err(|e| format!("Failed to build input stream: {e}"))?, sample_format => { @@ -215,6 +232,8 @@ impl AudioRecorder { cmd_rx, level_cb, stop_flag, + worker_eos_pending, + worker_audio_overruns, ); drop(stream); } @@ -328,10 +347,12 @@ impl AudioRecorder { fn build_stream( device: &cpal::Device, config: &cpal::SupportedStreamConfig, - sample_tx: mpsc::Sender, + sample_tx: mpsc::SyncSender, channels: usize, stop_flag: Arc, device_error: Arc, + eos_pending: Arc, + audio_overruns: Arc, ) -> Result where T: Sample + SizedSample + Send + 'static, @@ -340,11 +361,13 @@ impl AudioRecorder { let mut output_buffer = Vec::new(); let mut eos_sent = false; let stream_sample_tx = sample_tx.clone(); + let stream_eos_pending = Arc::clone(&eos_pending); + let stream_audio_overruns = Arc::clone(&audio_overruns); let stream_cb = move |data: &[T], _: &cpal::InputCallbackInfo| { if stop_flag.load(Ordering::Relaxed) { if !eos_sent { - let _ = stream_sample_tx.send(AudioChunk::EndOfStream); + stream_eos_pending.store(true, Ordering::Release); eos_sent = true; } return; @@ -369,12 +392,11 @@ impl AudioRecorder { } } - if stream_sample_tx - .send(AudioChunk::Samples(output_buffer.clone())) - .is_err() - { - log::error!("Failed to send samples"); - } + try_send_audio_chunk( + &stream_sample_tx, + AudioChunk::Samples(output_buffer.clone()), + &stream_audio_overruns, + ); }; device.build_input_stream( @@ -383,7 +405,7 @@ impl AudioRecorder { move |err| { log::error!("Stream error: {}", err); device_error.store(true, Ordering::SeqCst); - let _ = sample_tx.send(AudioChunk::EndOfStream); + eos_pending.store(true, Ordering::Release); }, None, ) @@ -459,13 +481,36 @@ pub fn is_no_input_device_error(error_message: &str) -> bool { && normalized.contains("coreaudio")) } +fn try_send_audio_chunk( + sample_tx: &mpsc::SyncSender, + chunk: AudioChunk, + audio_overruns: &AtomicUsize, +) { + match sample_tx.try_send(chunk) { + Ok(()) => {} + Err(mpsc::TrySendError::Full(_)) => { + audio_overruns.fetch_add(1, Ordering::Relaxed); + } + Err(mpsc::TrySendError::Disconnected(_)) => {} + } +} + +#[cfg(test)] +fn take_eos_if_drained(sample_rx: &mpsc::Receiver, eos_pending: &AtomicBool) -> bool { + if sample_rx.try_recv().is_ok() { + return false; + } + eos_pending.swap(false, Ordering::AcqRel) +} + #[cfg(test)] mod tests { use super::{ is_microphone_access_denied, is_no_input_device_error, join_worker_with_timeout, - run_consumer, AudioChunk, Cmd, FrameResampler, + run_consumer, take_eos_if_drained, try_send_audio_chunk, AudioChunk, Cmd, FrameResampler, + AUDIO_QUEUE_CAPACITY, }; - use std::sync::atomic::AtomicBool; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{mpsc, Arc}; use std::time::Duration; @@ -523,9 +568,13 @@ mod tests { #[test] fn start_command_applies_before_first_audio_chunk_after_start_request() { - let (sample_tx, sample_rx) = mpsc::channel(); + let (sample_tx, sample_rx) = mpsc::sync_channel(AUDIO_QUEUE_CAPACITY); let (cmd_tx, cmd_rx) = mpsc::channel(); let stop_flag = Arc::new(AtomicBool::new(false)); + let eos_pending = Arc::new(AtomicBool::new(false)); + let audio_overruns = Arc::new(AtomicUsize::new(0)); + let consumer_eos_pending = Arc::clone(&eos_pending); + let consumer_audio_overruns = Arc::clone(&audio_overruns); let consumer = std::thread::spawn(move || { run_consumer( @@ -536,6 +585,8 @@ mod tests { cmd_rx, None, stop_flag, + consumer_eos_pending, + consumer_audio_overruns, ); }); @@ -564,9 +615,13 @@ mod tests { #[test] fn eos_before_stop_unblocks_stop_without_drain_timeout() { - let (sample_tx, sample_rx) = mpsc::channel(); + let (sample_tx, sample_rx) = mpsc::sync_channel(AUDIO_QUEUE_CAPACITY); let (cmd_tx, cmd_rx) = mpsc::channel(); let stop_flag = Arc::new(AtomicBool::new(false)); + let eos_pending = Arc::new(AtomicBool::new(false)); + let audio_overruns = Arc::new(AtomicUsize::new(0)); + let consumer_eos_pending = Arc::clone(&eos_pending); + let consumer_audio_overruns = Arc::clone(&audio_overruns); let (barrier_tx, barrier_rx) = mpsc::channel(); let level_cb: Arc) + Send + Sync> = Arc::new(move |_| { let _ = barrier_tx.send(()); @@ -581,6 +636,8 @@ mod tests { cmd_rx, Some(level_cb), stop_flag, + consumer_eos_pending, + consumer_audio_overruns, ); }); @@ -600,6 +657,22 @@ mod tests { drop(cmd_tx); consumer.join().unwrap(); } + + #[test] + fn bounded_audio_queue_drops_newest_but_keeps_eos_reliable() { + let (sample_tx, sample_rx) = mpsc::sync_channel(AUDIO_QUEUE_CAPACITY); + let overruns = Arc::new(AtomicUsize::new(0)); + + for _ in 0..(AUDIO_QUEUE_CAPACITY + 20) { + try_send_audio_chunk(&sample_tx, AudioChunk::Samples(vec![0.0; 32]), &overruns); + } + let eos_pending = Arc::new(AtomicBool::new(true)); + + assert!(overruns.load(Ordering::Relaxed) >= 20); + let retained = sample_rx.try_iter().count(); + assert_eq!(retained, AUDIO_QUEUE_CAPACITY); + assert!(take_eos_if_drained(&sample_rx, &eos_pending)); + } } fn run_consumer( @@ -610,6 +683,8 @@ fn run_consumer( cmd_rx: mpsc::Receiver, level_cb: Option) + Send + Sync + 'static>>, stop_flag: Arc, + eos_pending: Arc, + audio_overruns: Arc, ) { let mut processed_samples = Vec::::new(); let mut raw_samples = VecDeque::::new(); @@ -637,8 +712,14 @@ fn run_consumer( // The cpal callback sees the stop flag, sends EndOfStream, and goes // silent — guaranteeing every captured sample is in the channel // ahead of the sentinel. + let eos_deadline = Instant::now() + Duration::from_secs(2); loop { - match sample_rx.recv_timeout(Duration::from_secs(2)) { + let remaining = eos_deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + log::warn!("Timed out waiting for EndOfStream from audio callback"); + break; + } + match sample_rx.recv_timeout(remaining.min(Duration::from_millis(20))) { Ok(AudioChunk::Samples(remaining)) => { frame_resampler.push(&remaining, &mut |frame: &[f32]| { handle_frame( @@ -651,10 +732,12 @@ fn run_consumer( }); } Ok(AudioChunk::EndOfStream) => break, - Err(_) => { - log::warn!("Timed out waiting for EndOfStream from audio callback"); - break; + Err(mpsc::RecvTimeoutError::Timeout) => { + if eos_pending.swap(false, Ordering::AcqRel) { + break; + } } + Err(mpsc::RecvTimeoutError::Disconnected) => break, } } } @@ -663,12 +746,20 @@ fn run_consumer( handle_frame(frame, true, &vad, &mut processed_samples, &mut raw_samples) }); let dropped_resampler_chunks = frame_resampler.take_dropped_chunks(); + let dropped_audio_chunks = audio_overruns.swap(0, Ordering::Relaxed); + if dropped_audio_chunks > 0 { + log::warn!( + "Audio callback dropped {} newest chunks because the consumer was behind", + dropped_audio_chunks + ); + } let _ = $reply_tx.send(RecorderStopOutput { samples: std::mem::take(&mut processed_samples), raw_samples: std::mem::take(&mut raw_samples).into_iter().collect(), device_error: false, dropped_resampler_chunks, + dropped_audio_chunks, }); // Resume the audio callback so the consumer loop can continue @@ -764,6 +855,7 @@ fn run_consumer( AudioChunk::Samples(s) => s, AudioChunk::EndOfStream => { end_of_stream_received = true; + eos_pending.store(false, Ordering::Release); for cmd in deferred_commands { if let Cmd::Stop(reply_tx) = cmd { handle_stop!(reply_tx, false); diff --git a/src-tauri/src/managers/audio.rs b/src-tauri/src/managers/audio.rs index a9ff6901..80fdfeb1 100644 --- a/src-tauri/src/managers/audio.rs +++ b/src-tauri/src/managers/audio.rs @@ -800,6 +800,7 @@ impl AudioRecordingManager { raw_samples: Vec::new(), device_error: false, dropped_resampler_chunks: 0, + dropped_audio_chunks: 0, } } } @@ -810,6 +811,7 @@ impl AudioRecordingManager { raw_samples: Vec::new(), device_error: false, dropped_resampler_chunks: 0, + dropped_audio_chunks: 0, } }; @@ -829,12 +831,18 @@ impl AudioRecordingManager { raw_samples, device_error, dropped_resampler_chunks, + dropped_audio_chunks, } = stop_output; if dropped_resampler_chunks > 0 { log::warn!( "Microphone diagnostics: audio resampler dropped {dropped_resampler_chunks} chunk(s) during recording" ); } + if dropped_audio_chunks > 0 { + log::warn!( + "Microphone diagnostics: audio callback dropped {dropped_audio_chunks} newest chunk(s)" + ); + } let sample_selection = if device_error { StopSamples::Empty } else { From 7923ac40ab2df8d09d2a64bd578da2c5feee2aba Mon Sep 17 00:00:00 2001 From: GalaxyRuler Date: Sun, 12 Jul 2026 10:44:51 +0300 Subject: [PATCH 7/8] test(provider): use RFC 5737 TEST-NET IP for non-loopback URL fixtures check:public-hygiene flags RFC 1918 private IPs (192.168.x) left in source. The P1-06 provider-TLS tests only need a NON-LOOPBACK host; swap 192.168.1.20 for 203.0.113.20 (TEST-NET-3, reserved for documentation) to keep the test semantics while passing the published-surface hygiene gate. Co-Authored-By: Claude Opus 4.8 --- src-tauri/src/llm_client.rs | 2 +- src-tauri/src/runtime_settings.rs | 2 +- src-tauri/src/settings.rs | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/llm_client.rs b/src-tauri/src/llm_client.rs index 4d1b369f..2d098dc6 100644 --- a/src-tauri/src/llm_client.rs +++ b/src-tauri/src/llm_client.rs @@ -442,7 +442,7 @@ mod tests { #[test] fn insecure_lan_transport_rejects_api_keys() { - let provider = provider("http://192.168.1.20:8000/v1".to_string()); + let provider = provider("http://203.0.113.20:8000/v1".to_string()); let error = build_headers(&provider, "secret-key") .expect_err("insecure LAN transport must not carry API keys"); assert!(error.contains("forbidden")); diff --git a/src-tauri/src/runtime_settings.rs b/src-tauri/src/runtime_settings.rs index b5876b03..8482d162 100644 --- a/src-tauri/src/runtime_settings.rs +++ b/src-tauri/src/runtime_settings.rs @@ -486,7 +486,7 @@ mod tests { settings .post_process_provider_mut("custom") .expect("custom provider") - .base_url = "http://192.168.1.20:8000/v1".to_string(); + .base_url = "http://203.0.113.20:8000/v1".to_string(); settings .post_process_models .insert("custom".to_string(), "test-model".to_string()); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index d02340fc..082a1bd2 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -3487,12 +3487,12 @@ mod tests { #[test] fn provider_url_policy_requires_tls_off_loopback() { - assert!(validate_post_process_base_url("https://192.168.1.20:8000/v1", false).is_ok()); + assert!(validate_post_process_base_url("https://203.0.113.20:8000/v1", false).is_ok()); assert!(validate_post_process_base_url("http://localhost:8000/v1", false).is_ok()); assert!(validate_post_process_base_url("http://127.0.0.1:8000/v1", false).is_ok()); assert!(validate_post_process_base_url("http://[::1]:8000/v1", false).is_ok()); - assert!(validate_post_process_base_url("http://192.168.1.20:8000/v1", false).is_err()); - assert!(validate_post_process_base_url("http://192.168.1.20:8000/v1", true).is_ok()); + assert!(validate_post_process_base_url("http://203.0.113.20:8000/v1", false).is_err()); + assert!(validate_post_process_base_url("http://203.0.113.20:8000/v1", true).is_ok()); } #[test] From 7e683868aaeaa385027e3533f3fe00c6a27e4efb Mon Sep 17 00:00:00 2001 From: GalaxyRuler Date: Sun, 12 Jul 2026 11:49:09 +0300 Subject: [PATCH 8/8] chore(bindings): regenerate src/bindings.ts for insecure-LAN provider setting P1-06 added the change_post_process_insecure_lan_setting command and the allow_insecure_lan_post_process AppSettings field; regenerate the specta TypeScript bindings so check:bindings drift passes. Co-Authored-By: Claude Opus 4.8 --- src/bindings.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/bindings.ts b/src/bindings.ts index e079c53f..8617215d 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -240,6 +240,14 @@ async changePostProcessBaseUrlSetting(providerId: string, baseUrl: string) : Pro else return { status: "error", error: e as any }; } }, +async changePostProcessInsecureLanSetting(enabled: boolean) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("change_post_process_insecure_lan_setting", { enabled }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, async changePostProcessApiKeySetting(providerId: string, apiKey: string, sessionOnly: boolean) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_post_process_api_key_setting", { providerId, apiKey, sessionOnly }) }; @@ -1411,7 +1419,7 @@ export type AdaptiveProfile = { id: string; name: string; enabled: boolean; clea export type AndroidAsrEngineKind = "zipformerWhisper" | "senseVoice" | "canary" | "moonshine" | "parakeet" export type AndroidAsrModelPackState = { id: string; displayName: string; description: string; language: string; sizeMb: number; minRamMb: number; engineKind: AndroidAsrEngineKind; installedDir: string; isInstalled: boolean; isDownloading: boolean; isActive: boolean; isSelectable: boolean; downloadPhase: string; downloadProgress: number; missingFiles: string[] } export type AndroidLlmModelPackState = { id: string; displayName: string; description: string; runtime: string; license: string; quantization: string; sizeMb: number; minRamMb: number; installedDir: string; modelPath: string; isInstalled: boolean; isDownloading: boolean; isActive: boolean; isSelectable: boolean; downloadPhase: string; downloadProgress: number; missingFiles: string[] } -export type AppSettings = { bindings?: Partial<{ [key in string]: ShortcutBinding }>; push_to_talk?: boolean; audio_feedback?: boolean; audio_feedback_volume?: number; sound_theme?: SoundTheme; start_hidden?: boolean; autostart_enabled?: boolean; update_checks_enabled?: boolean; selected_model?: string; always_on_microphone?: boolean; selected_microphone?: string | null; selected_microphone_id?: string | null; clamshell_microphone?: string | null; selected_output_device?: string | null; translate_to_english?: boolean; translation_enabled?: boolean; translation_request?: TranslationRequestSettings | null; selected_language?: string; dictation_language_mode?: DictationLanguageMode; overlay_position?: OverlayPosition; docked_pill_enabled?: boolean; warn_on_elevated_target?: boolean; debug_mode?: boolean; log_level?: LogLevel; custom_words?: string[]; dictionary_entries?: DictionaryEntry[]; dictionary_auto_learn_suppressed?: string[]; dictionary_learn_candidates?: LearnCandidate[]; dictionary_schema_version?: number; auto_add_dictionary_words?: boolean; dictionary_diagnostics?: DictionaryDiagnostics; snippets?: SnippetEntry[]; model_unload_timeout?: ModelUnloadTimeout; word_correction_threshold?: number; history_enabled?: boolean; recordings_enabled?: boolean; history_limit?: number; recording_retention_period?: RecordingRetentionPeriod; paste_method?: PasteMethod; clipboard_handling?: ClipboardHandling; auto_submit?: boolean; auto_submit_key?: AutoSubmitKey; post_process_enabled?: boolean; formatting_level?: FormattingLevel; post_process_provider_id?: string; post_process_providers?: PostProcessProvider[]; post_process_api_keys?: SecretMap; post_process_models?: Partial<{ [key in string]: string }>; post_process_prompts?: LLMPrompt[]; post_process_selected_prompt_id?: string | null; local_llm?: LocalLlmSettings; mute_while_recording?: boolean; append_trailing_space?: boolean; app_language?: string; experimental_enabled?: boolean; lazy_stream_close?: boolean; keyboard_implementation?: KeyboardImplementation; show_tray_icon?: boolean; paste_delay_ms?: number; typing_tool?: TypingTool; external_script_path: string | null; custom_filler_words?: string[] | null; adaptive_profiles_enabled?: boolean; context_awareness_enabled?: boolean; context_nearby_text_enabled?: boolean; adaptive_language_shortlist?: string[]; adaptive_default_profile_id?: string; adaptive_profiles?: AdaptiveProfile[]; adaptive_correction_memory_enabled?: boolean; adaptive_private_app_patterns?: string[]; whisper_accelerator?: WhisperAcceleratorSetting; ort_accelerator?: OrtAcceleratorSetting; whisper_gpu_device?: number; extra_recording_buffer_ms?: number } +export type AppSettings = { bindings?: Partial<{ [key in string]: ShortcutBinding }>; push_to_talk?: boolean; audio_feedback?: boolean; audio_feedback_volume?: number; sound_theme?: SoundTheme; start_hidden?: boolean; autostart_enabled?: boolean; update_checks_enabled?: boolean; selected_model?: string; always_on_microphone?: boolean; selected_microphone?: string | null; selected_microphone_id?: string | null; clamshell_microphone?: string | null; selected_output_device?: string | null; translate_to_english?: boolean; translation_enabled?: boolean; translation_request?: TranslationRequestSettings | null; selected_language?: string; dictation_language_mode?: DictationLanguageMode; overlay_position?: OverlayPosition; docked_pill_enabled?: boolean; warn_on_elevated_target?: boolean; debug_mode?: boolean; log_level?: LogLevel; custom_words?: string[]; dictionary_entries?: DictionaryEntry[]; dictionary_auto_learn_suppressed?: string[]; dictionary_learn_candidates?: LearnCandidate[]; dictionary_schema_version?: number; auto_add_dictionary_words?: boolean; dictionary_diagnostics?: DictionaryDiagnostics; snippets?: SnippetEntry[]; model_unload_timeout?: ModelUnloadTimeout; word_correction_threshold?: number; history_enabled?: boolean; recordings_enabled?: boolean; history_limit?: number; recording_retention_period?: RecordingRetentionPeriod; paste_method?: PasteMethod; clipboard_handling?: ClipboardHandling; auto_submit?: boolean; auto_submit_key?: AutoSubmitKey; post_process_enabled?: boolean; formatting_level?: FormattingLevel; post_process_provider_id?: string; post_process_providers?: PostProcessProvider[]; post_process_api_keys?: SecretMap; allow_insecure_lan_post_process?: boolean; post_process_models?: Partial<{ [key in string]: string }>; post_process_prompts?: LLMPrompt[]; post_process_selected_prompt_id?: string | null; local_llm?: LocalLlmSettings; mute_while_recording?: boolean; append_trailing_space?: boolean; app_language?: string; experimental_enabled?: boolean; lazy_stream_close?: boolean; keyboard_implementation?: KeyboardImplementation; show_tray_icon?: boolean; paste_delay_ms?: number; typing_tool?: TypingTool; external_script_path: string | null; custom_filler_words?: string[] | null; adaptive_profiles_enabled?: boolean; context_awareness_enabled?: boolean; context_nearby_text_enabled?: boolean; adaptive_language_shortlist?: string[]; adaptive_default_profile_id?: string; adaptive_profiles?: AdaptiveProfile[]; adaptive_correction_memory_enabled?: boolean; adaptive_private_app_patterns?: string[]; whisper_accelerator?: WhisperAcceleratorSetting; ort_accelerator?: OrtAcceleratorSetting; whisper_gpu_device?: number; extra_recording_buffer_ms?: number } export type AudioDevice = { index: string; name: string; is_default: boolean } export type AutoSubmitKey = "enter" | "ctrl_enter" | "cmd_enter" export type AvailableAccelerators = { whisper: string[]; ort: string[]; gpu_devices: GpuDeviceOption[] }