diff --git a/docs/architecture.md b/docs/architecture.md index 692c1ac99..c964fd656 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # OpenLess 2.0 架构 -状态:canonical,当前实现说明;更新:2026-09-08。平台范围见 [2.0 需求](2.0-requirements.md),文件定位见 [目录结构](structure.md)。 +状态:canonical,当前实现说明;更新:2026-09-13。平台范围见 [2.0 需求](2.0-requirements.md),文件定位见 [目录结构](structure.md)。 ## 1. 分层与工作区 @@ -69,6 +69,10 @@ Tauri 在 `src-tauri/src/coordinator.rs` 构造 Core,`core_adapters.rs` 组装 Siri、Classic、Typeless 三种胶囊共用 Core 的 `CapsuleStyle`,窗口尺寸与点击范围在保存偏好时同步。胶囊按显示器工作区底部定位,避开未自动隐藏的 Dock/任务栏;可见期间重新检查工作区。带正文的浮窗使用不透明底色,聊天面板另叠加细噪点纹理,圆角外部仍保留透明区域。 +思考动画覆盖转写、润色和原生文字写入,输入完成后才收尾。macOS 流式键盘输入在可读取 AX 光标的控件上等待原控件光标到达本批文字末尾,再完成写入和恢复输入源;仅读选区范围,不读正文。不可读、提交型 Return 或等待超过 10 秒时回到按键发送完成语义,本次会话停止继续探测该控件。目标应用的实际输入表现仍需设备验收。 + +选区直接润色在捕获文字和原输入目标后显示处理中提示,重复快捷键的 Busy 返回不覆盖该提示。已有语音选区入口在松开快捷键后继续显示思考动画,直到处理/替换完成,或交给确认和预览面板。录音提示音的 Web Audio context 在恢复超时或音频时钟冻结时丢弃并最多重试一次,重试沿用原请求的取消和迟到边界。 + 界面启动等待所选语言资源就绪;语言选择持久化到 `ol.locale`,其他 WebView 通过存储事件同步,日期、数字和默认风格展示随语言变化。用户修改的风格名称、说明和内容保持原文。旧版两种强制排版字段只保留数据兼容,界面清除其布局效果,窄屏改由响应式布局处理。 ## 6. 存储与外部服务 diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index 499436b6a..ad6340d2e 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -8663,7 +8663,10 @@ mod tests { ) .unwrap(); let first = backend.start().await.expect("first start must not fail"); - let second = backend.start().await.expect("handshake start must not fail"); + let second = backend + .start() + .await + .expect("handshake start must not fail"); assert!(first.backend.running); assert!(second.backend.running); let _ = data_dir; @@ -10705,9 +10708,16 @@ mod tests { assert_streaming_polish_deltas_flush_before_final_insert(true).await; } - async fn assert_cancel_drains_native_insertion( + #[derive(Clone, Copy, PartialEq, Eq)] + enum NativeInsertionEnd { + Complete, + Cancel, + DropStopAndCancel, + } + + async fn assert_native_insertion_lifecycle( streaming: bool, - drop_stop: bool, + ending: NativeInsertionEnd, translation: bool, ) { struct BlockingInsertion { @@ -10740,10 +10750,12 @@ mod tests { &self, text: String, ) -> BoxFuture<'static, Result> { - let writing = self.write(text); + let writing = (!text.is_empty()).then(|| self.write(text)); let actions = Arc::clone(&self.actions); boxed(async move { - writing.await?; + if let Some(writing) = writing { + writing.await?; + } actions.lock().unwrap().push("input source restored"); Ok(InsertOutcome::Inserted) }) @@ -10769,8 +10781,12 @@ mod tests { let actions = Arc::new(Mutex::new(Vec::new())); let started = Arc::new(tokio::sync::Semaphore::new(0)); let release = Arc::new(tokio::sync::Semaphore::new(0)); - let data_dir = TestDataDir::new("stream-cancel-drain"); + let data_dir = TestDataDir::new("native-insertion-lifecycle"); + let prefix = "长文字🙂\r\n".repeat(256); + let output = format!("{prefix}{}", "等待最后一段🌍\n".repeat(256)); + let host = Arc::new(FakeHost::default()); let mut deps = BackendDependencies::unsupported(); + deps.host_actions = host.clone(); deps.credential_store = Arc::new(crate::credentials::InMemoryCredentialStore::default()); deps.text_inserter = Arc::new(BlockingInserter(Arc::new(BlockingInsertion { actions: Arc::clone(&actions), @@ -10778,16 +10794,17 @@ mod tests { release: Arc::clone(&release), }))); deps.dictation_engine = Arc::new( - crate::testing::FixtureDictationEngine::successful("raw", "streamed") - .with_polish_deltas(if streaming { + crate::testing::FixtureDictationEngine::successful("raw", &output).with_polish_deltas( + if streaming { vec![crate::types::PolishDelta { - text: "streamed".into(), + text: prefix, offset: 0, is_final: false, }] } else { Vec::new() - }), + }, + ), ); deps.task_spawner = Arc::new(TokioTaskSpawner); let backend = Arc::new( @@ -10807,6 +10824,7 @@ mod tests { preferences.working_languages = vec!["简体中文".into()]; backend.set_preferences(preferences).unwrap(); backend.start().await.unwrap(); + let mut events = backend.subscribe(); let session_id = backend .start_dictation_with_options(DictationStartOptions { translation_requested: translation, @@ -10816,7 +10834,68 @@ mod tests { .unwrap(); let stopping_backend = Arc::clone(&backend); let stop = tokio::spawn(async move { stopping_backend.stop_dictation().await }); - started.acquire().await.unwrap().forget(); + tokio::time::timeout(std::time::Duration::from_secs(2), started.acquire()) + .await + .unwrap() + .unwrap() + .forget(); + if ending == NativeInsertionEnd::Complete { + // Hold both the streamed prefix and the final reconciliation tail. + // An LLM final delta must not end feedback before either native write. + for write_index in 0..if streaming { 2 } else { 1 } { + if write_index > 0 { + tokio::time::timeout(std::time::Duration::from_secs(2), started.acquire()) + .await + .unwrap() + .unwrap() + .forget(); + } + assert!(!stop.is_finished()); + assert_eq!( + backend.snapshot().dictation.phase, + DictationPhase::Inserting + ); + assert!(!host + .0 + .lock() + .unwrap() + .contains(&HostAction::HideDictationFeedback)); + while let Ok(event) = events.try_recv() { + assert!(!matches!( + event.kind, + BackendEventKind::DictationCompleted(_) + | BackendEventKind::DictationStateChanged(DictationStateSnapshot { + phase: DictationPhase::Completed | DictationPhase::Idle, + .. + }) + )); + } + release.add_permits(1); + } + let result = tokio::time::timeout(std::time::Duration::from_secs(2), stop) + .await + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(result.polished_text, output); + assert_eq!( + actions.lock().unwrap().last(), + Some(&"input source restored") + ); + assert_eq!( + std::iter::from_fn(|| events.try_recv().ok()) + .filter(|event| matches!(event.kind, BackendEventKind::DictationCompleted(_))) + .count(), + 1 + ); + assert!(host + .0 + .lock() + .unwrap() + .contains(&HostAction::HideDictationFeedback)); + return; + } + let drop_stop = ending == NativeInsertionEnd::DropStopAndCancel; if drop_stop { stop.abort(); tokio::task::yield_now().await; @@ -10855,22 +10934,38 @@ mod tests { #[tokio::test] async fn streaming_cancel_drains_native_write_before_restoring_and_releasing_voice() { - assert_cancel_drains_native_insertion(true, false, false).await; + assert_native_insertion_lifecycle(true, NativeInsertionEnd::Cancel, false).await; } #[tokio::test] async fn final_insert_cancel_waits_for_the_committed_native_effect() { - assert_cancel_drains_native_insertion(false, false, false).await; + assert_native_insertion_lifecycle(false, NativeInsertionEnd::Cancel, false).await; } #[tokio::test] async fn final_insert_cancellation_survives_a_dropped_stop_caller() { - assert_cancel_drains_native_insertion(false, true, false).await; + assert_native_insertion_lifecycle(false, NativeInsertionEnd::DropStopAndCancel, false) + .await; } #[tokio::test] async fn streaming_translation_cancel_drains_native_write() { - assert_cancel_drains_native_insertion(true, false, true).await; + assert_native_insertion_lifecycle(true, NativeInsertionEnd::Cancel, true).await; + } + + #[tokio::test] + async fn long_text_feedback_waits_for_stream_and_final_tail() { + assert_native_insertion_lifecycle(true, NativeInsertionEnd::Complete, false).await; + } + + #[tokio::test] + async fn translation_feedback_waits_for_stream_and_final_tail() { + assert_native_insertion_lifecycle(true, NativeInsertionEnd::Complete, true).await; + } + + #[tokio::test] + async fn non_streaming_feedback_waits_for_native_completion() { + assert_native_insertion_lifecycle(false, NativeInsertionEnd::Complete, false).await; } #[tokio::test] diff --git a/openless-all/app/scripts/audio-cue-runtime.test.mjs b/openless-all/app/scripts/audio-cue-runtime.test.mjs new file mode 100644 index 000000000..f854901b2 --- /dev/null +++ b/openless-all/app/scripts/audio-cue-runtime.test.mjs @@ -0,0 +1,154 @@ +import assert from 'node:assert/strict'; +import { mock } from 'node:test'; +import { tsImport } from 'tsx/esm/api'; +const { playRecordStartCue, stopAudioCue } = await tsImport( + '../src/lib/audioCue.ts', + import.meta.url, +); +// Exercise the actual playback lifecycle, including promises that never resolve. +let now = 0; +mock.timers.enable({ apis: ['setTimeout'] }); +mock.method(performance, 'now', () => now); +function tick(ms) { + now += ms; + mock.timers.tick(ms); +} +class FakeContext { + get currentTime() { + return this.frozen ? 0 : now / 1000; + } + constructor() { + this.state = FakeContext.initialState; + this.frozen = false; + this.voices = 0; + this.closeCalls = 0; + this.resumeImpl = FakeContext.initialResumeImpl; + this.destination = {}; + FakeContext.instances.push(this); + } + resume() { + return this.resumeImpl(); + } + close() { + this.closeCalls++; + this.state = 'closed'; + return Promise.resolve(); + } + createOscillator() { + this.voices++; + return { + frequency: { setValueAtTime() {} }, + connect: (gain) => gain, + start() {}, + stop() {}, + disconnect() {}, + }; + } + createGain() { + return { + gain: { + setValueAtTime() {}, + exponentialRampToValueAtTime() {}, + cancelScheduledValues() {}, + }, + connect() {}, + disconnect() {}, + }; + } +} +FakeContext.instances = []; +FakeContext.initialState = 'running'; +FakeContext.initialResumeImpl = () => new Promise(() => {}); +const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); +Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { AudioContext: FakeContext }, +}); +try { + FakeContext.initialState = 'suspended'; + playRecordStartCue(); + const stuck = FakeContext.instances[0]; + let releaseOldResume; + stuck.resumeImpl = () => new Promise((resolve) => (releaseOldResume = resolve)); + playRecordStartCue(); + FakeContext.initialState = 'running'; + tick(250); + const healthy = FakeContext.instances[1]; + assert.equal(stuck.closeCalls, 1, 'a hung resume must close the old context'); + assert.equal(healthy.voices, 2, 'the same recording recovers its cue'); + stuck.state = 'running'; + releaseOldResume(); + await Promise.resolve(); + assert.equal(stuck.voices, 0, 'late resolution cannot play through a discarded context'); + tick(315); + // Every request invalidates old work, including a new synchronous playback. + healthy.state = 'suspended'; + let releaseSuperseded; + healthy.resumeImpl = () => new Promise((resolve) => (releaseSuperseded = resolve)); + playRecordStartCue(); + healthy.state = 'running'; + playRecordStartCue(); + releaseSuperseded(); + await Promise.resolve(); + assert.equal(healthy.voices, 4, 'a superseded resume must not duplicate the newer cue'); + tick(315); + healthy.frozen = true; + playRecordStartCue(); + tick(315); + const recovered = FakeContext.instances[2]; + assert.equal(healthy.closeCalls, 1, 'running with a frozen clock must recover'); + assert.equal(recovered.voices, 2); + tick(315); + // The original stop/time boundary survives recreation, so a late retry stays silent. + recovered.state = 'suspended'; + playRecordStartCue(); + stopAudioCue(); + tick(1000); + assert.equal(recovered.closeCalls, 1, 'even a dropped late cue discards its broken context'); + assert.equal(FakeContext.instances.length, 3, 'stopped recordings do not replay late'); + playRecordStartCue(); + const next = FakeContext.instances[3]; + assert.equal(next.voices, 2, 'the next recording still works after a dropped cue'); + tick(315); + next.state = 'suspended'; + next.resumeImpl = () => { + throw new Error('audio interrupted'); + }; + FakeContext.initialState = 'suspended'; + assert.doesNotThrow(playRecordStartCue); + tick(250); + assert.equal(FakeContext.instances.length, 5, 'recovery retries at most once per request'); + assert.equal(FakeContext.instances[4].closeCalls, 1, 'failed retry releases audio resources'); + + // The 400ms deadline belongs to the recording, not to each resume attempt. + playRecordStartCue(); + let releaseRetry; + FakeContext.initialResumeImpl = () => new Promise((resolve) => (releaseRetry = resolve)); + tick(250); + const retry = FakeContext.instances[6]; + stopAudioCue(); + tick(151); + retry.state = 'running'; + releaseRetry(); + await Promise.resolve(); + assert.equal(retry.voices, 0, 'a recreated context cannot reset the late-cue deadline'); + playRecordStartCue(); + assert.equal(retry.voices, 2, 'dropping the old cue must not disable the next recording'); + tick(315); + + retry.state = 'suspended'; + retry.resumeImpl = () => Promise.reject(new Error('device changed')); + FakeContext.initialState = 'running'; + playRecordStartCue(); + await Promise.resolve(); + assert.equal(retry.closeCalls, 1, 'asynchronous resume rejection also releases the context'); + assert.equal(FakeContext.instances[7].voices, 2, 'device-change recovery plays only one cue'); + tick(315); + console.log('[audioCue.runtime.test] playback recovery assertions passed'); +} finally { + stopAudioCue(); + mock.restoreAll(); + mock.timers.reset(); + if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow); + else Reflect.deleteProperty(globalThis, 'window'); +} diff --git a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs index 63af12fdb..70dabbbcc 100644 --- a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs +++ b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs @@ -435,16 +435,17 @@ fn handle_selection_workspace_hotkey_pressed(inner: &Arc) { let inner = Arc::clone(inner); let host = inner.host.clone(); host.spawn(async move { - let result = inner - .backend - .services() - .selection - .begin_polish(openless_core::SelectionPolishRequest { + let operation = inner.backend.services().selection.begin_polish( + openless_core::SelectionPolishRequest { selected_text: None, mode: openless_core::PolishMode::Raw, instruction: None, - }) - .await; + }, + ); + let result = await_selection_polish_with_feedback(operation, || { + emit_selection_polish_capsule(&inner, CapsuleState::Polishing, "正在润色选区…"); + }) + .await; match result { Ok(_) => match inner.backend.services().selection.snapshot().await { Ok(snapshot) => { @@ -469,6 +470,9 @@ fn handle_selection_workspace_hotkey_pressed(inner: &Arc) { log::warn!("[selection-polish] read completed snapshot failed: {error}"); } }, + Err(error) if error.code == openless_core::BackendErrorCode::Busy => { + // 连按快捷键不能把仍在运行的选区动画改成 Error 并启动自动隐藏。 + } Err(error) => { log::warn!("[selection-polish] hotkey workflow failed: {error}"); let message = match error.message.as_str() { @@ -479,9 +483,6 @@ fn handle_selection_workspace_hotkey_pressed(inner: &Arc) { "selectionPolishTargetChanged" | "selectionPolishSelectionChanged" => { "选区已变化,未替换" } - _ if error.code == openless_core::BackendErrorCode::Busy => { - "选区润色正在进行中" - } _ => "润色失败,请重试", }; let state = if matches!( @@ -500,6 +501,72 @@ fn handle_selection_workspace_hotkey_pressed(inner: &Arc) { }); } +#[cfg(not(mobile))] +async fn await_selection_polish_with_feedback( + operation: impl std::future::Future< + Output = Result, + >, + show_processing: impl FnOnce(), +) -> Result { + let mut operation = std::pin::pin!(operation); + // First let Core accept the session and capture its target. A synchronous + // Busy/no-selection result must not replace another session's feedback. + match futures_util::poll!(operation.as_mut()) { + std::task::Poll::Ready(result) => result, + std::task::Poll::Pending => { + show_processing(); + operation.await + } + } +} + +#[cfg(all(test, not(mobile)))] +mod selection_feedback_tests { + use super::await_selection_polish_with_feedback; + use std::sync::atomic::{AtomicUsize, Ordering}; + + #[tokio::test] + async fn processing_feedback_stays_until_selection_operation_finishes() { + let shown = AtomicUsize::new(0); + let (completed, pending) = tokio::sync::oneshot::channel(); + let mut operation = std::pin::pin!(await_selection_polish_with_feedback( + async { pending.await.unwrap() }, + || { + shown.fetch_add(1, Ordering::SeqCst); + }, + )); + for _ in 0..3 { + assert!(futures_util::poll!(operation.as_mut()).is_pending()); + assert_eq!( + shown.load(Ordering::SeqCst), + 1, + "polling must not restart the animation" + ); + } + let session = openless_core::SessionId::new(); + completed.send(Ok(session)).unwrap(); + assert_eq!(operation.await.unwrap(), session); + } + + #[tokio::test] + async fn rejected_repeat_does_not_replace_the_running_feedback() { + let result = await_selection_polish_with_feedback( + async { + Err(openless_core::BackendError::new( + openless_core::BackendErrorCode::Busy, + "active", + )) + }, + || panic!("a rejected hotkey must not change feedback"), + ) + .await; + assert_eq!( + result.unwrap_err().code, + openless_core::BackendErrorCode::Busy + ); + } +} + #[cfg(not(mobile))] fn handle_selection_workspace_hotkey_released(inner: &Arc) { #[cfg(target_os = "windows")] diff --git a/openless-all/app/src-tauri/src/coordinator/selection_voice_session.rs b/openless-all/app/src-tauri/src/coordinator/selection_voice_session.rs index 216237099..c32aea991 100644 --- a/openless-all/app/src-tauri/src/coordinator/selection_voice_session.rs +++ b/openless-all/app/src-tauri/src/coordinator/selection_voice_session.rs @@ -2,7 +2,10 @@ use std::sync::{Arc, Weak}; -use super::{emit_capsule, schedule_capsule_idle, Coordinator, Inner, CAPSULE_AUTO_HIDE_DELAY_MS}; +use super::{ + emit_capsule, hide_core_capsule_if_current, schedule_capsule_idle, Coordinator, Inner, + CAPSULE_AUTO_HIDE_DELAY_MS, +}; use crate::coordinator_state::SessionId; use crate::selection::SelectionInsertionTarget; use crate::types::{CapsuleState, InsertStatus}; @@ -423,9 +426,8 @@ async fn end_selection_voice_session( .mark_processing(session_id) .await .map_err(core_error)?; - // 结束录音后熄灭胶囊;预览模式才打开华词面板,直接覆盖则静默处理。 - emit_capsule(inner, CapsuleState::Idle, 0.0, 0, None, None); - schedule_capsule_idle(inner, 0); + // 松开只结束录音;识别指令、润色和替换完成之前仍展示思考动画。 + let processing_epoch = emit_capsule(inner, CapsuleState::Transcribing, 0.0, 0, None, None); let workflow: Result = async { let capture = inner .selection_voice_capture @@ -475,7 +477,7 @@ async fn end_selection_voice_session( .process_transcript(session_id, transcript) .await .map_err(core_error)?; - continue_selection_voice_disposition(inner, disposition).await + continue_selection_voice_disposition(inner, disposition, processing_epoch).await } .await; @@ -520,6 +522,7 @@ enum EndWorkflowOutcome { async fn continue_selection_voice_disposition( inner: &Arc, disposition: SelectionVoiceDisposition, + processing_epoch: u64, ) -> Result { let route = inner .backend @@ -530,14 +533,19 @@ async fn continue_selection_voice_disposition( .map_err(core_error)?; match route { SelectionVoiceRoute::AwaitingIntent { .. } => { + hide_core_capsule_if_current(inner, processing_epoch); inner.host.show_selection_voice_intent_prompt(); Ok(EndWorkflowOutcome::AwaitingIntent) } SelectionVoiceRoute::QuestionCompleted { session_id } => { clear_host_session(inner, session_id); + hide_core_capsule_if_current(inner, processing_epoch); + Ok(EndWorkflowOutcome::Finished) + } + SelectionVoiceRoute::EditConversationOpened { .. } => { + hide_core_capsule_if_current(inner, processing_epoch); Ok(EndWorkflowOutcome::Finished) } - SelectionVoiceRoute::EditConversationOpened { .. } => Ok(EndWorkflowOutcome::Finished), SelectionVoiceRoute::ReadyToApply { preview } => { let coordinator = Coordinator { inner: Arc::clone(inner), @@ -557,9 +565,12 @@ impl Coordinator { disposition: SelectionVoiceDisposition, ) -> Result<(), String> { self.inner.host.hide_selection_voice_intent_prompt(); - let result = continue_selection_voice_disposition(&self.inner, disposition) - .await - .map(|_| ()); + let processing_epoch = + emit_capsule(&self.inner, CapsuleState::Polishing, 0.0, 0, None, None); + let result = + continue_selection_voice_disposition(&self.inner, disposition, processing_epoch) + .await + .map(|_| ()); if let Err(error) = &result { let _ = self .inner diff --git a/openless-all/app/src-tauri/src/core_adapters.rs b/openless-all/app/src-tauri/src/core_adapters.rs index 69fa20b9a..969b9acef 100644 --- a/openless-all/app/src-tauri/src/core_adapters.rs +++ b/openless-all/app/src-tauri/src/core_adapters.rs @@ -2804,6 +2804,8 @@ impl CoreTextInserter for TauriTextInserter { previous_input_source: Arc::new(Mutex::new(previous_input_source)), #[cfg(target_os = "macos")] streaming_ready, + #[cfg(target_os = "macos")] + confirm_keyboard_delivery: Arc::new(AtomicBool::new(true)), }) as Arc) }) } @@ -2825,6 +2827,8 @@ struct TauriTextInsertionSession { previous_input_source: Arc>>, #[cfg(target_os = "macos")] streaming_ready: bool, + #[cfg(target_os = "macos")] + confirm_keyboard_delivery: Arc, } impl TauriTextInsertionSession { @@ -2848,11 +2852,32 @@ impl TauriTextInsertionSession { let newline_mode = self.context.insertion.windows_sendinput_newline_mode; #[cfg(target_os = "macos")] let newline_mode = self.context.insertion.macos_newline_mode; + #[cfg(target_os = "macos")] + let confirm_delivery = Arc::clone(&self.confirm_keyboard_delivery); let finished = Arc::clone(&self.finished); let written = tauri::async_runtime::spawn_blocking(move || { if finished.load(Ordering::Acquire) { return 0; } + // CGEventPost returns before the target has consumed its input. + // Retain the original control before posting; inspect only its + // caret, on this blocking thread, before completing the write. + #[cfg(target_os = "macos")] + let delivery = if confirm_delivery.load(Ordering::Acquire) + && !(newline_mode == crate::types::MacosNewlineMode::Return + && chunk.contains('\n')) + { + crate::host_document::KeyboardDelivery::capture() + } else { + None + }; + #[cfg(target_os = "macos")] + if delivery + .as_ref() + .is_some_and(|delivery| !delivery.is_focused()) + { + return 0; + } #[cfg(target_os = "windows")] let result = crate::unicode_keystroke::type_unicode_chunk_with_options( &chunk, @@ -2863,10 +2888,23 @@ impl TauriTextInsertionSession { crate::unicode_keystroke::type_unicode_chunk_with_options(&chunk, newline_mode); #[cfg(target_os = "linux")] let result = crate::unicode_keystroke::type_unicode_chunk(&chunk); - match result { + let written = match result { Ok(written) => written, Err(error) => error.typed_chars(), + }; + #[cfg(target_os = "macos")] + { + let delivered = delivery.is_some_and(|delivery| { + let posted: String = chunk.chars().take(written).collect(); + delivery.wait(&posted) + }); + // Unsupported/stalled controls are tried once per session, + // so a missing AX caret cannot add a delay to every delta. + if !delivered { + confirm_delivery.store(false, Ordering::Release); + } } + written }) .await .map_err(|error| { diff --git a/openless-all/app/src-tauri/src/host_document/macos.rs b/openless-all/app/src-tauri/src/host_document/macos.rs index 07d4047c0..e1b282e8d 100644 --- a/openless-all/app/src-tauri/src/host_document/macos.rs +++ b/openless-all/app/src-tauri/src/host_document/macos.rs @@ -186,6 +186,7 @@ extern "C" { extern "C" { fn CFRelease(cf: CFTypeRef); fn CFRetain(cf: CFTypeRef) -> CFTypeRef; + fn CFEqual(a: CFTypeRef, b: CFTypeRef) -> u8; fn CFGetTypeID(cf: CFTypeRef) -> CFTypeId; fn CFStringGetTypeID() -> CFTypeId; fn CFNumberGetTypeID() -> CFTypeId; @@ -380,6 +381,209 @@ unsafe fn copy_selected_range(focused: AxUiElementRef) -> Option { (ok != 0).then_some(range) } +/// Confirms a posted keyboard chunk against the original text control's caret. +/// Only metadata is read; the target's document text is never fetched. +/// Create, wait and drop on the same blocking insertion thread. +pub(crate) struct KeyboardDelivery { + element: AxUiElementRef, + start: usize, +} + +impl KeyboardDelivery { + pub(crate) fn capture() -> Option { + let gate = GateInputs { + secure_input: crate::unicode_keystroke::is_secure_input_enabled(), + bundle_id: crate::selection::current_front_app_parts().1, + ..GateInputs::default() + }; + // SAFETY: the shared gate returns a retained AX element with a messaging + // timeout. This thread owns it until Drop, including failed caret reads. + unsafe { + let GatedElement::Ready(element) = focused_element_passing_the_gate(gate) else { + return None; + }; + let mut delivery = Self { element, start: 0 }; + delivery.start = copy_caret_offset(element)?; + Some(delivery) + } + } + + pub(crate) fn is_focused(&self) -> bool { + // AX capture can take time. Recheck the exact control before sending + // keys so a focus change during capture does not redirect this chunk. + unsafe { + let system = AXUIElementCreateSystemWide(); + if system.is_null() { + return false; + } + AXUIElementSetMessagingTimeout(system, AX_MESSAGING_TIMEOUT_SECS); + let focused = copy_element_attr(system, b"AXFocusedUIElement\0"); + CFRelease(system as CFTypeRef); + let Some(focused) = focused else { return false }; + let same = CFEqual(focused as CFTypeRef, self.element as CFTypeRef) != 0; + CFRelease(focused as CFTypeRef); + same + } + } + + /// False means this target cannot be confirmed; stop probing it for this session. + pub(crate) fn wait(self, posted_text: &str) -> bool { + let started = Instant::now(); + let outcome = wait_for_caret_delivery( + self.start, + posted_text, + || { + if crate::unicode_keystroke::is_secure_input_enabled() { + return None; + } + // SAFETY: self retains the same control throughout this wait. + unsafe { copy_selected_range(self.element) }.and_then(|range| { + if range.length < 0 { + return None; + } + caret_offset_from_location(range.location).map(|offset| (offset, range.length)) + }) + }, + || { + if started.elapsed() >= Duration::from_secs(10) { + return false; + } + std::thread::sleep(Duration::from_millis(10)); + true + }, + ); + if outcome == KeyboardDeliveryOutcome::TimedOut { + log::warn!( + "[insertion] target caret did not acknowledge posted keyboard input within 10s" + ); + } + outcome == KeyboardDeliveryOutcome::Delivered + } +} + +impl Drop for KeyboardDelivery { + fn drop(&mut self) { + // SAFETY: capture owns exactly one retained reference. + unsafe { CFRelease(self.element as CFTypeRef) }; + } +} + +#[derive(Debug, PartialEq, Eq)] +enum KeyboardDeliveryOutcome { + Delivered, + Unavailable, + TimedOut, +} + +fn wait_for_caret_delivery( + start: usize, + posted_text: &str, + mut read: impl FnMut() -> Option<(usize, isize)>, + mut wait: impl FnMut() -> bool, +) -> KeyboardDeliveryOutcome { + // AX offsets count UTF-16 units. CR is consumed without posting a key. + let units = posted_text + .chars() + .filter(|ch| *ch != '\r') + .map(char::len_utf16) + .sum(); + if units == 0 { + return KeyboardDeliveryOutcome::Delivered; + } + let Some(expected) = start.checked_add(units) else { + return KeyboardDeliveryOutcome::Unavailable; + }; + loop { + let Some((offset, selected_length)) = read() else { + return KeyboardDeliveryOutcome::Unavailable; + }; + if selected_length == 0 && offset >= expected { + return KeyboardDeliveryOutcome::Delivered; + } + if !wait() { + return KeyboardDeliveryOutcome::TimedOut; + } + } +} + +#[cfg(test)] +mod keyboard_delivery_tests { + use super::*; + + #[test] + fn posted_input_waits_until_target_consumes_the_last_character() { + // A selected range / intermediate caret is not an insertion receipt. + let mut samples = [(120, 20), (101, 0), (119, 0), (120, 0)].into_iter(); + let mut waits = 0; + assert_eq!( + wait_for_caret_delivery( + 100, + &"字".repeat(20), + || samples.next(), + || { + waits += 1; + true + } + ), + KeyboardDeliveryOutcome::Delivered + ); + assert_eq!(waits, 3); + } + + #[test] + fn unavailable_or_stalled_targets_do_not_wait_forever() { + assert_eq!( + wait_for_caret_delivery( + 0, + "input", + || None, + || panic!("unavailable target must stop") + ), + KeyboardDeliveryOutcome::Unavailable + ); + let mut waits = 0; + assert_eq!( + wait_for_caret_delivery( + 0, + "input", + || Some((3, 0)), + || { + waits += 1; + waits < 3 + } + ), + KeyboardDeliveryOutcome::TimedOut + ); + assert_eq!(waits, 3); + } + + #[test] + fn unicode_and_crlf_wait_for_the_actual_utf16_end() { + let mut samples = [(11, 0), (12, 0)].into_iter(); + let mut waits = 0; + assert_eq!( + wait_for_caret_delivery( + 7, + "A🙂\r\n界", + || samples.next(), + || { + waits += 1; + true + } + ), + KeyboardDeliveryOutcome::Delivered + ); + assert_eq!( + waits, 1, + "must neither stop at a scalar offset nor wait for swallowed CR" + ); + assert_eq!( + wait_for_caret_delivery(7, "\r", || panic!("no keys were posted"), || false), + KeyboardDeliveryOutcome::Delivered + ); + } +} + /// `AXStringForRange(range)` —— 只把光标附近那段跨进程拷回来。 unsafe fn copy_string_for_range( focused: AxUiElementRef, diff --git a/openless-all/app/src-tauri/src/host_document/mod.rs b/openless-all/app/src-tauri/src/host_document/mod.rs index f2eecbb5d..ab011509a 100644 --- a/openless-all/app/src-tauri/src/host_document/mod.rs +++ b/openless-all/app/src-tauri/src/host_document/mod.rs @@ -22,12 +22,15 @@ //! //! ## 本里程碑的范围 //! -//! 模块可用但**不接产品链路** —— 只有一个 debug 命令 `debug_read_cursor_context` -//! 在调它。接进润色 prompt 是下一步的事,那里才引入用户可见的开关(默认关)。 +//! 文档上下文读取由显式的上下文入口调用。`KeyboardDelivery` 另供 macOS 流式输入 +//! 确认完成时机:它复用相同的焦点/权限闸门,只读选区范围,不读取宿主正文。 #[cfg(target_os = "macos")] mod macos; +#[cfg(target_os = "macos")] +pub(crate) use macos::KeyboardDelivery; + // `minimal_edit` 目前只有 macOS 的观察回调在用,非 macOS 构建下没有消费方。 #[allow(unused_imports)] pub use openless_core::host_document::{ diff --git a/openless-all/app/src/lib/audioCue.ts b/openless-all/app/src/lib/audioCue.ts index 5755453a4..fc9adc574 100644 --- a/openless-all/app/src/lib/audioCue.ts +++ b/openless-all/app/src/lib/audioCue.ts @@ -57,6 +57,13 @@ let stopSeq = 0; // resume 期间录音已结束(发生过 stop)时,只有当「请求 → resume 完成」耗时超过这个阈值 // 才判定真迟到并丢弃;阈值内仍补响一声——快速点一下录音(resume 没跑完就已结束)也该有反馈。 const DEFERRED_CUE_LATE_THRESHOLD_MS = 400; +const RESUME_TIMEOUT_MS = 250; +let recoveryTimer: ReturnType | undefined; + +function clearRecoveryTimer(): void { + clearTimeout(recoveryTimer); + recoveryTimer = undefined; +} // 无 performance(理论兜底,Tauri WebView 里恒有)时回退 0:elapsedMs 恒为 0、永不判迟到, // 即宁可补播一声也不丢音——与"修复丢音"的初衷一致的安全方向。 @@ -158,9 +165,9 @@ function getContext(): AudioContext | null { // 其上的 activeVoices,并尽力 close 释放底层音频资源。已 closed / close 失败都忽略。 function discardContext(ctx: AudioContext): void { if (sharedCtx === ctx) { + stopVoices(); sharedCtx = null; } - activeVoices = []; try { void ctx.close().catch(() => undefined); } catch { @@ -244,58 +251,87 @@ export function primeAudioCue(): void { const ctx = getContext(); if (!ctx) return; if (audioContextActionForState(ctx.state) === 'resume') { - ctx.resume().catch(() => undefined); + try { + void ctx.resume().catch(() => undefined); + } catch { + discardContext(ctx); + } } } /** 播放「开始录音」提示音。无 Web Audio 或被挂起且无法恢复时静默降级。 */ export function playRecordStartCue(): void { - playRecordStartCueOnce(true); + clearRecoveryTimer(); + const myPlay = ++playSeq; + const stopAtRequest = stopSeq; + const requestedAt = nowMs(); + const shouldPlay = () => + shouldPlayDeferredCue({ + superseded: myPlay !== playSeq, + stoppedWhileWaiting: stopSeq !== stopAtRequest, + elapsedMs: nowMs() - requestedAt, + lateThresholdMs: DEFERRED_CUE_LATE_THRESHOLD_MS, + }); + playRecordStartCueOnce(true, myPlay, shouldPlay); } // allowRecreate 把「丢弃坏死 ctx 并重试」限制为最多一次,避免在唤不醒的 ctx 上无限递归。 -function playRecordStartCueOnce(allowRecreate: boolean): void { +function playRecordStartCueOnce( + allowRecreate: boolean, + myPlay: number, + shouldPlay: () => boolean, +): void { const ctx = getContext(); if (!ctx) return; - // ctx 已 running 直接排期;closed 已在 getContext 重建。其余(suspended / WebKit 非标准 - // interrupted / 未知态)必须先 resume 再排期,不能在 resume 未完成时就用冻结的 currentTime。 - if (audioContextActionForState(ctx.state) !== 'resume') { + const recover = () => { + if (myPlay !== playSeq || sharedCtx !== ctx) return; + discardContext(ctx); + if (allowRecreate && shouldPlay()) playRecordStartCueOnce(false, myPlay, shouldPlay); + }; + const schedule = () => { + const startedAt = ctx.currentTime; scheduleCueVoices(ctx); + // 部分音频中断仍报告 running,但音频时钟已经冻结;下一次按键前主动淘汰它。 + recoveryTimer = setTimeout( + () => { + if (ctx.currentTime <= startedAt) recover(); + }, + cueTotalDurationMs(recordStartCueTones()) + 50, + ); + }; + + if (audioContextActionForState(ctx.state) !== 'resume') { + schedule(); return; } - const myPlay = ++playSeq; - const stopAtRequest = stopSeq; - const requestedAt = nowMs(); - - // resume 完成(成功或被拒)后统一决策:排期 / 丢弃重建重试 / 放弃。 + // resume 可能永不 settle。超时也进入恢复路径;迟到的 Promise 不能再操作旧 context。 + let settled = false; const settle = (runningAfterResume: boolean): void => { + if (settled || myPlay !== playSeq || sharedCtx !== ctx) return; + settled = true; + clearRecoveryTimer(); const action = cueActionAfterResume({ runningAfterResume, - // 被更新一轮播放接管就让位;期间录音已停且 resume 真迟到才丢弃;否则照常补响—— - // 快速点一下录音(resume 没跑完就已结束)也该有提示音。 - shouldPlay: shouldPlayDeferredCue({ - superseded: myPlay !== playSeq, - stoppedWhileWaiting: stopSeq !== stopAtRequest, - elapsedMs: nowMs() - requestedAt, - lateThresholdMs: DEFERRED_CUE_LATE_THRESHOLD_MS, - }), + shouldPlay: shouldPlay(), allowRecreate, }); if (action === 'schedule') { - scheduleCueVoices(ctx); - } else if (action === 'recreate-retry') { - // resume 被拒、或 resolve 了但 ctx 仍非 running(被音频会话抢占后卡死)——「用久了没 - // 声音」的根因。丢弃这个唤不醒的 ctx,用全新 ctx 重试一次,让共享 ctx 自愈。 - discardContext(ctx); - playRecordStartCueOnce(false); + schedule(); + } else if (!runningAfterResume) { + // 即使本次已迟到,也要清掉坏 context,让下一次录音可以恢复。 + recover(); } - // 'drop':被接管 / 真迟到 / 重试后仍唤不醒——静默放弃。 }; - ctx - .resume() - .then(() => settle(ctx.state === 'running')) - .catch(() => settle(false)); + recoveryTimer = setTimeout(() => settle(false), RESUME_TIMEOUT_MS); + try { + void ctx.resume().then( + () => settle(ctx.state === 'running'), + () => settle(false), + ); + } catch { + settle(false); + } }