fix: harden Windows IME review edge cases

This commit is contained in:
millionart committed 2026-05-04 10:17:56 +08:00
1 parent 31045f7871
commit 2554259d80
5 files changed
+184 -32

No files matched your search

@@ -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)"
}
@@ -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
}
@@ -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, [
+50 -21
View File
@@ -120,7 +120,7 @@ struct Inner {
#[cfg(target_os = "windows")]
windows_ime: WindowsImeSessionController,
#[cfg(target_os = "windows")]
prepared_windows_ime_session: Arc<Mutex<Option<PreparedWindowsImeSessionSlot>>>,
prepared_windows_ime_session: Arc<Mutex<Vec<PreparedWindowsImeSessionSlot>>>,
state: Mutex<SessionState>,
asr: Mutex<Option<SessionResource<ActiveAsr>>>,
recorder: Mutex<Option<SessionResource<Recorder>>>,
@@ -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<Inner>) -> 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<Inner>) {
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
}
#[cfg(target_os = "windows")]
fn store_prepared_windows_ime_session(
slots: &mut Vec<PreparedWindowsImeSessionSlot>,
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<PreparedWindowsImeSessionSlot>,
slots: &mut Vec<PreparedWindowsImeSessionSlot>,
session_id: u64,
) -> Option<PreparedWindowsImeSession> {
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<_>>(),
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<_>>(),
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<_>>(),
vec![2]
);
}
#[test]
@@ -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<ComInitializeOwnership> {
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<Self> {
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);
}
}