diff --git a/openless-all/app/scripts/windows-ime-install-smoke.ps1 b/openless-all/app/scripts/windows-ime-install-smoke.ps1 index a3b25de7..7946c040 100644 --- a/openless-all/app/scripts/windows-ime-install-smoke.ps1 +++ b/openless-all/app/scripts/windows-ime-install-smoke.ps1 @@ -16,9 +16,9 @@ $KeyboardCategoryGuid = "{34745C63-B2F0-4784-8B67-5E12C8701A31}" $ImmersiveCategoryGuid = "{13A016DF-560B-46CD-947A-4C3AF1E0E35D}" $SystrayCategoryGuid = "{25504FB4-7BAB-4BC1-9C69-CF81890F0EF5}" -# Keep this script aligned with the backend status check and the TSF IPC path -# used by OpenLessImeSubmit-* named pipes. -$ExpectedBackendKeys = @( +# Keep this script aligned with the default Windows backend: OpenLess ships the +# TSF DLLs for optional diagnostics, but installers must not register the TIP. +$ExpectedUnregisteredKeys = @( "Software\Classes\CLSID\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\InprocServer32", "Software\WOW6432Node\Classes\CLSID\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\InprocServer32", "Software\Microsoft\CTF\TIP\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\LanguageProfile\0x00000804\{9B5F5E04-23F6-47DA-9A26-D221F6C3F02E}", @@ -103,7 +103,7 @@ function Assert-RegistryKey { Write-Host "[ok] $Label registry key present ($View)" } -function Get-DefaultRegistryValue { +function Assert-RegistryKeyAbsent { param( [Parameter(Mandatory = $true)] [Microsoft.Win32.RegistryView]$View, @@ -114,54 +114,41 @@ function Get-DefaultRegistryValue { ) $key = Open-LocalMachineSubKey -View $View -SubKey $SubKey - if ($null -eq $key) { - throw "Missing $Label registry key ($View): HKLM\$SubKey" - } - try { - $value = [string]$key.GetValue("") - if ([string]::IsNullOrWhiteSpace($value)) { - throw "$Label default registry value is empty ($View): HKLM\$SubKey" - } - return $value - } finally { + if ($null -ne $key) { $key.Close() + throw "Unexpected $Label registry key ($View): HKLM\$SubKey" } + Write-Host "[ok] $Label registry key absent ($View)" } function Assert-OpenLessImeInstalled { - $comKey = "Software\Classes\CLSID\$TextServiceClsid\InprocServer32" - $x64Dll = Get-DefaultRegistryValue -View Registry64 -SubKey $comKey -Label "x64 COM" - $x86Dll = Get-DefaultRegistryValue -View Registry32 -SubKey $comKey -Label "x86 COM" + $installRootCandidates = @( + (Join-Path $env:ProgramFiles "OpenLess") + ) + if ($env:ProgramFiles -ne ${env:ProgramFiles(x86)}) { + $installRootCandidates += (Join-Path ${env:ProgramFiles(x86)} "OpenLess") + } - foreach ($dll in @($x64Dll, $x86Dll)) { - if (-not (Test-Path -LiteralPath $dll -PathType Leaf)) { - throw "Registered IME DLL path does not exist: $dll" - } + $installRoot = $installRootCandidates | + Where-Object { Test-Path -LiteralPath (Join-Path $_ "openless.exe") -PathType Leaf } | + Select-Object -First 1 + if ([string]::IsNullOrWhiteSpace($installRoot)) { + throw "Installed OpenLess executable not found under: $($installRootCandidates -join ', ')" } - $installRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $x64Dll)) $expectedX64 = Join-Path $installRoot "windows-ime\x64\OpenLessIme.dll" $expectedX86 = Join-Path $installRoot "windows-ime\x86\OpenLessIme.dll" - if ($x64Dll -ne $expectedX64) { - throw "x64 COM DLL path points outside the installed IME directory. Expected '$expectedX64', got '$x64Dll'" - } - if ($x86Dll -ne $expectedX86) { - throw "x86 COM DLL path points outside the installed IME directory. Expected '$expectedX86', got '$x86Dll'" - } - if (-not (Test-Path -LiteralPath (Join-Path $installRoot "openless.exe") -PathType Leaf)) { - throw "Installed OpenLess executable not found under $installRoot" + foreach ($dll in @($expectedX64, $expectedX86)) { + if (-not (Test-Path -LiteralPath $dll -PathType Leaf)) { + throw "Packaged optional IME DLL path does not exist: $dll" + } } - Assert-RegistryKey -View Registry64 -SubKey "Software\Microsoft\CTF\TIP\$TextServiceClsid\LanguageProfile\$LangId\$ProfileGuid" -Label "TSF language profile" - Assert-RegistryKey -View Registry64 -SubKey "Software\Microsoft\CTF\TIP\$TextServiceClsid\Category\Category\$KeyboardCategoryGuid\$TextServiceClsid" -Label "TSF keyboard category" - Assert-RegistryKey -View Registry64 -SubKey "Software\Microsoft\CTF\TIP\$TextServiceClsid\Category\Category\$ImmersiveCategoryGuid\$TextServiceClsid" -Label "TSF immersive category" - Assert-RegistryKey -View Registry64 -SubKey "Software\Microsoft\CTF\TIP\$TextServiceClsid\Category\Category\$SystrayCategoryGuid\$TextServiceClsid" -Label "TSF systray category" - - foreach ($key in $ExpectedBackendKeys) { - Assert-RegistryKey -View Registry64 -SubKey $key -Label "backend-required" + foreach ($key in $ExpectedUnregisteredKeys) { + Assert-RegistryKeyAbsent -View Registry64 -SubKey $key -Label "default-disabled TSF" } - Write-Host "[ok] Windows IME backend would report installed" + Write-Host "[ok] OpenLess installed with TSF registration disabled by default" return $installRoot } diff --git a/openless-all/app/scripts/windows-package-msvc.test.mjs b/openless-all/app/scripts/windows-package-msvc.test.mjs index ce5aa4ec..f48cbafb 100644 --- a/openless-all/app/scripts/windows-package-msvc.test.mjs +++ b/openless-all/app/scripts/windows-package-msvc.test.mjs @@ -96,7 +96,7 @@ assert.deepEqual(tauriConfig.bundle.windows.wix.componentRefs, [ "OpenLessImeDllX86Component", ]); assert.equal(tauriConfig.bundle.windows.nsis.installMode, "perMachine", "NSIS must force a machine-wide install because TSF registration is machine-wide"); -assert.equal(tauriConfig.bundle.windows.nsis.installerHooks, "nsis/openless-ime-hooks.nsh", "NSIS must install and register the TSF DLLs"); +assert.equal(tauriConfig.bundle.windows.nsis.installerHooks, "nsis/openless-ime-hooks.nsh", "NSIS must stage the optional TSF DLLs and clean legacy registration"); assert.match(imeSolution, /Release\|Win32/, "IME solution should include a Win32 Release configuration"); assert.match(imeProject, /Release\|Win32/, "IME project should include a Win32 Release configuration"); @@ -113,16 +113,16 @@ assert.match(wixFragment, /Component Id="OpenLessImeDllX64Component"/, "WiX frag assert.match(wixFragment, /Component Id="OpenLessImeDllX86Component"/, "WiX fragment should define the x86 TSF DLL component"); assert.match(wixFragment, /Source="src-tauri\\target\\windows-ime-msvc\\x64\\Release\\OpenLessIme\.dll"/, "WiX fragment should consume the package-built x64 IME DLL"); assert.match(wixFragment, /Source="src-tauri\\target\\windows-ime-msvc\\x86\\Release\\OpenLessIme\.dll"/, "WiX fragment should consume the package-built x86 IME DLL"); -assert.match(wixFragment, /regsvr32\.exe/, "MSI should register and unregister the TSF DLL"); -assert.match(wixFragment, /\[System64Folder\]regsvr32\.exe/, "MSI should register the x64 IME with 64-bit regsvr32"); -assert.match(wixFragment, /\[WindowsFolder\]SysWOW64\\regsvr32\.exe/, "MSI should register the x86 IME with 32-bit regsvr32"); -assert.match(wixFragment, /RegisterOpenLessImeX64/, "MSI should register x64 OpenLess IME during install"); -assert.match(wixFragment, /RegisterOpenLessImeX86/, "MSI should register x86 OpenLess IME during install"); -assert.match(wixFragment, /UnregisterOpenLessImeX64/, "MSI should unregister x64 OpenLess IME during uninstall"); -assert.match(wixFragment, /UnregisterOpenLessImeX86/, "MSI should unregister x86 OpenLess IME during uninstall"); +assert.match(wixFragment, /regsvr32\.exe/, "MSI should retain regsvr32 custom actions for cleanup/diagnostics"); +assert.match(wixFragment, /\[System64Folder\]regsvr32\.exe/, "MSI should know how to unregister the x64 IME with 64-bit regsvr32"); +assert.match(wixFragment, /\[WindowsFolder\]SysWOW64\\regsvr32\.exe/, "MSI should know how to unregister the x86 IME with 32-bit regsvr32"); +assert.doesNotMatch(wixFragment, / { - log::warn!( - "[windows-ime] TSF unavailable; inserted via paced Unicode SendInput fallback" - ); + log::info!("[windows-ime] inserted via paced Unicode SendInput"); } InsertStatus::CopiedFallback => { - log::warn!( - "[windows-ime] TSF unavailable; Unicode SendInput failed, left text on clipboard" - ); + log::warn!("[windows-ime] Unicode SendInput failed, left text on clipboard"); } InsertStatus::PasteSent | InsertStatus::Failed => { - log::warn!( - "[windows-ime] TSF unavailable; Unicode SendInput fallback failed and copy fallback failed" - ); + log::warn!("[windows-ime] Unicode SendInput failed and copy fallback failed"); } } @@ -1861,8 +1855,6 @@ mod non_tsf_fallback_tests { // ─────────────────────────── helpers ─────────────────────────── - - fn read_whisper_credentials() -> (String, String, String) { let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey) .ok() @@ -1959,7 +1951,6 @@ fn enabled_hotwords(inner: &Arc) -> Vec { .collect() } - /// 读 Gemini 凭据。所有 LLM provider 共用 ark.* 槽位(persistence 没做 per-provider /// 隔离),所以这里也是从 `ArkApiKey` / `ArkModelId` / `ArkEndpoint` 三个槽读, /// 但回退默认值改成谷歌的:base_url 默认 `https://generativelanguage.googleapis.com/v1beta`, @@ -3037,7 +3028,6 @@ fn schedule_capsule_idle(inner: &Arc, delay_ms: u64) { }); } - // ─────────────────────────── audio bridge ─────────────────────────── struct DeferredAsrBridge { diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 8a86e65d..6df990c9 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -1004,10 +1004,7 @@ pub(super) async fn begin_session(inner: &Arc) -> Result<(), String> { /// begin_session 的带参版本,voice_agent=true 时在 Starting 阶段就标记好, /// 防止 finish_starting_session 处理 pending_stop 时丢失标志。 -pub(super) async fn begin_session_as( - inner: &Arc, - voice_agent: bool, -) -> Result<(), String> { +pub(super) async fn begin_session_as(inner: &Arc, voice_agent: bool) -> Result<(), String> { let current_session_id = { let mut state = inner.state.lock(); let Some(session_id) = @@ -1025,9 +1022,13 @@ pub(super) async fn begin_session_as( }; #[cfg(target_os = "windows")] { - let prepared = inner.windows_ime.prepare_session(); - let mut slots = inner.prepared_windows_ime_session.lock(); - store_prepared_windows_ime_session(&mut slots, current_session_id, prepared); + if inner.prefs.get().windows_tsf_backend_enabled { + let prepared = inner.windows_ime.prepare_session(); + let mut slots = inner.prepared_windows_ime_session.lock(); + store_prepared_windows_ime_session(&mut slots, current_session_id, prepared); + } else { + log::debug!("[windows-ime] TSF backend disabled; using Unicode SendInput insertion"); + } } // 翻译模式标志重置;hotkey 监听器在 Shift down 时再 set true。 inner @@ -2301,6 +2302,7 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { let prefs = inner.prefs.get(); let restore_clipboard = prefs.restore_clipboard_after_paste; let allow_non_tsf_insertion_fallback = prefs.allow_non_tsf_insertion_fallback; + let windows_tsf_backend_enabled = prefs.windows_tsf_backend_enabled; let paste_shortcut = prefs.paste_shortcut; // 流式路径下,字符已经通过 Unicode keystroke 落到光标处,跳过 inserter.insert。 let status = if already_streamed { @@ -2323,17 +2325,26 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { if focus_ready_for_paste { #[cfg(target_os = "windows")] { - let ime_target = capture_ime_submit_target(); - insert_with_windows_ime_first( - inner, - current_session_id, - &polished, - restore_clipboard, - allow_non_tsf_insertion_fallback, - paste_shortcut, - ime_target, - ) - .await + if windows_tsf_backend_enabled { + let ime_target = capture_ime_submit_target(); + insert_with_windows_ime_first( + inner, + current_session_id, + &polished, + restore_clipboard, + allow_non_tsf_insertion_fallback, + paste_shortcut, + ime_target, + ) + .await + } else if allow_non_tsf_insertion_fallback { + insert_via_non_tsf_fallback(inner, &polished, restore_clipboard, paste_shortcut) + } else { + log::warn!( + "[windows-ime] TSF backend disabled and Unicode insertion fallback disabled" + ); + InsertStatus::Failed + } } #[cfg(not(target_os = "windows"))] { diff --git a/openless-all/app/src-tauri/src/hotkey.rs b/openless-all/app/src-tauri/src/hotkey.rs index 7fa0eaea..d8f73d3c 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -996,7 +996,23 @@ mod platform { } _ => {} } - true + should_suppress_trigger(trigger) + } + + fn should_suppress_trigger(trigger: HotkeyTrigger) -> bool { + // Modifier-only hotkeys on Windows are safer when the physical key state + // is still allowed to flow through to the foreground app. Swallowing the + // down/up edges for Ctrl/Alt can leave shell and Chromium-family apps in + // odd input states while OpenLess later submits text through TSF. + !matches!( + trigger, + HotkeyTrigger::LeftControl + | HotkeyTrigger::RightControl + | HotkeyTrigger::LeftOption + | HotkeyTrigger::RightOption + | HotkeyTrigger::RightAlt + | HotkeyTrigger::Fn + ) } fn handle_optional_modifier_trigger( @@ -1091,10 +1107,10 @@ mod platform { let shared = shared(HotkeyTrigger::RightControl); let (ctx, rx) = callback_context(shared); - assert!(dispatch_keyboard_event(&ctx, VK_RCONTROL, WM_KEYDOWN)); - assert!(dispatch_keyboard_event(&ctx, VK_RCONTROL, WM_KEYDOWN)); - assert!(dispatch_keyboard_event(&ctx, VK_RCONTROL, WM_KEYUP)); - assert!(dispatch_keyboard_event(&ctx, VK_RCONTROL, WM_KEYUP)); + assert!(!dispatch_keyboard_event(&ctx, VK_RCONTROL, WM_KEYDOWN)); + assert!(!dispatch_keyboard_event(&ctx, VK_RCONTROL, WM_KEYDOWN)); + assert!(!dispatch_keyboard_event(&ctx, VK_RCONTROL, WM_KEYUP)); + assert!(!dispatch_keyboard_event(&ctx, VK_RCONTROL, WM_KEYUP)); assert_eq!( drain(&rx), @@ -1108,10 +1124,10 @@ mod platform { let (ctx, rx) = callback_context(shared); assert!(!dispatch_keyboard_event(&ctx, VK_LCONTROL, WM_KEYDOWN)); - assert!(dispatch_keyboard_event(&ctx, VK_RCONTROL, WM_KEYUP)); - assert!(dispatch_keyboard_event(&ctx, VK_RCONTROL, WM_KEYDOWN)); - assert!(dispatch_keyboard_event(&ctx, VK_RCONTROL, WM_KEYUP)); - assert!(dispatch_keyboard_event(&ctx, VK_RCONTROL, WM_KEYDOWN)); + assert!(!dispatch_keyboard_event(&ctx, VK_RCONTROL, WM_KEYUP)); + assert!(!dispatch_keyboard_event(&ctx, VK_RCONTROL, WM_KEYDOWN)); + assert!(!dispatch_keyboard_event(&ctx, VK_RCONTROL, WM_KEYUP)); + assert!(!dispatch_keyboard_event(&ctx, VK_RCONTROL, WM_KEYDOWN)); assert_eq!( drain(&rx), @@ -1155,8 +1171,8 @@ mod platform { let (left_ctx, left_rx) = callback_context(left_shared); assert!(!dispatch_keyboard_event(&left_ctx, VK_RMENU, WM_KEYDOWN)); - assert!(dispatch_keyboard_event(&left_ctx, VK_LMENU, WM_KEYDOWN)); - assert!(dispatch_keyboard_event(&left_ctx, VK_LMENU, WM_KEYUP)); + assert!(!dispatch_keyboard_event(&left_ctx, VK_LMENU, WM_KEYDOWN)); + assert!(!dispatch_keyboard_event(&left_ctx, VK_LMENU, WM_KEYUP)); assert_eq!( drain(&left_rx), vec![HotkeyEvent::Pressed, HotkeyEvent::Released] @@ -1169,7 +1185,7 @@ mod platform { VK_LMENU, WM_KEYDOWN )); - assert!(dispatch_keyboard_event( + assert!(!dispatch_keyboard_event( &right_option_ctx, VK_RMENU, WM_KEYDOWN @@ -1183,7 +1199,7 @@ mod platform { VK_LMENU, WM_KEYDOWN )); - assert!(dispatch_keyboard_event( + assert!(!dispatch_keyboard_event( &right_alt_ctx, VK_RMENU, WM_KEYDOWN diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 3348ee0e..2da63161 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -589,6 +589,11 @@ pub struct UserPreferences { /// 默认开启以保持可用性;关闭后可验证文本是否真正由 TSF 上屏。 #[serde(default = "default_true")] pub allow_non_tsf_insertion_fallback: bool, + /// Windows: opt back into the in-process TSF IME backend. Disabled by + /// default because Chromium/Electron hosts can hang when a TIP DLL joins + /// their TSF/COM message chain. + #[serde(default)] + pub windows_tsf_backend_enabled: bool, /// 用户的工作语言(多选,原生名)。会作为前提注入 LLM polish/translate 的 system prompt 头部, /// 让模型知道该用户在哪些语言间工作。详见 issue #4。 #[serde(default = "default_working_languages")] @@ -885,6 +890,8 @@ struct UserPreferencesWire { #[serde(default)] paste_shortcut: PasteShortcut, allow_non_tsf_insertion_fallback: bool, + #[serde(default)] + windows_tsf_backend_enabled: bool, working_languages: Vec, translation_target_language: String, chinese_script_preference: ChineseScriptPreference, @@ -1006,6 +1013,7 @@ impl Default for UserPreferencesWire { restore_clipboard_after_paste: prefs.restore_clipboard_after_paste, paste_shortcut: prefs.paste_shortcut, allow_non_tsf_insertion_fallback: prefs.allow_non_tsf_insertion_fallback, + windows_tsf_backend_enabled: prefs.windows_tsf_backend_enabled, working_languages: prefs.working_languages, translation_target_language: prefs.translation_target_language, chinese_script_preference: prefs.chinese_script_preference, @@ -1106,6 +1114,7 @@ impl<'de> Deserialize<'de> for UserPreferences { restore_clipboard_after_paste: wire.restore_clipboard_after_paste, paste_shortcut: wire.paste_shortcut, allow_non_tsf_insertion_fallback: wire.allow_non_tsf_insertion_fallback, + windows_tsf_backend_enabled: wire.windows_tsf_backend_enabled, working_languages: wire.working_languages, translation_target_language: wire.translation_target_language, chinese_script_preference: wire.chinese_script_preference, @@ -1849,6 +1858,7 @@ impl Default for UserPreferences { restore_clipboard_after_paste: true, paste_shortcut: PasteShortcut::default(), allow_non_tsf_insertion_fallback: true, + windows_tsf_backend_enabled: false, working_languages: default_working_languages(), translation_target_language: String::new(), chinese_script_preference: ChineseScriptPreference::Auto, @@ -2592,6 +2602,20 @@ mod tests { assert!(prefs.allow_non_tsf_insertion_fallback); } + #[test] + fn windows_tsf_backend_defaults_to_disabled() { + let prefs = UserPreferences::default(); + + assert!(!prefs.windows_tsf_backend_enabled); + } + + #[test] + fn missing_windows_tsf_backend_pref_defaults_to_disabled() { + let prefs: UserPreferences = serde_json::from_str("{}").unwrap(); + + assert!(!prefs.windows_tsf_backend_enabled); + } + #[test] fn missing_audio_cue_on_record_pref_defaults_to_enabled() { // 老用户的 preferences.json 没有这个字段 → 应默认开启(按下录音即提示)。 diff --git a/openless-all/app/src-tauri/src/windows_ime_profile.rs b/openless-all/app/src-tauri/src/windows_ime_profile.rs index 8ec380f2..3b8f752b 100644 --- a/openless-all/app/src-tauri/src/windows_ime_profile.rs +++ b/openless-all/app/src-tauri/src/windows_ime_profile.rs @@ -408,13 +408,15 @@ mod windows_impl { RegistrationInspection::Installed { dll_path } => WindowsImeStatus { state: WindowsImeInstallState::Installed, using_tsf_backend: true, - message: "OpenLess TSF IME registration is present".to_string(), + message: "Experimental OpenLess TSF IME registration is present".to_string(), dll_path: Some(dll_path), }, RegistrationInspection::NotInstalled => WindowsImeStatus { state: WindowsImeInstallState::NotInstalled, using_tsf_backend: false, - message: "OpenLess TSF IME registration was not found".to_string(), + message: + "OpenLess TSF IME is not registered; Windows uses Unicode SendInput by default" + .to_string(), dll_path: None, }, RegistrationInspection::Broken { dll_path, reason } => WindowsImeStatus { diff --git a/openless-all/app/src-tauri/wix/openless-ime.wxs b/openless-all/app/src-tauri/wix/openless-ime.wxs index 8cec239d..553aac11 100644 --- a/openless-all/app/src-tauri/wix/openless-ime.wxs +++ b/openless-all/app/src-tauri/wix/openless-ime.wxs @@ -57,8 +57,6 @@ uninstall and skipped the auto-update / minor-upgrade path. --> - - diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index fcf71ca3..f9547f89 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -682,8 +682,8 @@ export const en: typeof zhCN = { comboRecorded: 'Recorded', comboClear: 'Clear', comboConflict: 'This shortcut combination is not available', - allowNonTsfFallbackLabel: 'Allow non-TSF fallback', - allowNonTsfFallbackDesc: 'Windows: when TSF insertion fails, use paced Unicode SendInput; if that still fails, copy the text to the clipboard.', + allowNonTsfFallbackLabel: 'Allow Unicode SendInput insertion', + allowNonTsfFallbackDesc: 'Windows: use paced Unicode SendInput by default; if that fails, copy the text to the clipboard.', historyGroupTitle: 'History & context', historyRetentionLabel: 'History retention (days)', historyRetentionDesc: 'Entries older than this are pruned on new writes; 0 = no time-based pruning.', @@ -847,7 +847,7 @@ export const en: typeof zhCN = { hotkeyStarting: 'Installing…', hotkeyFailed: 'Listener failed', windowsImeLabel: 'Windows input method backend', - windowsImeDesc: 'Temporarily switches to the OpenLess TSF IME during voice sessions to avoid clipboard insertion limits.', + windowsImeDesc: 'Optional experimental TSF IME backend. Windows uses Unicode SendInput by default to avoid in-process text-service hangs.', windowsImeInstalled: 'Installed', windowsImeUnavailable: 'Unavailable', androidImeLabel: 'Input method (IME)', @@ -910,9 +910,9 @@ export const en: typeof zhCN = { down: 'Swipe down while recording to cancel without transcription or insertion.', }, windowsIme: { - installed: 'Installed. Voice input temporarily switches to the OpenLess IME.', - notInstalled: 'Not installed. OpenLess is using the clipboard/WM_PASTE fallback.', - registrationBroken: 'Registration is broken. Reinstall the OpenLess IME.', + installed: 'Experimental TSF backend is registered.', + notInstalled: 'Not registered. OpenLess uses Unicode SendInput by default.', + registrationBroken: 'Experimental TSF registration is broken.', notWindows: 'Only available on Windows.', }, }, diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 257bfb7f..0eceb288 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -684,8 +684,8 @@ export const ja: typeof zhCN = { comboRecorded: '記録済み', comboClear: 'クリア', comboConflict: 'このショートカットの組み合わせは使用できません', - allowNonTsfFallbackLabel: '非 TSF フォールバックを許可', - allowNonTsfFallbackDesc: 'Windows:TSF 入力が失敗した時は分割した Unicode SendInput を使い、それも失敗した場合はクリップボードへコピーします。', + allowNonTsfFallbackLabel: 'Unicode SendInput 入力を許可', + allowNonTsfFallbackDesc: 'Windows:既定で分割した Unicode SendInput を使い、失敗した場合はクリップボードへコピーします。', historyGroupTitle: '履歴とコンテキスト', historyRetentionLabel: '履歴保持期間(日)', historyRetentionDesc: '保持日数を超えた履歴は新規書き込み時に削除されます。0 = 時間で削除しない。', @@ -849,7 +849,7 @@ export const ja: typeof zhCN = { hotkeyStarting: 'インストール中…', hotkeyFailed: '監視失敗', windowsImeLabel: 'Windows 入力メソッドバックエンド', - windowsImeDesc: '音声セッション中に OpenLess TSF IME へ一時的に切り替え、クリップボード入力の制限を回避します。', + windowsImeDesc: '任意の実験的 TSF IME バックエンドです。Windows では既定で Unicode SendInput を使い、プロセス内テキストサービスのハングを避けます。', windowsImeInstalled: 'インストール済み', windowsImeUnavailable: '利用不可', androidImeLabel: '入力メソッド (IME)', @@ -878,9 +878,9 @@ export const ja: typeof zhCN = { androidOverlayCancelSwipeDirection: { up: '上へスワイプ', down: '下へスワイプ' }, androidOverlayCancelSwipeDirectionHint: { up: '録音中に上へスワイプすると、文字起こしや挿入をせずにキャンセルします。', down: '録音中に下へスワイプすると、文字起こしや挿入をせずにキャンセルします。' }, windowsIme: { - installed: 'インストール済み。音声入力時に OpenLess IME へ一時的に切り替えます。', - notInstalled: '未インストール。OpenLess は現在クリップボード / WM_PASTE フォールバックを使用しています。', - registrationBroken: '登録が破損しています。OpenLess IME を再インストールしてください。', + installed: '実験的 TSF バックエンドが登録されています。', + notInstalled: '未登録です。OpenLess は既定で Unicode SendInput を使用します。', + registrationBroken: '実験的 TSF 登録が破損しています。', notWindows: 'Windows のみ利用可能。', }, }, diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 395f92e1..a060b840 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -684,8 +684,8 @@ export const ko: typeof zhCN = { comboRecorded: '녹화됨', comboClear: '지우기', comboConflict: '이 단축키 조합은 사용할 수 없습니다', - allowNonTsfFallbackLabel: '비 TSF 폴백 허용', - allowNonTsfFallbackDesc: 'Windows: TSF 입력이 실패하면 분할된 Unicode SendInput을 사용하고, 그래도 실패하면 텍스트를 클립보드에 복사합니다.', + allowNonTsfFallbackLabel: 'Unicode SendInput 입력 허용', + allowNonTsfFallbackDesc: 'Windows: 기본적으로 분할된 Unicode SendInput을 사용하고, 실패하면 텍스트를 클립보드에 복사합니다.', historyGroupTitle: '기록 및 컨텍스트', historyRetentionLabel: '기록 보관 기간(일)', historyRetentionDesc: '보관 기간을 초과한 기록은 새 항목 작성 시 정리됩니다. 0 = 시간 기반 정리 비활성화.', @@ -849,7 +849,7 @@ export const ko: typeof zhCN = { hotkeyStarting: '설치 중…', hotkeyFailed: '감지 실패', windowsImeLabel: 'Windows 입력기 백엔드', - windowsImeDesc: '음성 세션 동안 OpenLess TSF 입력기로 일시적으로 전환하여 클립보드 입력 제한을 회피하기 위해 사용.', + windowsImeDesc: '선택적 실험용 TSF 입력기 백엔드입니다. Windows는 기본적으로 Unicode SendInput을 사용해 프로세스 내부 텍스트 서비스 멈춤을 피합니다.', windowsImeInstalled: '설치됨', windowsImeUnavailable: '사용 불가', androidImeLabel: '입력기 (IME)', @@ -878,9 +878,9 @@ export const ko: typeof zhCN = { androidOverlayCancelSwipeDirection: { up: '위로 스와이프', down: '아래로 스와이프' }, androidOverlayCancelSwipeDirectionHint: { up: '녹음 중 위로 밀면 전사와 삽입 없이 취소합니다.', down: '녹음 중 아래로 밀면 전사와 삽입 없이 취소합니다.' }, windowsIme: { - installed: '설치됨. 음성 입력 시 OpenLess 입력기로 일시 전환됩니다.', - notInstalled: '설치되지 않음. OpenLess 는 현재 클립보드 / WM_PASTE 폴백을 사용합니다.', - registrationBroken: '등록이 손상되었습니다. OpenLess 입력기를 재설치하세요.', + installed: '실험용 TSF 백엔드가 등록되어 있습니다.', + notInstalled: '등록되지 않음. OpenLess는 기본적으로 Unicode SendInput을 사용합니다.', + registrationBroken: '실험용 TSF 등록이 손상되었습니다.', notWindows: 'Windows 만 사용 가능.', }, }, diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 3147b416..6d5913a9 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -680,8 +680,8 @@ export const zhCN = { comboRecorded: '已录制', comboClear: '清除', comboConflict: '该快捷键组合不可用', - allowNonTsfFallbackLabel: '允许非 TSF 兜底', - allowNonTsfFallbackDesc: 'Windows:TSF 失败时使用分批 Unicode SendInput;如果仍失败,再复制到剪贴板。', + allowNonTsfFallbackLabel: '允许 Unicode SendInput 输入', + allowNonTsfFallbackDesc: 'Windows:默认使用分批 Unicode SendInput;如果失败,再复制到剪贴板。', historyGroupTitle: '历史与上下文', historyRetentionLabel: '历史保留天数', historyRetentionDesc: '超过保留天数的历史在写入新条目时被清理;0 = 不按时间清理。', @@ -845,7 +845,7 @@ export const zhCN = { hotkeyStarting: '安装中…', hotkeyFailed: '监听失败', windowsImeLabel: 'Windows 输入法后端', - windowsImeDesc: '语音输入时临时切到 OpenLess TSF,绕过剪贴板限制。', + windowsImeDesc: '可选的实验性 TSF 输入法后端。Windows 默认使用 Unicode SendInput,避免进程内文本服务卡死。', windowsImeInstalled: '已安装', windowsImeUnavailable: '不可用', androidImeLabel: '输入法 (IME)', @@ -908,9 +908,9 @@ export const zhCN = { down: '录音中向下滑取消本次听写,不转写、不插入。', }, windowsIme: { - installed: '已安装,按需切到 OpenLess 输入法。', - notInstalled: '未安装,走剪贴板 / WM_PASTE 兜底。', - registrationBroken: '注册损坏,请重装 OpenLess 输入法。', + installed: '实验性 TSF 后端已注册。', + notInstalled: '未注册。OpenLess 默认使用 Unicode SendInput。', + registrationBroken: '实验性 TSF 注册已损坏。', notWindows: '仅 Windows 可用。', }, }, diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 2fe58825..0e8e8c2b 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -682,8 +682,8 @@ export const zhTW: typeof zhCN = { pasteShortcutCtrlV: 'Ctrl+V(默認 / 多數應用)', pasteShortcutCtrlShiftV: 'Ctrl+Shift+V(kitty / alacritty / wezterm / 多數終端)', pasteShortcutShiftInsert: 'Shift+Insert(xterm / urxvt)', - allowNonTsfFallbackLabel: '允許非 TSF 兜底', - allowNonTsfFallbackDesc: 'Windows:TSF 失敗時使用分批 Unicode SendInput;如果仍失敗,再複製到剪貼簿。', + allowNonTsfFallbackLabel: '允許 Unicode SendInput 輸入', + allowNonTsfFallbackDesc: 'Windows:預設使用分批 Unicode SendInput;如果失敗,再複製到剪貼簿。', historyGroupTitle: '歷史與上下文', historyRetentionLabel: '歷史保留天數', historyRetentionDesc: '超過保留天數的歷史在寫入新條目時被清理;0 = 不按時間清理。', @@ -847,7 +847,7 @@ export const zhTW: typeof zhCN = { hotkeyStarting: '安裝中…', hotkeyFailed: '監聽失敗', windowsImeLabel: 'Windows 輸入法後端', - windowsImeDesc: '用於在語音會話期間臨時切換到 OpenLess TSF 輸入法,避免剪貼板插入限制。', + windowsImeDesc: '可選的實驗性 TSF 輸入法後端。Windows 預設使用 Unicode SendInput,避免進程內文字服務卡死。', windowsImeInstalled: '已安裝', windowsImeUnavailable: '不可用', androidImeLabel: '輸入法 (IME)', @@ -876,9 +876,9 @@ export const zhTW: typeof zhCN = { androidOverlayCancelSwipeDirection: { up: '向上滑', down: '向下滑' }, androidOverlayCancelSwipeDirectionHint: { up: '錄音中向上滑取消本次聽寫,不轉寫、不插入。', down: '錄音中向下滑取消本次聽寫,不轉寫、不插入。' }, windowsIme: { - installed: '已安裝。語音輸入時會臨時切換到 OpenLess 輸入法。', - notInstalled: '未安裝。OpenLess 正在使用剪貼板 / WM_PASTE 兜底。', - registrationBroken: '註冊已損壞。請重新安裝 OpenLess 輸入法。', + installed: '實驗性 TSF 後端已註冊。', + notInstalled: '未註冊。OpenLess 預設使用 Unicode SendInput。', + registrationBroken: '實驗性 TSF 註冊已損壞。', notWindows: '僅 Windows 可用。', }, }, diff --git a/openless-all/app/src/lib/ipc/mock-data.ts b/openless-all/app/src/lib/ipc/mock-data.ts index 402f8cc8..837e4e41 100644 --- a/openless-all/app/src/lib/ipc/mock-data.ts +++ b/openless-all/app/src/lib/ipc/mock-data.ts @@ -49,6 +49,7 @@ export let mockSettings: UserPreferences = { restoreClipboardAfterPaste: true, pasteShortcut: "ctrlV", allowNonTsfInsertionFallback: true, + windowsTsfBackendEnabled: false, workingLanguages: ["简体中文"], translationTargetLanguage: "", qaHotkey: defaultQaShortcut(), diff --git a/openless-all/app/src/lib/stylePrefs.test.ts b/openless-all/app/src/lib/stylePrefs.test.ts index d049a4a5..1692bba9 100644 --- a/openless-all/app/src/lib/stylePrefs.test.ts +++ b/openless-all/app/src/lib/stylePrefs.test.ts @@ -39,6 +39,7 @@ const previousPrefs: UserPreferences = { restoreClipboardAfterPaste: true, pasteShortcut: 'ctrlV', allowNonTsfInsertionFallback: true, + windowsTsfBackendEnabled: false, workingLanguages: ['简体中文'], translationTargetLanguage: '', chineseScriptPreference: 'auto', diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index fe8c8baa..4b8610f4 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -266,8 +266,9 @@ export interface UserPreferences { * 等终端只接受 Ctrl+Shift+V,硬编码 Ctrl+V 会被吞掉,听写文本只剩在剪贴板里。 * macOS 走 AX 直写不受影响。默认 'ctrlV' 与历史行为一致。 */ pasteShortcut: PasteShortcut; - /** Windows:TSF 失败后是否允许快捷键粘贴 / 剪贴板兜底。仅在剪贴板写失败时才再试 SendInput。关闭后可验证是否真实 TSF 上屏。 */ + /** Windows: allow paced Unicode SendInput insertion; if it fails, text is copied to the clipboard. */ allowNonTsfInsertionFallback: boolean; + windowsTsfBackendEnabled: boolean; /** 用户的工作语言(多选,原生名);作为前提注入 LLM polish/translate prompt 头部。 */ workingLanguages: string[]; /** 翻译模式目标语言(单选,原生名);空串 = 不启用 Shift 翻译。详见 issue #4。 */ diff --git a/openless-all/app/windows-ime/src/dllmain.cpp b/openless-all/app/windows-ime/src/dllmain.cpp index 624c79f7..da60c836 100644 --- a/openless-all/app/windows-ime/src/dllmain.cpp +++ b/openless-all/app/windows-ime/src/dllmain.cpp @@ -10,15 +10,49 @@ HINSTANCE g_module = nullptr; LONG g_lock_count = 0; LONG g_object_count = 0; +namespace { + +bool IsBlockedShellProcess() { + wchar_t path[MAX_PATH] = {}; + const DWORD length = GetModuleFileNameW(nullptr, path, ARRAYSIZE(path)); + if (length == 0 || length >= ARRAYSIZE(path)) { + return false; + } + + const wchar_t* name = path; + for (const wchar_t* cursor = path; *cursor != L'\0'; ++cursor) { + if (*cursor == L'\\' || *cursor == L'/') { + name = cursor + 1; + } + } + + constexpr const wchar_t* kBlockedProcesses[] = { + L"explorer.exe", + L"searchhost.exe", + L"startmenuexperiencehost.exe", + L"shellexperiencehost.exe", + L"textinputhost.exe", + }; + for (const wchar_t* blocked : kBlockedProcesses) { + if (_wcsicmp(name, blocked) == 0) { + return true; + } + } + return false; +} + +} // namespace + BOOL APIENTRY DllMain(HINSTANCE instance, DWORD reason, LPVOID reserved) { UNREFERENCED_PARAMETER(reserved); - // 不调用 DisableThreadLibraryCalls:DLL 现在用 /MT 静态链接 CRT,CRT 需要 - // DLL_THREAD_ATTACH / DLL_THREAD_DETACH 通知做 per-thread TLS 初始化与清理。 - // 在 host 进程(如 QQ / Office)切输入法新建 input thread 时禁用通知,会让 - // 静态 CRT 的 thread-local 资源泄漏 / 行为不稳定,反而把这次想修的崩溃问题 - // 重新引回来。详见 Microsoft 文档 DisableThreadLibraryCalls 备注。 + // Do not call DisableThreadLibraryCalls here. OpenLessIme is linked with the + // static CRT, and suppressing thread attach/detach callbacks can break CRT + // thread-local cleanup in host input processes. if (reason == DLL_PROCESS_ATTACH) { + if (IsBlockedShellProcess()) { + return FALSE; + } g_module = instance; } @@ -39,6 +73,10 @@ STDAPI DllGetClassObject(REFCLSID clsid, REFIID iid, void** object) { return CLASS_E_CLASSNOTAVAILABLE; } + if (IsBlockedShellProcess()) { + return CLASS_E_CLASSNOTAVAILABLE; + } + auto* factory = new (std::nothrow) OpenLessClassFactory(); if (factory == nullptr) { return E_OUTOFMEMORY; diff --git a/openless-all/app/windows-ime/src/text_service.cpp b/openless-all/app/windows-ime/src/text_service.cpp index f303eac1..8e2ff348 100644 --- a/openless-all/app/windows-ime/src/text_service.cpp +++ b/openless-all/app/windows-ime/src/text_service.cpp @@ -41,6 +41,23 @@ HRESULT WaitForAsyncEditCompletion( return HRESULT_FROM_WIN32(GetLastError()); } +bool IsExplorerProcess() { + wchar_t path[MAX_PATH] = {}; + const DWORD length = GetModuleFileNameW(nullptr, path, ARRAYSIZE(path)); + if (length == 0 || length >= ARRAYSIZE(path)) { + return false; + } + + const wchar_t* name = path; + for (const wchar_t* cursor = path; *cursor != L'\0'; ++cursor) { + if (*cursor == L'\\' || *cursor == L'/') { + name = cursor + 1; + } + } + + return _wcsicmp(name, L"explorer.exe") == 0; +} + } // namespace OpenLessTextService::OpenLessTextService() { @@ -94,6 +111,11 @@ STDMETHODIMP OpenLessTextService::ActivateEx(ITfThreadMgr* thread_mgr, return E_INVALIDARG; } + if (IsExplorerProcess()) { + Deactivate(); + return HRESULT_FROM_WIN32(ERROR_NOT_SUPPORTED); + } + Deactivate(); owner_thread_id_ = GetCurrentThreadId();