From a2adaaef6a446310a13a781e239135961c5991c9 Mon Sep 17 00:00:00 2001 From: vriesd Date: Sat, 5 Sep 2026 10:30:56 +0200 Subject: [PATCH 1/7] refactor(recording): use session-bound control commands --- crates/echo-ipc/src/lib.rs | 13 + crates/echo/src/rec.rs | 307 ++++++++++++++++++--- crates/echo/src/status.rs | 67 +++++ crates/echo/tests/recording_commands.rs | 177 ++++++++++++ docs/architecture.md | 35 ++- frontend/src/App.test.tsx | 129 +++++++-- frontend/src/App.tsx | 4 +- frontend/src/api/DesktopApi.ts | 5 +- frontend/src/api/previewDesktopApi.ts | 65 ++++- frontend/src/api/previewDesktopFixtures.ts | 3 + frontend/src/api/tauriDesktopApi.test.ts | 8 +- frontend/src/api/tauriDesktopApi.ts | 5 +- frontend/src/app/AppHistory.test.tsx | 2 +- frontend/src/app/useAppController.ts | 89 +++--- frontend/src/generated/ipc.ts | 4 +- frontend/src/home/HomeView.tsx | 7 +- frontend/src/tauri.test.ts | 36 ++- frontend/src/tauri.ts | 4 +- frontend/src/test/desktopApiHarness.ts | 8 +- src-tauri/src/commands/mod.rs | 3 +- src-tauri/src/commands/recording.rs | 36 ++- src-tauri/src/ipc.rs | 16 +- src-tauri/src/main.rs | 17 +- src-tauri/src/status.rs | 21 +- 24 files changed, 917 insertions(+), 144 deletions(-) create mode 100644 crates/echo/tests/recording_commands.rs diff --git a/crates/echo-ipc/src/lib.rs b/crates/echo-ipc/src/lib.rs index ea06454..3c5744a 100644 --- a/crates/echo-ipc/src/lib.rs +++ b/crates/echo-ipc/src/lib.rs @@ -28,11 +28,23 @@ pub struct AppStatus { pub last_run: Option, pub language_warning: Option, pub recording_in_process: bool, + pub recording_session_id: Option, + pub capture_stop_requested: bool, + pub recording_revision: u64, pub current_exe: String, pub first_path_hit: Option, pub stale_installs: Vec, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct RecordingSnapshot { + pub session_id: Option, + pub phase: AppPhase, + pub capture_stop_requested: bool, + pub revision: u64, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, TS)] pub enum AppPhase { Idle, @@ -846,6 +858,7 @@ macro_rules! schema_types { schema::NextSpeechRun => schema::NextSpeechRun, schema::Readiness => schema::Readiness, schema::RecordingPolicy => schema::RecordingPolicy, + schema::RecordingSnapshot => schema::RecordingSnapshot, schema::RecoveryReason => schema::RecoveryReason, schema::RecoveryTelemetry => schema::RecoveryTelemetry, schema::ResolvedSpeechEngine => schema::ResolvedSpeechEngine, diff --git a/crates/echo/src/rec.rs b/crates/echo/src/rec.rs index f12a59e..d2aabba 100644 --- a/crates/echo/src/rec.rs +++ b/crates/echo/src/rec.rs @@ -9,6 +9,7 @@ use echo_core::{ RecordingLimit, ResolvedRecordingLimit, Session, SessionState, SAMPLE_RATE_HZ, }; use fs2::FileExt; +use sha2::{Digest, Sha256}; use crate::audio::{self, AudioCapture, CancellationToken}; use crate::hotkey::HotkeyEvent; @@ -109,14 +110,16 @@ impl StopWhen { } } - fn clear_stop_request(&self) { - if let Some(session) = self.session() { - session.clear_stop_request(); - } + fn cancel_requested(&self) -> bool { + self.session().is_some_and(ToggleSession::cancel_requested) } - fn stop_requested(&self) -> bool { - self.session().is_some_and(ToggleSession::stop_requested) + fn session_id(&self) -> Option<&str> { + self.session().map(|session| session.token.as_str()) + } + + fn next_revision(&self) -> u64 { + self.session().map_or(0, ToggleSession::next_revision) } } @@ -141,7 +144,13 @@ pub fn run_rec_toggle() -> i32 { } match action { ToggleAction::Start(session) => run_record(StopWhen::ToggleFile(session)), - ToggleAction::Stop(_) => 0, + ToggleAction::Stop(owner) => { + // Preserve the CLI/hotkey toggle convention after capture: + // its stop gesture means cancel while transcription is live. + // Explicit desktop capture-stop never takes this path. + apply_toggle_stop_intent(&owner); + 0 + } } } Err(err) => { @@ -165,8 +174,56 @@ pub fn toggle_managed_recording() -> Result, String> { .map(|_| Some(recording_token)) .map_err(|err| err.to_string()) } - ToggleAction::Stop(owner) => Ok(owner.token), + ToggleAction::Stop(owner) => { + apply_toggle_stop_intent(&owner); + Ok(owner.token) + } + } +} + +fn apply_toggle_stop_intent(owner: &LockOwner) { + if status::read().state == "Transcribing" { + if let Some(token) = owner.token.as_deref() { + let _ = ToggleSession::request_cancel_for_token_in(&echo_core::data_dir(), token); + } + } +} + +/// Start a desktop capture. Unlike the CLI toggle this never converts a busy +/// session into a stop request: GUI controls name the session they intend. +pub fn start_managed_recording() -> Result { + let session = match ToggleSession::acquire_in(&echo_core::data_dir())? { + LockAcquisition::Started(session) => session, + LockAcquisition::Busy(_) => return Err("Another recording is already active.".to_string()), + }; + let token = session.token.clone(); + let (send, receive) = std::sync::mpsc::sync_channel(1); + std::thread::Builder::new() + .name("echo-record-start".to_string()) + .spawn(move || { + let _ = run_record_started(StopWhen::ToggleFile(session), Some(send)); + }) + .map_err(|err| err.to_string())?; + receive + .recv() + .map_err(|_| "recording worker exited before starting".to_string())??; + Ok(token) +} + +pub fn request_capture_stop(session_id: &str) -> Result { + let current = status::read(); + if current.state != "Recording" || current.session_id.as_deref() != Some(session_id) { + return Ok(false); } + ToggleSession::request_stop_for_token_in(&echo_core::data_dir(), session_id) +} + +pub fn request_transcription_cancel(session_id: &str) -> Result { + let current = status::read(); + if current.state != "Transcribing" || current.session_id.as_deref() != Some(session_id) { + return Ok(false); + } + ToggleSession::request_cancel_for_token_in(&echo_core::data_dir(), session_id) } pub fn stop_shortcut_recording(activation: &str) -> Result { @@ -181,14 +238,24 @@ pub fn stop_shortcut_recording(activation: &str) -> Result { } fn run_record(stop: StopWhen) -> i32 { + run_record_started(stop, None) +} + +fn run_record_started( + stop: StopWhen, + started: Option>>, +) -> i32 { let config = match crate::settings::runtime_config() { Ok(config) => config, Err(error) => { + if let Some(sender) = started { + let _ = sender.send(Err(error.clone())); + } eprintln!("{error}"); let state = SessionState::Failed { reason: FailReason::EngineError, }; - let _ = status::write_status(state, None, Some(&error), None); + let _ = write_session_status(&stop, state, None, Some(&error), None); crate::notify::notify_session_failure(FailReason::EngineError, Some(&error)); return 1; } @@ -196,19 +263,24 @@ fn run_record(stop: StopWhen) -> i32 { let environment = std::env::var("ECHO_RECORD_SECONDS").ok(); let limit = echo_core::resolve_recording_limit(environment.as_deref(), config.record_seconds).limit; - run_record_with_limit(stop, limit, &config) + run_record_with_limit(stop, limit, &config, started) } fn run_record_with_limit( mut stop: StopWhen, limit: RecordingLimit, config: &echo_core::Config, + started: Option>>, ) -> i32 { let mut session = Session::new(); - log_state(&session); - let _ = status::write_status(session.state(), None, None, None); apply_edge(&mut session, HotkeyEvent::Down); - let _ = status::write_recording(limit); + let initial = write_session_recording(&stop, limit); + if let Some(sender) = started { + let _ = sender.send(initial.clone()); + } + if initial.is_err() { + return 1; + } // The HUD lives until after injection: the longest wait in the session // (transcription) gets an indicator, and the outcome gets a state. let _in_process = InProcessSession::start(); @@ -222,7 +294,7 @@ fn run_record_with_limit( hud.set_state(crate::ui::hud::HudState::Failed); let _ = session.fail(reason); log_state(&session); - let _ = status::write_status(session.state(), None, None, None); + let _ = write_session_status(&stop, session.state(), None, None, None); crate::notify::notify_session_failure(reason, None); return 1; } @@ -234,16 +306,15 @@ fn run_record_with_limit( let target = injector.focus(); (injector, target) }); - stop.clear_stop_request(); hud.set_state(crate::ui::hud::HudState::Transcribing); apply_edge(&mut session, HotkeyEvent::Up); - let _ = status::write_status(session.state(), None, None, None); + let _ = write_session_status(&stop, session.state(), None, None, None); let (dict, dictionary_warning) = dictionary_for_transcription(Dictionary::load()); let mut persistence_warnings = Vec::new(); if let Some(warning) = dictionary_warning { eprintln!("{warning}"); - let _ = status::write_status(session.state(), None, Some(&warning), None); + let _ = write_session_status(&stop, session.state(), None, Some(&warning), None); crate::notify::notify_persistence_failure(&warning); persistence_warnings.push(warning); } @@ -262,7 +333,13 @@ fn run_record_with_limit( log_state(&session); let detail = err.to_string(); let visible_detail = joined_details(&persistence_warnings, Some(&detail)); - let _ = status::write_status(session.state(), None, visible_detail.as_deref(), None); + let _ = write_session_status( + &stop, + session.state(), + None, + visible_detail.as_deref(), + None, + ); eprintln!("{detail}"); crate::notify::notify_session_failure(reason, Some(&detail)); return 1; @@ -272,7 +349,7 @@ fn run_record_with_limit( &capture.pcm, crate::transcribe::TranscriptionPurpose::Dictation(&dict), Instant::now() + Duration::from_secs(15 * 60), - &|| stop.stop_requested(), + &|| stop.cancel_requested(), ) { Ok(transcript) => transcript, Err(err) => { @@ -288,7 +365,13 @@ fn run_record_with_limit( let _ = session.fail(reason); log_state(&session); let visible_detail = joined_details(&persistence_warnings, detail); - let _ = status::write_status(session.state(), None, visible_detail.as_deref(), None); + let _ = write_session_status( + &stop, + session.state(), + None, + visible_detail.as_deref(), + None, + ); crate::notify::notify_session_failure(reason, detail); return 1; } @@ -338,7 +421,8 @@ fn run_record_with_limit( let persistence_detail = joined_details(&persistence_warnings, None); if failed { // Leave the Failed state visible; the next session overwrites it. - let _ = status::write_status( + let _ = write_session_status( + &stop, session.state(), Some(&transcript.text), persistence_detail.as_deref(), @@ -346,7 +430,8 @@ fn run_record_with_limit( ); return 1; } - let _ = status::write_status( + let _ = write_session_status( + &stop, session.state(), Some(&transcript.text), persistence_detail.as_deref(), @@ -370,6 +455,35 @@ fn new_history_id() -> String { uuid::Uuid::new_v4().to_string() } +fn write_session_recording(stop: &StopWhen, limit: RecordingLimit) -> Result<(), String> { + match stop.session_id() { + Some(session_id) => { + status::write_recording_for_session(session_id, stop.next_revision(), limit) + } + None => status::write_recording(limit), + } +} + +fn write_session_status( + stop: &StopWhen, + state: SessionState, + last: Option<&str>, + error: Option<&str>, + last_history_id: Option<&str>, +) -> Result<(), String> { + match stop.session_id() { + Some(session_id) => status::write_status_for_session( + session_id, + stop.next_revision(), + state, + last, + error, + last_history_id, + ), + None => status::write_status(state, last, error, last_history_id), + } +} + fn dictionary_for_transcription( result: Result, ) -> (Dictionary, Option) { @@ -534,6 +648,7 @@ struct ToggleSession { directory: PrivateDir, _gate: std::fs::File, token: String, + revision: AtomicU64, } pub struct RecordingSession(ToggleSession); @@ -565,6 +680,7 @@ struct LockOwner { pid: u32, token: Option, start_time_ticks: Option, + scoped_intents: bool, } impl ToggleSession { @@ -576,7 +692,7 @@ impl ToggleSession { match Self::acquire_in(dir)? { LockAcquisition::Started(session) => Ok(ToggleAction::Start(session)), LockAcquisition::Busy(owner) => { - write_stop_request(&dir.join("recording.stop"), &owner)?; + write_stop_request(&intent_path(dir, "stop", &owner), &owner)?; Ok(ToggleAction::Stop(owner)) } } @@ -585,7 +701,6 @@ impl ToggleSession { #[cfg(test)] fn request_stop_if_active_in(dir: &Path) -> Result { let lock_path = dir.join("recording.lock"); - let stop_path = dir.join("recording.stop"); let Some(owner) = live_lock_owner(&lock_path) else { if let Ok(directory) = PrivateDir::open(dir) { let _ = directory.remove_file("recording.lock".as_ref()); @@ -593,7 +708,7 @@ impl ToggleSession { } return Ok(false); }; - write_stop_request(&stop_path, &owner)?; + write_stop_request(&intent_path(dir, "stop", &owner), &owner)?; Ok(true) } @@ -605,7 +720,19 @@ impl ToggleSession { if owner.token.as_deref() != Some(token) { return Ok(false); } - write_stop_request(&dir.join("recording.stop"), &owner)?; + write_stop_request(&intent_path(dir, "stop", &owner), &owner)?; + Ok(true) + } + + fn request_cancel_for_token_in(dir: &Path, token: &str) -> Result { + let lock_path = dir.join("recording.lock"); + let Some(owner) = live_lock_owner(&lock_path) else { + return Ok(false); + }; + if owner.token.as_deref() != Some(token) { + return Ok(false); + } + write_stop_request(&intent_path(dir, "cancel", &owner), &owner)?; Ok(true) } @@ -647,7 +774,7 @@ impl ToggleSession { Ok(mut lock) => { writeln!( lock, - "{}\n{token}\n{}", + "{}\n{token}\n{}\nscoped-intents-v1", std::process::id(), process.start_time_ticks ) @@ -674,6 +801,7 @@ impl ToggleSession { directory, _gate: gate, token, + revision: AtomicU64::new(0), })); } Err(err) => return Err(err.to_string()), @@ -684,14 +812,39 @@ impl ToggleSession { fn stop_requested(&self) -> bool { self.directory - .read_to_string("recording.stop".as_ref()) + .read_to_string(self.intent_name("stop").as_ref()) + .ok() + .is_some_and(|request| stop_request_matches(Some(&self.token), &request)) + } + + fn cancel_requested(&self) -> bool { + self.directory + .read_to_string(self.intent_name("cancel").as_ref()) .ok() .is_some_and(|request| stop_request_matches(Some(&self.token), &request)) } + fn next_revision(&self) -> u64 { + self.revision.fetch_add(2, Ordering::SeqCst) + 2 + } + + fn intent_name(&self, kind: &str) -> String { + self.directory + .read_to_string("recording.lock".as_ref()) + .ok() + .and_then(|raw| parse_lock_owner(&raw)) + .filter(|owner| { + owner.token.as_deref() == Some(self.token.as_str()) || owner.token.is_none() + }) + .map(|owner| intent_file_name(kind, &owner)) + .unwrap_or_else(|| scoped_intent_name(kind, &self.token)) + } + fn clear_stop_request(&self) { if self.stop_requested() { - let _ = self.directory.remove_file("recording.stop".as_ref()); + let _ = self + .directory + .remove_file(scoped_intent_name("stop", &self.token).as_ref()); } } } @@ -706,6 +859,13 @@ impl Drop for ToggleSession { .is_some_and(|owner| owner.token.as_deref() == Some(self.token.as_str())); if still_owned { let _ = self.directory.remove_file("recording.stop".as_ref()); + let _ = self.directory.remove_file("recording.cancel".as_ref()); + let _ = self + .directory + .remove_file(scoped_intent_name("stop", &self.token).as_ref()); + let _ = self + .directory + .remove_file(scoped_intent_name("cancel", &self.token).as_ref()); let _ = self.directory.remove_file("recording.lock".as_ref()); } } @@ -747,13 +907,37 @@ fn parse_lock_owner(raw: &str) -> Option { .map(str::parse) .transpose() .ok()?; + let scoped_intents = lines + .next() + .is_some_and(|marker| marker.trim() == "scoped-intents-v1"); Some(LockOwner { pid, token, start_time_ticks, + scoped_intents, }) } +fn scoped_intent_name(kind: &str, token: &str) -> String { + let digest = Sha256::digest(token.as_bytes()); + let digest = digest + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + format!("recording.{kind}.{digest}") +} + +fn intent_path(dir: &Path, kind: &str, owner: &LockOwner) -> PathBuf { + dir.join(intent_file_name(kind, owner)) +} + +fn intent_file_name(kind: &str, owner: &LockOwner) -> String { + match (owner.scoped_intents, owner.token.as_deref()) { + (true, Some(token)) => scoped_intent_name(kind, token), + _ => format!("recording.{kind}"), + } +} + fn live_lock_owner(path: &Path) -> Option { let directory = PrivateDir::open(path.parent()?).ok()?; let raw = directory.read_to_string(path.file_name()?).ok()?; @@ -837,6 +1021,26 @@ pub fn session_active() -> bool { session_active_at(&echo_core::data_dir().join("recording.lock")) } +#[must_use] +pub fn capture_stop_requested_for(session_id: Option<&str>) -> bool { + let Some(session_id) = session_id else { + return false; + }; + let dir = echo_core::data_dir(); + let owner = live_lock_owner(&dir.join("recording.lock")); + let Some(owner) = owner.filter(|owner| owner.token.as_deref() == Some(session_id)) else { + return false; + }; + PrivateDir::open(&dir) + .ok() + .and_then(|directory| { + directory + .read_to_string(intent_path(&dir, "stop", &owner).file_name()?.as_ref()) + .ok() + }) + .is_some_and(|request| stop_request_matches(Some(session_id), &request)) +} + pub(crate) fn session_active_at(path: &Path) -> bool { lock_owner_is_alive(path) } @@ -1157,15 +1361,16 @@ mod tests { assert!(!dir.join("recording.stop").exists()); let session = ToggleSession::try_start_in(&dir).unwrap().unwrap(); + let stop = dir.join(scoped_intent_name("stop", &session.token)); assert!(ToggleSession::request_stop_if_active_in(&dir).unwrap()); assert!(ToggleSession::request_stop_if_active_in(&dir).unwrap()); assert!(dir.join("recording.lock").exists()); - assert!(dir.join("recording.stop").exists()); + assert!(stop.exists()); drop(session); assert!(!ToggleSession::request_stop_if_active_in(&dir).unwrap()); assert!(!dir.join("recording.lock").exists()); - assert!(!dir.join("recording.stop").exists()); + assert!(!stop.exists()); } #[cfg(unix)] @@ -1267,7 +1472,36 @@ mod tests { } #[test] - fn a_new_stop_request_can_cancel_transcription_after_capture_stop_is_cleared() { + fn stale_cancel_request_cannot_cancel_a_replacement_session() { + let dir = std::env::temp_dir().join(format!("echo-cancel-token-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + let first = ToggleSession::try_start_in(&dir).unwrap().unwrap(); + let first_owner = lock_owner(&dir.join("recording.lock")).unwrap(); + drop(first); + let second = ToggleSession::try_start_in(&dir).unwrap().unwrap(); + write_stop_request(&dir.join("recording.cancel"), &first_owner).unwrap(); + assert!(!second.cancel_requested()); + assert!(!ToggleSession::request_cancel_for_token_in(&dir, "old-token").unwrap()); + } + + #[test] + fn delayed_old_session_intent_cannot_replace_new_session_intent() { + let dir = std::env::temp_dir().join(format!("echo-scoped-intent-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + let first = ToggleSession::try_start_in(&dir).unwrap().unwrap(); + let old = lock_owner(&dir.join("recording.lock")).unwrap(); + drop(first); + let second = ToggleSession::try_start_in(&dir).unwrap().unwrap(); + assert!(ToggleSession::request_stop_for_token_in(&dir, &second.token).unwrap()); + assert!(ToggleSession::request_cancel_for_token_in(&dir, &second.token).unwrap()); + write_stop_request(&intent_path(&dir, "stop", &old), &old).unwrap(); + write_stop_request(&intent_path(&dir, "cancel", &old), &old).unwrap(); + assert!(second.stop_requested()); + assert!(second.cancel_requested()); + } + + #[test] + fn duplicate_capture_stop_cannot_become_a_transcription_cancel() { let dir = std::env::temp_dir().join(format!( "echo-transcription-stop-{}-{}", std::process::id(), @@ -1277,10 +1511,14 @@ mod tests { assert!(ToggleSession::request_stop_if_active_in(&dir).unwrap()); assert!(session.stop_requested()); + assert!(!session.cancel_requested()); session.clear_stop_request(); assert!(!session.stop_requested()); assert!(ToggleSession::request_stop_if_active_in(&dir).unwrap()); assert!(session.stop_requested()); + assert!(!session.cancel_requested()); + assert!(ToggleSession::request_cancel_for_token_in(&dir, &session.token).unwrap()); + assert!(session.cancel_requested()); drop(session); let _ = fs::remove_dir_all(dir); @@ -1408,16 +1646,17 @@ mod tests { ToggleAction::Start(session) => session, ToggleAction::Stop(_) => panic!("first toggle should start"), }; + let stop = dir.join(scoped_intent_name("stop", &first.token)); assert!(dir.join("recording.lock").is_file()); assert!(matches!( ToggleSession::start_or_stop_in(&dir).unwrap(), ToggleAction::Stop(_) )); - assert!(dir.join("recording.stop").is_file()); + assert!(stop.is_file()); drop(first); assert!(!dir.join("recording.lock").exists()); - assert!(!dir.join("recording.stop").exists()); + assert!(!stop.exists()); assert!(matches!( ToggleSession::start_or_stop_in(&dir).unwrap(), ToggleAction::Start(_) diff --git a/crates/echo/src/status.rs b/crates/echo/src/status.rs index 8eb49c8..0b3a3fc 100644 --- a/crates/echo/src/status.rs +++ b/crates/echo/src/status.rs @@ -16,6 +16,8 @@ pub struct Status { /// persistence errors, so the desktop app can expose the actual problem. pub error: Option, pub recording_limit: Option, + pub session_id: Option, + pub revision: u64, } impl Status { @@ -27,6 +29,8 @@ impl Status { last_history_id: None, error: None, recording_limit: None, + session_id: None, + revision: 0, } } } @@ -56,10 +60,53 @@ pub fn write_status( ) } +/// Only the recording owner calls this while it holds the lease. Keeping the +/// identity in the same atomic status file prevents a reader from combining +/// an old phase with a replacement lock token. +pub fn write_status_for_session( + session_id: &str, + revision: u64, + state: SessionState, + last: Option<&str>, + error: Option<&str>, + last_history_id: Option<&str>, +) -> Result<(), String> { + write_atomic_private( + &status_path(), + render_for_session(session_id, revision, state, last, error, last_history_id).as_bytes(), + ) +} + pub fn write_recording(limit: RecordingLimit) -> Result<(), String> { write_atomic_private(&status_path(), render_recording(limit).as_bytes()) } +pub fn write_recording_for_session( + session_id: &str, + revision: u64, + limit: RecordingLimit, +) -> Result<(), String> { + let mut body = render_writer("Recording"); + body.push_str(&format!("session_id={session_id}\n")); + body.push_str(&format!("session_revision={revision}\n")); + body.push_str(&format!("recording_limit_seconds={}\n", limit.seconds())); + write_atomic_private(&status_path(), body.as_bytes()) +} + +fn render_for_session( + session_id: &str, + revision: u64, + state: SessionState, + last: Option<&str>, + error: Option<&str>, + last_history_id: Option<&str>, +) -> String { + let mut body = render(state, last, error, last_history_id); + body.push_str(&format!("session_id={session_id}\n")); + body.push_str(&format!("session_revision={revision}\n")); + body +} + fn render_recording(limit: RecordingLimit) -> String { let mut body = render_writer("Recording"); body.push_str(&format!("recording_limit_seconds={}\n", limit.seconds())); @@ -194,6 +241,13 @@ fn parse(raw: &str, alive: impl Fn(ProcessIdentity) -> bool) -> Status { .and_then(RecordingLimit::new) }) .flatten(); + let session_id = field("session_id=") + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let revision = field("session_revision=") + .and_then(|value| value.parse().ok()) + .unwrap_or(0); let active = state != "Idle" && !state.starts_with("Failed"); let writer = field("pid=") .and_then(|pid| pid.parse().ok()) @@ -209,6 +263,8 @@ fn parse(raw: &str, alive: impl Fn(ProcessIdentity) -> bool) -> Status { last_history_id, error, recording_limit: None, + session_id: None, + revision: 0, }; } Status { @@ -217,6 +273,8 @@ fn parse(raw: &str, alive: impl Fn(ProcessIdentity) -> bool) -> Status { last_history_id, error, recording_limit, + session_id, + revision, } } @@ -241,6 +299,15 @@ mod tests { assert!(body.contains("recording_limit_seconds=600\n")); } + #[test] + fn owner_status_keeps_the_session_identity_with_its_phase() { + let body = render_for_session("session-a", 7, SessionState::Transcribing, None, None, None); + let status = parse(&body, |_| true); + assert_eq!(status.state, "Transcribing"); + assert_eq!(status.session_id.as_deref(), Some("session-a")); + assert_eq!(status.revision, 7); + } + #[test] fn old_and_malformed_recording_limits_are_ignored() { let old = parse("state=Recording\npid=42\n", |_| true); diff --git a/crates/echo/tests/recording_commands.rs b/crates/echo/tests/recording_commands.rs new file mode 100644 index 0000000..f453037 --- /dev/null +++ b/crates/echo/tests/recording_commands.rs @@ -0,0 +1,177 @@ +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, ExitStatus, Stdio}; +use std::time::{Duration, Instant}; + +struct Owner(Child); + +impl Drop for Owner { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +fn command(root: &Path, helper: &str) -> Command { + let mut command = Command::new(std::env::current_exe().unwrap()); + command + .args(["--exact", helper, "--ignored", "--nocapture"]) + .env("ECHO_CONTROL_TEST_ROOT", root) + .env("ECHO_DATA_DIR", root.join("data")) + .env("ECHO_CONFIG_DIR", root.join("config")) + .env("ECHO_MODEL_DIR", root.join("models")) + .env("ECHO_ENGINE", "whisper") + .env("ECHO_WHISPER_MODEL", "small") + .env("ECHO_SKIP_INJECT", "1") + .env("ECHO_HUD", "0") + .env("ECHO_RECORD_SECONDS", "10") + .env( + "ECHO_AUDIO_FIXTURE", + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/claude_code.wav"), + ) + .env("PATH", root.join("bin")) + .stdout(Stdio::null()); + command +} + +fn wait_for(path: &Path) { + let deadline = Instant::now() + Duration::from_secs(5); + while !path.exists() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + assert!(path.exists(), "missing {}", path.display()); +} + +fn wait(child: &mut Child) -> ExitStatus { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if let Some(status) = child.try_wait().unwrap() { + return status; + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!("recording command did not finish"); + } + std::thread::sleep(Duration::from_millis(10)); + } +} + +fn control(root: &Path, action: &str, session: &str, accepted: bool) { + let mut child = command(root, "control_helper") + .env("ECHO_CONTROL_TEST_ACTION", action) + .env("ECHO_CONTROL_TEST_SESSION", session) + .env("ECHO_CONTROL_TEST_ACCEPTED", accepted.to_string()) + .spawn() + .unwrap(); + assert!(wait(&mut child).success()); +} + +fn exercise_transcription(cancel: bool) { + use std::os::unix::fs::PermissionsExt; + let root = tempfile::tempdir().unwrap(); + for name in ["bin", "models", "data", "config"] { + std::fs::create_dir(root.path().join(name)).unwrap(); + } + std::fs::write(root.path().join("models/ggml-small.bin"), []).unwrap(); + let runtime = root.path().join("bin/whisper-cli"); + std::fs::write( + &runtime, + r#"#!/bin/sh +printf ready > "$ECHO_CONTROL_TEST_ROOT/engine-ready" +attempt=0 +while [ ! -f "$ECHO_CONTROL_TEST_ROOT/release-engine" ]; do + attempt=$((attempt + 1)) + [ "$attempt" -lt 1000 ] || exit 2 + /bin/sleep 0.01 +done +printf '%s\n' '{"model":{"type":"small","multilingual":true},"result":{"language":"en"},"transcription":[{"text":" preserved transcript"}]}' +"#, + ) + .unwrap(); + std::fs::set_permissions(&runtime, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let mut owner = Owner(command(root.path(), "owner_helper").spawn().unwrap()); + let token_path = root.path().join("session"); + wait_for(&token_path); + let session = std::fs::read_to_string(&token_path).unwrap(); + control(root.path(), "stop", "replaced-session", false); + control(root.path(), "stop", &session, true); + wait_for(&root.path().join("engine-ready")); + control(root.path(), "stop", &session, false); + let status = std::fs::read_to_string(root.path().join("data/status")).unwrap(); + assert!(status + .lines() + .any(|line| line == format!("pid={}", owner.0.id()))); + assert!(status.contains("state=Transcribing\n")); + assert!(!std::fs::read_dir(root.path().join("data")) + .unwrap() + .any(|entry| entry + .unwrap() + .file_name() + .to_string_lossy() + .starts_with("recording.cancel"))); + assert!(!root.path().join("data/history.json").exists()); + + if cancel { + control(root.path(), "cancel", "replaced-session", false); + control(root.path(), "cancel", &session, true); + } else { + std::fs::write(root.path().join("release-engine"), []).unwrap(); + } + assert!(wait(&mut owner.0).success()); + if cancel { + assert!(!root.path().join("data/history.json").exists()); + assert!(std::fs::read_to_string(root.path().join("data/status")) + .unwrap() + .starts_with("state=Failed")); + } else { + let history = echo_core::History::load_from(root.path().join("data/history.json")).unwrap(); + assert_eq!(history.rows().len(), 1); + assert_eq!(history.rows()[0].text, "preserved transcript"); + } +} + +#[test] +fn duplicate_capture_stop_preserves_the_running_transcription() { + exercise_transcription(false); +} + +#[test] +fn explicit_cancel_terminates_the_running_transcription() { + exercise_transcription(true); +} + +#[test] +#[ignore = "isolated recording owner"] +fn owner_helper() { + let Some(root) = std::env::var_os("ECHO_CONTROL_TEST_ROOT") else { + return; + }; + let root = PathBuf::from(root); + let session = echo::rec::start_managed_recording().unwrap(); + echo_core::write_atomic_private(&root.join("session"), session.as_bytes()).unwrap(); + let deadline = Instant::now() + Duration::from_secs(15); + while echo::rec::session_active() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + assert!(!echo::rec::session_active()); +} + +#[test] +#[ignore = "isolated recording requester"] +fn control_helper() { + let Ok(action) = std::env::var("ECHO_CONTROL_TEST_ACTION") else { + return; + }; + let session = std::env::var("ECHO_CONTROL_TEST_SESSION").unwrap(); + let accepted = match action.as_str() { + "stop" => echo::rec::request_capture_stop(&session), + "cancel" => echo::rec::request_transcription_cancel(&session), + _ => panic!("unknown test command"), + } + .unwrap(); + assert_eq!( + accepted, + std::env::var("ECHO_CONTROL_TEST_ACCEPTED").unwrap() == "true" + ); +} diff --git a/docs/architecture.md b/docs/architecture.md index 36b629c..6511b43 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -50,6 +50,14 @@ upgrade takeover all use that lease. A fixed gate file supplies kernel-backed exclusion. The token-bearing lock file remains a compatibility and diagnostic record for older Echo processes. +Home uses explicit start, capture-stop, and transcription-cancel commands. +Each acknowledgement identifies its session. The recording owner publishes +that identity and a monotonic session revision with its status. Capture-stop +and cancellation use separate files scoped to the session, so a delayed request +cannot overwrite a replacement session's control signal. CLI, tray, and shortcut +toggles remain adapters over these intents. Legacy owners retain their existing +flat control-file protocol. + ## Desktop boundary Tauri command functions are thin adapters. Settings owns serialized config @@ -74,10 +82,11 @@ successful insertion and a failed insertion with recoverable text. ## IPC contract -Rust is authoritative for serialized commands, events, and payloads. `ipc-gen` -writes the TypeScript contract and command manifest. CI regenerates them and -fails on drift. Frontend code imports generated types and calls a `DesktopApi` -interface rather than constructing command strings throughout the UI. +Rust defines the payload schemas. `ipc-gen` exports their TypeScript types, +and CI checks those generated types for drift. Separate contract tests check +command and event registrations and their payload types. Frontend code imports +generated types and calls a `DesktopApi` interface rather than constructing +command strings throughout the UI. The production adapter invokes Tauri. The preview adapter is isolated to the browser development graph and cannot enter the production bundle. @@ -105,3 +114,21 @@ attribution are represented in the desktop SBOM. Third-party workflow actions are pinned to full commit SHAs. See [RELEASING.md](RELEASING.md) for the operator contract. + +## Offline tooling + +The desktop runs the Rust speech and installation code. The Python and shell +tools below support verification, runtime publication, and research outside the +desktop process. + +| Purpose | Entry points | +| --- | --- | +| CI regression and archive verification | `verify-stt-benchmark.sh`, `verify-stt-corpus.sh`, `verify-whisper-runtime-archive.sh` | +| Managed GPU runtime publication | `build-whisper-vulkan-receipt.sh`, `generate-managed-inventory.py`, and the installer proof in [RELEASING.md](RELEASING.md) | +| Offline admission and tuning research | `sweep-whisper-admission.py`, `promote-whisper-admission.py`, `compose-whisper-admission-set.py`, and their probe and identity modules | + +The admission tools remain maintained research tools, as recorded in the +[evidence history](history/evidence-2026-08-30.md#durable-acceleration-decisions). +They are not application startup dependencies. Their lack of desktop callers +does not make them obsolete. Retiring this workflow requires checking its +research consumers and preserving the benchmark and archive checks used by CI. diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index f9a2980..645eeee 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1,8 +1,9 @@ -import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { act, fireEvent, render, renderHook, screen, waitFor, within } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import App from './App' import { createPreviewDesktopApi } from './api/previewDesktopApi' import { useSerialPoll } from './hooks/useSerialPoll' +import { useAppController } from './app/useAppController' import { addDictionaryEntry, configureDesktopApi, @@ -12,11 +13,13 @@ import { getReadiness, quitApp, removeStaleInstalls, - toggleRecording, + startCapture, + stopCapture, } from './tauri' import { deferred, resetDesktopApiMocks } from './test/desktopApiHarness' import type { AppStatus, + RecordingSnapshot, } from './generated/ipc' const previewDesktopApi = createPreviewDesktopApi() @@ -217,15 +220,16 @@ describe('Echo desktop shell', () => { }, ) - it('keeps a successful stop pending through stale statuses and polling errors', async () => { + it('uses the session-bound stop acknowledgement without reconstructing a stop state', async () => { vi.useFakeTimers({ shouldAdvanceTime: true }) - const recording = { ...richPreviewStatus(), phase: 'Recording' } satisfies AppStatus - const transcribing = { ...recording, phase: 'Transcribing', recordingInProcess: false } satisfies AppStatus - const idle = { ...recording, phase: 'Idle', recordingInProcess: false } satisfies AppStatus - vi.mocked(toggleRecording).mockResolvedValueOnce(undefined) + const recording = { ...richPreviewStatus(), phase: 'Recording', recordingSessionId: 'test-session', recordingRevision: 2 } satisfies AppStatus + const stopping = { ...recording, captureStopRequested: true, recordingRevision: 3 } + const transcribing = { ...recording, phase: 'Transcribing', recordingInProcess: false, recordingRevision: 4 } satisfies AppStatus + const idle = { ...recording, phase: 'Idle', recordingInProcess: false, recordingRevision: 6 } satisfies AppStatus + vi.mocked(stopCapture).mockResolvedValueOnce({ sessionId: 'test-session', phase: 'Recording', captureStopRequested: true, revision: 3 }) vi.mocked(getAppStatus) .mockResolvedValueOnce(recording) - .mockResolvedValueOnce(recording) + .mockResolvedValueOnce(stopping) .mockRejectedValueOnce(new Error('temporary status error')) .mockResolvedValueOnce(transcribing) .mockResolvedValueOnce(idle) @@ -234,12 +238,8 @@ describe('Echo desktop shell', () => { const stop = await screen.findByRole('button', { name: 'Stop and transcribe' }) fireEvent.click(stop) await act(async () => {}) - expect(toggleRecording).toHaveBeenCalledOnce() - - const stopping = screen.getByRole('button', { name: 'Stopping recording' }) - expect(stopping).toBeDisabled() - fireEvent.click(stopping) - expect(toggleRecording).toHaveBeenCalledOnce() + expect(stopCapture).toHaveBeenCalledOnce() + expect(screen.getByRole('button', { name: 'Stopping recording' })).toBeDisabled() await act(async () => vi.advanceTimersByTimeAsync(400)) expect(screen.getByRole('button', { name: 'Stopping recording' })).toBeDisabled() @@ -253,17 +253,110 @@ describe('Echo desktop shell', () => { }) it('releases a rejected stop request for retry', async () => { - const recording = { ...richPreviewStatus(), phase: 'Recording' } satisfies AppStatus + const recording = { ...richPreviewStatus(), phase: 'Recording', recordingSessionId: 'test-session' } satisfies AppStatus vi.mocked(getAppStatus).mockResolvedValue(recording) - vi.mocked(toggleRecording) + vi.mocked(stopCapture) .mockRejectedValueOnce(new Error('stop was rejected')) - .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce({ sessionId: 'test-session', phase: 'Recording', captureStopRequested: false, revision: 2 }) render() fireEvent.click(await screen.findByRole('button', { name: 'Stop and transcribe' })) expect(await screen.findByRole('alert')).toHaveTextContent('stop was rejected') fireEvent.click(screen.getByRole('button', { name: 'Stop and transcribe' })) - await waitFor(() => expect(toggleRecording).toHaveBeenCalledTimes(2)) + await waitFor(() => expect(stopCapture).toHaveBeenCalledTimes(2)) + }) + + it('accepts an external session first observed during transcription', async () => { + vi.useFakeTimers() + const initial = { ...richPreviewStatus(), recordingSessionId: 'previous', recordingRevision: 6 } + const external = { ...initial, recordingSessionId: 'external', recordingRevision: 4, phase: 'Transcribing' } satisfies AppStatus + vi.mocked(getAppStatus) + .mockResolvedValueOnce(initial) + .mockResolvedValueOnce(external) + .mockResolvedValue({ ...external, phase: 'Idle', recordingRevision: 6, lastTranscript: 'external completed' }) + try { + render() + await act(async () => vi.advanceTimersByTimeAsync(0)) + await act(async () => vi.advanceTimersByTimeAsync(400)) + expect(screen.getByRole('heading', { name: 'Transcribing locally…' })).toBeInTheDocument() + await act(async () => vi.advanceTimersByTimeAsync(400)) + expect(screen.getByText('external completed')).toBeInTheDocument() + } finally { + vi.useRealTimers() + } + }) + + it('ignores a poll started before an accepted stop acknowledgement', async () => { + vi.useFakeTimers() + const recording = { ...richPreviewStatus(), phase: 'Recording', recordingSessionId: 'session-a', recordingRevision: 2 } satisfies AppStatus + const oldPoll = deferred() + const freshPoll = deferred() + vi.mocked(getAppStatus) + .mockResolvedValueOnce(recording) + .mockImplementationOnce(() => oldPoll.promise) + .mockImplementationOnce(() => freshPoll.promise) + vi.mocked(stopCapture).mockResolvedValueOnce({ sessionId: 'session-a', phase: 'Recording', revision: 3, captureStopRequested: true }) + try { + const { result } = renderHook(() => useAppController()) + await act(async () => vi.advanceTimersByTimeAsync(0)) + await act(async () => vi.advanceTimersByTimeAsync(400)) + let request = Promise.resolve() + await act(async () => { request = result.current.toggleRecording() }) + expect(result.current.status.captureStopRequested).toBe(true) + await act(async () => oldPoll.resolve(recording)) + expect(result.current.status.captureStopRequested).toBe(true) + await act(async () => freshPoll.resolve({ ...recording, phase: 'Transcribing', recordingRevision: 4 })) + expect(result.current.status.phase).toBe('Transcribing') + await act(() => request) + } finally { + vi.useRealTimers() + } + }) + + it.each(['session-a', 'replacement'])('ignores an old acknowledgement after observing %s progress', async (sessionId) => { + vi.useFakeTimers() + const recording = { ...richPreviewStatus(), phase: 'Recording', recordingSessionId: 'session-a', recordingRevision: 2 } satisfies AppStatus + const progressed = { ...recording, phase: 'Transcribing', recordingSessionId: sessionId, recordingRevision: 4 } satisfies AppStatus + const reply = deferred() + const freshPoll = deferred() + vi.mocked(stopCapture).mockImplementationOnce(() => reply.promise) + vi.mocked(getAppStatus).mockResolvedValueOnce(recording).mockResolvedValueOnce(progressed).mockImplementationOnce(() => freshPoll.promise) + try { + const { result } = renderHook(() => useAppController()) + await act(async () => vi.advanceTimersByTimeAsync(0)) + let request = Promise.resolve() + await act(async () => { request = result.current.toggleRecording() }) + await act(async () => vi.advanceTimersByTimeAsync(400)) + expect(result.current.status.phase).toBe('Transcribing') + await act(async () => reply.resolve({ sessionId: 'session-a', phase: 'Recording', revision: 3, captureStopRequested: true })) + expect(result.current.status.phase).toBe('Transcribing') + expect(result.current.status.recordingSessionId).toBe(sessionId) + await act(async () => freshPoll.resolve(progressed)) + await act(() => request) + } finally { + vi.useRealTimers() + } + }) + + it('shows the accepted start while the next status read is pending', async () => { + vi.useFakeTimers() + const initial = richPreviewStatus() + const freshPoll = deferred() + vi.mocked(getAppStatus).mockResolvedValueOnce(initial).mockImplementationOnce(() => freshPoll.promise) + vi.mocked(startCapture).mockResolvedValueOnce({ sessionId: 'started', phase: 'Recording', revision: 2, captureStopRequested: false }) + try { + const { result } = renderHook(() => useAppController()) + await act(async () => vi.advanceTimersByTimeAsync(0)) + let request = Promise.resolve() + await act(async () => { request = result.current.toggleRecording() }) + expect(result.current.status.recordingSessionId).toBe('started') + expect(result.current.status.phase).toBe('Recording') + await act(async () => freshPoll.resolve({ ...initial, recordingSessionId: 'started', phase: 'Recording', recordingRevision: 2 })) + await act(() => request) + expect(startCapture).toHaveBeenCalledOnce() + } finally { + vi.useRealTimers() + } }) it('reports a rejected dictionary entry without clearing the form or leaving it busy', async () => { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 11a549e..a622c41 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -36,7 +36,7 @@ function App() { error, setError, recordingSeconds, - stopPending, + recordingRequestPending, refreshStatus, toggleRecording, quitApp, @@ -115,7 +115,7 @@ function App() { status={status} history={history} recordingSeconds={recordingSeconds} - stopPending={stopPending} + recordingRequestPending={recordingRequestPending} onToggleRecording={toggleRecording} onOpenSettings={() => setView('settings')} /> diff --git a/frontend/src/api/DesktopApi.ts b/frontend/src/api/DesktopApi.ts index a8cea13..3ca5ed9 100644 --- a/frontend/src/api/DesktopApi.ts +++ b/frontend/src/api/DesktopApi.ts @@ -1,5 +1,6 @@ import type { AppStatus, + RecordingSnapshot, ComponentId, DictionaryBatchResult, DictionaryItem, @@ -34,7 +35,9 @@ export interface DesktopApi { startDictionaryTrainingSample(): Promise finishDictionaryTrainingSample(captureId: string): Promise cancelDictionaryTrainingSample(captureId: string): Promise - toggleRecording(): Promise + startCapture(): Promise + stopCapture(sessionId: string): Promise + cancelTranscription(sessionId: string): Promise stopRecording(activation: string): Promise getRecordingLevel(): Promise copyText(text: string): Promise diff --git a/frontend/src/api/previewDesktopApi.ts b/frontend/src/api/previewDesktopApi.ts index 1c7bee0..fe37541 100644 --- a/frontend/src/api/previewDesktopApi.ts +++ b/frontend/src/api/previewDesktopApi.ts @@ -12,6 +12,7 @@ import type { MicrophoneSnapshot, MicrophoneTestResult, Readiness, + RecordingSnapshot, SetupEvent, SetupPlanId, ComponentId, @@ -58,6 +59,7 @@ function ipcSnapshot(value: T): T { export function createPreviewDesktopApi(): PreviewDesktopApi { let previewStatus: AppStatus = richPreviewStatus() + let recordingSequence = 0 let previewSettings: Settings = defaultPreviewSettings() let previewRecordingDeadline: number | null = null @@ -260,23 +262,52 @@ export function createPreviewDesktopApi(): PreviewDesktopApi { return Promise.resolve(true) } - function toggleRecording(): Promise { - if (previewStatus.phase === 'Recording') { + function recordingSnapshot(): RecordingSnapshot { + return { + sessionId: previewStatus.recordingSessionId, + phase: previewStatus.phase, + captureStopRequested: previewStatus.captureStopRequested, + revision: previewStatus.recordingRevision, + } + } + + function startCapture(): Promise { + if (['Recording', 'Transcribing', 'Injecting'].includes(previewStatus.phase)) { + return Promise.reject(new Error('Another recording is already active.')) + } + const sessionId = `preview-${++recordingSequence}` + const limit = previewSettings.recordSeconds.effective + previewStatus = { + ...previewStatus, + recordingSessionId: sessionId, + recordingRevision: 2, + captureStopRequested: false, + recordingInProcess: true, + phase: 'Recording', + recordingLimitSeconds: limit, + } + previewRecordingDeadline = schedulePreview(() => { + previewRecordingDeadline = null stopPreviewRecording() - } else { - const limit = previewSettings.recordSeconds.effective + }, limit * 1000) + return Promise.resolve(recordingSnapshot()) + } + + function stopCapture(sessionId: string): Promise { + if (previewStatus.recordingSessionId === sessionId) stopPreviewRecording() + return Promise.resolve(recordingSnapshot()) + } + + function cancelTranscription(sessionId: string): Promise { + if (previewStatus.recordingSessionId === sessionId && previewStatus.phase === 'Transcribing') { previewStatus = { ...previewStatus, - recordingInProcess: true, - phase: 'Recording', - recordingLimitSeconds: limit, + phase: 'Failed', + recordingRevision: previewStatus.recordingRevision + 2, + lastError: 'Transcription cancelled.', } - previewRecordingDeadline = schedulePreview(() => { - previewRecordingDeadline = null - stopPreviewRecording() - }, limit * 1000) } - return Promise.resolve() + return Promise.resolve(recordingSnapshot()) } function stopRecording(activation: string): Promise { @@ -295,10 +326,14 @@ export function createPreviewDesktopApi(): PreviewDesktopApi { previewStatus = { ...previewStatus, recordingInProcess: false, + captureStopRequested: false, + recordingRevision: previewStatus.recordingRevision + 2, phase: 'Transcribing', } + const sessionId = previewStatus.recordingSessionId schedulePreview(() => { - previewStatus = { ...previewStatus, phase: 'Idle' } + if (previewStatus.recordingSessionId !== sessionId || previewStatus.phase !== 'Transcribing') return + previewStatus = { ...previewStatus, phase: 'Idle', recordingRevision: previewStatus.recordingRevision + 2 } }, 900) return true } @@ -807,7 +842,9 @@ export function createPreviewDesktopApi(): PreviewDesktopApi { startDictionaryTrainingSample, finishDictionaryTrainingSample, cancelDictionaryTrainingSample, - toggleRecording, + startCapture, + stopCapture, + cancelTranscription, stopRecording, getRecordingLevel, copyText, diff --git a/frontend/src/api/previewDesktopFixtures.ts b/frontend/src/api/previewDesktopFixtures.ts index 062fbcc..a8cb63b 100644 --- a/frontend/src/api/previewDesktopFixtures.ts +++ b/frontend/src/api/previewDesktopFixtures.ts @@ -77,6 +77,9 @@ export function richPreviewStatus(): AppStatus { }, languageWarning: null, recordingInProcess: false, + recordingSessionId: null, + captureStopRequested: false, + recordingRevision: 0, currentExe: '/usr/bin/echo-desktop', firstPathHit: '/usr/bin/echo-desktop', staleInstalls: [], diff --git a/frontend/src/api/tauriDesktopApi.test.ts b/frontend/src/api/tauriDesktopApi.test.ts index 38dc5f4..8c41037 100644 --- a/frontend/src/api/tauriDesktopApi.test.ts +++ b/frontend/src/api/tauriDesktopApi.test.ts @@ -45,7 +45,9 @@ describe('Tauri desktop adapter contract', () => { const capture = await tauriDesktopApi.startDictionaryTrainingSample() await tauriDesktopApi.finishDictionaryTrainingSample(String(capture)) await tauriDesktopApi.cancelDictionaryTrainingSample(String(capture)) - await tauriDesktopApi.toggleRecording() + await tauriDesktopApi.startCapture() + await tauriDesktopApi.stopCapture('session') + await tauriDesktopApi.cancelTranscription('session') await tauriDesktopApi.stopRecording('activation') await tauriDesktopApi.getRecordingLevel() await tauriDesktopApi.copyText('text') @@ -84,7 +86,9 @@ describe('Tauri desktop adapter contract', () => { ['start_dictionary_training_sample'], ['finish_dictionary_training_sample', { captureId: 'undefined' }], ['cancel_dictionary_training_sample', { captureId: 'undefined' }], - ['toggle_recording'], + ['start_capture'], + ['stop_capture', { sessionId: 'session' }], + ['cancel_transcription', { sessionId: 'session' }], ['stop_recording', { activation: 'activation' }], ['get_recording_level'], ['copy_text', { text: 'text' }], diff --git a/frontend/src/api/tauriDesktopApi.ts b/frontend/src/api/tauriDesktopApi.ts index 3de5a30..83c7552 100644 --- a/frontend/src/api/tauriDesktopApi.ts +++ b/frontend/src/api/tauriDesktopApi.ts @@ -13,6 +13,7 @@ import type { MicrophoneTestResult, ModelInventory, Readiness, + RecordingSnapshot, SettingsChange, SettingsSnapshot, SetupEvent, @@ -42,7 +43,9 @@ export function createTauriDesktopApi(): DesktopApi { invoke('finish_dictionary_training_sample', { captureId }), cancelDictionaryTrainingSample: (captureId) => invoke('cancel_dictionary_training_sample', { captureId }), - toggleRecording: () => invoke('toggle_recording'), + startCapture: () => invoke('start_capture'), + stopCapture: (sessionId) => invoke('stop_capture', { sessionId }), + cancelTranscription: (sessionId) => invoke('cancel_transcription', { sessionId }), stopRecording: (activation) => invoke('stop_recording', { activation }), getRecordingLevel: () => invoke('get_recording_level'), copyText: (text) => invoke('copy_text', { text }), diff --git a/frontend/src/app/AppHistory.test.tsx b/frontend/src/app/AppHistory.test.tsx index 9fca2e2..0e638a3 100644 --- a/frontend/src/app/AppHistory.test.tsx +++ b/frontend/src/app/AppHistory.test.tsx @@ -245,7 +245,7 @@ describe('Echo desktop shell', () => { status={richPreviewStatus()} history={history} recordingSeconds={0} - stopPending={false} + recordingRequestPending={false} onToggleRecording={async () => undefined} onOpenSettings={vi.fn()} />, diff --git a/frontend/src/app/useAppController.ts b/frontend/src/app/useAppController.ts index 4ad2764..c559f01 100644 --- a/frontend/src/app/useAppController.ts +++ b/frontend/src/app/useAppController.ts @@ -1,10 +1,10 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useDictionary } from '../dictionary/useDictionary' -import type { AppStatus } from '../generated/ipc' +import type { AppStatus, RecordingSnapshot } from '../generated/ipc' import { useHistory } from '../history/useHistory' import { useSerialPoll } from '../hooks/useSerialPoll' -import { getAppStatus, quitApp, toggleRecording } from '../tauri' +import { getAppStatus, quitApp, startCapture, stopCapture } from '../tauri' import type { ThemeMode, View } from '../types' import { messageFrom } from './formatting' import { useElapsedSeconds } from './useElapsedSeconds' @@ -33,13 +33,14 @@ const initialStatus: AppStatus = { lastRun: null, languageWarning: null, recordingInProcess: false, + recordingSessionId: null, + captureStopRequested: false, + recordingRevision: 0, currentExe: '', firstPathHit: null, staleInstalls: [], } -type StopState = 'none' | 'requesting' | 'awaiting-status' - export function useAppController() { const [view, setView] = useState('home') const [status, setStatus] = useState(initialStatus) @@ -49,11 +50,11 @@ export function useAppController() { }) const [error, setError] = useState(null) const [recordingStartedAt, setRecordingStartedAt] = useState(null) - const previousPhase = useRef('Idle') + const currentStatus = useRef(initialStatus) + const statusEpoch = useRef(0) const previousHistoryId = useRef(null) const toggleInFlight = useRef(false) - const stopStateRef = useRef('none') - const [stopState, setStopState] = useState('none') + const [recordingRequestPending, setRecordingRequestPending] = useState(false) const recordingSeconds = useElapsedSeconds(recordingStartedAt) const reportError = useCallback((reason: unknown) => setError(messageFrom(reason)), []) const { @@ -71,28 +72,55 @@ export function useAppController() { } = useDictionary(reportError) const applyStatus = useCallback((next: AppStatus) => { + const previous = currentStatus.current + if ( + next.recordingSessionId !== null && + next.recordingSessionId === previous.recordingSessionId && + next.recordingRevision < previous.recordingRevision + ) return + currentStatus.current = next setStatus(next) - if (stopStateRef.current === 'awaiting-status' && next.phase !== 'Recording') { - stopStateRef.current = 'none' - setStopState('none') - } const observedAt = Date.now() setRecordingStartedAt((current) => - next.phase === 'Recording' ? (current ?? observedAt) : null) + next.phase === 'Recording' + ? (next.recordingSessionId === previous.recordingSessionId ? current ?? observedAt : observedAt) + : null) if (next.lastHistoryId !== null && next.lastHistoryId !== previousHistoryId.current) { previousHistoryId.current = next.lastHistoryId void refreshHistory().catch(reportError) } - if (previousPhase.current !== 'Idle' && ['Idle', 'Failed'].includes(next.phase)) { + if (previous.phase !== 'Idle' && ['Idle', 'Failed'].includes(next.phase)) { void refreshDictionary().catch(reportError) } - previousPhase.current = next.phase }, [refreshDictionary, refreshHistory, reportError]) + const applyRecordingSnapshot = useCallback((snapshot: RecordingSnapshot, requestedFrom: string | null) => { + const current = currentStatus.current + if ( + current.recordingSessionId !== requestedFrom && + current.recordingSessionId !== snapshot.sessionId + ) return + applyStatus({ + ...current, + phase: snapshot.phase, + recordingSessionId: snapshot.sessionId, + captureStopRequested: snapshot.captureStopRequested, + recordingRevision: snapshot.revision, + }) + }, [applyStatus]) + + const readStatus = useCallback(async () => { + const epoch = statusEpoch.current + return { epoch, snapshot: await getAppStatus() } + }, []) + const applyStatusObservation = useCallback((result: Awaited>) => { + if (result.epoch === statusEpoch.current) applyStatus(result.snapshot) + }, [applyStatus]) + const pollWhileVisible = useCallback(() => !document.hidden, []) const refreshStatus = useSerialPoll({ - request: getAppStatus, - onResult: applyStatus, + request: readStatus, + onResult: applyStatusObservation, onError: reportError, intervalMs: 400, shouldPoll: pollWhileVisible, @@ -116,32 +144,31 @@ export function useAppController() { }, [view]) const toggle = useCallback(async () => { - const phase = previousPhase.current + const { phase, recordingSessionId } = currentStatus.current const processing = phase === 'Transcribing' || phase === 'Injecting' - if (toggleInFlight.current || stopStateRef.current !== 'none' || processing) return + if (toggleInFlight.current || recordingRequestPending || processing) return const stopping = phase === 'Recording' - if (stopping) { - stopStateRef.current = 'requesting' - setStopState('requesting') - } toggleInFlight.current = true + statusEpoch.current += 1 + setRecordingRequestPending(true) try { - await toggleRecording() + let snapshot: RecordingSnapshot if (stopping) { - stopStateRef.current = 'awaiting-status' - setStopState('awaiting-status') + if (!recordingSessionId) throw new Error('Recording session is no longer available.') + snapshot = await stopCapture(recordingSessionId) + } else { + snapshot = await startCapture() } + statusEpoch.current += 1 + applyRecordingSnapshot(snapshot, recordingSessionId) await refreshStatus() } catch (reason) { - if (stopping) { - stopStateRef.current = 'none' - setStopState('none') - } reportError(reason) } finally { toggleInFlight.current = false + setRecordingRequestPending(false) } - }, [refreshStatus, reportError]) + }, [applyRecordingSnapshot, recordingRequestPending, refreshStatus, reportError]) const quit = useCallback(async () => { try { @@ -164,7 +191,7 @@ export function useAppController() { error, setError, recordingSeconds, - stopPending: stopState !== 'none', + recordingRequestPending, refreshStatus, toggleRecording: toggle, quitApp: quit, diff --git a/frontend/src/generated/ipc.ts b/frontend/src/generated/ipc.ts index b48a8ce..47252b9 100644 --- a/frontend/src/generated/ipc.ts +++ b/frontend/src/generated/ipc.ts @@ -6,7 +6,7 @@ export type ActiveComponentOrigin = "managed" | "system" | "external"; export type AppPhase = "Idle" | "Recording" | "Transcribing" | "Injecting" | "Failed"; -export type AppStatus = { phase: AppPhase, lastTranscript: string | null, lastHistoryId: string | null, microphoneReady: boolean, engineName: string, engineReady: boolean, injectionName: string, injectionReady: boolean, shortcut: ShortcutStatus, hudEnabled: boolean, recordingLimitSeconds: number | null, recordingPolicy: RecordingPolicy, settingsPath: string, version: string, lastError: string | null, lastRun: LastRun | null, languageWarning: string | null, recordingInProcess: boolean, currentExe: string, firstPathHit: string | null, staleInstalls: Array, }; +export type AppStatus = { phase: AppPhase, lastTranscript: string | null, lastHistoryId: string | null, microphoneReady: boolean, engineName: string, engineReady: boolean, injectionName: string, injectionReady: boolean, shortcut: ShortcutStatus, hudEnabled: boolean, recordingLimitSeconds: number | null, recordingPolicy: RecordingPolicy, settingsPath: string, version: string, lastError: string | null, lastRun: LastRun | null, languageWarning: string | null, recordingInProcess: boolean, recordingSessionId: string | null, captureStopRequested: boolean, recordingRevision: number, currentExe: string, firstPathHit: string | null, staleInstalls: Array, }; export type AudioHost = "pipe-wire" | "pulse-audio" | "alsa" | "core-audio" | "wasapi" | "other"; @@ -84,6 +84,8 @@ export type Readiness = { managedSupported: boolean, unsupportedReason: string | export type RecordingPolicy = { minimumSeconds: number, defaultSeconds: number, maximumSeconds: number, presetsSeconds: Array, }; +export type RecordingSnapshot = { sessionId: string | null, phase: AppPhase, captureStopRequested: boolean, revision: number, }; + export type RecoveryReason = "quarantined" | "quarantineUnreadable" | "runtimeFailure" | "timeout" | "malformedOutput" | "missingReceipt" | "receiptMismatch" | "cpuFallback" | "identityMismatch"; export type RecoveryTelemetry = { identityKey: string, acceleratedAttempted: boolean, fallbackReason?: RecoveryReason | null, }; diff --git a/frontend/src/home/HomeView.tsx b/frontend/src/home/HomeView.tsx index b643ed9..9c61dcb 100644 --- a/frontend/src/home/HomeView.tsx +++ b/frontend/src/home/HomeView.tsx @@ -14,21 +14,22 @@ export function HomeView({ status, history, recordingSeconds, - stopPending, + recordingRequestPending, onToggleRecording, onOpenSettings, }: { status: AppStatus history: HistoryItem[] recordingSeconds: number - stopPending: boolean + recordingRequestPending: boolean onToggleRecording: () => Promise onOpenSettings: () => void }) { const shortcut = presentShortcut(status.shortcut) const recording = status.phase === 'Recording' const processing = status.phase === 'Transcribing' || status.phase === 'Injecting' - const busy = processing || stopPending + const stopPending = recording && status.captureStopRequested + const busy = processing || stopPending || recordingRequestPending const heroState = recording && !stopPending ? 'recording' : busy diff --git a/frontend/src/tauri.test.ts b/frontend/src/tauri.test.ts index ae7cd6a..30b4a04 100644 --- a/frontend/src/tauri.test.ts +++ b/frontend/src/tauri.test.ts @@ -14,7 +14,7 @@ const { seedPreviewStatus, setSettings, stopRecording, - toggleRecording, + startCapture, } = createPreviewDesktopApi() function deferred() { @@ -71,27 +71,33 @@ describe('settings preview wrappers', () => { }) it('snapshots the effective limit when preview recording starts', async () => { - await toggleRecording() - expect((await getAppStatus()).recordingLimitSeconds).toBe(600) - - await setSettings({ kind: 'recordSeconds', value: 120 }) - expect((await getAppStatus()).recordingLimitSeconds).toBe(600) - const shortcut = (await getAppStatus()).shortcut - if (shortcut.kind !== 'active') throw new Error('active preview shortcut') - seedPreviewStatus({ - shortcut: { ...shortcut, activation: 'native-toggle:preview-test' }, - }) - expect(await stopRecording('native-toggle:preview-test')).toBe(true) + vi.useFakeTimers() + try { + await startCapture() + expect((await getAppStatus()).recordingLimitSeconds).toBe(600) + + await setSettings({ kind: 'recordSeconds', value: 120 }) + expect((await getAppStatus()).recordingLimitSeconds).toBe(600) + const shortcut = (await getAppStatus()).shortcut + if (shortcut.kind !== 'active') throw new Error('active preview shortcut') + seedPreviewStatus({ + shortcut: { ...shortcut, activation: 'native-toggle:preview-test' }, + }) + expect(await stopRecording('native-toggle:preview-test')).toBe(true) - await toggleRecording() - expect((await getAppStatus()).recordingLimitSeconds).toBe(120) + await vi.advanceTimersByTimeAsync(900) + await startCapture() + expect((await getAppStatus()).recordingLimitSeconds).toBe(120) + } finally { + vi.useRealTimers() + } }) it('stops preview recording at the snapped deadline', async () => { vi.useFakeTimers() try { await setSettings({ kind: 'recordSeconds', value: 1 }) - await toggleRecording() + await startCapture() expect((await getAppStatus()).phase).toBe('Recording') await vi.advanceTimersByTimeAsync(1_001) diff --git a/frontend/src/tauri.ts b/frontend/src/tauri.ts index b01fa8e..0acb895 100644 --- a/frontend/src/tauri.ts +++ b/frontend/src/tauri.ts @@ -43,7 +43,9 @@ export const finishDictionaryTrainingSample: DesktopApi['finishDictionaryTrainin api().finishDictionaryTrainingSample(captureId) export const cancelDictionaryTrainingSample: DesktopApi['cancelDictionaryTrainingSample'] = (captureId) => api().cancelDictionaryTrainingSample(captureId) -export const toggleRecording: DesktopApi['toggleRecording'] = () => api().toggleRecording() +export const startCapture: DesktopApi['startCapture'] = () => api().startCapture() +export const stopCapture: DesktopApi['stopCapture'] = (sessionId) => api().stopCapture(sessionId) +export const cancelTranscription: DesktopApi['cancelTranscription'] = (sessionId) => api().cancelTranscription(sessionId) export const stopRecording: DesktopApi['stopRecording'] = (activation) => api().stopRecording(activation) export const getRecordingLevel: DesktopApi['getRecordingLevel'] = () => api().getRecordingLevel() export const copyText: DesktopApi['copyText'] = (text) => api().copyText(text) diff --git a/frontend/src/test/desktopApiHarness.ts b/frontend/src/test/desktopApiHarness.ts index 63915e4..ccc5bd4 100644 --- a/frontend/src/test/desktopApiHarness.ts +++ b/frontend/src/test/desktopApiHarness.ts @@ -17,7 +17,9 @@ export function createDesktopApiMocks(actual: DesktopApi): DesktopApi { startDictionaryTrainingSample: vi.fn(actual.startDictionaryTrainingSample), finishDictionaryTrainingSample: vi.fn(actual.finishDictionaryTrainingSample), cancelDictionaryTrainingSample: vi.fn(actual.cancelDictionaryTrainingSample), - toggleRecording: vi.fn(actual.toggleRecording), + startCapture: vi.fn(actual.startCapture), + stopCapture: vi.fn(actual.stopCapture), + cancelTranscription: vi.fn(actual.cancelTranscription), stopRecording: vi.fn(actual.stopRecording), getRecordingLevel: vi.fn(actual.getRecordingLevel), copyText: vi.fn(actual.copyText), @@ -58,7 +60,9 @@ export function resetDesktopApiMocks(mocks: DesktopApi, actual: DesktopApi): voi vi.mocked(mocks.startDictionaryTrainingSample).mockReset().mockImplementation(actual.startDictionaryTrainingSample) vi.mocked(mocks.finishDictionaryTrainingSample).mockReset().mockImplementation(actual.finishDictionaryTrainingSample) vi.mocked(mocks.cancelDictionaryTrainingSample).mockReset().mockImplementation(actual.cancelDictionaryTrainingSample) - vi.mocked(mocks.toggleRecording).mockReset().mockImplementation(actual.toggleRecording) + vi.mocked(mocks.startCapture).mockReset().mockImplementation(actual.startCapture) + vi.mocked(mocks.stopCapture).mockReset().mockImplementation(actual.stopCapture) + vi.mocked(mocks.cancelTranscription).mockReset().mockImplementation(actual.cancelTranscription) vi.mocked(mocks.stopRecording).mockReset().mockImplementation(actual.stopRecording) vi.mocked(mocks.getRecordingLevel).mockReset().mockImplementation(actual.getRecordingLevel) vi.mocked(mocks.copyText).mockReset().mockImplementation(actual.copyText) diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 58b553b..2c59eff 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -20,7 +20,8 @@ pub(super) use library::{ delete_history_item, get_dictionary, get_history, remove_dictionary_entry, }; pub(super) use recording::{ - get_recording_level, start_recording_thread, stop_recording, toggle_recording, + cancel_transcription, get_recording_level, start_capture, start_recording_thread, stop_capture, + stop_recording, }; #[cfg(feature = "status-perf-probe")] pub(super) use settings::run_test_hook; diff --git a/src-tauri/src/commands/recording.rs b/src-tauri/src/commands/recording.rs index a4e4c2c..1fcecef 100644 --- a/src-tauri/src/commands/recording.rs +++ b/src-tauri/src/commands/recording.rs @@ -1,6 +1,36 @@ +use echo_desktop::ipc::RecordingSnapshot; + +fn snapshot() -> RecordingSnapshot { + crate::status::recording_snapshot(&echo::status::read()) +} + +/// Explicit GUI start. The recording owner publishes status; this command +/// only starts that owner and returns its identity acknowledgement. +#[tauri::command] +pub(crate) async fn start_capture() -> Result { + crate::blocking::run_blocking("start recording", || { + echo::rec::start_managed_recording()?; + Ok(snapshot()) + }) + .await? +} + +#[tauri::command] +pub(crate) async fn stop_capture(session_id: String) -> Result { + crate::blocking::run_blocking("stop capture", move || { + let _ = echo::rec::request_capture_stop(&session_id)?; + Ok(snapshot()) + }) + .await? +} + #[tauri::command] -pub(crate) fn toggle_recording() -> Result<(), String> { - start_recording_thread().map(|_| ()) +pub(crate) async fn cancel_transcription(session_id: String) -> Result { + crate::blocking::run_blocking("cancel transcription", move || { + let _ = echo::rec::request_transcription_cancel(&session_id)?; + Ok(snapshot()) + }) + .await? } #[tauri::command] @@ -17,6 +47,8 @@ pub(crate) fn get_recording_level() -> f32 { } } +/// Tray and shortcut retain their public toggle affordance. Desktop Home uses +/// the explicit session-bound commands above. pub(crate) fn start_recording_thread() -> Result, String> { echo::rec::toggle_managed_recording() } diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index 7fff71b..cb96bc1 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -97,9 +97,19 @@ mod tests { payload_types: &[], }, CommandContract { - handler: "toggle_recording", + handler: "start_capture", source: RECORDING, - payload_types: &[], + payload_types: &["RecordingSnapshot"], + }, + CommandContract { + handler: "stop_capture", + source: RECORDING, + payload_types: &["RecordingSnapshot"], + }, + CommandContract { + handler: "cancel_transcription", + source: RECORDING, + payload_types: &["RecordingSnapshot"], }, CommandContract { handler: "stop_recording", @@ -278,6 +288,6 @@ mod tests { manifest_types.insert((*payload_type).to_string()); } } - assert_eq!(manifest_types.len(), 19); + assert_eq!(manifest_types.len(), 20); } } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index b11b071..eae92b3 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -4,12 +4,13 @@ use std::sync::atomic::{AtomicBool, Ordering}; use commands::{ add_dictionary_entries_batch, add_dictionary_entry, cancel_dictionary_training_sample, - clear_history, copy_text, delete_history_item, finish_dictionary_training_sample, - get_app_status, get_dictionary, get_history, get_microphones, get_recording_level, - get_settings, get_shortcut_status, list_gpu_devices, list_languages, list_models, quit_app, - remove_dictionary_entry, remove_stale_installs, repair_legacy_shortcut, retry_shortcut, - set_microphone, set_settings, start_dictionary_training_sample, stop_recording, - test_input_device, test_microphone_fallback, toggle_recording, DictionaryTrainingCaptures, + cancel_transcription, clear_history, copy_text, delete_history_item, + finish_dictionary_training_sample, get_app_status, get_dictionary, get_history, + get_microphones, get_recording_level, get_settings, get_shortcut_status, list_gpu_devices, + list_languages, list_models, quit_app, remove_dictionary_entry, remove_stale_installs, + repair_legacy_shortcut, retry_shortcut, set_microphone, set_settings, start_capture, + start_dictionary_training_sample, stop_capture, stop_recording, test_input_device, + test_microphone_fallback, DictionaryTrainingCaptures, }; use tauri::{Manager, WindowEvent}; @@ -206,7 +207,9 @@ fn run_desktop() -> Result<(), String> { start_dictionary_training_sample, finish_dictionary_training_sample, cancel_dictionary_training_sample, - toggle_recording, + start_capture, + stop_capture, + cancel_transcription, stop_recording, get_recording_level, copy_text, diff --git a/src-tauri/src/status.rs b/src-tauri/src/status.rs index d6c3ada..f1e7a71 100644 --- a/src-tauri/src/status.rs +++ b/src-tauri/src/status.rs @@ -33,6 +33,19 @@ fn app_phase(state: &str) -> AppPhase { } } +pub(super) fn recording_snapshot( + status: &echo::status::Status, +) -> echo_desktop::ipc::RecordingSnapshot { + let capture_stop_requested = status.state == "Recording" + && echo::rec::capture_stop_requested_for(status.session_id.as_deref()); + echo_desktop::ipc::RecordingSnapshot { + session_id: status.session_id.clone(), + phase: app_phase(&status.state), + capture_stop_requested, + revision: status.revision + u64::from(capture_stop_requested), + } +} + fn recording_policy_dto() -> RecordingPolicy { RecordingPolicy { minimum_seconds: echo_core::RecordingLimit::MIN.seconds(), @@ -726,8 +739,9 @@ pub(super) fn app_status() -> AppStatus { let settings_path = echo_core::config_path().to_string_lossy().into_owned(); #[cfg(feature = "status-perf-probe")] timer.mark(crate::perf::StatusStage::Presentation); + let recording = recording_snapshot(&status); let app_status = AppStatus { - phase: app_phase(&status.state), + phase: recording.phase, last_transcript: status.last, last_history_id: status.last_history_id, microphone_ready: health.microphone_ready, @@ -745,6 +759,9 @@ pub(super) fn app_status() -> AppStatus { last_run, language_warning: health.language_warning, recording_in_process, + recording_session_id: recording.session_id, + capture_stop_requested: recording.capture_stop_requested, + recording_revision: recording.revision, current_exe: health.current_exe, first_path_hit: health.first_path_hit, stale_installs: health.stale_installs, @@ -1391,6 +1408,8 @@ mod tests { last_history_id: None, error: None, recording_limit: echo_core::RecordingLimit::new(120), + session_id: None, + revision: 0, }; assert_eq!( project_recording_limit(&active, echo_core::RecordingLimit::MAX) From c19f625c27ca14fb80cf475a7ae62bc956eca431 Mon Sep 17 00:00:00 2001 From: vriesd Date: Sat, 5 Sep 2026 10:53:31 +0200 Subject: [PATCH 2/7] fix recording start receipts and legacy stops --- crates/echo/src/rec.rs | 61 +++++++++++++++++++------ crates/echo/tests/recording_commands.rs | 2 +- src-tauri/src/commands/recording.rs | 9 +++- 3 files changed, 55 insertions(+), 17 deletions(-) diff --git a/crates/echo/src/rec.rs b/crates/echo/src/rec.rs index d2aabba..945e77b 100644 --- a/crates/echo/src/rec.rs +++ b/crates/echo/src/rec.rs @@ -191,7 +191,13 @@ fn apply_toggle_stop_intent(owner: &LockOwner) { /// Start a desktop capture. Unlike the CLI toggle this never converts a busy /// session into a stop request: GUI controls name the session they intend. -pub fn start_managed_recording() -> Result { +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StartedRecording { + pub session_id: String, + pub revision: u64, +} + +pub fn start_managed_recording() -> Result { let session = match ToggleSession::acquire_in(&echo_core::data_dir())? { LockAcquisition::Started(session) => session, LockAcquisition::Busy(_) => return Err("Another recording is already active.".to_string()), @@ -204,10 +210,13 @@ pub fn start_managed_recording() -> Result { let _ = run_record_started(StopWhen::ToggleFile(session), Some(send)); }) .map_err(|err| err.to_string())?; - receive + let revision = receive .recv() .map_err(|_| "recording worker exited before starting".to_string())??; - Ok(token) + Ok(StartedRecording { + session_id: token, + revision, + }) } pub fn request_capture_stop(session_id: &str) -> Result { @@ -243,7 +252,7 @@ fn run_record(stop: StopWhen) -> i32 { fn run_record_started( stop: StopWhen, - started: Option>>, + started: Option>>, ) -> i32 { let config = match crate::settings::runtime_config() { Ok(config) => config, @@ -270,7 +279,7 @@ fn run_record_with_limit( mut stop: StopWhen, limit: RecordingLimit, config: &echo_core::Config, - started: Option>>, + started: Option>>, ) -> i32 { let mut session = Session::new(); apply_edge(&mut session, HotkeyEvent::Down); @@ -455,12 +464,17 @@ fn new_history_id() -> String { uuid::Uuid::new_v4().to_string() } -fn write_session_recording(stop: &StopWhen, limit: RecordingLimit) -> Result<(), String> { +fn write_session_recording(stop: &StopWhen, limit: RecordingLimit) -> Result { match stop.session_id() { Some(session_id) => { - status::write_recording_for_session(session_id, stop.next_revision(), limit) + let revision = stop.next_revision(); + status::write_recording_for_session(session_id, revision, limit)?; + Ok(revision) + } + None => { + status::write_recording(limit)?; + Ok(0) } - None => status::write_recording(limit), } } @@ -811,17 +825,31 @@ impl ToggleSession { } fn stop_requested(&self) -> bool { - self.directory + let scoped = self + .directory .read_to_string(self.intent_name("stop").as_ref()) .ok() - .is_some_and(|request| stop_request_matches(Some(&self.token), &request)) + .is_some_and(|request| stop_request_matches(Some(&self.token), &request)); + scoped + || self + .directory + .read_to_string("recording.stop".as_ref()) + .ok() + .is_some_and(|request| stop_request_matches(Some(&self.token), &request)) } fn cancel_requested(&self) -> bool { - self.directory + let scoped = self + .directory .read_to_string(self.intent_name("cancel").as_ref()) .ok() - .is_some_and(|request| stop_request_matches(Some(&self.token), &request)) + .is_some_and(|request| stop_request_matches(Some(&self.token), &request)); + scoped + || self + .directory + .read_to_string("recording.cancel".as_ref()) + .ok() + .is_some_and(|request| stop_request_matches(Some(&self.token), &request)) } fn next_revision(&self) -> u64 { @@ -1031,14 +1059,19 @@ pub fn capture_stop_requested_for(session_id: Option<&str>) -> bool { let Some(owner) = owner.filter(|owner| owner.token.as_deref() == Some(session_id)) else { return false; }; - PrivateDir::open(&dir) + let scoped = PrivateDir::open(&dir) .ok() .and_then(|directory| { directory .read_to_string(intent_path(&dir, "stop", &owner).file_name()?.as_ref()) .ok() }) - .is_some_and(|request| stop_request_matches(Some(session_id), &request)) + .is_some_and(|request| stop_request_matches(Some(session_id), &request)); + scoped + || PrivateDir::open(&dir) + .ok() + .and_then(|directory| directory.read_to_string("recording.stop".as_ref()).ok()) + .is_some_and(|request| stop_request_matches(Some(session_id), &request)) } pub(crate) fn session_active_at(path: &Path) -> bool { diff --git a/crates/echo/tests/recording_commands.rs b/crates/echo/tests/recording_commands.rs index f453037..25d1ae1 100644 --- a/crates/echo/tests/recording_commands.rs +++ b/crates/echo/tests/recording_commands.rs @@ -149,7 +149,7 @@ fn owner_helper() { }; let root = PathBuf::from(root); let session = echo::rec::start_managed_recording().unwrap(); - echo_core::write_atomic_private(&root.join("session"), session.as_bytes()).unwrap(); + echo_core::write_atomic_private(&root.join("session"), session.session_id.as_bytes()).unwrap(); let deadline = Instant::now() + Duration::from_secs(15); while echo::rec::session_active() && Instant::now() < deadline { std::thread::sleep(Duration::from_millis(10)); diff --git a/src-tauri/src/commands/recording.rs b/src-tauri/src/commands/recording.rs index 1fcecef..bc474d5 100644 --- a/src-tauri/src/commands/recording.rs +++ b/src-tauri/src/commands/recording.rs @@ -9,8 +9,13 @@ fn snapshot() -> RecordingSnapshot { #[tauri::command] pub(crate) async fn start_capture() -> Result { crate::blocking::run_blocking("start recording", || { - echo::rec::start_managed_recording()?; - Ok(snapshot()) + let started = echo::rec::start_managed_recording()?; + Ok(RecordingSnapshot { + session_id: Some(started.session_id), + phase: echo_desktop::ipc::AppPhase::Recording, + capture_stop_requested: false, + revision: started.revision, + }) }) .await? } From 7f33a709c7ae9be1a95640746faa895880e2d849 Mon Sep 17 00:00:00 2001 From: vriesd Date: Sat, 5 Sep 2026 11:08:01 +0200 Subject: [PATCH 3/7] fix(recording): preserve legacy request timing and coverage --- crates/echo/src/process_identity.rs | 28 +++++++++++++++++++++---- crates/echo/src/rec.rs | 25 ++++++++++++++++++++-- crates/echo/tests/recording_commands.rs | 27 ++++++++++++++++++++---- 3 files changed, 70 insertions(+), 10 deletions(-) diff --git a/crates/echo/src/process_identity.rs b/crates/echo/src/process_identity.rs index 7a54257..462f2ac 100644 --- a/crates/echo/src/process_identity.rs +++ b/crates/echo/src/process_identity.rs @@ -11,6 +11,7 @@ pub struct ProcessObservation { pub pid: u32, pub start_time_ticks: u64, pub state: char, + /// Earliest plausible start time, accounting for `/proc/uptime` precision. pub start_unix_nanos: Option, } @@ -63,17 +64,27 @@ fn process_start_unix_nanos(start_time_ticks: u64) -> Option { if ticks_per_second == 0 { return None; } - let uptime = std::fs::read_to_string("/proc/uptime").ok()?; - let uptime_nanos = decimal_seconds_to_nanos(uptime.split_whitespace().next()?)?; let now_nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .ok()? .as_nanos(); + let uptime = std::fs::read_to_string("/proc/uptime").ok()?; + let uptime_nanos = decimal_seconds_to_nanos(uptime.split_whitespace().next()?)?; let started_since_boot = (start_time_ticks as u128) .checked_mul(1_000_000_000)? .checked_div(ticks_per_second)?; - now_nanos - .checked_sub(uptime_nanos)? + earliest_start_time(now_nanos, uptime_nanos, started_since_boot) +} + +fn earliest_start_time( + now_before_read: u128, + uptime_floor: u128, + started_since_boot: u128, +) -> Option { + // Linux truncates uptime to hundredths. Use its upper bound so a fresh + // legacy lock is not mistaken for a lock from before the process existed. + now_before_read + .checked_sub(uptime_floor.checked_add(10_000_000)?)? .checked_add(started_since_boot) } @@ -120,6 +131,15 @@ fn native_word(raw: &[u8]) -> Option { mod tests { use super::*; + #[test] + fn uptime_precision_cannot_place_a_live_process_after_its_new_lock() { + let wall_before_read = 10_005_000_000; + let lock_created = 10_001_000_000; + let earliest = earliest_start_time(wall_before_read, 1_000_000_000, 1_000_000_000).unwrap(); + assert_eq!(earliest, 9_995_000_000); + assert!(earliest <= lock_created); + } + #[test] fn stat_parser_handles_spaces_and_parentheses_in_comm() { let mut trailing = vec!["0"; 18]; diff --git a/crates/echo/src/rec.rs b/crates/echo/src/rec.rs index 945e77b..bd6e20a 100644 --- a/crates/echo/src/rec.rs +++ b/crates/echo/src/rec.rs @@ -189,8 +189,7 @@ fn apply_toggle_stop_intent(owner: &LockOwner) { } } -/// Start a desktop capture. Unlike the CLI toggle this never converts a busy -/// session into a stop request: GUI controls name the session they intend. +/// The identity and revision acknowledged by the owner when capture starts. #[derive(Debug, Clone, PartialEq, Eq)] pub struct StartedRecording { pub session_id: String, @@ -1517,6 +1516,28 @@ mod tests { assert!(!ToggleSession::request_cancel_for_token_in(&dir, "old-token").unwrap()); } + #[test] + fn scoped_owner_accepts_only_its_token_in_a_legacy_flat_request() { + let directory = tempfile::tempdir().unwrap(); + let session = ToggleSession::try_start_in(directory.path()) + .unwrap() + .unwrap(); + let stop = directory.path().join("recording.stop"); + fs::write(&stop, "stop\n").unwrap(); + assert!(!session.stop_requested()); + fs::write(&stop, "replaced-session\n").unwrap(); + assert!(!session.stop_requested()); + fs::write(&stop, format!("{}\n", session.token)).unwrap(); + assert!(session.stop_requested()); + assert!(!session.cancel_requested()); + fs::write( + directory.path().join("recording.cancel"), + format!("{}\n", session.token), + ) + .unwrap(); + assert!(session.cancel_requested()); + } + #[test] fn delayed_old_session_intent_cannot_replace_new_session_intent() { let dir = std::env::temp_dir().join(format!("echo-scoped-intent-{}", std::process::id())); diff --git a/crates/echo/tests/recording_commands.rs b/crates/echo/tests/recording_commands.rs index 25d1ae1..1ebecd3 100644 --- a/crates/echo/tests/recording_commands.rs +++ b/crates/echo/tests/recording_commands.rs @@ -66,7 +66,7 @@ fn control(root: &Path, action: &str, session: &str, accepted: bool) { assert!(wait(&mut child).success()); } -fn exercise_transcription(cancel: bool) { +fn exercise_transcription(cancel: bool, legacy_stop: bool) { use std::os::unix::fs::PermissionsExt; let root = tempfile::tempdir().unwrap(); for name in ["bin", "models", "data", "config"] { @@ -95,7 +95,15 @@ printf '%s\n' '{"model":{"type":"small","multilingual":true},"result":{"language wait_for(&token_path); let session = std::fs::read_to_string(&token_path).unwrap(); control(root.path(), "stop", "replaced-session", false); - control(root.path(), "stop", &session, true); + if legacy_stop { + std::fs::write( + root.path().join("data/recording.stop"), + format!("{session}\n"), + ) + .unwrap(); + } else { + control(root.path(), "stop", &session, true); + } wait_for(&root.path().join("engine-ready")); control(root.path(), "stop", &session, false); let status = std::fs::read_to_string(root.path().join("data/status")).unwrap(); @@ -133,12 +141,17 @@ printf '%s\n' '{"model":{"type":"small","multilingual":true},"result":{"language #[test] fn duplicate_capture_stop_preserves_the_running_transcription() { - exercise_transcription(false); + exercise_transcription(false, false); } #[test] fn explicit_cancel_terminates_the_running_transcription() { - exercise_transcription(true); + exercise_transcription(true, false); +} + +#[test] +fn legacy_flat_capture_stop_preserves_transcription() { + exercise_transcription(false, true); } #[test] @@ -149,6 +162,12 @@ fn owner_helper() { }; let root = PathBuf::from(root); let session = echo::rec::start_managed_recording().unwrap(); + let status = echo::status::read(); + assert_eq!( + status.session_id.as_deref(), + Some(session.session_id.as_str()) + ); + assert!(session.revision > 0 && status.revision >= session.revision); echo_core::write_atomic_private(&root.join("session"), session.session_id.as_bytes()).unwrap(); let deadline = Instant::now() + Duration::from_secs(15); while echo::rec::session_active() && Instant::now() < deadline { From be00672128d3bde1345eb63cf319e50c8ee6b63e Mon Sep 17 00:00:00 2001 From: vriesd Date: Sat, 5 Sep 2026 11:37:06 +0200 Subject: [PATCH 4/7] fix recording control acknowledgements --- crates/echo/src/rec.rs | 39 +++++++++++++++++++++++++++-- src-tauri/src/commands/recording.rs | 12 +++++++-- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/crates/echo/src/rec.rs b/crates/echo/src/rec.rs index bd6e20a..ed5045e 100644 --- a/crates/echo/src/rec.rs +++ b/crates/echo/src/rec.rs @@ -196,6 +196,12 @@ pub struct StartedRecording { pub revision: u64, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RecordingControlAck { + pub session_id: String, + pub revision: u64, +} + pub fn start_managed_recording() -> Result { let session = match ToggleSession::acquire_in(&echo_core::data_dir())? { LockAcquisition::Started(session) => session, @@ -219,11 +225,20 @@ pub fn start_managed_recording() -> Result { } pub fn request_capture_stop(session_id: &str) -> Result { + Ok(request_capture_stop_ack(session_id)?.is_some()) +} + +pub fn request_capture_stop_ack(session_id: &str) -> Result, String> { let current = status::read(); if current.state != "Recording" || current.session_id.as_deref() != Some(session_id) { - return Ok(false); + return Ok(None); } - ToggleSession::request_stop_for_token_in(&echo_core::data_dir(), session_id) + ToggleSession::request_stop_for_token_in(&echo_core::data_dir(), session_id).map(|accepted| { + accepted.then(|| RecordingControlAck { + session_id: session_id.to_string(), + revision: current.revision + 1, + }) + }) } pub fn request_transcription_cancel(session_id: &str) -> Result { @@ -872,6 +887,14 @@ impl ToggleSession { let _ = self .directory .remove_file(scoped_intent_name("stop", &self.token).as_ref()); + if self + .directory + .read_to_string("recording.stop".as_ref()) + .ok() + .is_some_and(|request| stop_request_matches(Some(&self.token), &request)) + { + let _ = self.directory.remove_file("recording.stop".as_ref()); + } } } } @@ -1459,6 +1482,18 @@ mod tests { assert!(session.stop_requested()); } + #[test] + fn clearing_a_matching_legacy_stop_removes_the_flat_signal() { + let dir = std::env::temp_dir().join(format!("echo-clear-flat-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + let session = ToggleSession::try_start_in(&dir).unwrap().unwrap(); + fs::write(dir.join("recording.stop"), format!("{}\n", session.token)).unwrap(); + assert!(session.stop_requested()); + session.clear_stop_request(); + assert!(!session.stop_requested()); + assert!(!dir.join("recording.stop").exists()); + } + #[test] fn live_pid_only_lock_receives_an_observable_legacy_stop_request() { let dir = std::env::temp_dir().join(format!( diff --git a/src-tauri/src/commands/recording.rs b/src-tauri/src/commands/recording.rs index bc474d5..047528a 100644 --- a/src-tauri/src/commands/recording.rs +++ b/src-tauri/src/commands/recording.rs @@ -23,8 +23,16 @@ pub(crate) async fn start_capture() -> Result { #[tauri::command] pub(crate) async fn stop_capture(session_id: String) -> Result { crate::blocking::run_blocking("stop capture", move || { - let _ = echo::rec::request_capture_stop(&session_id)?; - Ok(snapshot()) + let ack = echo::rec::request_capture_stop_ack(&session_id)?; + Ok(match ack { + Some(ack) => RecordingSnapshot { + session_id: Some(ack.session_id), + phase: echo_desktop::ipc::AppPhase::Recording, + capture_stop_requested: true, + revision: ack.revision, + }, + None => snapshot(), + }) }) .await? } From 20b6c1dbad55903936700f83b6b9eb13ae452022 Mon Sep 17 00:00:00 2001 From: vriesd Date: Sat, 5 Sep 2026 11:38:16 +0200 Subject: [PATCH 5/7] fix stale recording control replies --- crates/echo/src/rec.rs | 15 ++++++++++++-- src-tauri/src/commands/recording.rs | 31 ++++++++++++++++------------- 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/crates/echo/src/rec.rs b/crates/echo/src/rec.rs index ed5045e..0f12b74 100644 --- a/crates/echo/src/rec.rs +++ b/crates/echo/src/rec.rs @@ -242,11 +242,22 @@ pub fn request_capture_stop_ack(session_id: &str) -> Result Result { + Ok(request_transcription_cancel_ack(session_id)?.is_some()) +} + +pub fn request_transcription_cancel_ack( + session_id: &str, +) -> Result, String> { let current = status::read(); if current.state != "Transcribing" || current.session_id.as_deref() != Some(session_id) { - return Ok(false); + return Ok(None); } - ToggleSession::request_cancel_for_token_in(&echo_core::data_dir(), session_id) + ToggleSession::request_cancel_for_token_in(&echo_core::data_dir(), session_id).map(|accepted| { + accepted.then(|| RecordingControlAck { + session_id: session_id.to_string(), + revision: current.revision + 1, + }) + }) } pub fn stop_shortcut_recording(activation: &str) -> Result { diff --git a/src-tauri/src/commands/recording.rs b/src-tauri/src/commands/recording.rs index 047528a..f456991 100644 --- a/src-tauri/src/commands/recording.rs +++ b/src-tauri/src/commands/recording.rs @@ -1,9 +1,5 @@ use echo_desktop::ipc::RecordingSnapshot; -fn snapshot() -> RecordingSnapshot { - crate::status::recording_snapshot(&echo::status::read()) -} - /// Explicit GUI start. The recording owner publishes status; this command /// only starts that owner and returns its identity acknowledgement. #[tauri::command] @@ -24,14 +20,13 @@ pub(crate) async fn start_capture() -> Result { pub(crate) async fn stop_capture(session_id: String) -> Result { crate::blocking::run_blocking("stop capture", move || { let ack = echo::rec::request_capture_stop_ack(&session_id)?; - Ok(match ack { - Some(ack) => RecordingSnapshot { - session_id: Some(ack.session_id), - phase: echo_desktop::ipc::AppPhase::Recording, - capture_stop_requested: true, - revision: ack.revision, - }, - None => snapshot(), + let ack = + ack.ok_or_else(|| "recording session changed before stop was accepted".to_string())?; + Ok(RecordingSnapshot { + session_id: Some(ack.session_id), + phase: echo_desktop::ipc::AppPhase::Recording, + capture_stop_requested: true, + revision: ack.revision, }) }) .await? @@ -40,8 +35,16 @@ pub(crate) async fn stop_capture(session_id: String) -> Result Result { crate::blocking::run_blocking("cancel transcription", move || { - let _ = echo::rec::request_transcription_cancel(&session_id)?; - Ok(snapshot()) + let ack = echo::rec::request_transcription_cancel_ack(&session_id)?; + let ack = ack.ok_or_else(|| { + "recording session changed before cancellation was accepted".to_string() + })?; + Ok(RecordingSnapshot { + session_id: Some(ack.session_id), + phase: echo_desktop::ipc::AppPhase::Transcribing, + capture_stop_requested: false, + revision: ack.revision, + }) }) .await? } From 2e80c62cfe7c631564fa7df92cb60878037b65a7 Mon Sep 17 00:00:00 2001 From: vriesd Date: Sat, 5 Sep 2026 11:39:43 +0200 Subject: [PATCH 6/7] test recording control receipt identity --- crates/echo/tests/recording_commands.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/echo/tests/recording_commands.rs b/crates/echo/tests/recording_commands.rs index 1ebecd3..c839adf 100644 --- a/crates/echo/tests/recording_commands.rs +++ b/crates/echo/tests/recording_commands.rs @@ -183,14 +183,19 @@ fn control_helper() { return; }; let session = std::env::var("ECHO_CONTROL_TEST_SESSION").unwrap(); - let accepted = match action.as_str() { - "stop" => echo::rec::request_capture_stop(&session), - "cancel" => echo::rec::request_transcription_cancel(&session), + let before = echo::status::read(); + let ack = match action.as_str() { + "stop" => echo::rec::request_capture_stop_ack(&session), + "cancel" => echo::rec::request_transcription_cancel_ack(&session), _ => panic!("unknown test command"), } .unwrap(); assert_eq!( - accepted, + ack.is_some(), std::env::var("ECHO_CONTROL_TEST_ACCEPTED").unwrap() == "true" ); + if let Some(ack) = ack { + assert_eq!(ack.session_id, session); + assert_eq!(ack.revision, before.revision + 1); + } } From 2c9ab89dc99408c8c253ee52ac2d1374f3789537 Mon Sep 17 00:00:00 2001 From: vriesd Date: Sat, 5 Sep 2026 11:40:24 +0200 Subject: [PATCH 7/7] test stale recording control replies --- frontend/src/api/previewDesktopApi.ts | 20 ++++++---- frontend/src/tauri.test.ts | 8 ++++ src-tauri/src/commands/recording.rs | 54 +++++++++++++++++---------- 3 files changed, 55 insertions(+), 27 deletions(-) diff --git a/frontend/src/api/previewDesktopApi.ts b/frontend/src/api/previewDesktopApi.ts index fe37541..b172bc7 100644 --- a/frontend/src/api/previewDesktopApi.ts +++ b/frontend/src/api/previewDesktopApi.ts @@ -294,18 +294,22 @@ export function createPreviewDesktopApi(): PreviewDesktopApi { } function stopCapture(sessionId: string): Promise { - if (previewStatus.recordingSessionId === sessionId) stopPreviewRecording() + if (previewStatus.recordingSessionId !== sessionId || previewStatus.phase !== 'Recording') { + return Promise.reject(new Error('Recording session changed before stop was accepted.')) + } + stopPreviewRecording() return Promise.resolve(recordingSnapshot()) } function cancelTranscription(sessionId: string): Promise { - if (previewStatus.recordingSessionId === sessionId && previewStatus.phase === 'Transcribing') { - previewStatus = { - ...previewStatus, - phase: 'Failed', - recordingRevision: previewStatus.recordingRevision + 2, - lastError: 'Transcription cancelled.', - } + if (previewStatus.recordingSessionId !== sessionId || previewStatus.phase !== 'Transcribing') { + return Promise.reject(new Error('Recording session changed before cancellation was accepted.')) + } + previewStatus = { + ...previewStatus, + phase: 'Failed', + recordingRevision: previewStatus.recordingRevision + 2, + lastError: 'Transcription cancelled.', } return Promise.resolve(recordingSnapshot()) } diff --git a/frontend/src/tauri.test.ts b/frontend/src/tauri.test.ts index 30b4a04..e6071ce 100644 --- a/frontend/src/tauri.test.ts +++ b/frontend/src/tauri.test.ts @@ -15,6 +15,8 @@ const { setSettings, stopRecording, startCapture, + stopCapture, + cancelTranscription, } = createPreviewDesktopApi() function deferred() { @@ -40,6 +42,12 @@ function deferred() { } describe('settings preview wrappers', () => { + it('rejects stale stop and cancellation requests without returning another session', async () => { + const started = await startCapture() + await expect(stopCapture(`${started.sessionId}-stale`)).rejects.toThrow('session changed') + await stopCapture(String(started.sessionId)) + await expect(cancelTranscription(`${started.sessionId}-stale`)).rejects.toThrow('session changed') + }) beforeEach(() => resetPreviewSettings()) it('mirrors the Rust recording policy in one preview fixture', async () => { diff --git a/src-tauri/src/commands/recording.rs b/src-tauri/src/commands/recording.rs index f456991..b4a9bac 100644 --- a/src-tauri/src/commands/recording.rs +++ b/src-tauri/src/commands/recording.rs @@ -1,5 +1,27 @@ use echo_desktop::ipc::RecordingSnapshot; +fn stop_reply(ack: Option) -> Result { + let ack = + ack.ok_or_else(|| "recording session changed before stop was accepted".to_string())?; + Ok(RecordingSnapshot { + session_id: Some(ack.session_id), + phase: echo_desktop::ipc::AppPhase::Recording, + capture_stop_requested: true, + revision: ack.revision, + }) +} + +fn cancel_reply(ack: Option) -> Result { + let ack = ack + .ok_or_else(|| "recording session changed before cancellation was accepted".to_string())?; + Ok(RecordingSnapshot { + session_id: Some(ack.session_id), + phase: echo_desktop::ipc::AppPhase::Transcribing, + capture_stop_requested: false, + revision: ack.revision, + }) +} + /// Explicit GUI start. The recording owner publishes status; this command /// only starts that owner and returns its identity acknowledgement. #[tauri::command] @@ -19,15 +41,7 @@ pub(crate) async fn start_capture() -> Result { #[tauri::command] pub(crate) async fn stop_capture(session_id: String) -> Result { crate::blocking::run_blocking("stop capture", move || { - let ack = echo::rec::request_capture_stop_ack(&session_id)?; - let ack = - ack.ok_or_else(|| "recording session changed before stop was accepted".to_string())?; - Ok(RecordingSnapshot { - session_id: Some(ack.session_id), - phase: echo_desktop::ipc::AppPhase::Recording, - capture_stop_requested: true, - revision: ack.revision, - }) + stop_reply(echo::rec::request_capture_stop_ack(&session_id)?) }) .await? } @@ -35,16 +49,7 @@ pub(crate) async fn stop_capture(session_id: String) -> Result Result { crate::blocking::run_blocking("cancel transcription", move || { - let ack = echo::rec::request_transcription_cancel_ack(&session_id)?; - let ack = ack.ok_or_else(|| { - "recording session changed before cancellation was accepted".to_string() - })?; - Ok(RecordingSnapshot { - session_id: Some(ack.session_id), - phase: echo_desktop::ipc::AppPhase::Transcribing, - capture_stop_requested: false, - revision: ack.revision, - }) + cancel_reply(echo::rec::request_transcription_cancel_ack(&session_id)?) }) .await? } @@ -68,3 +73,14 @@ pub(crate) fn get_recording_level() -> f32 { pub(crate) fn start_recording_thread() -> Result, String> { echo::rec::toggle_managed_recording() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stale_controls_cannot_reply_with_a_replacement_snapshot() { + assert!(stop_reply(None).unwrap_err().contains("session changed")); + assert!(cancel_reply(None).unwrap_err().contains("session changed")); + } +}