From 10bb726636e5a2ebaed8950d3c2fa5e89c6733a9 Mon Sep 17 00:00:00 2001 From: lvbaiqing Date: Sun, 8 Nov 2026 22:11:03 +0800 Subject: [PATCH 1/3] fix(macos): recover live input before first write --- .../app/e2e/live-input-settings.spec.ts | 27 ++++++ .../app/src-tauri/src/core_adapters.rs | 89 +++++++++++++++++-- .../app/src-tauri/src/host_document/macos.rs | 15 ++++ openless-all/app/src/i18n/de.ts | 6 +- openless-all/app/src/i18n/en.ts | 6 +- openless-all/app/src/i18n/es.ts | 6 +- openless-all/app/src/i18n/fr.ts | 6 +- openless-all/app/src/i18n/ja.ts | 6 +- openless-all/app/src/i18n/ko.ts | 6 +- openless-all/app/src/i18n/zh-CN.ts | 6 +- openless-all/app/src/i18n/zh-TW.ts | 6 +- .../pages/settings/RecordingInputSection.tsx | 22 ----- .../app/src/pages/settings/navigation.ts | 7 ++ openless-all/app/src/pages/settings/tabs.tsx | 40 +++++++++ 14 files changed, 203 insertions(+), 45 deletions(-) create mode 100644 openless-all/app/e2e/live-input-settings.spec.ts diff --git a/openless-all/app/e2e/live-input-settings.spec.ts b/openless-all/app/e2e/live-input-settings.spec.ts new file mode 100644 index 000000000..17abeef9b --- /dev/null +++ b/openless-all/app/e2e/live-input-settings.spec.ts @@ -0,0 +1,27 @@ +import { expect, test } from '@playwright/test'; + +for (const [os, ua, platform] of [ + ['macOS', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)', 'MacIntel'], + ['Windows', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', 'Win32'], +] as const) { + test(`live transcript input has a dedicated advanced settings page on ${os}`, async ({ + page, + }) => { + await page.addInitScript( + ({ ua, platform }) => { + Object.defineProperty(navigator, 'userAgent', { value: ua }); + Object.defineProperty(navigator, 'platform', { value: platform }); + Object.defineProperty(navigator, 'userAgentData', { value: undefined }); + }, + { ua, platform }, + ); + await page.goto('/'); + await page.getByRole('button', { name: '设置', exact: true }).click(); + const dialog = page.getByRole('dialog', { name: '设置', exact: true }); + await dialog.getByRole('button', { name: '高级功能', exact: true }).click(); + await dialog.locator('[data-ol-advanced-entry="liveInput"]').click(); + const panel = dialog.locator('[data-ol-advanced-page="liveInput"]'); + await expect(panel).toBeVisible(); + await expect(panel.getByText('实时原文显示在输入框', { exact: true })).toBeVisible(); + }); +} diff --git a/openless-all/app/src-tauri/src/core_adapters.rs b/openless-all/app/src-tauri/src/core_adapters.rs index ed357d2a2..dec9b817d 100644 --- a/openless-all/app/src-tauri/src/core_adapters.rs +++ b/openless-all/app/src-tauri/src/core_adapters.rs @@ -5004,10 +5004,10 @@ impl TauriTextInsertionSession { let live_final = final_text.clone(); let live_result = tauri::async_runtime::spawn_blocking(move || { let mut guard = live.lock(); - guard.take().map(|mut live| match live_final { - Some(text) => live.replace(&text), - None => live.cancel(), - }) + match live_final { + Some(text) => Some(replace_live_composition(&mut guard, &text)), + None => guard.take().map(|mut live| live.cancel()), + } }) .await .map_err(|e| BackendError::new(BackendErrorCode::Platform, e.to_string()))?; @@ -5067,6 +5067,41 @@ impl TauriTextInsertionSession { } } +#[cfg(target_os = "macos")] +fn replace_live_composition( + slot: &mut Option, + text: &str, +) -> Result<(), String> { + replace_unwritten_target( + slot, + text, + crate::host_document::LiveComposition::capture, + |live, value| live.replace(value), + |live| live.has_written(), + ) +} + +#[cfg(any(test, target_os = "macos"))] +fn replace_unwritten_target( + slot: &mut Option, + text: &str, + capture: impl FnOnce() -> Result, + mut replace: impl FnMut(&mut T, &str) -> Result<(), String>, + written: impl Fn(&T) -> bool, +) -> Result<(), String> { + if let Some(live) = slot.as_mut() { + match replace(live, text) { + Ok(()) => return Ok(()), + Err(error) if written(live) => return Err(error), + Err(_) => *slot = None, + } + } + let mut live = capture()?; + let result = replace(&mut live, text); + *slot = Some(live); + result +} + impl TextInsertionSession for TauriTextInsertionSession { fn supports_streaming(&self) -> bool { #[cfg(target_os = "macos")] @@ -5104,9 +5139,10 @@ impl TextInsertionSession for TauriTextInsertionSession { } tauri::async_runtime::spawn_blocking(move || { let mut live = session.live_composition.lock(); - live.as_mut() - .ok_or_else(|| "live input is unavailable in this editor".to_string())? - .replace(&text) + match replace_live_composition(&mut live, &text) { + Err(_) if live.as_ref().is_none_or(|target| !target.has_written()) => Ok(()), + result => result, + } }) .await .map_err(|e| BackendError::new(BackendErrorCode::Platform, e.to_string()))? @@ -5595,6 +5631,45 @@ fn map_tauri_error(error: impl std::fmt::Display) -> BackendError { #[cfg(test)] mod tests { + #[test] + fn live_input_retargets_only_before_any_write() { + #[derive(Debug)] + struct Target { written: bool, current: bool } + let mut slot = Some(Target { written: false, current: false }); + let capture = || Ok(Target { written: false, current: true }); + let replace = |target: &mut Target, _: &str| { + if !target.current { return Err("target changed".into()); } + target.written = true; + Ok(()) + }; + let written = |target: &Target| target.written; + super::replace_unwritten_target(&mut slot, "text", capture, replace, written).unwrap(); + assert!(slot.as_ref().unwrap().written); + slot.as_mut().unwrap().current = false; + let result = super::replace_unwritten_target( + &mut slot, + "more", + || panic!("a written target must never be replaced"), + replace, + written, + ); + assert_eq!(result.unwrap_err(), "target changed"); + } + + #[test] + fn live_input_keeps_target_when_write_outcome_is_uncertain() { + let mut slot = None; + let result = super::replace_unwritten_target( + &mut slot, + "text", + || Ok(false), + |written, _| { *written = true; Err("verification failed".into()) }, + |written| *written, + ); + assert_eq!(result.unwrap_err(), "verification failed"); + assert_eq!(slot, Some(true)); + } + use super::*; #[cfg(target_os = "windows")] 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 62fb99fd8..e59151413 100644 --- a/openless-all/app/src-tauri/src/host_document/macos.rs +++ b/openless-all/app/src-tauri/src/host_document/macos.rs @@ -1669,8 +1669,23 @@ pub(crate) struct LiveComposition { } impl LiveComposition { + pub(crate) fn has_written(&self) -> bool { + self.wrote + } + pub(crate) fn capture() -> Result { + match crate::selection::current_front_app_pid() { + Some(pid) if pid != std::process::id() as i32 => {} + _ => return Err("liveInputTargetUnavailable".into()), + } let (_, _, target) = capture_voice_edit_target()?; + let mut owner_pid = 0; + if unsafe { AXUIElementGetPid(target.element.as_ref(), &mut owner_pid) } != AX_ERROR_SUCCESS + || owner_pid <= 0 + || owner_pid == std::process::id() as i32 + { + return Err("liveInputTargetUnavailable".into()); + } let start = utf16_offset_to_char_offset(&target.text, target.selection_utf16.location as usize); let end = utf16_offset_to_char_offset( diff --git a/openless-all/app/src/i18n/de.ts b/openless-all/app/src/i18n/de.ts index ab0b4695d..14580c741 100644 --- a/openless-all/app/src/i18n/de.ts +++ b/openless-all/app/src/i18n/de.ts @@ -1208,6 +1208,7 @@ export const de: typeof zhCN = { phoneDesc: 'Ein Telefon oder ein anderes Gerät für die Ferneingabe verbinden.', }, advancedPages: { + liveInputTitle: 'Texteingabe', experiments: 'Experimentelle Funktionen und Erweiterungen konfigurieren.', voiceEnhancementTitle: 'Sprachverbesserung und Wiedergabeunterdrückung', cursorContextTitle: 'Cursorkontext und kurze Eingaben', @@ -2206,10 +2207,10 @@ export const de: typeof zhCN = { 'SendInput gibt Unicode direkt ein und umgeht TSF / IME. Ein Wechsel der Eingabemethode ist nicht erforderlich.', streamingInsertHintLinux: 'Verwendet das fcitx5-Plugin zur Textübermittlung. Laufendes Einfügen simuliert Tasten über enigo + XTest.', - macosCompositionInputLabel: 'Live-Transkript im Eingabefeld (macOS)', + macosCompositionInputLabel: 'Live-Transkript im Eingabefeld', macosCompositionInputDesc: 'Zeigt die Erkennung im ursprünglichen Eingabefeld und ersetzt sie anschließend durch den überarbeiteten oder übersetzten Text. Erfordert Bedienungshilfen und einen kompatiblen Editor. Cursorbewegungen oder manuelle Änderungen stoppen die Ersetzung.', - windowsCompositionInputLabel: 'Live-Transkript im Eingabefeld anzeigen (Windows)', + windowsCompositionInputLabel: 'Live-Transkript im Eingabefeld anzeigen', windowsCompositionInputDesc: 'Während du sprichst, erscheint der erkannte Text unterstrichen im Eingabefeld und wird beim Beenden durch das Endergebnis ersetzt. Erfordert die OpenLess-TSF-Eingabekomponente und eine App mit Unterstützung für TSF.', streamingInsertSaveClipboardLabel: 'In Zwischenablage kopieren', @@ -2428,6 +2429,7 @@ export const de: typeof zhCN = { backToAdvanced: 'Zurück zu erweiterten Funktionen', back: 'Zurück', advancedPages: { + liveInput: 'Live-Transkript im Eingabefeld konfigurieren.', experiments: 'Experimentelle Funktionen und Erweiterungen konfigurieren.', customSyncServer: 'Nur für selbst gehostete Dienste; die offizielle Synchronisierung benötigt keine Konfiguration.', diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index ab16b95e2..16964cd3d 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1241,6 +1241,7 @@ export const en: typeof zhCN = { phoneDesc: 'Connect a phone or another device for remote input.', }, advancedPages: { + liveInputTitle: 'Text insertion', experiments: 'Configure experimental features and extensions.', voiceEnhancementTitle: 'Voice enhancement and playback suppression', cursorContextTitle: 'Cursor context and short-input behavior', @@ -2145,10 +2146,10 @@ export const en: typeof zhCN = { 'SendInput Unicode types directly, bypassing TSF / IME — no input-method switching needed.', streamingInsertHintLinux: 'Uses fcitx5 plugin for text submission; streaming insertion uses enigo + XTest for keystroke synthesis.', - macosCompositionInputLabel: 'Live transcript in the input field (macOS)', + macosCompositionInputLabel: 'Live transcript in the input field', macosCompositionInputDesc: 'Shows recognition in the captured editor, then replaces it with the final polished or translated text. Requires Accessibility and editor support. Moving the caret or editing stops replacement.', - windowsCompositionInputLabel: 'Show live transcript in the input box (Windows)', + windowsCompositionInputLabel: 'Show live transcript in the input box', windowsCompositionInputDesc: 'While you speak, the recognized text appears underlined in the input box and is replaced by the final result when you finish. Requires the OpenLess TSF input component and a target app that supports the Text Services Framework (TSF).', streamingInsertSaveClipboardLabel: 'Copy to clipboard', @@ -2362,6 +2363,7 @@ export const en: typeof zhCN = { backToAdvanced: 'Back to advanced features', back: 'Back', advancedPages: { + liveInput: 'Configure the live transcript in the input field.', experiments: 'Configure experimental features and extensions.', customSyncServer: 'For self-hosted services only; official sync needs no configuration.', localModels: experimentalAsr.description, diff --git a/openless-all/app/src/i18n/es.ts b/openless-all/app/src/i18n/es.ts index 2b8fdac9b..cd85688b1 100644 --- a/openless-all/app/src/i18n/es.ts +++ b/openless-all/app/src/i18n/es.ts @@ -1201,6 +1201,7 @@ export const es: typeof zhCN = { phoneDesc: 'Conecta un teléfono u otro dispositivo para la entrada remota.', }, advancedPages: { + liveInputTitle: 'Inserción de texto', experiments: 'Configura funciones experimentales y extensiones.', voiceEnhancementTitle: 'Mejora de voz y supresión de reproducción', cursorContextTitle: 'Contexto del cursor y frases cortas', @@ -2185,10 +2186,10 @@ export const es: typeof zhCN = { 'SendInput Unicode escribe directamente, sin pasar por TSF / IME y sin cambiar de método de entrada.', streamingInsertHintLinux: 'Usa el complemento fcitx5 para enviar texto; la inserción progresiva simula teclas mediante enigo + XTest.', - macosCompositionInputLabel: 'Transcripción en directo en el campo de entrada (macOS)', + macosCompositionInputLabel: 'Transcripción en directo en el campo de entrada', macosCompositionInputDesc: 'Muestra el reconocimiento en el campo original y después lo sustituye por el texto corregido o traducido. Requiere permiso de Accesibilidad y un editor compatible. Mover el cursor o editar el texto detiene la sustitución.', - windowsCompositionInputLabel: 'Mostrar la transcripción en vivo en el campo (Windows)', + windowsCompositionInputLabel: 'Mostrar la transcripción en vivo en el campo', windowsCompositionInputDesc: 'Mientras hablas, el texto reconocido aparece subrayado en el campo y se reemplaza por el resultado final al terminar. Requiere el componente de entrada TSF de OpenLess y una aplicación compatible con TSF.', streamingInsertSaveClipboardLabel: 'Copiar al portapapeles', @@ -2407,6 +2408,7 @@ export const es: typeof zhCN = { backToAdvanced: 'Volver a funciones avanzadas', back: 'Atrás', advancedPages: { + liveInput: 'Configurar la transcripción en directo en el campo.', experiments: 'Configura funciones experimentales y extensiones.', customSyncServer: 'Solo para servicios propios; la sincronización oficial no requiere configuración.', diff --git a/openless-all/app/src/i18n/fr.ts b/openless-all/app/src/i18n/fr.ts index e3e8a859b..c3ca9ecfb 100644 --- a/openless-all/app/src/i18n/fr.ts +++ b/openless-all/app/src/i18n/fr.ts @@ -1218,6 +1218,7 @@ export const fr: typeof zhCN = { phoneDesc: 'Connecter un téléphone ou un autre appareil pour la saisie à distance.', }, advancedPages: { + liveInputTitle: 'Insertion de texte', experiments: 'Configurer les fonctions expérimentales et les extensions.', voiceEnhancementTitle: 'Amélioration vocale et suppression de lecture', cursorContextTitle: 'Contexte du curseur et phrases courtes', @@ -2212,10 +2213,10 @@ export const fr: typeof zhCN = { 'SendInput Unicode écrit directement, sans passer par TSF / IME et sans changer de méthode de saisie.', streamingInsertHintLinux: 'Utilise le complément fcitx5 pour envoyer le texte ; l’insertion progressive simule les touches avec enigo + XTest.', - macosCompositionInputLabel: 'Transcription en direct dans le champ de saisie (macOS)', + macosCompositionInputLabel: 'Transcription en direct dans le champ de saisie', macosCompositionInputDesc: 'Affiche la transcription dans le champ initial, puis la remplace par le résultat corrigé ou traduit. Nécessite les autorisations d’accessibilité et un éditeur compatible. Déplacer le curseur ou modifier le texte arrête le remplacement.', - windowsCompositionInputLabel: 'Afficher la transcription en direct dans le champ (Windows)', + windowsCompositionInputLabel: 'Afficher la transcription en direct dans le champ', windowsCompositionInputDesc: "Pendant la dictée, le texte reconnu apparaît souligné dans le champ puis est remplacé par le résultat final. Nécessite le composant de saisie TSF d'OpenLess et une application compatible avec TSF.", streamingInsertSaveClipboardLabel: 'Copier dans le presse-papiers', @@ -2437,6 +2438,7 @@ export const fr: typeof zhCN = { backToAdvanced: 'Retour aux fonctions avancées', back: 'Retour', advancedPages: { + liveInput: 'Configurer la transcription en direct dans le champ.', experiments: 'Configurer les fonctions expérimentales et les extensions.', customSyncServer: 'Pour les services auto-hébergés uniquement ; la synchronisation officielle ne nécessite aucune configuration.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 54aac4abb..19aaa1a1f 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1172,6 +1172,7 @@ export const ja: typeof zhCN = { phoneDesc: 'スマホや別の端末を接続してリモート入力します。', }, advancedPages: { + liveInputTitle: '文字入力', experiments: '実験的な機能と拡張オプションを設定します。', voiceEnhancementTitle: '音声強調と再生抑制', cursorContextTitle: 'カーソル文脈と短文の動作', @@ -2115,10 +2116,10 @@ export const ja: typeof zhCN = { 'SendInput Unicode で TSF / IME を迂回。入力ソースの切替は不要です。', streamingInsertHintLinux: 'fcitx5 プラグインで文字を送信。ストリーミング入力は enigo + XTest でキー合成。', - macosCompositionInputLabel: '入力欄に認識中の原文を表示(macOS)', + macosCompositionInputLabel: '入力欄に認識中の原文を表示', macosCompositionInputDesc: '録音中の認識結果を元の入力欄に表示し、終了後に整形または翻訳した文章へ置き換えます。アクセシビリティ権限と対応エディタが必要です。カーソル移動や手動編集で置き換えを停止します。', - windowsCompositionInputLabel: 'リアルタイム原文を入力欄に表示(Windows)', + windowsCompositionInputLabel: 'リアルタイム原文を入力欄に表示', windowsCompositionInputDesc: '録音中、認識された文字を下線付きの変換中テキストとして入力欄に表示し、終了時に最終結果に置き換えます。OpenLess の TSF 入力コンポーネントと、テキストサービスフレームワーク(TSF)に対応したアプリが必要です。', streamingInsertSaveClipboardLabel: 'クリップボードに保存', @@ -2330,6 +2331,7 @@ export const ja: typeof zhCN = { backToAdvanced: '高度な機能に戻る', back: '戻る', advancedPages: { + liveInput: '入力欄に表示するリアルタイム原文を設定します。', experiments: '実験的な機能と拡張オプションを設定します。', customSyncServer: 'セルフホスト専用です。公式の同期には設定は不要です。', localModels: experimentalAsr.description, diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index f7d5555a6..5cbb12426 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1165,6 +1165,7 @@ export const ko: typeof zhCN = { phoneDesc: '휴대폰이나 다른 기기를 연결해 원격으로 입력합니다.', }, advancedPages: { + liveInputTitle: '텍스트 삽입', experiments: '실험 기능과 확장 옵션을 설정합니다.', voiceEnhancementTitle: '음성 향상과 재생 억제', cursorContextTitle: '커서 문맥과 짧은 입력', @@ -2101,10 +2102,10 @@ export const ko: typeof zhCN = { streamingInsertHintWindows: 'SendInput Unicode 로 TSF / IME 를 우회. 입력 소스 전환 불필요.', streamingInsertHintLinux: 'fcitx5 플러그인으로 텍스트 전송. 스트리밍 입력은 enigo + XTest 키 합성 사용.', - macosCompositionInputLabel: '입력란에 실시간 원문 표시(macOS)', + macosCompositionInputLabel: '입력란에 실시간 원문 표시', macosCompositionInputDesc: '녹음 중 인식한 원문을 원래 입력란에 표시하고 완료 후 정리하거나 번역한 결과로 바꿉니다. 손쉬운 사용 권한과 지원되는 편집기가 필요합니다. 커서를 이동하거나 직접 편집하면 실시간 교체를 중지합니다.', - windowsCompositionInputLabel: '실시간 원문을 입력창에 표시 (Windows)', + windowsCompositionInputLabel: '실시간 원문을 입력창에 표시', windowsCompositionInputDesc: '녹음 중 인식된 텍스트를 밑줄이 그어진 조합 텍스트로 입력창에 표시하고, 완료되면 최종 결과로 바꿉니다. OpenLess TSF 입력 구성 요소와 TSF를 지원하는 앱이 필요합니다.', streamingInsertSaveClipboardLabel: '클립보드에 저장', @@ -2314,6 +2315,7 @@ export const ko: typeof zhCN = { backToAdvanced: '고급 기능으로 돌아가기', back: '뒤로', advancedPages: { + liveInput: '입력란의 실시간 원문 표시를 설정합니다.', experiments: '실험 기능과 확장 옵션을 설정합니다.', customSyncServer: '자체 호스팅 서비스 전용입니다. 공식 동기화는 설정이 필요하지 않습니다.', localModels: experimentalAsr.description, diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index f5c02d176..357edf098 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1190,6 +1190,7 @@ export const zhCN = { phoneDesc: '连接手机或其他设备进行远程输入。', }, advancedPages: { + liveInputTitle: '文字插入', experiments: '配置实验功能与扩展选项。', voiceEnhancementTitle: '语音增强与播放抑制', cursorContextTitle: '光标上下文与短句行为', @@ -2020,10 +2021,10 @@ export const zhCN = { streamingInsertHintMac: '临时切到 ABC 输入源,避免 CJK IME 拦截,会话结束后自动切回。', streamingInsertHintWindows: 'SendInput Unicode 直接送字符,绕过 TSF / IME,不切输入法。', streamingInsertHintLinux: '通过 fcitx5 插件提交文字;流式输入使用 enigo + XTest 合成按键。', - macosCompositionInputLabel: '实时原文显示在输入框(macOS)', + macosCompositionInputLabel: '实时原文显示在输入框', macosCompositionInputDesc: '录音时在原输入框显示识别文字,结束后整体替换为整理或翻译结果。需要辅助功能权限及编辑器支持;移动光标或编辑原文会停止实时替换。', - windowsCompositionInputLabel: '实时原文显示在输入框(Windows)', + windowsCompositionInputLabel: '实时原文显示在输入框', windowsCompositionInputDesc: '录音时把识别到的文字以带下划线的组合文本显示在输入框中,结束后替换为最终结果。需要启用 OpenLess 的 TSF 输入法组件,且目标应用支持文本服务框架(TSF)。', streamingInsertSaveClipboardLabel: '同步到剪贴板', @@ -2228,6 +2229,7 @@ export const zhCN = { backToAdvanced: '返回高级功能', back: '返回', advancedPages: { + liveInput: '配置实时原文在输入框中的显示。', experiments: '配置实验功能与扩展选项。', customSyncServer: '仅供自建服务使用;默认使用官方同步,无需配置。', localModels: experimentalAsr.description, diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 5ae126bc9..352e672c1 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1135,6 +1135,7 @@ export const zhTW: typeof zhCN = { phoneDesc: '連接手機或其他裝置進行遠端輸入。', }, advancedPages: { + liveInputTitle: '文字插入', experiments: '設定實驗功能與擴充選項。', voiceEnhancementTitle: '語音增強與播放抑制', cursorContextTitle: '游標上下文與短句行為', @@ -2021,10 +2022,10 @@ export const zhTW: typeof zhCN = { streamingInsertHintMac: '臨時切到 ABC 輸入源,避免 CJK IME 攔截,會話結束後自動切回。', streamingInsertHintWindows: 'SendInput Unicode 直接送字元,繞過 TSF / IME,不切輸入法。', streamingInsertHintLinux: '透過 fcitx5 外掛提交文字;串流輸入使用 enigo + XTest 合成按鍵。', - macosCompositionInputLabel: '即時原文顯示在輸入框(macOS)', + macosCompositionInputLabel: '即時原文顯示在輸入框', macosCompositionInputDesc: '錄音時在原輸入框顯示辨識文字,完成後整段取代為整理或翻譯結果。需要輔助使用授權及編輯器支援;移動游標或手動編輯會停止即時取代。', - windowsCompositionInputLabel: '即時原文顯示在輸入框(Windows)', + windowsCompositionInputLabel: '即時原文顯示在輸入框', windowsCompositionInputDesc: '錄音時把辨識到的文字以帶底線的組合文字顯示在輸入框中,結束後替換為最終結果。需要啟用 OpenLess 的 TSF 輸入法元件,且目標應用支援文字服務框架(TSF)。', streamingInsertSaveClipboardLabel: '同步到剪貼簿', @@ -2228,6 +2229,7 @@ export const zhTW: typeof zhCN = { backToAdvanced: '返回進階功能', back: '返回', advancedPages: { + liveInput: '設定即時原文在輸入框中的顯示。', experiments: '設定實驗功能與擴充選項。', customSyncServer: '僅供自建服務使用;預設使用官方同步,無須設定。', localModels: experimentalAsr.description, diff --git a/openless-all/app/src/pages/settings/RecordingInputSection.tsx b/openless-all/app/src/pages/settings/RecordingInputSection.tsx index f866cc2fc..a4260d22f 100644 --- a/openless-all/app/src/pages/settings/RecordingInputSection.tsx +++ b/openless-all/app/src/pages/settings/RecordingInputSection.tsx @@ -900,28 +900,6 @@ export function RecordingInputSection({ onToggle={(next) => void savePrefs({ ...prefs, streamingInsertSaveClipboard: next })} /> - {capability.adapter === 'macEventTap' && ( - - void savePrefs({ ...prefs, macosCompositionInput: next })} - /> - - )} - {capability.adapter === 'windowsLowLevel' && prefs.windowsInsertionMode === 'tsf' && ( - - void savePrefs({ ...prefs, windowsCompositionInput: next })} - /> - - )} )} {surface === 'text' && showDesktopStartup && ( diff --git a/openless-all/app/src/pages/settings/navigation.ts b/openless-all/app/src/pages/settings/navigation.ts index 525fc4adb..3d879e0e5 100644 --- a/openless-all/app/src/pages/settings/navigation.ts +++ b/openless-all/app/src/pages/settings/navigation.ts @@ -20,6 +20,12 @@ export const ADVANCED_PAGES = [ titleKey: 'settings.advancedPages.voiceEnhancementTitle', beta: true, }, + { + id: 'liveInput', + icon: 'keyboard', + titleKey: 'settings.advancedPages.liveInputTitle', + beta: true, + }, { id: 'cursorContext', icon: 'search', @@ -46,6 +52,7 @@ export type AdvancedPageId = AdvancedPage['id']; export function visibleAdvancedPages(platform: PlatformKind | undefined, os: OS): AdvancedPage[] { return ADVANCED_PAGES.filter((page) => { + if (page.id === 'liveInput') return platform === 'desktop' && (os === 'mac' || os === 'win'); if (page.id === 'localModels') return platform === 'desktop' && os === 'mac'; if (page.id === 'vocabularyLearning') return platform === 'desktop' || platform === 'android'; if (page.id === 'debug') diff --git a/openless-all/app/src/pages/settings/tabs.tsx b/openless-all/app/src/pages/settings/tabs.tsx index 63e33c700..d0857ee0b 100644 --- a/openless-all/app/src/pages/settings/tabs.tsx +++ b/openless-all/app/src/pages/settings/tabs.tsx @@ -617,6 +617,45 @@ function CursorContextSection() { ); } +function LiveInputSection() { + const { t } = useTranslation(); + const { prefs, updatePrefs } = useHotkeySettings(); + const os = detectOS(); + if (!prefs || (os !== 'mac' && os !== 'win')) return null; + const mac = os === 'mac'; + return ( + + + {t('settings.advancedPages.liveInputTitle')} + + + + void updatePrefs((current) => + mac + ? { ...current, macosCompositionInput: next } + : { ...current, windowsCompositionInput: next }, + ) + } + /> + + + ); +} + // Privacy: local-first explanation + permission management · data storage. export function PrivacyTab() { const { t } = useTranslation(); @@ -705,6 +744,7 @@ export function AdvancedTab({ )} {item.id === 'voiceEnhancement' && } + {item.id === 'liveInput' && } {item.id === 'cursorContext' && } {item.id === 'vocabularyLearning' && } {item.id === 'startupDiagnostics' && } From ceb5e7c1a5418328397012c5772844c9dee57265 Mon Sep 17 00:00:00 2001 From: lvbaiqing Date: Sun, 8 Nov 2026 22:46:39 +0800 Subject: [PATCH 2/3] fix(macos): retain target after uncertain AX write --- openless-all/app/src-tauri/src/host_document/macos.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 e59151413..8a2e2ad85 100644 --- a/openless-all/app/src-tauri/src/host_document/macos.rs +++ b/openless-all/app/src-tauri/src/host_document/macos.rs @@ -1750,13 +1750,14 @@ impl LiveComposition { K_CF_STRING_ENCODING_UTF8, ); let attr = cfstring_from_static(b"AXSelectedText\0").ok_or("liveInputUnavailable")?; + // A failed AX call can have an unknown outcome; never retarget after submission. + self.wrote = true; let result = AXUIElementSetAttributeValue(self.target.element.as_ref(), attr, value); CFRelease(attr); CFRelease(value); if result != AX_ERROR_SUCCESS { return Err("liveInputUnsupportedEditor".into()); } - self.wrote = true; let expected = format!("{start}{text}{suffix}"); if voice_edit_text(self.target.element.as_ref()).as_deref() != Some(&expected) { return Err("liveInputEditorTransformedText".into()); From dd96a8f6ad9b4f2393c37aaff67f3206841433f4 Mon Sep 17 00:00:00 2001 From: lvbaiqing Date: Sun, 8 Nov 2026 22:58:15 +0800 Subject: [PATCH 3/3] test(macos): verify late live-input capture in native editor --- .../app/src-tauri/src/core_adapters.rs | 37 ++++++++++++++++--- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/openless-all/app/src-tauri/src/core_adapters.rs b/openless-all/app/src-tauri/src/core_adapters.rs index dec9b817d..ba32b1b54 100644 --- a/openless-all/app/src-tauri/src/core_adapters.rs +++ b/openless-all/app/src-tauri/src/core_adapters.rs @@ -5634,11 +5634,22 @@ mod tests { #[test] fn live_input_retargets_only_before_any_write() { #[derive(Debug)] - struct Target { written: bool, current: bool } - let mut slot = Some(Target { written: false, current: false }); - let capture = || Ok(Target { written: false, current: true }); + struct Target { + written: bool, + current: bool, + } + let mut slot = Some(Target { + written: false, + current: false, + }); + let capture = || Ok(Target { + written: false, + current: true, + }); let replace = |target: &mut Target, _: &str| { - if !target.current { return Err("target changed".into()); } + if !target.current { + return Err("target changed".into()); + } target.written = true; Ok(()) }; @@ -5663,13 +5674,29 @@ mod tests { &mut slot, "text", || Ok(false), - |written, _| { *written = true; Err("verification failed".into()) }, + |written, _| { + *written = true; + Err("verification failed".into()) + }, |written| *written, ); assert_eq!(result.unwrap_err(), "verification failed"); assert_eq!(slot, Some(true)); } + #[cfg(target_os = "macos")] + #[test] + #[ignore = "requires the foreground disposable native-live-input-fixture app"] + fn live_input_captures_late_focused_native_editor() { + let (_, bundle) = crate::selection::current_front_app_parts(); + assert_eq!(bundle.as_deref(), Some("org.openless.live-input-fixture")); + let mut slot = None; + super::replace_live_composition(&mut slot, "实时原文").unwrap(); + let mut live = slot.expect("captured live editor"); + assert!(live.has_written()); + live.cancel().unwrap(); + } + use super::*; #[cfg(target_os = "windows")]