From 2554259d8057ae39f766cf321043fb0724f8807c Mon Sep 17 00:00:00 2001 From: millionart Date: Mon, 4 May 2026 10:17:56 +0800 Subject: [PATCH] fix: harden Windows IME review edge cases --- .../app/scripts/windows-ime-register.ps1 | 12 ++- .../app/scripts/windows-ime-unregister.ps1 | 75 ++++++++++++++++++- .../app/scripts/windows-package-msvc.test.mjs | 7 ++ openless-all/app/src-tauri/src/coordinator.rs | 71 ++++++++++++------ .../app/src-tauri/src/windows_ime_profile.rs | 51 +++++++++++-- 5 files changed, 184 insertions(+), 32 deletions(-) diff --git a/openless-all/app/scripts/windows-ime-register.ps1 b/openless-all/app/scripts/windows-ime-register.ps1 index f252ccb0..ee051148 100644 --- a/openless-all/app/scripts/windows-ime-register.ps1 +++ b/openless-all/app/scripts/windows-ime-register.ps1 @@ -34,8 +34,16 @@ function Test-IsAdministrator { } $appRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$registrationRoot = Join-Path $appRoot "src-tauri\target\windows-ime-register" $stagingStamp = "{0:yyyyMMddHHmmss}-{1}" -f (Get-Date), $PID -$stagingRoot = Join-Path $appRoot "src-tauri\target\windows-ime-register\$stagingStamp" +$stagingRoot = Join-Path $registrationRoot $stagingStamp +$registrationManifest = Join-Path $registrationRoot "active-registration.json" +$registeredDlls = [ordered]@{} + +function Save-RegistrationManifest { + New-Item -ItemType Directory -Path $registrationRoot -Force | Out-Null + $registeredDlls | ConvertTo-Json | Set-Content -Path $registrationManifest -Encoding UTF8 +} function Get-DllPath { param( @@ -74,5 +82,7 @@ foreach ($platform in @("x64", "Win32")) { if ($process.ExitCode -ne 0) { throw "$platform regsvr32 failed with exit code $($process.ExitCode)" } + $registeredDlls[$platform] = $dll + Save-RegistrationManifest Write-Host "[ok] OpenLess TSF IME registered ($platform)" } diff --git a/openless-all/app/scripts/windows-ime-unregister.ps1 b/openless-all/app/scripts/windows-ime-unregister.ps1 index 4d51a004..f123b71a 100644 --- a/openless-all/app/scripts/windows-ime-unregister.ps1 +++ b/openless-all/app/scripts/windows-ime-unregister.ps1 @@ -34,6 +34,64 @@ function Test-IsAdministrator { } $appRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$registrationRoot = Join-Path $appRoot "src-tauri\target\windows-ime-register" +$registrationManifest = Join-Path $registrationRoot "active-registration.json" + +function Get-ManifestDllPath { + param( + [ValidateSet("x64", "Win32")] + [string]$Platform + ) + + if (-not (Test-Path $registrationManifest)) { + return $null + } + + try { + $manifest = Get-Content -Raw -Path $registrationManifest | ConvertFrom-Json + $property = $manifest.PSObject.Properties[$Platform] + if ($null -ne $property -and -not [string]::IsNullOrWhiteSpace($property.Value)) { + return $property.Value + } + } catch { + Write-Host "[warn] Failed to read OpenLess IME registration manifest: $($_.Exception.Message)" + } + + return $null +} + +function Get-LatestStagedDllPath { + param( + [ValidateSet("x64", "Win32")] + [string]$Platform + ) + + if (-not (Test-Path $registrationRoot)) { + return $null + } + + $folder = if ($Platform -eq "Win32") { "x86" } else { $Platform } + $dll = Get-ChildItem -Path $registrationRoot -Filter OpenLessIme.dll -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -like "*\$folder\$Configuration\OpenLessIme.dll" } | + Sort-Object LastWriteTimeUtc -Descending | + Select-Object -First 1 + + if ($null -ne $dll) { + return $dll.FullName + } + + return $null +} + +function Get-LegacyDllPath { + param( + [ValidateSet("x64", "Win32")] + [string]$Platform + ) + + $legacyFolder = if ($Platform -eq "Win32") { "Win32" } else { $Platform } + return Join-Path $appRoot "windows-ime\$legacyFolder\$Configuration\OpenLessIme.dll" +} function Get-DllPath { param( @@ -41,8 +99,17 @@ function Get-DllPath { [string]$Platform ) - $folder = if ($Platform -eq "Win32") { "Win32" } else { $Platform } - return Join-Path $appRoot "windows-ime\$folder\$Configuration\OpenLessIme.dll" + $manifestDll = Get-ManifestDllPath $Platform + if ($null -ne $manifestDll) { + return $manifestDll + } + + $stagedDll = Get-LatestStagedDllPath $Platform + if ($null -ne $stagedDll) { + return $stagedDll + } + + return Get-LegacyDllPath $Platform } if (-not (Test-IsAdministrator)) { @@ -63,3 +130,7 @@ foreach ($platform in @("x64", "Win32")) { } Write-Host "[ok] OpenLess TSF IME unregistered ($platform)" } + +if (Test-Path $registrationManifest) { + Remove-Item -LiteralPath $registrationManifest -Force +} diff --git a/openless-all/app/scripts/windows-package-msvc.test.mjs b/openless-all/app/scripts/windows-package-msvc.test.mjs index e5b4ef3d..193f0ed0 100644 --- a/openless-all/app/scripts/windows-package-msvc.test.mjs +++ b/openless-all/app/scripts/windows-package-msvc.test.mjs @@ -9,6 +9,7 @@ const scriptPath = join(scriptsDir, "windows-package-msvc.ps1"); const launcherPath = join(scriptsDir, "windows-package-msvc.cmd"); const imeBuildPath = join(scriptsDir, "windows-ime-build.ps1"); const imeRegisterPath = join(scriptsDir, "windows-ime-register.ps1"); +const imeUnregisterPath = join(scriptsDir, "windows-ime-unregister.ps1"); const imeSolutionPath = join(appRoot, "windows-ime", "OpenLessIme.sln"); const imeProjectPath = join(appRoot, "windows-ime", "OpenLessIme.vcxproj"); const imeEditSessionPath = join(appRoot, "windows-ime", "src", "edit_session.cpp"); @@ -20,6 +21,7 @@ const script = readFileSync(scriptPath, "utf8"); const launcher = readFileSync(launcherPath, "utf8"); const imeBuild = readFileSync(imeBuildPath, "utf8"); const imeRegister = readFileSync(imeRegisterPath, "utf8"); +const imeUnregister = readFileSync(imeUnregisterPath, "utf8"); const imeSolution = readFileSync(imeSolutionPath, "utf8"); const imeProject = readFileSync(imeProjectPath, "utf8"); const imeEditSession = readFileSync(imeEditSessionPath, "utf8"); @@ -71,6 +73,11 @@ assert.match(imeRegister, /Get-Date/, "IME register should create a fresh stagin assert.match(imeRegister, /\$PID/, "IME register should include the process id in the staging output to avoid path reuse"); assert.match(imeRegister, /-OutputDirectory/, "IME register should pass a staging output directory to the build script"); assert.match(imeRegister, /-IntermediateDirectory/, "IME register should pass a staging intermediate directory to the build script"); +assert.match(imeRegister, /active-registration\.json/, "IME register should persist the staged DLL paths it registered"); +assert.match(imeUnregister, /active-registration\.json/, "IME unregister should read the registered staged DLL manifest"); +assert.match(imeUnregister, /windows-ime-register/, "IME unregister should target the same staging root used by register"); +assert.match(imeUnregister, /ConvertFrom-Json/, "IME unregister should parse persisted registered DLL paths"); +assert.doesNotMatch(imeUnregister, /windows-ime\\\$folder\\\$Configuration\\OpenLessIme\.dll/, "IME unregister must not only derive legacy build-output DLL paths"); assert.deepEqual(tauriConfig.bundle.windows.wix.fragmentPaths, ["wix/openless-ime.wxs"]); assert.deepEqual(tauriConfig.bundle.windows.wix.componentRefs, [ diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 773d9556..2ef328b6 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -120,7 +120,7 @@ struct Inner { #[cfg(target_os = "windows")] windows_ime: WindowsImeSessionController, #[cfg(target_os = "windows")] - prepared_windows_ime_session: Arc>>, + prepared_windows_ime_session: Arc>>, state: Mutex, asr: Mutex>>, recorder: Mutex>>, @@ -215,7 +215,7 @@ impl Coordinator { #[cfg(target_os = "windows")] windows_ime: WindowsImeSessionController::new(), #[cfg(target_os = "windows")] - prepared_windows_ime_session: Arc::new(Mutex::new(None)), + prepared_windows_ime_session: Arc::new(Mutex::new(Vec::new())), state: Mutex::new(SessionState::default()), asr: Mutex::new(None), recorder: Mutex::new(None), @@ -957,10 +957,8 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { #[cfg(target_os = "windows")] { let prepared = inner.windows_ime.prepare_session(); - *inner.prepared_windows_ime_session.lock() = Some(PreparedWindowsImeSessionSlot { - session_id: current_session_id, - prepared, - }); + let mut slots = inner.prepared_windows_ime_session.lock(); + store_prepared_windows_ime_session(&mut slots, current_session_id, prepared); } // 翻译模式标志重置;hotkey 监听器在 Shift down 时再 set true。 inner @@ -1652,19 +1650,28 @@ fn cancel_session(inner: &Arc) { schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); } +#[cfg(target_os = "windows")] +fn store_prepared_windows_ime_session( + slots: &mut Vec, + session_id: u64, + prepared: PreparedWindowsImeSession, +) { + slots.retain(|slot| slot.session_id != session_id); + slots.push(PreparedWindowsImeSessionSlot { + session_id, + prepared, + }); +} + #[cfg(target_os = "windows")] fn take_matching_prepared_windows_ime_session( - slot: &mut Option, + slots: &mut Vec, session_id: u64, ) -> Option { - if slot - .as_ref() - .map(|slot| slot.session_id == session_id) - .unwrap_or(false) - { - return slot.take().map(|slot| slot.prepared); - } - None + let index = slots + .iter() + .position(|slot| slot.session_id == session_id)?; + Some(slots.remove(index).prepared) } #[cfg(target_os = "windows")] @@ -2622,16 +2629,38 @@ mod tests { #[test] #[cfg(target_os = "windows")] fn prepared_windows_ime_slot_is_taken_only_for_matching_session() { - let mut slot = Some(PreparedWindowsImeSessionSlot { + let mut slots = vec![PreparedWindowsImeSessionSlot { session_id: 2, prepared: PreparedWindowsImeSession::unavailable(), - }); + }]; - assert!(take_matching_prepared_windows_ime_session(&mut slot, 1).is_none()); - assert_eq!(slot.as_ref().map(|slot| slot.session_id), Some(2)); + assert!(take_matching_prepared_windows_ime_session(&mut slots, 1).is_none()); + assert_eq!( + slots.iter().map(|slot| slot.session_id).collect::>(), + vec![2] + ); - assert!(take_matching_prepared_windows_ime_session(&mut slot, 2).is_some()); - assert!(slot.is_none()); + assert!(take_matching_prepared_windows_ime_session(&mut slots, 2).is_some()); + assert!(slots.is_empty()); + } + + #[test] + #[cfg(target_os = "windows")] + fn prepared_windows_ime_sessions_keep_overlapping_snapshots() { + let mut slots = Vec::new(); + store_prepared_windows_ime_session(&mut slots, 1, PreparedWindowsImeSession::unavailable()); + store_prepared_windows_ime_session(&mut slots, 2, PreparedWindowsImeSession::unavailable()); + + assert_eq!( + slots.iter().map(|slot| slot.session_id).collect::>(), + vec![1, 2] + ); + + assert!(take_matching_prepared_windows_ime_session(&mut slots, 1).is_some()); + assert_eq!( + slots.iter().map(|slot| slot.session_id).collect::>(), + vec![2] + ); } #[test] 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 57d55c5f..f2160975 100644 --- a/openless-all/app/src-tauri/src/windows_ime_profile.rs +++ b/openless-all/app/src-tauri/src/windows_ime_profile.rs @@ -184,7 +184,8 @@ mod windows_impl { use std::ffi::c_void; use std::path::Path; use std::ptr; - use windows::core::GUID; + use windows::core::{GUID, HRESULT}; + use windows::Win32::Foundation::RPC_E_CHANGED_MODE; use windows::Win32::System::Com::{ CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED, @@ -209,21 +210,47 @@ mod windows_impl { TF_IPPMF_FORSESSION | TF_IPPMF_DONTCARECURRENTINPUTLANGUAGE | TF_IPPMF_ENABLEPROFILE; const PROFILE_RESTORE_FLAGS: u32 = TF_IPPMF_FORSESSION | TF_IPPMF_DONTCARECURRENTINPUTLANGUAGE; - struct ComApartment; + pub(super) struct ComInitializeOwnership { + pub(super) should_uninitialize: bool, + } + + pub(super) fn coinitialize_result_ownership( + result: HRESULT, + ) -> WindowsImeProfileResult { + if result == RPC_E_CHANGED_MODE { + return Ok(ComInitializeOwnership { + should_uninitialize: false, + }); + } + + result + .ok() + .map(|_| ComInitializeOwnership { + should_uninitialize: true, + }) + .map_err(|err| WindowsImeProfileError::WindowsApi(format!("CoInitializeEx: {err}"))) + } + + struct ComApartment { + should_uninitialize: bool, + } impl ComApartment { fn initialize() -> WindowsImeProfileResult { - unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) } - .ok() - .map_err(|err| { - WindowsImeProfileError::WindowsApi(format!("CoInitializeEx: {err}")) - })?; - Ok(Self) + let ownership = coinitialize_result_ownership(unsafe { + CoInitializeEx(None, COINIT_APARTMENTTHREADED) + })?; + Ok(Self { + should_uninitialize: ownership.should_uninitialize, + }) } } impl Drop for ComApartment { fn drop(&mut self) { + if !self.should_uninitialize { + return; + } unsafe { CoUninitialize(); } @@ -628,6 +655,7 @@ mod windows_tests { use super::*; use std::ffi::c_void; use std::ptr; + use windows::Win32::Foundation::RPC_E_CHANGED_MODE; use windows::Win32::UI::Input::KeyboardAndMouse::HKL; use windows::Win32::UI::TextServices::GUID_TFCAT_TIP_KEYBOARD; @@ -683,4 +711,11 @@ mod windows_tests { assert_eq!(formatted, "{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}"); assert!(parse_guid(&formatted).is_ok()); } + + #[test] + fn com_changed_mode_is_accepted_without_uninitializing() { + let ownership = windows_impl::coinitialize_result_ownership(RPC_E_CHANGED_MODE).unwrap(); + + assert!(!ownership.should_uninitialize); + } }