From 7005a060b0b2f18c0caaeeeb1c74dd7ded21fdba Mon Sep 17 00:00:00 2001 From: millionart Date: Fri, 1 May 2026 18:05:39 +0800 Subject: [PATCH 01/43] docs: design temporary TSF IME flow --- ...-05-01-windows-temporary-tsf-ime-design.md | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-01-windows-temporary-tsf-ime-design.md diff --git a/docs/superpowers/specs/2026-05-01-windows-temporary-tsf-ime-design.md b/docs/superpowers/specs/2026-05-01-windows-temporary-tsf-ime-design.md new file mode 100644 index 00000000..5b482249 --- /dev/null +++ b/docs/superpowers/specs/2026-05-01-windows-temporary-tsf-ime-design.md @@ -0,0 +1,143 @@ +# Windows 临时激活式 TSF IME 设计 + +## 背景 + +OpenLess 当前 Windows 插入链路仍依赖剪贴板:先把最终文本写入剪贴板,再向焦点控件发送 `WM_PASTE`。这比模拟 `Ctrl+V` 更稳,但本质仍是粘贴工具,不是 Windows 输入法。 + +目标是在 Windows 上新增真正的 TSF 输入法后端,让语音结果通过系统文本输入框架提交,同时不破坏用户平时使用微软拼音、搜狗或英文键盘的手动输入体验。 + +## 目标 + +- OpenLess 在语音会话期间临时切换到 OpenLess TSF 输入法。 +- 录音、ASR、润色、胶囊 UI、历史保存继续由现有 Tauri/Rust 主程序负责。 +- OpenLess TSF IME DLL 只负责系统输入法身份、接收最终文本、通过 TSF 提交到当前文本上下文。 +- 提交、取消或失败后自动恢复会话开始前的输入法 profile。 +- 不要求用户手动切换输入法。 +- 不把第三方中文输入法代理进 OpenLess IME;用户平时中文手打仍使用原输入法。 + +## 非目标 + +- 不把录音、网络请求、ASR、LLM、Tauri UI 放进 IME DLL。 +- 不实现拼音候选、中文转换、词库或第三方 IME 代理。 +- 不移除现有 Windows `WM_PASTE` 路径;它保留为未安装 TSF IME、切换失败或提交失败时的回退路径。 +- 不承诺 UAC 安全桌面、管理员权限目标窗口、游戏、远程桌面或强隔离应用中的完整可用性。 + +## 架构 + +新增 Windows-only 输入层由三个部分组成: + +1. `OpenLess` 主程序:沿用现有 `Coordinator` 状态机。语音热键开始时记录当前输入 profile 并临时激活 OpenLess TSF profile;语音结束后把最终文本发送给 IME;会话收尾时恢复原 profile。 +2. `OpenLess TSF IME DLL`:COM in-proc text service,注册为 TSF input processor。它实现最小可用的激活、停用、编辑会话和文本提交能力,不持有产品业务状态。 +3. `OpenLess IME IPC`:本机 IPC 通道,连接主程序和当前被 TSF 加载的 IME 实例。主程序发送带 session id 的最终文本;IME 在可写 TSF context 中调用 `ITfInsertAtSelection::InsertTextAtSelection`。 + +TSF IME 使用官方 profile 注册路径,而不是手写默认输入法注册表项。安装阶段注册 COM in-proc server、TSF text service、language profile,并把 OpenLess profile 加入当前用户可用输入法列表。 + +## 会话时序 + +1. 用户按下当前 OpenLess 全局热键。 +2. `Coordinator` 从 `Idle` 进入录音启动流程。 +3. Windows 输入 profile 守护逻辑读取并保存当前活动 profile,包括键盘布局或 TSF input processor。 +4. 守护逻辑激活 OpenLess TSF profile,范围优先使用当前桌面 session。 +5. 用户说话,现有 recorder、ASR、polish 流程不变。 +6. 用户再次按热键结束录音;`Coordinator` 获得最终 polished text。 +7. 主程序通过 IPC 向 OpenLess IME 发送 `{ session_id, text }`。 +8. 当前焦点应用中的 OpenLess IME 实例在 TSF edit session 中提交文本。 +9. 主程序收到提交成功、超时或失败结果。 +10. 无论成功、取消还是失败,守护逻辑都尝试恢复第 3 步保存的输入 profile。 +11. `Coordinator` 按现有规则保存历史并更新胶囊状态。 + +## Profile 切换策略 + +会话开始时记录完整 active profile,而不是只记录语言 ID。记录内容至少包括: + +- profile type:keyboard layout 或 TSF input processor; +- language id; +- text service CLSID; +- profile GUID; +- HKL; +- 激活范围。 + +激活 OpenLess profile 时使用 TSF profile manager。若当前输入语言与 OpenLess profile 不一致,使用允许切换到指定 profile 的标志,避免因语言不匹配导致激活失败。 + +恢复时优先恢复原始 profile。若原始 profile 不再可用,记录 warning 并保持系统当前输入法,不再反复切换。恢复失败不阻塞历史保存。 + +## IPC 协议 + +MVP 使用本机低延迟 IPC,协议保持小而明确: + +- `SubmitText { session_id, text, created_at }` +- `SubmitResult { session_id, status, error_code }` +- `CancelSession { session_id }` +- `Ping` + +`session_id` 必须由现有 `DictationSession` 或 coordinator 会话生成,IME 只接受当前最新待提交 session,避免过期文本在焦点变化后落入错误应用。 + +IPC 超时策略: + +- 等待 IME 连接:短超时,失败后走现有 `WM_PASTE` 回退。 +- 等待提交结果:短超时,失败后恢复原 profile 并走回退或报 `CopiedFallback`。 +- 会话取消:发送 `CancelSession`,IME 丢弃待提交文本。 + +## 失败与恢复 + +必须把“用户文字不丢失”作为约束: + +- OpenLess profile 激活失败:不进入 TSF 提交流程,继续使用现有 Windows 插入后端。 +- IME DLL 未安装或未注册:设置页显示状态,语音输入仍可用但使用回退后端。 +- IPC 断开或超时:恢复原 profile,并使用现有 `WM_PASTE` 路径。 +- TSF 提交返回只读、无 selection、context disconnected 或 no lock:恢复原 profile,并使用现有回退路径。 +- 用户在 Processing 阶段取消:不提交文本,恢复原 profile。 +- OpenLess 主程序崩溃:下次启动检查是否存在“上次会话临时切换未恢复”标记;若存在,尝试恢复最近保存的 profile。 + +## 用户体验 + +平时用户继续使用原输入法。只有语音会话期间,系统输入指示器可能短暂切到 OpenLess。会话结束后自动回到原输入法。 + +设置页新增 Windows-only 输入后端状态: + +- TSF 输入法已安装并可用; +- TSF 输入法未安装; +- TSF 输入法注册异常; +- 当前使用剪贴板/`WM_PASTE` 回退。 + +默认行为保持保守:未安装 TSF IME 时,不改变现有插入体验。安装 TSF IME 后,Windows 平台优先使用临时激活式 TSF 后端。 + +## 文件与模块边界 + +计划新增或调整的主要区域: + +- `openless-all/app/src-tauri/src/insertion.rs`:保留现有回退后端,新增 Windows TSF 后端选择入口。 +- `openless-all/app/src-tauri/src/windows_ime_profile.rs`:封装 active profile 读取、OpenLess profile 激活、原 profile 恢复。 +- `openless-all/app/src-tauri/src/windows_ime_ipc.rs`:封装主程序到 IME 的 IPC。 +- `openless-all/app/windows-ime/`:新增 Windows-only TSF IME DLL 工程,包含 COM 注册、TSF text service、edit session、IPC 客户端。 +- `openless-all/app/scripts/`:新增 Windows IME 注册、注销、打包脚本。 +- `openless-all/app/src/lib/ipc.ts` 与设置页:暴露 Windows TSF 后端安装/健康状态。 + +Rust 业务模块仍遵守现有约束:叶子模块不互相调用;跨模块编排继续放在 `coordinator.rs`。 + +## 验证 + +自动验证: + +- Rust backend type check:`cargo check --manifest-path openless-all/app/src-tauri/Cargo.toml` +- Windows IME 工程 build。 +- profile 记录/恢复逻辑单元测试。 +- IPC 协议编解码和过期 session 丢弃测试。 +- 前端构建:`npm run build` + +手动验证: + +- Notepad:微软拼音为当前输入法,按 OpenLess 热键录音,提交后文本进入光标位置,并自动回到微软拼音。 +- 浏览器文本框:同上。 +- VS Code 编辑器:同上。 +- 取消录音:不插入文本,并恢复原输入法。 +- 未安装 OpenLess IME:语音输入仍走现有回退路径。 +- 目标窗口不可写:不丢文本,恢复原输入法,并给出可理解状态。 + +## 参考 + +- Microsoft Learn: Custom Input Method Editor requirements +- Microsoft Learn: Text Services Framework +- Microsoft Learn: Text Service Registration +- Microsoft Learn: `ITfInputProcessorProfileMgr::ActivateProfile` +- Microsoft Learn: `ITfInsertAtSelection::InsertTextAtSelection` From 14cacc646df9632fae671d8dfb26e3147d26df5e Mon Sep 17 00:00:00 2001 From: millionart Date: Fri, 1 May 2026 18:19:41 +0800 Subject: [PATCH 02/43] chore: ignore local worktrees --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 261fa427..2a2095e5 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ apps/ promo/ promo-openless/ docs/old-promo/ +.worktrees/ # Planning docs are kept local only, not published to the public repo. docs/plans/ From 271a51ad7955326ccbec9df4a7090de0c88e5c18 Mon Sep 17 00:00:00 2001 From: millionart Date: Fri, 1 May 2026 18:20:01 +0800 Subject: [PATCH 03/43] docs: plan temporary TSF IME implementation --- .../2026-05-01-windows-temporary-tsf-ime.md | 2191 +++++++++++++++++ 1 file changed, 2191 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-01-windows-temporary-tsf-ime.md diff --git a/docs/superpowers/plans/2026-05-01-windows-temporary-tsf-ime.md b/docs/superpowers/plans/2026-05-01-windows-temporary-tsf-ime.md new file mode 100644 index 00000000..582aa827 --- /dev/null +++ b/docs/superpowers/plans/2026-05-01-windows-temporary-tsf-ime.md @@ -0,0 +1,2191 @@ +# Windows Temporary TSF IME Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a Windows-only TSF input-method backend that temporarily activates OpenLess during a voice session, commits the final dictated text through TSF, and restores the user's previous input method. + +**Architecture:** Keep the existing Tauri/Rust app as the only owner of hotkeys, recording, ASR, polish, UI, history, and fallback insertion. Add a small Windows TSF COM DLL that registers as an input processor and accepts one active `SubmitText` request from the app over a local named pipe. Add a Rust Windows IME controller that records/restores the active input profile and falls back to the current `WM_PASTE` path whenever TSF activation or commit cannot complete. + +**Tech Stack:** Rust 2021, Tauri 2, `windows` crate Win32/TSF bindings, Tokio named pipes on Windows, C++17 Windows SDK COM/TSF DLL, PowerShell registration scripts, React/TypeScript settings surface. + +--- + +## File Structure + +- Create: `openless-all/app/src-tauri/src/windows_ime_protocol.rs` + - Shared Rust message types for app-side JSONL IPC. + - Pure tests for serialization and stale session rejection. +- Create: `openless-all/app/src-tauri/src/windows_ime_profile.rs` + - Windows-only TSF profile snapshot, OpenLess profile activation, and restoration. + - Non-Windows stub so cross-platform builds keep compiling. +- Create: `openless-all/app/src-tauri/src/windows_ime_ipc.rs` + - Windows-only named-pipe server that tracks the most recent OpenLess IME client and submits text with a timeout. + - Non-Windows stub returning `Unavailable`. +- Create: `openless-all/app/src-tauri/src/windows_ime_session.rs` + - Session guard that combines profile switching, IPC submit, fallback routing, and restoration. +- Modify: `openless-all/app/src-tauri/src/insertion.rs` + - Keep existing clipboard/`WM_PASTE` insertion as the Windows fallback and expose a clearly named fallback method. +- Modify: `openless-all/app/src-tauri/src/coordinator.rs` + - Prepare Windows IME session on voice-session start. + - Submit through TSF first on Windows, then fallback. + - Restore input profile on success, failure, and cancellation. +- Modify: `openless-all/app/src-tauri/src/lib.rs` + - Register the new Rust modules and Tauri commands. +- Modify: `openless-all/app/src-tauri/src/commands.rs` + - Expose Windows IME install/status commands. +- Modify: `openless-all/app/src-tauri/src/types.rs` + - Add Windows IME status value types for IPC to the frontend. +- Modify: `openless-all/app/src-tauri/Cargo.toml` + - Add Windows API feature gates required for COM, TSF, named-pipe helpers, registry, and process/thread lookup. +- Create: `openless-all/app/windows-ime/OpenLessIme.sln` +- Create: `openless-all/app/windows-ime/OpenLessIme.vcxproj` +- Create: `openless-all/app/windows-ime/src/guids.h` +- Create: `openless-all/app/windows-ime/src/dllmain.cpp` +- Create: `openless-all/app/windows-ime/src/class_factory.h` +- Create: `openless-all/app/windows-ime/src/class_factory.cpp` +- Create: `openless-all/app/windows-ime/src/text_service.h` +- Create: `openless-all/app/windows-ime/src/text_service.cpp` +- Create: `openless-all/app/windows-ime/src/edit_session.h` +- Create: `openless-all/app/windows-ime/src/edit_session.cpp` +- Create: `openless-all/app/windows-ime/src/ipc_client.h` +- Create: `openless-all/app/windows-ime/src/ipc_client.cpp` +- Create: `openless-all/app/windows-ime/src/registry.h` +- Create: `openless-all/app/windows-ime/src/registry.cpp` +- Create: `openless-all/app/windows-ime/src/resource.rc` + - Minimal C++ TSF text service DLL. +- Create: `openless-all/app/scripts/windows-ime-register.ps1` +- Create: `openless-all/app/scripts/windows-ime-unregister.ps1` +- Create: `openless-all/app/scripts/windows-ime-build.ps1` + - Build, register, and unregister scripts for the TSF DLL. +- Modify: `openless-all/app/scripts/windows-preflight.ps1` + - Check MSBuild and Windows SDK when TSF IME work is requested. +- Modify: `openless-all/app/src/lib/types.ts` +- Modify: `openless-all/app/src/lib/ipc.ts` +- Modify: `openless-all/app/src/i18n/zh-CN.ts` +- Modify: `openless-all/app/src/i18n/en.ts` +- Modify: `openless-all/app/src/pages/Settings.tsx` + - Windows-only TSF IME status and actions. + +Use these fixed identifiers in every Rust, C++, and script location: + +```text +OpenLess TSF text service CLSID: {6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D} +OpenLess TSF profile GUID: {9B5F5E04-23F6-47DA-9A26-D221F6C3F02E} +OpenLess TSF category GUID: GUID_TFCAT_TIP_KEYBOARD +OpenLess TSF language id: 0x0804 +OpenLess named pipe: \\.\pipe\OpenLessImeSubmit +OpenLess protocol version: 1 +``` + +--- + +### Task 1: Shared IME IPC Protocol + +**Files:** +- Create: `openless-all/app/src-tauri/src/windows_ime_protocol.rs` +- Modify: `openless-all/app/src-tauri/src/lib.rs` + +- [ ] **Step 1: Write failing protocol serialization tests** + +Add this test module to the new file before adding production types: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn submit_text_roundtrips_as_camel_case_json() { + let message = ImePipeMessage::SubmitText { + protocol_version: OPENLESS_IME_PROTOCOL_VERSION, + session_id: "session-1".to_string(), + text: "你好 OpenLess".to_string(), + created_at: "2026-05-01T12:00:00Z".to_string(), + }; + + let json = encode_message(&message).expect("encode"); + assert!(json.contains("\"submitText\"")); + assert!(json.ends_with('\n')); + + let decoded = decode_message(json.trim_end()).expect("decode"); + assert_eq!(decoded, message); + } + + #[test] + fn stale_submit_result_is_rejected() { + let result = ImePipeMessage::SubmitResult { + protocol_version: OPENLESS_IME_PROTOCOL_VERSION, + session_id: "old-session".to_string(), + status: ImeSubmitStatus::Committed, + error_code: None, + }; + + assert!(is_result_for_pending_session(&result, "current-session").is_err()); + assert!(is_result_for_pending_session(&result, "old-session").is_ok()); + } +} +``` + +- [ ] **Step 2: Run the test and verify it fails because types are missing** + +Run: + +```powershell +cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml windows_ime_protocol --lib +``` + +Expected: compile fails with missing `ImePipeMessage`, `OPENLESS_IME_PROTOCOL_VERSION`, `encode_message`, `decode_message`, `ImeSubmitStatus`, and `is_result_for_pending_session`. + +- [ ] **Step 3: Add the protocol implementation** + +Put this implementation above the test module: + +```rust +use serde::{Deserialize, Serialize}; + +pub const OPENLESS_IME_PROTOCOL_VERSION: u32 = 1; +pub const OPENLESS_IME_PIPE_NAME: &str = r"\\.\pipe\OpenLessImeSubmit"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum ImePipeMessage { + ClientReady { + protocol_version: u32, + client_id: String, + process_id: u32, + thread_id: u32, + }, + SubmitText { + protocol_version: u32, + session_id: String, + text: String, + created_at: String, + }, + SubmitResult { + protocol_version: u32, + session_id: String, + status: ImeSubmitStatus, + error_code: Option, + }, + CancelSession { + protocol_version: u32, + session_id: String, + }, + Ping { + protocol_version: u32, + }, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum ImeSubmitStatus { + Committed, + Rejected, + Failed, +} + +pub fn encode_message(message: &ImePipeMessage) -> Result { + let mut line = serde_json::to_string(message)?; + line.push('\n'); + Ok(line) +} + +pub fn decode_message(line: &str) -> Result { + serde_json::from_str(line) +} + +pub fn is_result_for_pending_session( + message: &ImePipeMessage, + pending_session_id: &str, +) -> Result<(), &'static str> { + match message { + ImePipeMessage::SubmitResult { session_id, .. } if session_id == pending_session_id => Ok(()), + ImePipeMessage::SubmitResult { .. } => Err("submit result belongs to a different session"), + _ => Err("message is not a submit result"), + } +} +``` + +- [ ] **Step 4: Register the module** + +Add this to `openless-all/app/src-tauri/src/lib.rs` beside the other `mod` declarations: + +```rust +mod windows_ime_protocol; +``` + +- [ ] **Step 5: Run the protocol tests** + +Run: + +```powershell +cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml windows_ime_protocol --lib +``` + +Expected: both protocol tests pass. + +- [ ] **Step 6: Commit** + +```powershell +git add -- openless-all/app/src-tauri/src/windows_ime_protocol.rs openless-all/app/src-tauri/src/lib.rs +git commit -m "feat: add Windows IME IPC protocol" +``` + +--- + +### Task 2: Profile Snapshot State Machine + +**Files:** +- Create: `openless-all/app/src-tauri/src/windows_ime_profile.rs` +- Modify: `openless-all/app/src-tauri/src/lib.rs` + +- [ ] **Step 1: Write failing pure state tests** + +Create `openless-all/app/src-tauri/src/windows_ime_profile.rs` with these tests first: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + fn text_service_snapshot() -> ImeProfileSnapshot { + ImeProfileSnapshot { + kind: ImeProfileKind::TextService, + lang_id: 0x0804, + clsid: Some("{11111111-1111-1111-1111-111111111111}".to_string()), + profile_guid: Some("{22222222-2222-2222-2222-222222222222}".to_string()), + hkl: None, + } + } + + #[test] + fn restore_is_required_when_openless_is_active_and_snapshot_exists() { + assert_eq!( + restore_decision(Some(&text_service_snapshot()), true), + ProfileRestoreDecision::RestoreSavedProfile + ); + } + + #[test] + fn restore_is_skipped_when_snapshot_is_missing() { + assert_eq!( + restore_decision(None, true), + ProfileRestoreDecision::KeepCurrentProfile + ); + } + + #[test] + fn restore_is_skipped_when_user_already_changed_away_from_openless() { + assert_eq!( + restore_decision(Some(&text_service_snapshot()), false), + ProfileRestoreDecision::KeepCurrentProfile + ); + } +} +``` + +- [ ] **Step 2: Run the test and verify it fails because profile types are missing** + +Run: + +```powershell +cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml windows_ime_profile --lib +``` + +Expected: compile fails with missing snapshot and decision types. + +- [ ] **Step 3: Add platform-neutral profile types** + +Add this implementation above the test module: + +```rust +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ImeProfileKind { + KeyboardLayout, + TextService, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImeProfileSnapshot { + pub kind: ImeProfileKind, + pub lang_id: u16, + pub clsid: Option, + pub profile_guid: Option, + pub hkl: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProfileRestoreDecision { + RestoreSavedProfile, + KeepCurrentProfile, +} + +pub fn restore_decision( + saved: Option<&ImeProfileSnapshot>, + openless_profile_is_current: bool, +) -> ProfileRestoreDecision { + if saved.is_some() && openless_profile_is_current { + ProfileRestoreDecision::RestoreSavedProfile + } else { + ProfileRestoreDecision::KeepCurrentProfile + } +} +``` + +- [ ] **Step 4: Add public manager API with non-Windows stub** + +Add this API below the pure types: + +```rust +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WindowsImeProfileError { + Unavailable(String), + WindowsApi(String), +} + +impl std::fmt::Display for WindowsImeProfileError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Unavailable(message) | Self::WindowsApi(message) => write!(f, "{message}"), + } + } +} + +impl std::error::Error for WindowsImeProfileError {} + +pub type WindowsImeProfileResult = Result; + +#[cfg(not(target_os = "windows"))] +pub struct WindowsImeProfileManager; + +#[cfg(not(target_os = "windows"))] +impl WindowsImeProfileManager { + pub fn new() -> Self { + Self + } + + pub fn capture_active_profile(&self) -> WindowsImeProfileResult { + Err(WindowsImeProfileError::Unavailable( + "Windows TSF profiles are only available on Windows".to_string(), + )) + } + + pub fn activate_openless_profile(&self) -> WindowsImeProfileResult<()> { + Err(WindowsImeProfileError::Unavailable( + "Windows TSF profiles are only available on Windows".to_string(), + )) + } + + pub fn restore_profile(&self, _snapshot: &ImeProfileSnapshot) -> WindowsImeProfileResult<()> { + Err(WindowsImeProfileError::Unavailable( + "Windows TSF profiles are only available on Windows".to_string(), + )) + } + + pub fn is_openless_profile_active(&self) -> WindowsImeProfileResult { + Ok(false) + } +} +``` + +- [ ] **Step 5: Register the module** + +Add this to `openless-all/app/src-tauri/src/lib.rs`: + +```rust +mod windows_ime_profile; +``` + +- [ ] **Step 6: Run the profile tests** + +Run: + +```powershell +cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml windows_ime_profile --lib +``` + +Expected: all profile state tests pass. + +- [ ] **Step 7: Commit** + +```powershell +git add -- openless-all/app/src-tauri/src/windows_ime_profile.rs openless-all/app/src-tauri/src/lib.rs +git commit -m "feat: add Windows IME profile state" +``` + +--- + +### Task 3: Windows TSF Profile Manager + +**Files:** +- Modify: `openless-all/app/src-tauri/Cargo.toml` +- Modify: `openless-all/app/src-tauri/src/windows_ime_profile.rs` + +- [ ] **Step 1: Write failing Windows-only compile test for fixed identifiers** + +Add these tests inside `windows_ime_profile.rs`: + +```rust +#[cfg(all(test, target_os = "windows"))] +mod windows_tests { + use super::*; + + #[test] + fn openless_profile_identifiers_are_fixed() { + assert_eq!(OPENLESS_TSF_LANG_ID, 0x0804); + assert_eq!( + OPENLESS_TEXT_SERVICE_CLSID_BRACED, + "{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}" + ); + assert_eq!( + OPENLESS_PROFILE_GUID_BRACED, + "{9B5F5E04-23F6-47DA-9A26-D221F6C3F02E}" + ); + } +} +``` + +- [ ] **Step 2: Run the Windows-only test and verify it fails** + +Run on Windows: + +```powershell +cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml openless_profile_identifiers_are_fixed --lib +``` + +Expected: compile fails because the constants are not defined. + +- [ ] **Step 3: Extend Windows API features** + +In `openless-all/app/src-tauri/Cargo.toml`, extend the Windows dependency features to include: + +```toml + "Win32_Globalization", + "Win32_System_Com", + "Win32_System_Ole", + "Win32_System_Registry", + "Win32_UI_TextServices", +``` + +The resulting Windows dependency block keeps the existing features and includes the new ones: + +```toml +[target.'cfg(target_os = "windows")'.dependencies] +windows = { version = "0.58", features = [ + "Win32_Foundation", + "Win32_Globalization", + "Win32_System_Com", + "Win32_System_Ole", + "Win32_System_Registry", + "Win32_System_Threading", + "Win32_UI_Input_KeyboardAndMouse", + "Win32_UI_Shell", + "Win32_UI_TextServices", + "Win32_UI_WindowsAndMessaging", +] } +winreg = "0.52" +``` + +- [ ] **Step 4: Add Windows constants and GUID parsing helpers** + +Add these items near the top of `windows_ime_profile.rs`: + +```rust +pub const OPENLESS_TSF_LANG_ID: u16 = 0x0804; +pub const OPENLESS_TEXT_SERVICE_CLSID_BRACED: &str = + "{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}"; +pub const OPENLESS_PROFILE_GUID_BRACED: &str = + "{9B5F5E04-23F6-47DA-9A26-D221F6C3F02E}"; + +#[cfg(target_os = "windows")] +fn parse_guid(value: &str) -> WindowsImeProfileResult { + windows::core::GUID::from(value).map_err(|err| { + WindowsImeProfileError::WindowsApi(format!("invalid GUID {value}: {err}")) + }) +} +``` + +- [ ] **Step 5: Add Windows profile manager skeleton** + +Replace the non-Windows-only manager coverage with a Windows implementation guarded by `#[cfg(target_os = "windows")]`: + +```rust +#[cfg(target_os = "windows")] +pub struct WindowsImeProfileManager; + +#[cfg(target_os = "windows")] +impl WindowsImeProfileManager { + pub fn new() -> Self { + Self + } + + pub fn capture_active_profile(&self) -> WindowsImeProfileResult { + windows_impl::capture_active_profile() + } + + pub fn activate_openless_profile(&self) -> WindowsImeProfileResult<()> { + windows_impl::activate_openless_profile() + } + + pub fn restore_profile(&self, snapshot: &ImeProfileSnapshot) -> WindowsImeProfileResult<()> { + windows_impl::restore_profile(snapshot) + } + + pub fn is_openless_profile_active(&self) -> WindowsImeProfileResult { + windows_impl::is_openless_profile_active() + } +} +``` + +- [ ] **Step 6: Implement the Windows TSF calls** + +Add this module in `windows_ime_profile.rs`. Keep all COM calls inside this module: + +```rust +#[cfg(target_os = "windows")] +mod windows_impl { + use super::*; + use windows::core::{Interface, GUID}; + use windows::Win32::Foundation::HKL; + use windows::Win32::System::Com::{ + CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_INPROC_SERVER, + COINIT_APARTMENTTHREADED, + }; + use windows::Win32::UI::Input::KeyboardAndMouse::GetKeyboardLayout; + use windows::Win32::UI::TextServices::{ + ITfInputProcessorProfileMgr, CLSID_TF_InputProcessorProfiles, + TF_PROFILETYPE_INPUTPROCESSOR, TF_PROFILETYPE_KEYBOARDLAYOUT, + TF_IPPMF_FORPROCESS, + }; + + struct ComApartment; + + impl ComApartment { + fn init() -> WindowsImeProfileResult { + unsafe { + CoInitializeEx(None, COINIT_APARTMENTTHREADED).map_err(|err| { + WindowsImeProfileError::WindowsApi(format!("CoInitializeEx failed: {err}")) + })?; + } + Ok(Self) + } + } + + impl Drop for ComApartment { + fn drop(&mut self) { + unsafe { + CoUninitialize(); + } + } + } + + fn profile_mgr() -> WindowsImeProfileResult { + unsafe { + CoCreateInstance(&CLSID_TF_InputProcessorProfiles, None, CLSCTX_INPROC_SERVER) + .map_err(|err| { + WindowsImeProfileError::WindowsApi(format!( + "CoCreateInstance(CLSID_TF_InputProcessorProfiles) failed: {err}" + )) + }) + } + } + + pub fn capture_active_profile() -> WindowsImeProfileResult { + let _com = ComApartment::init()?; + let mgr = profile_mgr()?; + unsafe { + let profile = mgr.GetActiveProfile(GUID::zeroed()).map_err(|err| { + WindowsImeProfileError::WindowsApi(format!("GetActiveProfile failed: {err}")) + })?; + if profile.dwProfileType == TF_PROFILETYPE_INPUTPROCESSOR { + return Ok(ImeProfileSnapshot { + kind: ImeProfileKind::TextService, + lang_id: profile.langid as u16, + clsid: Some(format!("{:?}", profile.clsid)), + profile_guid: Some(format!("{:?}", profile.guidProfile)), + hkl: None, + }); + } + let hkl = GetKeyboardLayout(0); + Ok(ImeProfileSnapshot { + kind: ImeProfileKind::KeyboardLayout, + lang_id: profile.langid as u16, + clsid: None, + profile_guid: None, + hkl: Some(hkl.0), + }) + } + } + + pub fn activate_openless_profile() -> WindowsImeProfileResult<()> { + let _com = ComApartment::init()?; + let mgr = profile_mgr()?; + let clsid = parse_guid(OPENLESS_TEXT_SERVICE_CLSID_BRACED)?; + let profile_guid = parse_guid(OPENLESS_PROFILE_GUID_BRACED)?; + unsafe { + mgr.ActivateProfile( + TF_PROFILETYPE_INPUTPROCESSOR, + OPENLESS_TSF_LANG_ID, + &clsid, + &profile_guid, + windows::Win32::Foundation::HKL(0), + TF_IPPMF_FORPROCESS, + ) + .map_err(|err| { + WindowsImeProfileError::WindowsApi(format!( + "ActivateProfile(OpenLess) failed: {err}" + )) + }) + } + } + + pub fn restore_profile(snapshot: &ImeProfileSnapshot) -> WindowsImeProfileResult<()> { + let _com = ComApartment::init()?; + let mgr = profile_mgr()?; + unsafe { + match snapshot.kind { + ImeProfileKind::TextService => { + let clsid = parse_guid(snapshot.clsid.as_deref().ok_or_else(|| { + WindowsImeProfileError::WindowsApi( + "saved text service profile has no CLSID".to_string(), + ) + })?)?; + let profile_guid = parse_guid(snapshot.profile_guid.as_deref().ok_or_else(|| { + WindowsImeProfileError::WindowsApi( + "saved text service profile has no profile GUID".to_string(), + ) + })?)?; + mgr.ActivateProfile( + TF_PROFILETYPE_INPUTPROCESSOR, + snapshot.lang_id, + &clsid, + &profile_guid, + HKL(0), + TF_IPPMF_FORPROCESS, + ) + } + ImeProfileKind::KeyboardLayout => { + mgr.ActivateProfile( + TF_PROFILETYPE_KEYBOARDLAYOUT, + snapshot.lang_id, + &GUID::zeroed(), + &GUID::zeroed(), + HKL(snapshot.hkl.unwrap_or_default()), + TF_IPPMF_FORPROCESS, + ) + } + } + .map_err(|err| { + WindowsImeProfileError::WindowsApi(format!("restore profile failed: {err}")) + }) + } + } + + pub fn is_openless_profile_active() -> WindowsImeProfileResult { + let active = capture_active_profile()?; + Ok(active.kind == ImeProfileKind::TextService + && active.clsid.as_deref() == Some(OPENLESS_TEXT_SERVICE_CLSID_BRACED) + && active.profile_guid.as_deref() == Some(OPENLESS_PROFILE_GUID_BRACED)) + } +} +``` + +If `windows` crate signatures differ, adjust only the type adapters around `GetActiveProfile` and `ActivateProfile`; keep the public API and behavior unchanged. + +- [ ] **Step 7: Run Windows type check** + +Run: + +```powershell +cargo check --manifest-path openless-all/app/src-tauri/Cargo.toml +``` + +Expected: backend type-checks on Windows. + +- [ ] **Step 8: Run profile tests** + +Run: + +```powershell +cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml windows_ime_profile --lib +``` + +Expected: pure profile tests pass; Windows identifier test passes. + +- [ ] **Step 9: Commit** + +```powershell +git add -- openless-all/app/src-tauri/Cargo.toml openless-all/app/src-tauri/src/windows_ime_profile.rs +git commit -m "feat: manage Windows TSF input profiles" +``` + +--- + +### Task 4: Rust Named-Pipe IME Server + +**Files:** +- Create: `openless-all/app/src-tauri/src/windows_ime_ipc.rs` +- Modify: `openless-all/app/src-tauri/src/lib.rs` + +- [ ] **Step 1: Write failing pending-submit tests** + +Create `windows_ime_ipc.rs` with this test-first state logic: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::windows_ime_protocol::ImeSubmitStatus; + + #[test] + fn pending_submit_accepts_only_matching_session() { + let mut pending = PendingImeSubmit::new("session-1".to_string()); + assert!(pending.accept_result("session-2", ImeSubmitStatus::Committed).is_err()); + assert_eq!( + pending.accept_result("session-1", ImeSubmitStatus::Committed), + Ok(ImeSubmitStatus::Committed) + ); + } + + #[test] + fn pending_submit_rejects_second_result_after_completion() { + let mut pending = PendingImeSubmit::new("session-1".to_string()); + assert_eq!( + pending.accept_result("session-1", ImeSubmitStatus::Committed), + Ok(ImeSubmitStatus::Committed) + ); + assert!(pending.accept_result("session-1", ImeSubmitStatus::Committed).is_err()); + } +} +``` + +- [ ] **Step 2: Run the test and verify it fails because `PendingImeSubmit` is missing** + +Run: + +```powershell +cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml windows_ime_ipc --lib +``` + +Expected: compile fails with missing `PendingImeSubmit`. + +- [ ] **Step 3: Add pending-submit state** + +Add this implementation above the tests: + +```rust +use std::time::Duration; + +use crate::windows_ime_protocol::ImeSubmitStatus; + +pub const IME_CLIENT_WAIT_TIMEOUT: Duration = Duration::from_millis(700); +pub const IME_SUBMIT_TIMEOUT: Duration = Duration::from_millis(900); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WindowsImeIpcError { + Unavailable(String), + NoReadyClient, + Timeout, + Protocol(String), + Io(String), +} + +impl std::fmt::Display for WindowsImeIpcError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Unavailable(message) + | Self::Protocol(message) + | Self::Io(message) => write!(f, "{message}"), + Self::NoReadyClient => write!(f, "no OpenLess IME client is ready"), + Self::Timeout => write!(f, "OpenLess IME IPC timed out"), + } + } +} + +impl std::error::Error for WindowsImeIpcError {} + +pub type WindowsImeIpcResult = Result; + +#[derive(Debug)] +pub struct PendingImeSubmit { + session_id: String, + completed: bool, +} + +impl PendingImeSubmit { + pub fn new(session_id: String) -> Self { + Self { + session_id, + completed: false, + } + } + + pub fn accept_result( + &mut self, + session_id: &str, + status: ImeSubmitStatus, + ) -> WindowsImeIpcResult { + if self.completed { + return Err(WindowsImeIpcError::Protocol( + "submit result arrived after completion".to_string(), + )); + } + if self.session_id != session_id { + return Err(WindowsImeIpcError::Protocol( + "submit result belongs to a different session".to_string(), + )); + } + self.completed = true; + Ok(status) + } +} +``` + +- [ ] **Step 4: Add public server API stubs** + +Add this API below `PendingImeSubmit`: + +```rust +#[derive(Debug, Clone)] +pub struct ImeSubmitRequest { + pub session_id: String, + pub text: String, + pub created_at: String, +} + +#[derive(Clone)] +pub struct WindowsImeIpcServer { + inner: std::sync::Arc>, +} + +#[derive(Debug, Default)] +struct WindowsImeIpcState { + ready_client_id: Option, +} + +impl WindowsImeIpcServer { + pub fn new() -> Self { + Self { + inner: std::sync::Arc::new(parking_lot::Mutex::new(WindowsImeIpcState::default())), + } + } + + pub fn mark_client_ready_for_test(&self, client_id: String) { + self.inner.lock().ready_client_id = Some(client_id); + } + + pub fn has_ready_client(&self) -> bool { + self.inner.lock().ready_client_id.is_some() + } +} +``` + +- [ ] **Step 5: Add Windows async submit implementation** + +Add a Windows-only `submit_text` implementation. Keep the non-Windows implementation as an immediate `Unavailable` error: + +```rust +#[cfg(not(target_os = "windows"))] +impl WindowsImeIpcServer { + pub async fn submit_text( + &self, + _request: ImeSubmitRequest, + ) -> WindowsImeIpcResult { + Err(WindowsImeIpcError::Unavailable( + "Windows IME IPC is only available on Windows".to_string(), + )) + } +} + +#[cfg(target_os = "windows")] +impl WindowsImeIpcServer { + pub async fn submit_text( + &self, + request: ImeSubmitRequest, + ) -> WindowsImeIpcResult { + if !self.has_ready_client() { + return Err(WindowsImeIpcError::NoReadyClient); + } + + windows_pipe::submit_text_over_pipe(request).await + } +} + +#[cfg(target_os = "windows")] +mod windows_pipe { + use super::*; + use crate::windows_ime_protocol::{ + decode_message, encode_message, ImePipeMessage, OPENLESS_IME_PIPE_NAME, + OPENLESS_IME_PROTOCOL_VERSION, + }; + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + use tokio::net::windows::named_pipe::ClientOptions; + + pub async fn submit_text_over_pipe( + request: ImeSubmitRequest, + ) -> WindowsImeIpcResult { + let client = ClientOptions::new() + .open(OPENLESS_IME_PIPE_NAME) + .map_err(|err| WindowsImeIpcError::Io(format!("open IME pipe failed: {err}")))?; + let (reader, mut writer) = tokio::io::split(client); + let mut reader = BufReader::new(reader); + let submit = ImePipeMessage::SubmitText { + protocol_version: OPENLESS_IME_PROTOCOL_VERSION, + session_id: request.session_id.clone(), + text: request.text, + created_at: request.created_at, + }; + let line = encode_message(&submit) + .map_err(|err| WindowsImeIpcError::Protocol(format!("encode submit failed: {err}")))?; + writer + .write_all(line.as_bytes()) + .await + .map_err(|err| WindowsImeIpcError::Io(format!("write submit failed: {err}")))?; + writer + .flush() + .await + .map_err(|err| WindowsImeIpcError::Io(format!("flush submit failed: {err}")))?; + + let mut response = String::new(); + let read = tokio::time::timeout(IME_SUBMIT_TIMEOUT, reader.read_line(&mut response)) + .await + .map_err(|_| WindowsImeIpcError::Timeout)? + .map_err(|err| WindowsImeIpcError::Io(format!("read submit result failed: {err}")))?; + if read == 0 { + return Err(WindowsImeIpcError::Io("IME pipe closed before result".to_string())); + } + + match decode_message(response.trim_end()) + .map_err(|err| WindowsImeIpcError::Protocol(format!("decode result failed: {err}")))? + { + ImePipeMessage::SubmitResult { + session_id, + status, + .. + } if session_id == request.session_id => Ok(status), + _ => Err(WindowsImeIpcError::Protocol( + "unexpected IME submit result".to_string(), + )), + } + } +} +``` + +This MVP opens the named pipe for each submit. The C++ IME DLL owns the pipe server because it is the active TSF instance inside the focused process. + +- [ ] **Step 6: Register the module** + +Add this to `lib.rs`: + +```rust +mod windows_ime_ipc; +``` + +- [ ] **Step 7: Run tests** + +Run: + +```powershell +cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml windows_ime_ipc --lib +``` + +Expected: pending-submit tests pass. + +- [ ] **Step 8: Run type check** + +Run: + +```powershell +cargo check --manifest-path openless-all/app/src-tauri/Cargo.toml +``` + +Expected: backend type-checks. + +- [ ] **Step 9: Commit** + +```powershell +git add -- openless-all/app/src-tauri/src/windows_ime_ipc.rs openless-all/app/src-tauri/src/lib.rs +git commit -m "feat: add Windows IME IPC client" +``` + +--- + +### Task 5: Windows IME Session Guard and Fallback Routing + +**Files:** +- Create: `openless-all/app/src-tauri/src/windows_ime_session.rs` +- Modify: `openless-all/app/src-tauri/src/insertion.rs` +- Modify: `openless-all/app/src-tauri/src/coordinator.rs` +- Modify: `openless-all/app/src-tauri/src/lib.rs` + +- [ ] **Step 1: Write failing routing tests** + +Create `windows_ime_session.rs` with these tests: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::types::InsertStatus; + use crate::windows_ime_protocol::ImeSubmitStatus; + + #[test] + fn committed_ime_result_maps_to_inserted() { + assert_eq!( + map_ime_status_to_insert_status(ImeSubmitStatus::Committed), + InsertStatus::Inserted + ); + } + + #[test] + fn rejected_ime_result_requests_fallback() { + assert!(should_fallback_after_ime_result(ImeSubmitStatus::Rejected)); + assert!(should_fallback_after_ime_result(ImeSubmitStatus::Failed)); + assert!(!should_fallback_after_ime_result(ImeSubmitStatus::Committed)); + } +} +``` + +- [ ] **Step 2: Run the test and verify it fails because mapping functions are missing** + +Run: + +```powershell +cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml windows_ime_session --lib +``` + +Expected: compile fails with missing mapping functions. + +- [ ] **Step 3: Add mapping functions and session result types** + +Add this implementation above the tests: + +```rust +use crate::types::InsertStatus; +use crate::windows_ime_ipc::{ImeSubmitRequest, WindowsImeIpcServer}; +use crate::windows_ime_profile::{ImeProfileSnapshot, WindowsImeProfileManager}; +use crate::windows_ime_protocol::ImeSubmitStatus; + +#[derive(Debug)] +pub enum WindowsImeSessionError { + Profile(String), + Ipc(String), +} + +impl std::fmt::Display for WindowsImeSessionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Profile(message) | Self::Ipc(message) => write!(f, "{message}"), + } + } +} + +impl std::error::Error for WindowsImeSessionError {} + +pub fn map_ime_status_to_insert_status(status: ImeSubmitStatus) -> InsertStatus { + match status { + ImeSubmitStatus::Committed => InsertStatus::Inserted, + ImeSubmitStatus::Rejected | ImeSubmitStatus::Failed => InsertStatus::CopiedFallback, + } +} + +pub fn should_fallback_after_ime_result(status: ImeSubmitStatus) -> bool { + !matches!(status, ImeSubmitStatus::Committed) +} + +#[derive(Debug)] +pub struct PreparedWindowsImeSession { + saved_profile: Option, + openless_activated: bool, +} + +impl PreparedWindowsImeSession { + pub fn unavailable() -> Self { + Self { + saved_profile: None, + openless_activated: false, + } + } + + pub fn is_ready_for_tsf_submit(&self) -> bool { + self.saved_profile.is_some() && self.openless_activated + } +} +``` + +- [ ] **Step 4: Add Windows session controller** + +Add this controller below `PreparedWindowsImeSession`: + +```rust +pub struct WindowsImeSessionController { + profile_manager: WindowsImeProfileManager, + ipc: WindowsImeIpcServer, +} + +impl WindowsImeSessionController { + pub fn new() -> Self { + Self { + profile_manager: WindowsImeProfileManager::new(), + ipc: WindowsImeIpcServer::new(), + } + } + + pub fn prepare_session(&self) -> PreparedWindowsImeSession { + #[cfg(not(target_os = "windows"))] + { + PreparedWindowsImeSession::unavailable() + } + + #[cfg(target_os = "windows")] + { + let saved_profile = match self.profile_manager.capture_active_profile() { + Ok(snapshot) => Some(snapshot), + Err(err) => { + log::warn!("[windows-ime] capture active profile failed: {err}"); + None + } + }; + if saved_profile.is_none() { + return PreparedWindowsImeSession::unavailable(); + } + match self.profile_manager.activate_openless_profile() { + Ok(()) => PreparedWindowsImeSession { + saved_profile, + openless_activated: true, + }, + Err(err) => { + log::warn!("[windows-ime] activate OpenLess profile failed: {err}"); + PreparedWindowsImeSession::unavailable() + } + } + } + } + + pub async fn submit_prepared( + &self, + prepared: &PreparedWindowsImeSession, + request: ImeSubmitRequest, + ) -> Result { + if !prepared.is_ready_for_tsf_submit() { + return Err(WindowsImeSessionError::Ipc( + "OpenLess IME session is not active".to_string(), + )); + } + let status = self + .ipc + .submit_text(request) + .await + .map_err(|err| WindowsImeSessionError::Ipc(err.to_string()))?; + Ok(map_ime_status_to_insert_status(status)) + } + + pub fn restore_session(&self, prepared: PreparedWindowsImeSession) { + let Some(saved_profile) = prepared.saved_profile else { + return; + }; + match self.profile_manager.is_openless_profile_active() { + Ok(true) => { + if let Err(err) = self.profile_manager.restore_profile(&saved_profile) { + log::warn!("[windows-ime] restore previous profile failed: {err}"); + } + } + Ok(false) => {} + Err(err) => log::warn!("[windows-ime] profile active check failed: {err}"), + } + } +} +``` + +- [ ] **Step 5: Register the module** + +Add this to `lib.rs`: + +```rust +mod windows_ime_session; +``` + +- [ ] **Step 6: Expose a fallback-only insertion method** + +In `insertion.rs`, keep current behavior but rename the Windows/Linux helper intent by adding this method to `impl TextInserter` under `#[cfg(not(target_os = "macos"))]`: + +```rust +#[cfg(not(target_os = "macos"))] +pub fn insert_via_clipboard_fallback( + &self, + text: &str, + restore_clipboard_after_paste: bool, +) -> InsertStatus { + self.insert(text, restore_clipboard_after_paste) +} +``` + +- [ ] **Step 7: Wire the controller into coordinator state** + +In `coordinator.rs`, add the controller and prepared session field near the existing `inserter` field: + +```rust +#[cfg(target_os = "windows")] +use crate::windows_ime_session::{PreparedWindowsImeSession, WindowsImeSessionController}; +``` + +Add fields to the coordinator inner state: + +```rust +#[cfg(target_os = "windows")] +windows_ime: WindowsImeSessionController, +#[cfg(target_os = "windows")] +prepared_windows_ime_session: Arc>>, +``` + +Initialize them where `TextInserter::new()` is initialized: + +```rust +#[cfg(target_os = "windows")] +windows_ime: WindowsImeSessionController::new(), +#[cfg(target_os = "windows")] +prepared_windows_ime_session: Arc::new(Mutex::new(None)), +``` + +- [ ] **Step 8: Prepare TSF session when recording starts** + +In the recording-start path, immediately after the coordinator accepts the hotkey edge and before recorder start, add: + +```rust +#[cfg(target_os = "windows")] +{ + let prepared = inner.windows_ime.prepare_session(); + *inner.prepared_windows_ime_session.lock() = Some(prepared); +} +``` + +This code belongs in the same start-session branch that changes phase from `Idle` to `Starting`. + +- [ ] **Step 9: Submit through TSF first in `end_session`** + +Replace the direct insertion call: + +```rust +let status = inner.inserter.insert(&polished, restore_clipboard); +``` + +with Windows-first routing: + +```rust +#[cfg(target_os = "windows")] +let status = { + let prepared = inner.prepared_windows_ime_session.lock().take(); + if let Some(prepared) = prepared { + let request = crate::windows_ime_ipc::ImeSubmitRequest { + session_id: Uuid::new_v4().to_string(), + text: polished.clone(), + created_at: Utc::now().to_rfc3339(), + }; + let tsf_status = inner.windows_ime.submit_prepared(&prepared, request).await; + inner.windows_ime.restore_session(prepared); + match tsf_status { + Ok(InsertStatus::Inserted) => InsertStatus::Inserted, + Ok(_) | Err(_) => inner + .inserter + .insert_via_clipboard_fallback(&polished, restore_clipboard), + } + } else { + inner + .inserter + .insert_via_clipboard_fallback(&polished, restore_clipboard) + } +}; + +#[cfg(not(target_os = "windows"))] +let status = inner.inserter.insert(&polished, restore_clipboard); +``` + +- [ ] **Step 10: Restore on cancellation** + +In the cancellation path that handles active `Starting`, `Listening`, or `Processing` sessions, add: + +```rust +#[cfg(target_os = "windows")] +if let Some(prepared) = inner.prepared_windows_ime_session.lock().take() { + inner.windows_ime.restore_session(prepared); +} +``` + +Place it before returning the session to `Idle`. + +- [ ] **Step 11: Run focused tests** + +Run: + +```powershell +cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml windows_ime_session --lib +``` + +Expected: routing tests pass. + +- [ ] **Step 12: Run backend type check** + +Run: + +```powershell +cargo check --manifest-path openless-all/app/src-tauri/Cargo.toml +``` + +Expected: backend type-checks. + +- [ ] **Step 13: Commit** + +```powershell +git add -- openless-all/app/src-tauri/src/windows_ime_session.rs openless-all/app/src-tauri/src/insertion.rs openless-all/app/src-tauri/src/coordinator.rs openless-all/app/src-tauri/src/lib.rs +git commit -m "feat: route Windows insertion through temporary TSF IME" +``` + +--- + +### Task 6: C++ TSF DLL Project Skeleton + +**Files:** +- Create: `openless-all/app/windows-ime/OpenLessIme.sln` +- Create: `openless-all/app/windows-ime/OpenLessIme.vcxproj` +- Create: `openless-all/app/windows-ime/src/guids.h` +- Create: `openless-all/app/windows-ime/src/dllmain.cpp` +- Create: `openless-all/app/windows-ime/src/class_factory.h` +- Create: `openless-all/app/windows-ime/src/class_factory.cpp` +- Create: `openless-all/app/windows-ime/src/text_service.h` +- Create: `openless-all/app/windows-ime/src/text_service.cpp` +- Create: `openless-all/app/windows-ime/src/registry.h` +- Create: `openless-all/app/windows-ime/src/registry.cpp` +- Create: `openless-all/app/windows-ime/src/resource.rc` + +- [ ] **Step 1: Create the C++ project files** + +Create a Visual Studio DLL project that builds `OpenLessIme.dll` for x64 with C++17 and the Windows SDK. The `.vcxproj` must include: + +```xml +DynamicLibrary +Unicode +stdcpp17 +msctf.lib;ole32.lib;uuid.lib;advapi32.lib;%(AdditionalDependencies) +``` + +Include every `src/*.cpp`, `src/*.h`, and `src/resource.rc` file listed in this task. + +- [ ] **Step 2: Add fixed GUID constants** + +Create `src/guids.h`: + +```cpp +#pragma once + +#include + +// {6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D} +inline constexpr GUID CLSID_OpenLessTextService = { + 0x6b9f3f4f, + 0x5ee7, + 0x42d6, + {0x9c, 0x61, 0x9f, 0x80, 0xb0, 0x3a, 0x5d, 0x7d}}; + +// {9B5F5E04-23F6-47DA-9A26-D221F6C3F02E} +inline constexpr GUID GUID_OpenLessProfile = { + 0x9b5f5e04, + 0x23f6, + 0x47da, + {0x9a, 0x26, 0xd2, 0x21, 0xf6, 0xc3, 0xf0, 0x2e}}; + +inline constexpr wchar_t kOpenLessImeName[] = L"OpenLess Voice Input"; +inline constexpr LANGID kOpenLessLangId = 0x0804; +``` + +- [ ] **Step 3: Add DLL exports and module lifetime** + +Create `src/dllmain.cpp` with exports: + +```cpp +#include +#include "class_factory.h" +#include "registry.h" +#include "guids.h" + +HINSTANCE g_module = nullptr; +long g_lock_count = 0; +long g_object_count = 0; + +BOOL APIENTRY DllMain(HINSTANCE module, DWORD reason, LPVOID) { + if (reason == DLL_PROCESS_ATTACH) { + g_module = module; + DisableThreadLibraryCalls(module); + } + return TRUE; +} + +STDAPI DllCanUnloadNow() { + return (g_lock_count == 0 && g_object_count == 0) ? S_OK : S_FALSE; +} + +STDAPI DllGetClassObject(REFCLSID clsid, REFIID iid, void** result) { + if (!result) { + return E_POINTER; + } + *result = nullptr; + if (clsid != CLSID_OpenLessTextService) { + return CLASS_E_CLASSNOTAVAILABLE; + } + auto* factory = new (std::nothrow) OpenLessClassFactory(); + if (!factory) { + return E_OUTOFMEMORY; + } + const HRESULT hr = factory->QueryInterface(iid, result); + factory->Release(); + return hr; +} + +STDAPI DllRegisterServer() { + return RegisterOpenLessTextService(g_module); +} + +STDAPI DllUnregisterServer() { + return UnregisterOpenLessTextService(); +} +``` + +- [ ] **Step 4: Add class factory** + +Create `class_factory.h/.cpp` implementing `IClassFactory`. It must: + +- Support `IUnknown` and `IClassFactory`. +- Increment `g_object_count` on construction and decrement it on destruction. +- `CreateInstance` returns a new `OpenLessTextService`. +- `LockServer` increments/decrements `g_lock_count`. + +Use this `CreateInstance` body: + +```cpp +HRESULT OpenLessClassFactory::CreateInstance(IUnknown* outer, REFIID iid, void** result) { + if (!result) { + return E_POINTER; + } + *result = nullptr; + if (outer) { + return CLASS_E_NOAGGREGATION; + } + auto* service = new (std::nothrow) OpenLessTextService(); + if (!service) { + return E_OUTOFMEMORY; + } + const HRESULT hr = service->QueryInterface(iid, result); + service->Release(); + return hr; +} +``` + +- [ ] **Step 5: Add minimal text service class** + +Create `text_service.h/.cpp` implementing `ITfTextInputProcessorEx`. It must: + +- Support `IUnknown`, `ITfTextInputProcessor`, and `ITfTextInputProcessorEx`. +- Store `ITfThreadMgr* thread_mgr_` and `TfClientId client_id_`. +- `ActivateEx` stores the thread manager and client id, starts the IPC server thread, and returns `S_OK`. +- `Deactivate` stops the IPC server thread, releases the thread manager, clears client id, and returns `S_OK`. + +Use this method shape: + +```cpp +HRESULT OpenLessTextService::ActivateEx(ITfThreadMgr* thread_mgr, TfClientId client_id, DWORD) { + if (!thread_mgr) { + return E_INVALIDARG; + } + thread_mgr_ = thread_mgr; + thread_mgr_->AddRef(); + client_id_ = client_id; + ipc_client_.Start(this); + return S_OK; +} + +HRESULT OpenLessTextService::Deactivate() { + ipc_client_.Stop(); + if (thread_mgr_) { + thread_mgr_->Release(); + thread_mgr_ = nullptr; + } + client_id_ = TF_CLIENTID_NULL; + return S_OK; +} +``` + +Add a method used by the IPC client: + +```cpp +HRESULT OpenLessTextService::SubmitTextFromPipe(const std::wstring& session_id, + const std::wstring& text); +``` + +For this task, return `E_NOTIMPL` from `SubmitTextFromPipe`; Task 7 replaces it with real edit-session submission. + +- [ ] **Step 6: Add COM and TSF registration code** + +Create `registry.h/.cpp` with: + +```cpp +HRESULT RegisterOpenLessTextService(HINSTANCE module); +HRESULT UnregisterOpenLessTextService(); +``` + +`RegisterOpenLessTextService` must: + +- Write HKCU COM registration under `Software\Classes\CLSID\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}`. +- Set `InprocServer32` default value to the DLL path. +- Set `ThreadingModel` to `Apartment`. +- Create `ITfInputProcessorProfiles`. +- Call `Register(CLSID_OpenLessTextService)`. +- Call `AddLanguageProfile(CLSID_OpenLessTextService, 0x0804, GUID_OpenLessProfile, L"OpenLess Voice Input", ...)`. +- Call `EnableLanguageProfile(CLSID_OpenLessTextService, 0x0804, GUID_OpenLessProfile, TRUE)`. + +`UnregisterOpenLessTextService` must call `Unregister(CLSID_OpenLessTextService)` and remove the HKCU COM registration key. + +- [ ] **Step 7: Build the DLL** + +Run from a Developer PowerShell: + +```powershell +MSBuild openless-all/app/windows-ime/OpenLessIme.sln /p:Configuration=Release /p:Platform=x64 +``` + +Expected: `openless-all/app/windows-ime/x64/Release/OpenLessIme.dll` exists. + +- [ ] **Step 8: Commit** + +```powershell +git add -- openless-all/app/windows-ime +git commit -m "feat: scaffold OpenLess TSF IME DLL" +``` + +--- + +### Task 7: TSF Edit Session Text Commit + +**Files:** +- Create: `openless-all/app/windows-ime/src/edit_session.h` +- Create: `openless-all/app/windows-ime/src/edit_session.cpp` +- Modify: `openless-all/app/windows-ime/src/text_service.h` +- Modify: `openless-all/app/windows-ime/src/text_service.cpp` + +- [ ] **Step 1: Add edit session class** + +Create `edit_session.h/.cpp` implementing `ITfEditSession`: + +```cpp +class OpenLessEditSession final : public ITfEditSession { +public: + OpenLessEditSession(ITfContext* context, std::wstring text); + + STDMETHODIMP QueryInterface(REFIID iid, void** result) override; + STDMETHODIMP_(ULONG) AddRef() override; + STDMETHODIMP_(ULONG) Release() override; + STDMETHODIMP DoEditSession(TfEditCookie edit_cookie) override; + +private: + ~OpenLessEditSession() = default; + + long ref_count_ = 1; + ITfContext* context_ = nullptr; + std::wstring text_; +}; +``` + +`DoEditSession` must query `ITfInsertAtSelection` from the context and call: + +```cpp +insert_at_selection->InsertTextAtSelection( + edit_cookie, + TF_IAS_QUERYONLY, + text_.c_str(), + static_cast(text_.size()), + nullptr); +``` + +Then call the same method without `TF_IAS_QUERYONLY` to commit text: + +```cpp +insert_at_selection->InsertTextAtSelection( + edit_cookie, + 0, + text_.c_str(), + static_cast(text_.size()), + nullptr); +``` + +Return the HRESULT from the committing call. Release every COM pointer acquired in the method. + +- [ ] **Step 2: Replace `SubmitTextFromPipe` with real TSF submission** + +In `text_service.cpp`, implement: + +```cpp +HRESULT OpenLessTextService::SubmitTextFromPipe(const std::wstring&, + const std::wstring& text) { + if (!thread_mgr_ || client_id_ == TF_CLIENTID_NULL) { + return E_UNEXPECTED; + } + + ITfDocumentMgr* document_mgr = nullptr; + HRESULT hr = thread_mgr_->GetFocus(&document_mgr); + if (FAILED(hr) || !document_mgr) { + return FAILED(hr) ? hr : E_FAIL; + } + + ITfContext* context = nullptr; + hr = document_mgr->GetTop(&context); + document_mgr->Release(); + if (FAILED(hr) || !context) { + return FAILED(hr) ? hr : E_FAIL; + } + + auto* session = new (std::nothrow) OpenLessEditSession(context, text); + if (!session) { + context->Release(); + return E_OUTOFMEMORY; + } + + HRESULT edit_result = E_FAIL; + hr = context->RequestEditSession( + client_id_, + session, + TF_ES_SYNC | TF_ES_READWRITE, + &edit_result); + session->Release(); + context->Release(); + if (FAILED(hr)) { + return hr; + } + return edit_result; +} +``` + +- [ ] **Step 3: Build the DLL** + +Run: + +```powershell +MSBuild openless-all/app/windows-ime/OpenLessIme.sln /p:Configuration=Release /p:Platform=x64 +``` + +Expected: build succeeds. + +- [ ] **Step 4: Commit** + +```powershell +git add -- openless-all/app/windows-ime/src/edit_session.h openless-all/app/windows-ime/src/edit_session.cpp openless-all/app/windows-ime/src/text_service.h openless-all/app/windows-ime/src/text_service.cpp +git commit -m "feat: commit dictated text through TSF edit sessions" +``` + +--- + +### Task 8: C++ Named-Pipe Server in the IME DLL + +**Files:** +- Create: `openless-all/app/windows-ime/src/ipc_client.h` +- Create: `openless-all/app/windows-ime/src/ipc_client.cpp` +- Modify: `openless-all/app/windows-ime/src/text_service.h` +- Modify: `openless-all/app/windows-ime/src/text_service.cpp` + +- [ ] **Step 1: Add IPC server class** + +Create `ipc_client.h` with: + +```cpp +class OpenLessTextService; + +class OpenLessPipeServer { +public: + OpenLessPipeServer(); + ~OpenLessPipeServer(); + + void Start(OpenLessTextService* service); + void Stop(); + +private: + void Run(); + HRESULT HandleSubmitLine(const std::wstring& line); + bool WriteResult(const std::wstring& session_id, const wchar_t* status, const wchar_t* error_code); + + std::atomic stop_requested_{false}; + std::thread thread_; + OpenLessTextService* service_ = nullptr; +}; +``` + +- [ ] **Step 2: Implement one-submit-at-a-time JSONL handling** + +Create `ipc_client.cpp` using Windows named pipes: + +- Pipe name: `\\.\pipe\OpenLessImeSubmit` +- Pipe mode: message pipe, byte read mode, blocking wait. +- Accept one client at a time. +- Read one UTF-8 JSON line. +- Extract `type`, `sessionId`, and `text`. +- Reject messages whose `type` is not `submitText`. +- Convert `text` from UTF-8 to UTF-16. +- Call `service_->SubmitTextFromPipe(session_id, text)`. +- Write one JSONL `submitResult` response with `committed`, `rejected`, or `failed`. + +Use a small local parser limited to the protocol keys: + +```cpp +std::wstring ExtractJsonStringField(const std::wstring& json, const wchar_t* field_name); +``` + +The parser only needs to handle JSON emitted by Rust `serde_json` for this protocol. It must reject missing fields and return `failed` with `protocolError`. + +- [ ] **Step 3: Start and stop pipe server from the text service** + +In `OpenLessTextService::ActivateEx`, call: + +```cpp +pipe_server_.Start(this); +``` + +In `OpenLessTextService::Deactivate`, call: + +```cpp +pipe_server_.Stop(); +``` + +Store `OpenLessPipeServer pipe_server_;` as a member of `OpenLessTextService`. + +- [ ] **Step 4: Build the DLL** + +Run: + +```powershell +MSBuild openless-all/app/windows-ime/OpenLessIme.sln /p:Configuration=Release /p:Platform=x64 +``` + +Expected: build succeeds. + +- [ ] **Step 5: Commit** + +```powershell +git add -- openless-all/app/windows-ime/src/ipc_client.h openless-all/app/windows-ime/src/ipc_client.cpp openless-all/app/windows-ime/src/text_service.h openless-all/app/windows-ime/src/text_service.cpp +git commit -m "feat: receive OpenLess IME submissions over a named pipe" +``` + +--- + +### Task 9: Registration and Build Scripts + +**Files:** +- Create: `openless-all/app/scripts/windows-ime-build.ps1` +- Create: `openless-all/app/scripts/windows-ime-register.ps1` +- Create: `openless-all/app/scripts/windows-ime-unregister.ps1` +- Modify: `openless-all/app/scripts/windows-preflight.ps1` + +- [ ] **Step 1: Add build script** + +Create `windows-ime-build.ps1`: + +```powershell +param( + [ValidateSet("Debug", "Release")] + [string]$Configuration = "Release" +) + +$ErrorActionPreference = "Stop" +$appRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$solution = Join-Path $appRoot "windows-ime\OpenLessIme.sln" + +$msbuild = Get-Command MSBuild.exe -ErrorAction SilentlyContinue +if (-not $msbuild) { + throw "MSBuild.exe not found. Run from Developer PowerShell or install Visual Studio Build Tools with Desktop development with C++." +} + +& $msbuild.Source $solution /p:Configuration=$Configuration /p:Platform=x64 +if ($LASTEXITCODE -ne 0) { + throw "OpenLessIme build failed with exit code $LASTEXITCODE" +} + +$dll = Join-Path $appRoot "windows-ime\x64\$Configuration\OpenLessIme.dll" +if (-not (Test-Path $dll)) { + throw "OpenLessIme.dll was not produced at $dll" +} + +Write-Host "[ok] $dll" +``` + +- [ ] **Step 2: Add register script** + +Create `windows-ime-register.ps1`: + +```powershell +param( + [ValidateSet("Debug", "Release")] + [string]$Configuration = "Release" +) + +$ErrorActionPreference = "Stop" +$appRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$dll = Join-Path $appRoot "windows-ime\x64\$Configuration\OpenLessIme.dll" + +if (-not (Test-Path $dll)) { + & (Join-Path $PSScriptRoot "windows-ime-build.ps1") -Configuration $Configuration +} + +$regsvr32 = Join-Path $env:WINDIR "System32\regsvr32.exe" +& $regsvr32 /s $dll +if ($LASTEXITCODE -ne 0) { + throw "regsvr32 failed with exit code $LASTEXITCODE" +} + +Write-Host "[ok] OpenLess TSF IME registered for current user" +``` + +- [ ] **Step 3: Add unregister script** + +Create `windows-ime-unregister.ps1`: + +```powershell +param( + [ValidateSet("Debug", "Release")] + [string]$Configuration = "Release" +) + +$ErrorActionPreference = "Stop" +$appRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$dll = Join-Path $appRoot "windows-ime\x64\$Configuration\OpenLessIme.dll" + +if (-not (Test-Path $dll)) { + Write-Host "[skip] OpenLessIme.dll not found at $dll" + exit 0 +} + +$regsvr32 = Join-Path $env:WINDIR "System32\regsvr32.exe" +& $regsvr32 /u /s $dll +if ($LASTEXITCODE -ne 0) { + throw "regsvr32 /u failed with exit code $LASTEXITCODE" +} + +Write-Host "[ok] OpenLess TSF IME unregistered" +``` + +- [ ] **Step 4: Extend preflight** + +In `windows-preflight.ps1`, add an `ime` option to the `ValidateSet` and check: + +```powershell +if ($Toolchain -eq "all" -or $Toolchain -eq "msvc" -or $Toolchain -eq "ime") { + Write-Host "" + Write-Host "== Windows IME route ==" + if (-not (Test-Command "MSBuild.exe")) { + Write-Host "[hint] Install Visual Studio Build Tools and run from Developer PowerShell." + $failed = $true + } + $msctf = Get-ChildItem -LiteralPath (Join-Path ${env:ProgramFiles(x86)} "Windows Kits\10\Lib") -Recurse -Filter msctf.lib -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match "\\um\\x64\\msctf\.lib$" } | + Select-Object -First 1 + if ($msctf) { + Write-Host "[ok] msctf.lib -> $($msctf.FullName)" + } else { + Write-Host "[missing] msctf.lib" + $failed = $true + } +} +``` + +- [ ] **Step 5: Run scripts** + +Run: + +```powershell +.\openless-all\app\scripts\windows-preflight.ps1 -Toolchain ime +.\openless-all\app\scripts\windows-ime-build.ps1 +``` + +Expected: preflight passes and the IME DLL builds. + +- [ ] **Step 6: Commit** + +```powershell +git add -- openless-all/app/scripts/windows-ime-build.ps1 openless-all/app/scripts/windows-ime-register.ps1 openless-all/app/scripts/windows-ime-unregister.ps1 openless-all/app/scripts/windows-preflight.ps1 +git commit -m "feat: add Windows IME build and registration scripts" +``` + +--- + +### Task 10: Tauri Commands and Settings Status + +**Files:** +- Modify: `openless-all/app/src-tauri/src/types.rs` +- Modify: `openless-all/app/src-tauri/src/commands.rs` +- Modify: `openless-all/app/src-tauri/src/lib.rs` +- Modify: `openless-all/app/src/lib/types.ts` +- Modify: `openless-all/app/src/lib/ipc.ts` +- Modify: `openless-all/app/src/i18n/zh-CN.ts` +- Modify: `openless-all/app/src/i18n/en.ts` +- Modify: `openless-all/app/src/pages/Settings.tsx` + +- [ ] **Step 1: Add backend status types** + +In `types.rs`, add: + +```rust +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum WindowsImeInstallState { + NotWindows, + NotInstalled, + Installed, + RegistrationBroken, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WindowsImeStatus { + pub state: WindowsImeInstallState, + pub using_tsf_backend: bool, + pub message: Option, +} +``` + +- [ ] **Step 2: Add status command** + +In `commands.rs`, add: + +```rust +#[tauri::command] +pub fn get_windows_ime_status() -> WindowsImeStatus { + #[cfg(not(target_os = "windows"))] + { + WindowsImeStatus { + state: WindowsImeInstallState::NotWindows, + using_tsf_backend: false, + message: Some("Windows TSF IME is only available on Windows.".to_string()), + } + } + + #[cfg(target_os = "windows")] + { + match crate::windows_ime_profile::WindowsImeProfileManager::new() + .is_openless_profile_active() + { + Ok(_) => WindowsImeStatus { + state: WindowsImeInstallState::Installed, + using_tsf_backend: true, + message: None, + }, + Err(err) => WindowsImeStatus { + state: WindowsImeInstallState::NotInstalled, + using_tsf_backend: false, + message: Some(err.to_string()), + }, + } + } +} +``` + +Use this as a health signal only; active-profile false is not a failure because OpenLess should be active only during voice sessions. + +- [ ] **Step 3: Register command** + +Add `get_windows_ime_status` to the Tauri `invoke_handler!` list in `lib.rs`. + +- [ ] **Step 4: Add frontend types and IPC wrapper** + +In `src/lib/types.ts`: + +```ts +export type WindowsImeInstallState = + | 'notWindows' + | 'notInstalled' + | 'installed' + | 'registrationBroken'; + +export interface WindowsImeStatus { + state: WindowsImeInstallState; + usingTsfBackend: boolean; + message?: string | null; +} +``` + +In `src/lib/ipc.ts`: + +```ts +export async function getWindowsImeStatus(): Promise { + if (isBrowserDev()) { + return { + state: 'notWindows', + usingTsfBackend: false, + message: 'Browser dev mock', + }; + } + return invoke('get_windows_ime_status'); +} +``` + +- [ ] **Step 5: Add Settings UI row** + +In `Settings.tsx`, add a Windows-only status row using existing UI atoms. Text keys: + +Chinese source: + +```ts +windowsImeTitle: 'Windows 输入法后端', +windowsImeInstalled: '已安装,语音输入会临时切换到 OpenLess 输入法', +windowsImeNotInstalled: '未安装,当前使用剪贴板/WM_PASTE 回退', +windowsImeRegistrationBroken: '注册异常,请重新安装 OpenLess 输入法', +windowsImeNotWindows: '仅 Windows 可用', +``` + +English: + +```ts +windowsImeTitle: 'Windows input method backend', +windowsImeInstalled: 'Installed. Voice input temporarily switches to the OpenLess IME.', +windowsImeNotInstalled: 'Not installed. OpenLess is using the clipboard/WM_PASTE fallback.', +windowsImeRegistrationBroken: 'Registration is broken. Reinstall the OpenLess IME.', +windowsImeNotWindows: 'Only available on Windows.', +``` + +- [ ] **Step 6: Run frontend build** + +Run: + +```powershell +cd openless-all/app +npm run build +``` + +Expected: TypeScript and Vite build succeed. + +- [ ] **Step 7: Run backend type check** + +Run: + +```powershell +cargo check --manifest-path openless-all/app/src-tauri/Cargo.toml +``` + +Expected: backend type-checks. + +- [ ] **Step 8: Commit** + +```powershell +git add -- openless-all/app/src-tauri/src/types.rs openless-all/app/src-tauri/src/commands.rs openless-all/app/src-tauri/src/lib.rs openless-all/app/src/lib/types.ts openless-all/app/src/lib/ipc.ts openless-all/app/src/i18n/zh-CN.ts openless-all/app/src/i18n/en.ts openless-all/app/src/pages/Settings.tsx +git commit -m "feat: show Windows TSF IME backend status" +``` + +--- + +### Task 11: End-to-End Windows Verification + +**Files:** +- Modify only files needed to fix defects found during verification. + +- [ ] **Step 1: Run full automated checks** + +Run: + +```powershell +cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml --lib +cargo check --manifest-path openless-all/app/src-tauri/Cargo.toml +cd openless-all/app +npm run build +.\scripts\windows-ime-build.ps1 +``` + +Expected: + +- Rust tests pass. +- Rust backend type-checks. +- Frontend build succeeds. +- `OpenLessIme.dll` builds. + +- [ ] **Step 2: Register the IME** + +Run: + +```powershell +.\openless-all\app\scripts\windows-ime-register.ps1 +``` + +Expected: script prints `[ok] OpenLess TSF IME registered for current user`. + +- [ ] **Step 3: Manual Notepad verification** + +1. Open Notepad. +2. Switch to Microsoft Pinyin. +3. Start OpenLess. +4. Press the configured voice hotkey to start recording. +5. Speak a short phrase. +6. Press the configured voice hotkey again to finish. + +Expected: + +- Input indicator briefly switches to OpenLess during the voice session. +- Final text appears at the Notepad caret. +- Input indicator returns to Microsoft Pinyin. +- Clipboard content is unchanged when TSF commit succeeds. + +- [ ] **Step 4: Manual browser verification** + +Repeat Step 3 in a browser text field. + +Expected: text appears in the focused browser field and input profile restores. + +- [ ] **Step 5: Manual VS Code verification** + +Repeat Step 3 in a VS Code editor tab. + +Expected: text appears at the editor caret and input profile restores. + +- [ ] **Step 6: Cancellation verification** + +1. Open Notepad with Microsoft Pinyin active. +2. Press the OpenLess voice hotkey to start. +3. Cancel during recording or processing using the existing cancel path. + +Expected: + +- No text is inserted. +- Input profile returns to Microsoft Pinyin. +- Clipboard content is unchanged. + +- [ ] **Step 7: Fallback verification** + +Unregister the IME: + +```powershell +.\openless-all\app\scripts\windows-ime-unregister.ps1 +``` + +Run a normal voice session in Notepad. + +Expected: + +- Voice input still inserts through the existing Windows fallback path. +- Settings reports the TSF backend as not installed. +- User text is not lost. + +- [ ] **Step 8: Final verification review** + +Run: + +```powershell +git status --short +git diff -- openless-all/app/src-tauri openless-all/app/windows-ime openless-all/app/scripts openless-all/app/src docs/superpowers/plans/2026-05-01-windows-temporary-tsf-ime.md +``` + +Expected: every remaining diff is tied to the TSF IME implementation or a verification fix discovered in this task. If no code changed during verification, leave the branch without an extra commit. If verification changed code, stage the exact files shown by `git status --short` that are tied to this TSF IME work and commit with: + +```powershell +git commit -m "fix: harden Windows TSF IME verification path" +``` + +--- + +## Self-Review Checklist + +- The plan covers TSF profile activation, final-text IPC, TSF edit-session commit, restore on success/failure/cancel, fallback behavior, settings status, registration scripts, and manual verification. +- The plan keeps ASR, polish, recorder, and UI ownership in the Tauri/Rust app. +- The plan keeps third-party Chinese IME behavior by restoring the user's previous input profile after each voice session. +- The plan preserves the existing Windows `WM_PASTE` fallback. +- The plan avoids putting network, ASR, LLM, or Tauri UI inside the IME DLL. From 83426bc0570dccfc989951bbf1ecc01ea954f00c Mon Sep 17 00:00:00 2001 From: millionart Date: Fri, 1 May 2026 18:34:10 +0800 Subject: [PATCH 04/43] feat: add Windows IME IPC protocol --- openless-all/app/src-tauri/src/lib.rs | 1 + .../app/src-tauri/src/windows_ime_protocol.rs | 112 ++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 openless-all/app/src-tauri/src/windows_ime_protocol.rs diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 4d7b9f10..533a6d87 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -20,6 +20,7 @@ mod persistence; mod polish; mod recorder; mod types; +mod windows_ime_protocol; #[cfg(target_os = "macos")] use std::sync::mpsc; diff --git a/openless-all/app/src-tauri/src/windows_ime_protocol.rs b/openless-all/app/src-tauri/src/windows_ime_protocol.rs new file mode 100644 index 00000000..adf6c93b --- /dev/null +++ b/openless-all/app/src-tauri/src/windows_ime_protocol.rs @@ -0,0 +1,112 @@ +use serde::{Deserialize, Serialize}; + +pub const OPENLESS_IME_PROTOCOL_VERSION: u32 = 1; +pub const OPENLESS_IME_PIPE_NAME: &str = r"\\.\pipe\OpenLessImeSubmit"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum ImePipeMessage { + ClientReady { + protocol_version: u32, + client_id: String, + process_id: u32, + thread_id: u32, + }, + SubmitText { + protocol_version: u32, + session_id: String, + text: String, + created_at: String, + }, + SubmitResult { + protocol_version: u32, + session_id: String, + status: ImeSubmitStatus, + error_code: Option, + }, + CancelSession { + protocol_version: u32, + session_id: String, + }, + Ping { + protocol_version: u32, + }, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum ImeSubmitStatus { + Committed, + Rejected, + Failed, +} + +pub fn encode_message(message: &ImePipeMessage) -> Result { + let mut line = serde_json::to_string(message)?; + line.push('\n'); + Ok(line) +} + +pub fn decode_message(line: &str) -> Result { + serde_json::from_str(line) +} + +pub fn is_result_for_pending_session( + message: &ImePipeMessage, + pending_session_id: &str, +) -> Result<(), &'static str> { + match message { + ImePipeMessage::SubmitResult { session_id, .. } if session_id == pending_session_id => { + Ok(()) + } + ImePipeMessage::SubmitResult { .. } => Err("submit result belongs to a different session"), + _ => Err("message is not a submit result"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn submit_text_roundtrips_as_camel_case_json() { + let message = ImePipeMessage::SubmitText { + protocol_version: OPENLESS_IME_PROTOCOL_VERSION, + session_id: "session-1".to_string(), + text: "\u{4f60}\u{597d} OpenLess".to_string(), + created_at: "2026-05-01T12:00:00Z".to_string(), + }; + + let json = encode_message(&message).expect("encode"); + assert!(json.contains("\"submitText\"")); + assert!(json.contains("\"sessionId\"")); + assert!(json.contains("\"createdAt\"")); + assert!(!json.contains("\"session_id\"")); + assert!(!json.contains("\"created_at\"")); + assert!(json.ends_with('\n')); + + let decoded = decode_message(json.trim_end()).expect("decode"); + assert_eq!(decoded, message); + } + + #[test] + fn stale_submit_result_is_rejected() { + let result = ImePipeMessage::SubmitResult { + protocol_version: OPENLESS_IME_PROTOCOL_VERSION, + session_id: "old-session".to_string(), + status: ImeSubmitStatus::Committed, + error_code: Some("ime-busy".to_string()), + }; + + let json = encode_message(&result).expect("encode"); + assert!(json.contains("\"errorCode\"")); + assert!(!json.contains("\"error_code\"")); + + assert!(is_result_for_pending_session(&result, "current-session").is_err()); + assert!(is_result_for_pending_session(&result, "old-session").is_ok()); + } +} From 6d088508faf2227ba910536e816bfda4f0034bd3 Mon Sep 17 00:00:00 2001 From: millionart Date: Fri, 1 May 2026 18:45:35 +0800 Subject: [PATCH 05/43] feat: add Windows IME profile state --- openless-all/app/src-tauri/src/lib.rs | 1 + .../app/src-tauri/src/windows_ime_profile.rs | 120 ++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 openless-all/app/src-tauri/src/windows_ime_profile.rs diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 533a6d87..3210c9db 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -20,6 +20,7 @@ mod persistence; mod polish; mod recorder; mod types; +mod windows_ime_profile; mod windows_ime_protocol; #[cfg(target_os = "macos")] diff --git a/openless-all/app/src-tauri/src/windows_ime_profile.rs b/openless-all/app/src-tauri/src/windows_ime_profile.rs new file mode 100644 index 00000000..82af76fc --- /dev/null +++ b/openless-all/app/src-tauri/src/windows_ime_profile.rs @@ -0,0 +1,120 @@ +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ImeProfileKind { + KeyboardLayout, + TextService, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImeProfileSnapshot { + pub kind: ImeProfileKind, + pub lang_id: u16, + pub clsid: Option, + pub profile_guid: Option, + pub hkl: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProfileRestoreDecision { + RestoreSavedProfile, + KeepCurrentProfile, +} + +pub fn restore_decision( + saved: Option<&ImeProfileSnapshot>, + openless_profile_is_current: bool, +) -> ProfileRestoreDecision { + if saved.is_some() && openless_profile_is_current { + ProfileRestoreDecision::RestoreSavedProfile + } else { + ProfileRestoreDecision::KeepCurrentProfile + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WindowsImeProfileError { + Unavailable(String), + WindowsApi(String), +} + +impl std::fmt::Display for WindowsImeProfileError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Unavailable(message) | Self::WindowsApi(message) => write!(f, "{message}"), + } + } +} + +impl std::error::Error for WindowsImeProfileError {} + +pub type WindowsImeProfileResult = Result; + +#[cfg(not(target_os = "windows"))] +pub struct WindowsImeProfileManager; + +#[cfg(not(target_os = "windows"))] +impl WindowsImeProfileManager { + pub fn new() -> Self { + Self + } + + pub fn capture_active_profile(&self) -> WindowsImeProfileResult { + Err(WindowsImeProfileError::Unavailable( + "Windows TSF profiles are only available on Windows".to_string(), + )) + } + + pub fn activate_openless_profile(&self) -> WindowsImeProfileResult<()> { + Err(WindowsImeProfileError::Unavailable( + "Windows TSF profiles are only available on Windows".to_string(), + )) + } + + pub fn restore_profile(&self, _snapshot: &ImeProfileSnapshot) -> WindowsImeProfileResult<()> { + Err(WindowsImeProfileError::Unavailable( + "Windows TSF profiles are only available on Windows".to_string(), + )) + } + + pub fn is_openless_profile_active(&self) -> WindowsImeProfileResult { + Ok(false) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn text_service_snapshot() -> ImeProfileSnapshot { + ImeProfileSnapshot { + kind: ImeProfileKind::TextService, + lang_id: 0x0804, + clsid: Some("{11111111-1111-1111-1111-111111111111}".to_string()), + profile_guid: Some("{22222222-2222-2222-2222-222222222222}".to_string()), + hkl: None, + } + } + + #[test] + fn restore_is_required_when_openless_is_active_and_snapshot_exists() { + assert_eq!( + restore_decision(Some(&text_service_snapshot()), true), + ProfileRestoreDecision::RestoreSavedProfile + ); + } + + #[test] + fn restore_is_skipped_when_snapshot_is_missing() { + assert_eq!( + restore_decision(None, true), + ProfileRestoreDecision::KeepCurrentProfile + ); + } + + #[test] + fn restore_is_skipped_when_user_already_changed_away_from_openless() { + assert_eq!( + restore_decision(Some(&text_service_snapshot()), false), + ProfileRestoreDecision::KeepCurrentProfile + ); + } +} From 45aae5e7cd38d28bf05033474f8d52371ede5a17 Mon Sep 17 00:00:00 2001 From: millionart Date: Fri, 1 May 2026 18:52:12 +0800 Subject: [PATCH 06/43] fix: validate Windows IME profile snapshots --- .../app/src-tauri/src/windows_ime_profile.rs | 92 ++++++++++++++++--- 1 file changed, 80 insertions(+), 12 deletions(-) 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 82af76fc..6d9c3b0f 100644 --- a/openless-all/app/src-tauri/src/windows_ime_profile.rs +++ b/openless-all/app/src-tauri/src/windows_ime_profile.rs @@ -6,11 +6,53 @@ pub enum ImeProfileKind { #[derive(Debug, Clone, PartialEq, Eq)] pub struct ImeProfileSnapshot { - pub kind: ImeProfileKind, - pub lang_id: u16, - pub clsid: Option, - pub profile_guid: Option, - pub hkl: Option, + kind: ImeProfileKind, + lang_id: u16, + clsid: Option, + profile_guid: Option, + hkl: Option, +} + +impl ImeProfileSnapshot { + pub fn text_service(lang_id: u16, clsid: String, profile_guid: String) -> Self { + Self { + kind: ImeProfileKind::TextService, + lang_id, + clsid: Some(clsid), + profile_guid: Some(profile_guid), + hkl: None, + } + } + + pub fn keyboard_layout(lang_id: u16, hkl: isize) -> Self { + Self { + kind: ImeProfileKind::KeyboardLayout, + lang_id, + clsid: None, + profile_guid: None, + hkl: Some(hkl), + } + } + + pub fn kind(&self) -> &ImeProfileKind { + &self.kind + } + + pub fn lang_id(&self) -> u16 { + self.lang_id + } + + pub fn clsid(&self) -> Option<&str> { + self.clsid.as_deref() + } + + pub fn profile_guid(&self) -> Option<&str> { + self.profile_guid.as_deref() + } + + pub fn hkl(&self) -> Option { + self.hkl + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -85,13 +127,39 @@ mod tests { use super::*; fn text_service_snapshot() -> ImeProfileSnapshot { - ImeProfileSnapshot { - kind: ImeProfileKind::TextService, - lang_id: 0x0804, - clsid: Some("{11111111-1111-1111-1111-111111111111}".to_string()), - profile_guid: Some("{22222222-2222-2222-2222-222222222222}".to_string()), - hkl: None, - } + ImeProfileSnapshot::text_service( + 0x0804, + "{11111111-1111-1111-1111-111111111111}".to_string(), + "{22222222-2222-2222-2222-222222222222}".to_string(), + ) + } + + #[test] + fn text_service_constructor_sets_required_profile_data() { + let snapshot = text_service_snapshot(); + + assert_eq!(snapshot.kind(), &ImeProfileKind::TextService); + assert_eq!(snapshot.lang_id(), 0x0804); + assert_eq!( + snapshot.clsid(), + Some("{11111111-1111-1111-1111-111111111111}") + ); + assert_eq!( + snapshot.profile_guid(), + Some("{22222222-2222-2222-2222-222222222222}") + ); + assert_eq!(snapshot.hkl(), None); + } + + #[test] + fn keyboard_layout_constructor_sets_required_hkl_data() { + let snapshot = ImeProfileSnapshot::keyboard_layout(0x0409, 0x0409_0409); + + assert_eq!(snapshot.kind(), &ImeProfileKind::KeyboardLayout); + assert_eq!(snapshot.lang_id(), 0x0409); + assert_eq!(snapshot.clsid(), None); + assert_eq!(snapshot.profile_guid(), None); + assert_eq!(snapshot.hkl(), Some(0x0409_0409)); } #[test] From 975d63385a794b852c3097e4aee3a94c15e6a684 Mon Sep 17 00:00:00 2001 From: millionart Date: Fri, 1 May 2026 19:08:54 +0800 Subject: [PATCH 07/43] feat: manage Windows TSF input profiles --- openless-all/app/src-tauri/Cargo.toml | 9 +- .../app/src-tauri/src/windows_ime_profile.rs | 229 ++++++++++++++++++ 2 files changed, 236 insertions(+), 2 deletions(-) diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index 2d68c83c..ce6a08ad 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -54,10 +54,15 @@ objc2-app-kit = "0.2" [target.'cfg(target_os = "windows")'.dependencies] windows = { version = "0.58", features = [ "Win32_Foundation", - "Win32_UI_Shell", + "Win32_Globalization", + "Win32_System_Com", + "Win32_System_Ole", + "Win32_System_Registry", + "Win32_System_Threading", "Win32_UI_Input_KeyboardAndMouse", + "Win32_UI_Shell", + "Win32_UI_TextServices", "Win32_UI_WindowsAndMessaging", - "Win32_System_Threading", ] } winreg = "0.52" 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 6d9c3b0f..1248161d 100644 --- a/openless-all/app/src-tauri/src/windows_ime_profile.rs +++ b/openless-all/app/src-tauri/src/windows_ime_profile.rs @@ -1,3 +1,14 @@ +pub const OPENLESS_TSF_LANG_ID: u16 = 0x0804; +pub const OPENLESS_TEXT_SERVICE_CLSID_BRACED: &str = "{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}"; +pub const OPENLESS_PROFILE_GUID_BRACED: &str = "{9B5F5E04-23F6-47DA-9A26-D221F6C3F02E}"; + +#[cfg(target_os = "windows")] +fn parse_guid(value: &str) -> WindowsImeProfileResult { + uuid::Uuid::parse_str(value) + .map(|uuid| windows::core::GUID::from_u128(uuid.as_u128())) + .map_err(|err| WindowsImeProfileError::WindowsApi(format!("invalid GUID {value}: {err}"))) +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum ImeProfileKind { KeyboardLayout, @@ -90,6 +101,32 @@ impl std::error::Error for WindowsImeProfileError {} pub type WindowsImeProfileResult = Result; +#[cfg(target_os = "windows")] +pub struct WindowsImeProfileManager; + +#[cfg(target_os = "windows")] +impl WindowsImeProfileManager { + pub fn new() -> Self { + Self + } + + pub fn capture_active_profile(&self) -> WindowsImeProfileResult { + windows_impl::capture_active_profile() + } + + pub fn activate_openless_profile(&self) -> WindowsImeProfileResult<()> { + windows_impl::activate_openless_profile() + } + + pub fn restore_profile(&self, snapshot: &ImeProfileSnapshot) -> WindowsImeProfileResult<()> { + windows_impl::restore_profile(snapshot) + } + + pub fn is_openless_profile_active(&self) -> WindowsImeProfileResult { + windows_impl::is_openless_profile_active() + } +} + #[cfg(not(target_os = "windows"))] pub struct WindowsImeProfileManager; @@ -122,6 +159,180 @@ impl WindowsImeProfileManager { } } +#[cfg(target_os = "windows")] +mod windows_impl { + use super::*; + use std::ffi::c_void; + use std::ptr; + use windows::core::GUID; + use windows::Win32::System::Com::{ + CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_INPROC_SERVER, + COINIT_APARTMENTTHREADED, + }; + use windows::Win32::UI::Input::KeyboardAndMouse::{GetKeyboardLayout, HKL}; + use windows::Win32::UI::TextServices::{ + CLSID_TF_InputProcessorProfiles, ITfInputProcessorProfileMgr, TF_INPUTPROCESSORPROFILE, + TF_IPPMF_FORPROCESS, TF_PROFILETYPE_INPUTPROCESSOR, TF_PROFILETYPE_KEYBOARDLAYOUT, + }; + + struct ComApartment; + + impl ComApartment { + fn initialize() -> WindowsImeProfileResult { + unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) } + .ok() + .map_err(|err| { + WindowsImeProfileError::WindowsApi(format!("CoInitializeEx: {err}")) + })?; + Ok(Self) + } + } + + impl Drop for ComApartment { + fn drop(&mut self) { + unsafe { + CoUninitialize(); + } + } + } + + pub fn capture_active_profile() -> WindowsImeProfileResult { + with_profile_manager(|manager| { + let mut profile = TF_INPUTPROCESSORPROFILE::default(); + unsafe { + manager.GetActiveProfile(&GUID::zeroed(), &mut profile)?; + } + + if profile.dwProfileType == TF_PROFILETYPE_INPUTPROCESSOR { + Ok(ImeProfileSnapshot::text_service( + profile.langid, + format!("{:?}", profile.clsid), + format!("{:?}", profile.guidProfile), + )) + } else { + let hkl = unsafe { GetKeyboardLayout(0) }; + Ok(ImeProfileSnapshot::keyboard_layout( + lang_id_from_hkl(hkl), + hkl_to_isize(hkl), + )) + } + }) + } + + pub fn activate_openless_profile() -> WindowsImeProfileResult<()> { + let clsid = parse_guid(OPENLESS_TEXT_SERVICE_CLSID_BRACED)?; + let profile_guid = parse_guid(OPENLESS_PROFILE_GUID_BRACED)?; + + with_profile_manager(|manager| unsafe { + manager.ActivateProfile( + TF_PROFILETYPE_INPUTPROCESSOR, + OPENLESS_TSF_LANG_ID, + &clsid, + &profile_guid, + null_hkl(), + TF_IPPMF_FORPROCESS, + ) + }) + } + + pub fn restore_profile(snapshot: &ImeProfileSnapshot) -> WindowsImeProfileResult<()> { + match snapshot.kind() { + ImeProfileKind::TextService => { + let clsid = parse_required_guid("text service CLSID", snapshot.clsid())?; + let profile_guid = + parse_required_guid("text service profile GUID", snapshot.profile_guid())?; + + with_profile_manager(|manager| unsafe { + manager.ActivateProfile( + TF_PROFILETYPE_INPUTPROCESSOR, + snapshot.lang_id(), + &clsid, + &profile_guid, + null_hkl(), + TF_IPPMF_FORPROCESS, + ) + }) + } + ImeProfileKind::KeyboardLayout => { + let hkl = HKL(snapshot.hkl().unwrap_or_default() as *mut c_void); + let zero_guid = GUID::zeroed(); + + with_profile_manager(|manager| unsafe { + manager.ActivateProfile( + TF_PROFILETYPE_KEYBOARDLAYOUT, + snapshot.lang_id(), + &zero_guid, + &zero_guid, + hkl, + TF_IPPMF_FORPROCESS, + ) + }) + } + } + } + + pub fn is_openless_profile_active() -> WindowsImeProfileResult { + let snapshot = capture_active_profile()?; + + Ok(matches!(snapshot.kind(), ImeProfileKind::TextService) + && snapshot.lang_id() == OPENLESS_TSF_LANG_ID + && snapshot.clsid().map(normalize_guid_string).as_deref() + == Some(OPENLESS_TEXT_SERVICE_CLSID_BRACED) + && snapshot + .profile_guid() + .map(normalize_guid_string) + .as_deref() + == Some(OPENLESS_PROFILE_GUID_BRACED)) + } + + fn with_profile_manager( + operation: impl FnOnce(&ITfInputProcessorProfileMgr) -> windows::core::Result, + ) -> WindowsImeProfileResult { + let _com = ComApartment::initialize()?; + let manager: ITfInputProcessorProfileMgr = unsafe { + CoCreateInstance(&CLSID_TF_InputProcessorProfiles, None, CLSCTX_INPROC_SERVER) + } + .map_err(windows_api_error( + "CoCreateInstance ITfInputProcessorProfileMgr", + ))?; + + operation(&manager).map_err(windows_api_error("ITfInputProcessorProfileMgr operation")) + } + + fn parse_required_guid(label: &str, value: Option<&str>) -> WindowsImeProfileResult { + parse_guid(value.ok_or_else(|| { + WindowsImeProfileError::WindowsApi(format!("missing {label} in saved IME profile")) + })?) + } + + fn normalize_guid_string(value: &str) -> String { + let upper = value.trim().to_ascii_uppercase(); + if upper.starts_with('{') && upper.ends_with('}') { + upper + } else { + format!("{{{upper}}}") + } + } + + fn lang_id_from_hkl(hkl: HKL) -> u16 { + (hkl_to_isize(hkl) as u32 & 0xffff) as u16 + } + + fn hkl_to_isize(hkl: HKL) -> isize { + hkl.0 as isize + } + + fn null_hkl() -> HKL { + HKL(ptr::null_mut()) + } + + fn windows_api_error( + context: &'static str, + ) -> impl FnOnce(windows::core::Error) -> WindowsImeProfileError { + move |err| WindowsImeProfileError::WindowsApi(format!("{context}: {err}")) + } +} + #[cfg(test)] mod tests { use super::*; @@ -186,3 +397,21 @@ mod tests { ); } } + +#[cfg(all(test, target_os = "windows"))] +mod windows_tests { + use super::*; + + #[test] + fn openless_profile_identifiers_are_fixed() { + assert_eq!(OPENLESS_TSF_LANG_ID, 0x0804); + assert_eq!( + OPENLESS_TEXT_SERVICE_CLSID_BRACED, + "{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}" + ); + assert_eq!( + OPENLESS_PROFILE_GUID_BRACED, + "{9B5F5E04-23F6-47DA-9A26-D221F6C3F02E}" + ); + } +} From d48fad6387d95ff9cdfa889b013067ea5f8ef577 Mon Sep 17 00:00:00 2001 From: millionart Date: Fri, 1 May 2026 19:17:18 +0800 Subject: [PATCH 08/43] fix: query active keyboard TIP profile --- .../app/src-tauri/src/windows_ime_profile.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) 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 1248161d..4dacecf7 100644 --- a/openless-all/app/src-tauri/src/windows_ime_profile.rs +++ b/openless-all/app/src-tauri/src/windows_ime_profile.rs @@ -172,7 +172,8 @@ mod windows_impl { use windows::Win32::UI::Input::KeyboardAndMouse::{GetKeyboardLayout, HKL}; use windows::Win32::UI::TextServices::{ CLSID_TF_InputProcessorProfiles, ITfInputProcessorProfileMgr, TF_INPUTPROCESSORPROFILE, - TF_IPPMF_FORPROCESS, TF_PROFILETYPE_INPUTPROCESSOR, TF_PROFILETYPE_KEYBOARDLAYOUT, + GUID_TFCAT_TIP_KEYBOARD, TF_IPPMF_FORPROCESS, TF_PROFILETYPE_INPUTPROCESSOR, + TF_PROFILETYPE_KEYBOARDLAYOUT, }; struct ComApartment; @@ -200,7 +201,7 @@ mod windows_impl { with_profile_manager(|manager| { let mut profile = TF_INPUTPROCESSORPROFILE::default(); unsafe { - manager.GetActiveProfile(&GUID::zeroed(), &mut profile)?; + manager.GetActiveProfile(&active_profile_category_guid(), &mut profile)?; } if profile.dwProfileType == TF_PROFILETYPE_INPUTPROCESSOR { @@ -305,6 +306,10 @@ mod windows_impl { })?) } + pub(super) fn active_profile_category_guid() -> GUID { + GUID_TFCAT_TIP_KEYBOARD + } + fn normalize_guid_string(value: &str) -> String { let upper = value.trim().to_ascii_uppercase(); if upper.starts_with('{') && upper.ends_with('}') { @@ -401,6 +406,7 @@ mod tests { #[cfg(all(test, target_os = "windows"))] mod windows_tests { use super::*; + use windows::Win32::UI::TextServices::GUID_TFCAT_TIP_KEYBOARD; #[test] fn openless_profile_identifiers_are_fixed() { @@ -414,4 +420,12 @@ mod windows_tests { "{9B5F5E04-23F6-47DA-9A26-D221F6C3F02E}" ); } + + #[test] + fn active_profile_capture_uses_keyboard_tip_category() { + assert_eq!( + windows_impl::active_profile_category_guid(), + GUID_TFCAT_TIP_KEYBOARD + ); + } } From 6b50b0fe7c6e7325af1f1f67bab919b9f57a4505 Mon Sep 17 00:00:00 2001 From: millionart Date: Fri, 1 May 2026 19:23:30 +0800 Subject: [PATCH 09/43] fix: capture TSF keyboard layout profile --- .../app/src-tauri/src/windows_ime_profile.rs | 74 ++++++++++++++----- 1 file changed, 54 insertions(+), 20 deletions(-) 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 4dacecf7..78fb4325 100644 --- a/openless-all/app/src-tauri/src/windows_ime_profile.rs +++ b/openless-all/app/src-tauri/src/windows_ime_profile.rs @@ -169,7 +169,7 @@ mod windows_impl { CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED, }; - use windows::Win32::UI::Input::KeyboardAndMouse::{GetKeyboardLayout, HKL}; + use windows::Win32::UI::Input::KeyboardAndMouse::HKL; use windows::Win32::UI::TextServices::{ CLSID_TF_InputProcessorProfiles, ITfInputProcessorProfileMgr, TF_INPUTPROCESSORPROFILE, GUID_TFCAT_TIP_KEYBOARD, TF_IPPMF_FORPROCESS, TF_PROFILETYPE_INPUTPROCESSOR, @@ -198,26 +198,24 @@ mod windows_impl { } pub fn capture_active_profile() -> WindowsImeProfileResult { - with_profile_manager(|manager| { + let profile = with_profile_manager(|manager| { let mut profile = TF_INPUTPROCESSORPROFILE::default(); unsafe { manager.GetActiveProfile(&active_profile_category_guid(), &mut profile)?; } - if profile.dwProfileType == TF_PROFILETYPE_INPUTPROCESSOR { - Ok(ImeProfileSnapshot::text_service( - profile.langid, - format!("{:?}", profile.clsid), - format!("{:?}", profile.guidProfile), - )) - } else { - let hkl = unsafe { GetKeyboardLayout(0) }; - Ok(ImeProfileSnapshot::keyboard_layout( - lang_id_from_hkl(hkl), - hkl_to_isize(hkl), - )) - } - }) + Ok(profile) + })?; + + if profile.dwProfileType == TF_PROFILETYPE_INPUTPROCESSOR { + Ok(ImeProfileSnapshot::text_service( + profile.langid, + format!("{:?}", profile.clsid), + format!("{:?}", profile.guidProfile), + )) + } else { + keyboard_layout_snapshot_from_tsf(profile.langid, profile.hkl) + } } pub fn activate_openless_profile() -> WindowsImeProfileResult<()> { @@ -310,6 +308,20 @@ mod windows_impl { GUID_TFCAT_TIP_KEYBOARD } + pub(super) fn keyboard_layout_snapshot_from_tsf( + lang_id: u16, + hkl: HKL, + ) -> WindowsImeProfileResult { + let hkl_value = hkl_to_isize(hkl); + if hkl_value == 0 { + return Err(WindowsImeProfileError::WindowsApi( + "active keyboard layout profile has no HKL".to_string(), + )); + } + + Ok(ImeProfileSnapshot::keyboard_layout(lang_id, hkl_value)) + } + fn normalize_guid_string(value: &str) -> String { let upper = value.trim().to_ascii_uppercase(); if upper.starts_with('{') && upper.ends_with('}') { @@ -319,10 +331,6 @@ mod windows_impl { } } - fn lang_id_from_hkl(hkl: HKL) -> u16 { - (hkl_to_isize(hkl) as u32 & 0xffff) as u16 - } - fn hkl_to_isize(hkl: HKL) -> isize { hkl.0 as isize } @@ -406,6 +414,9 @@ mod tests { #[cfg(all(test, target_os = "windows"))] mod windows_tests { use super::*; + use std::ffi::c_void; + use std::ptr; + use windows::Win32::UI::Input::KeyboardAndMouse::HKL; use windows::Win32::UI::TextServices::GUID_TFCAT_TIP_KEYBOARD; #[test] @@ -428,4 +439,27 @@ mod windows_tests { GUID_TFCAT_TIP_KEYBOARD ); } + + #[test] + fn keyboard_layout_snapshot_uses_tsf_profile_values() { + let snapshot = windows_impl::keyboard_layout_snapshot_from_tsf( + 0x0411, + HKL(0x0411_0411usize as *mut c_void), + ) + .unwrap(); + + assert_eq!(snapshot.kind(), &ImeProfileKind::KeyboardLayout); + assert_eq!(snapshot.lang_id(), 0x0411); + assert_eq!(snapshot.hkl(), Some(0x0411_0411)); + } + + #[test] + fn keyboard_layout_snapshot_rejects_missing_hkl() { + let err = windows_impl::keyboard_layout_snapshot_from_tsf(0x0409, HKL(ptr::null_mut())) + .unwrap_err(); + + assert!(err + .to_string() + .contains("active keyboard layout profile has no HKL")); + } } From d4655b7cb91ca68d4e7c86bd8b20bcbb55ae4c92 Mon Sep 17 00:00:00 2001 From: millionart Date: Fri, 1 May 2026 19:31:06 +0800 Subject: [PATCH 10/43] feat: add Windows IME IPC client --- openless-all/app/src-tauri/src/lib.rs | 1 + .../app/src-tauri/src/windows_ime_ipc.rs | 365 ++++++++++++++++++ 2 files changed, 366 insertions(+) create mode 100644 openless-all/app/src-tauri/src/windows_ime_ipc.rs diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 3210c9db..9bc92e98 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -20,6 +20,7 @@ mod persistence; mod polish; mod recorder; mod types; +mod windows_ime_ipc; mod windows_ime_profile; mod windows_ime_protocol; diff --git a/openless-all/app/src-tauri/src/windows_ime_ipc.rs b/openless-all/app/src-tauri/src/windows_ime_ipc.rs new file mode 100644 index 00000000..3fe8b0c3 --- /dev/null +++ b/openless-all/app/src-tauri/src/windows_ime_ipc.rs @@ -0,0 +1,365 @@ +use std::time::Duration; + +use crate::windows_ime_protocol::ImeSubmitStatus; + +pub const IME_CLIENT_WAIT_TIMEOUT: Duration = Duration::from_millis(700); +pub const IME_SUBMIT_TIMEOUT: Duration = Duration::from_millis(900); +const IME_PIPE_RETRY_INTERVAL: Duration = Duration::from_millis(25); + +const ERROR_FILE_NOT_FOUND: u32 = 2; +const ERROR_PATH_NOT_FOUND: u32 = 3; +const ERROR_SEM_TIMEOUT: u32 = 121; +const ERROR_PIPE_BUSY: u32 = 231; +const NMPWAIT_NOWAIT: u32 = 0x00000001; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WindowsImeIpcError { + Unavailable(String), + NoReadyClient, + Timeout, + Protocol(String), + Io(String), +} + +impl std::fmt::Display for WindowsImeIpcError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Unavailable(message) | Self::Protocol(message) | Self::Io(message) => { + write!(f, "{message}") + } + Self::NoReadyClient => write!(f, "no OpenLess IME client is ready"), + Self::Timeout => write!(f, "OpenLess IME IPC timed out"), + } + } +} + +impl std::error::Error for WindowsImeIpcError {} + +pub type WindowsImeIpcResult = Result; + +fn map_wait_named_pipe_error(error_code: Option) -> WindowsImeIpcError { + match error_code { + Some(ERROR_FILE_NOT_FOUND | ERROR_PATH_NOT_FOUND | ERROR_PIPE_BUSY) => { + WindowsImeIpcError::NoReadyClient + } + Some(ERROR_SEM_TIMEOUT) => WindowsImeIpcError::Timeout, + Some(code) => WindowsImeIpcError::Io(format!("WaitNamedPipeW failed with OS error {code}")), + None => WindowsImeIpcError::Io("WaitNamedPipeW failed without OS error".to_string()), + } +} + +fn is_retryable_pipe_error(error_code: Option) -> bool { + matches!( + error_code, + Some(ERROR_FILE_NOT_FOUND | ERROR_PATH_NOT_FOUND | ERROR_PIPE_BUSY | ERROR_SEM_TIMEOUT) + ) +} + +#[derive(Debug)] +pub struct PendingImeSubmit { + session_id: String, + completed: bool, +} + +impl PendingImeSubmit { + pub fn new(session_id: String) -> Self { + Self { + session_id, + completed: false, + } + } + + pub fn accept_result( + &mut self, + session_id: &str, + status: ImeSubmitStatus, + ) -> WindowsImeIpcResult { + if self.completed { + return Err(WindowsImeIpcError::Protocol( + "submit result arrived after completion".to_string(), + )); + } + if self.session_id != session_id { + return Err(WindowsImeIpcError::Protocol( + "submit result belongs to a different session".to_string(), + )); + } + self.completed = true; + Ok(status) + } +} + +#[derive(Debug, Clone)] +pub struct ImeSubmitRequest { + pub session_id: String, + pub text: String, + pub created_at: String, +} + +#[derive(Clone)] +pub struct WindowsImeIpcServer { + inner: std::sync::Arc>, +} + +#[derive(Debug, Default)] +struct WindowsImeIpcState { + ready_client_id: Option, +} + +impl WindowsImeIpcServer { + pub fn new() -> Self { + Self { + inner: std::sync::Arc::new(parking_lot::Mutex::new(WindowsImeIpcState::default())), + } + } + + pub fn mark_client_ready_for_test(&self, client_id: String) { + self.inner.lock().ready_client_id = Some(client_id); + } + + pub fn has_ready_client(&self) -> bool { + self.inner.lock().ready_client_id.is_some() + } + + pub async fn submit_text( + &self, + request: ImeSubmitRequest, + ) -> WindowsImeIpcResult { + #[cfg(target_os = "windows")] + { + let _ = self; + submit_text_to_platform(request).await + } + + #[cfg(not(target_os = "windows"))] + { + let _ = self; + let _ = request; + Err(WindowsImeIpcError::Unavailable( + "OpenLess IME IPC is only available on Windows".to_string(), + )) + } + } +} + +impl Default for WindowsImeIpcServer { + fn default() -> Self { + Self::new() + } +} + +#[cfg(target_os = "windows")] +async fn submit_text_to_platform( + request: ImeSubmitRequest, +) -> WindowsImeIpcResult { + windows_pipe::submit_text_over_pipe(request).await +} + +#[cfg(target_os = "windows")] +mod windows_pipe { + use std::ffi::OsStr; + use std::os::windows::ffi::OsStrExt; + use std::time::Instant; + + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + use tokio::net::windows::named_pipe::{ClientOptions, NamedPipeClient}; + + use super::{ + ImeSubmitRequest, PendingImeSubmit, WindowsImeIpcError, WindowsImeIpcResult, + IME_CLIENT_WAIT_TIMEOUT, IME_PIPE_RETRY_INTERVAL, IME_SUBMIT_TIMEOUT, + }; + use crate::windows_ime_protocol::{ + decode_message, encode_message, ImePipeMessage, OPENLESS_IME_PIPE_NAME, + OPENLESS_IME_PROTOCOL_VERSION, + }; + + extern "system" { + fn WaitNamedPipeW(lpNamedPipeName: *const u16, nTimeOut: u32) -> i32; + } + + pub async fn submit_text_over_pipe( + request: ImeSubmitRequest, + ) -> WindowsImeIpcResult { + let mut pending = PendingImeSubmit::new(request.session_id.clone()); + let pipe = open_pipe_with_retry().await?; + let (read_half, mut write_half) = tokio::io::split(pipe); + let mut reader = BufReader::new(read_half); + + let message = ImePipeMessage::SubmitText { + protocol_version: OPENLESS_IME_PROTOCOL_VERSION, + session_id: request.session_id, + text: request.text, + created_at: request.created_at, + }; + let line = encode_message(&message) + .map_err(|error| WindowsImeIpcError::Protocol(error.to_string()))?; + + let response = tokio::time::timeout(IME_SUBMIT_TIMEOUT, async { + write_half + .write_all(line.as_bytes()) + .await + .map_err(|error| WindowsImeIpcError::Io(error.to_string()))?; + write_half + .flush() + .await + .map_err(|error| WindowsImeIpcError::Io(error.to_string()))?; + + let mut response = String::new(); + let bytes_read = reader + .read_line(&mut response) + .await + .map_err(|error| WindowsImeIpcError::Io(error.to_string()))?; + + if bytes_read == 0 { + return Err(WindowsImeIpcError::Io( + "IME pipe closed before submit result".to_string(), + )); + } + + Ok(response) + }) + .await + .map_err(|_| WindowsImeIpcError::Timeout)??; + + match decode_message(response.trim_end()) + .map_err(|error| WindowsImeIpcError::Protocol(error.to_string()))? + { + ImePipeMessage::SubmitResult { + protocol_version, + session_id, + status, + .. + } if protocol_version == OPENLESS_IME_PROTOCOL_VERSION => { + pending.accept_result(&session_id, status) + } + ImePipeMessage::SubmitResult { + protocol_version, .. + } => Err(WindowsImeIpcError::Protocol(format!( + "unsupported IME protocol version {protocol_version}" + ))), + _ => Err(WindowsImeIpcError::Protocol( + "message is not a submit result".to_string(), + )), + } + } + + async fn open_pipe_with_retry() -> WindowsImeIpcResult { + let deadline = Instant::now() + IME_CLIENT_WAIT_TIMEOUT; + + loop { + let retry_error = match wait_for_pipe_client() { + Ok(()) => match ClientOptions::new().open(OPENLESS_IME_PIPE_NAME) { + Ok(pipe) => return Ok(pipe), + Err(error) => { + let error_code = error.raw_os_error().map(|code| code as u32); + if !super::is_retryable_pipe_error(error_code) { + return Err(WindowsImeIpcError::Io(error.to_string())); + } + super::map_wait_named_pipe_error(error_code) + } + }, + Err(error) => { + if !is_retryable_wait_error(&error) { + return Err(error); + } + error + } + }; + + if Instant::now() >= deadline { + return Err(retry_error); + } + tokio::time::sleep(next_retry_delay(deadline)).await; + } + } + + fn wait_for_pipe_client() -> WindowsImeIpcResult<()> { + let pipe_name = OsStr::new(OPENLESS_IME_PIPE_NAME) + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + + let is_ready = unsafe { WaitNamedPipeW(pipe_name.as_ptr(), super::NMPWAIT_NOWAIT) }; + if is_ready != 0 { + return Ok(()); + } + + Err(super::map_wait_named_pipe_error( + std::io::Error::last_os_error() + .raw_os_error() + .map(|code| code as u32), + )) + } + + fn is_retryable_wait_error(error: &WindowsImeIpcError) -> bool { + matches!( + error, + WindowsImeIpcError::NoReadyClient | WindowsImeIpcError::Timeout + ) + } + + fn next_retry_delay(deadline: Instant) -> std::time::Duration { + deadline + .saturating_duration_since(Instant::now()) + .min(IME_PIPE_RETRY_INTERVAL) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pending_submit_accepts_only_matching_session() { + let mut pending = PendingImeSubmit::new("session-1".to_string()); + assert!(pending + .accept_result("session-2", ImeSubmitStatus::Committed) + .is_err()); + assert_eq!( + pending.accept_result("session-1", ImeSubmitStatus::Committed), + Ok(ImeSubmitStatus::Committed) + ); + } + + #[test] + fn pending_submit_rejects_second_result_after_completion() { + let mut pending = PendingImeSubmit::new("session-1".to_string()); + assert_eq!( + pending.accept_result("session-1", ImeSubmitStatus::Committed), + Ok(ImeSubmitStatus::Committed) + ); + assert!(pending + .accept_result("session-1", ImeSubmitStatus::Committed) + .is_err()); + } + + #[test] + fn wait_pipe_error_mapping_treats_missing_or_busy_pipe_as_no_ready_client() { + assert_eq!( + map_wait_named_pipe_error(Some(2)), + WindowsImeIpcError::NoReadyClient + ); + assert_eq!( + map_wait_named_pipe_error(Some(231)), + WindowsImeIpcError::NoReadyClient + ); + } + + #[test] + fn wait_pipe_error_mapping_treats_wait_timeout_as_timeout() { + assert_eq!( + map_wait_named_pipe_error(Some(121)), + WindowsImeIpcError::Timeout + ); + } + + #[test] + fn missing_busy_and_timeout_pipe_errors_are_retryable_before_deadline() { + assert!(is_retryable_pipe_error(Some(2))); + assert!(is_retryable_pipe_error(Some(3))); + assert!(is_retryable_pipe_error(Some(121))); + assert!(is_retryable_pipe_error(Some(231))); + assert!(!is_retryable_pipe_error(Some(5))); + assert!(!is_retryable_pipe_error(None)); + } +} From 3a6f7f3c92317b2e4bb8071f8ecb9754dea6bb66 Mon Sep 17 00:00:00 2001 From: millionart Date: Fri, 1 May 2026 19:53:51 +0800 Subject: [PATCH 11/43] feat: route Windows insertion through temporary TSF IME --- openless-all/app/src-tauri/src/coordinator.rs | 80 +++++++++ openless-all/app/src-tauri/src/insertion.rs | 9 + openless-all/app/src-tauri/src/lib.rs | 1 + .../app/src-tauri/src/windows_ime_session.rs | 169 ++++++++++++++++++ 4 files changed, 259 insertions(+) create mode 100644 openless-all/app/src-tauri/src/windows_ime_session.rs diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index a6f46d93..93937a0e 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -31,6 +31,8 @@ use crate::types::{ CapsulePayload, CapsuleState, DictationSession, HotkeyCapability, HotkeyMode, HotkeyStatus, HotkeyStatusState, InsertStatus, PolishMode, }; +#[cfg(target_os = "windows")] +use crate::windows_ime_session::{PreparedWindowsImeSession, WindowsImeSessionController}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum SessionPhase { @@ -89,6 +91,10 @@ struct Inner { prefs: PreferencesStore, vocab: DictionaryStore, inserter: TextInserter, + #[cfg(target_os = "windows")] + windows_ime: WindowsImeSessionController, + #[cfg(target_os = "windows")] + prepared_windows_ime_session: Arc>>, state: Mutex, asr: Mutex>, recorder: Mutex>, @@ -118,6 +124,10 @@ impl Coordinator { prefs, vocab, inserter: TextInserter::new(), + #[cfg(target_os = "windows")] + windows_ime: WindowsImeSessionController::new(), + #[cfg(target_os = "windows")] + prepared_windows_ime_session: Arc::new(Mutex::new(None)), state: Mutex::new(SessionState::default()), asr: Mutex::new(None), recorder: Mutex::new(None), @@ -465,6 +475,11 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { // 如果迟到错误到达时 id 已不匹配就 drop,不会误中止后续 session。 state.session_id = state.session_id.wrapping_add(1); } + #[cfg(target_os = "windows")] + { + let prepared = inner.windows_ime.prepare_session(); + *inner.prepared_windows_ime_session.lock() = Some(prepared); + } // 翻译模式标志重置;hotkey 监听器在 Shift down 时再 set true。 inner.translation_modifier_seen.store(false, Ordering::SeqCst); @@ -486,6 +501,7 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { Some(message.clone()), None, ); + restore_prepared_windows_ime_session(inner); inner.state.lock().phase = SessionPhase::Idle; return Err(message); } @@ -500,6 +516,7 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { Some(message.clone()), None, ); + restore_prepared_windows_ime_session(inner); inner.state.lock().phase = SessionPhase::Idle; schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); return Err(message); @@ -536,6 +553,7 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { } } if cancel_raced_during_starting(inner) { + restore_prepared_windows_ime_session(inner); inner.state.lock().phase = SessionPhase::Idle; return Ok(()); } @@ -547,6 +565,7 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { Some(format!("ASR 连接失败: {e}")), None, ); + restore_prepared_windows_ime_session(inner); inner.state.lock().phase = SessionPhase::Idle; schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); return Err(e.to_string()); @@ -560,6 +579,7 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { if let Some(rec) = inner.recorder.lock().take() { rec.stop(); } + restore_prepared_windows_ime_session(inner); inner.state.lock().phase = SessionPhase::Idle; return Ok(()); } @@ -637,6 +657,7 @@ fn start_recorder_for_starting( Some(format!("录音启动失败: {e}")), None, ); + restore_prepared_windows_ime_session(inner); inner.state.lock().phase = SessionPhase::Idle; schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); return Err(e.to_string()); @@ -697,6 +718,7 @@ fn abort_recording_with_error(inner: &Arc, message: String) { ActiveAsr::Whisper(w) => w.cancel(), } } + restore_prepared_windows_ime_session(inner); emit_capsule( inner, @@ -749,6 +771,7 @@ async fn finish_starting_session(inner: &Arc) { ActiveAsr::Whisper(w) => w.cancel(), } } + restore_prepared_windows_ime_session(inner); inner.state.lock().phase = SessionPhase::Idle; } BeginOutcome::Started | BeginOutcome::PendingStop => { @@ -781,6 +804,7 @@ async fn end_session(inner: &Arc) -> Result<(), String> { let asr = match asr_opt { Some(a) => a, None => { + restore_prepared_windows_ime_session(inner); inner.state.lock().phase = SessionPhase::Idle; return Ok(()); } @@ -803,6 +827,7 @@ async fn end_session(inner: &Arc) -> Result<(), String> { Some(format!("识别失败: {e}")), None, ); + restore_prepared_windows_ime_session(inner); inner.state.lock().phase = SessionPhase::Idle; schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); return Err(e.to_string()); @@ -821,6 +846,7 @@ async fn end_session(inner: &Arc) -> Result<(), String> { Some(format!("识别失败: {e}")), None, ); + restore_prepared_windows_ime_session(inner); inner.state.lock().phase = SessionPhase::Idle; schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); return Err(e.to_string()); @@ -832,6 +858,7 @@ async fn end_session(inner: &Arc) -> Result<(), String> { // 优先级高于 empty 检查 — 用户取消 → 静默丢弃,不写失败历史也不弹错误胶囊。 if inner.state.lock().cancelled { log::info!("[coord] cancel detected after ASR — discarding transcript"); + restore_prepared_windows_ime_session(inner); inner.state.lock().phase = SessionPhase::Idle; return Ok(()); } @@ -863,6 +890,7 @@ async fn end_session(inner: &Arc) -> Result<(), String> { Some("ASR returned empty transcript".to_string()), None, ); + restore_prepared_windows_ime_session(inner); inner.state.lock().phase = SessionPhase::Idle; schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); return Err("ASR returned empty transcript".to_string()); @@ -907,12 +935,16 @@ async fn end_session(inner: &Arc) -> Result<(), String> { "[coord] cancel detected before insert — discarding output (chars={})", polished.chars().count() ); + restore_prepared_windows_ime_session(inner); return Ok(()); } let focus_target = inner.state.lock().focus_target; restore_focus_target_if_possible(focus_target); let restore_clipboard = inner.prefs.get().restore_clipboard_after_paste; + #[cfg(target_os = "windows")] + let status = insert_with_windows_ime_first(inner, &polished, restore_clipboard).await; + #[cfg(not(target_os = "windows"))] let status = inner.inserter.insert(&polished, restore_clipboard); let inserted_chars = polished.chars().count() as u32; @@ -1016,6 +1048,7 @@ fn cancel_session(inner: &Arc) { ActiveAsr::Whisper(w) => w.cancel(), } } + restore_prepared_windows_ime_session(inner); // Processing 阶段保持 phase=Processing 让 end_session 自己走完检查 + 收尾; // 其他阶段直接转 Idle。 if phase != SessionPhase::Processing { @@ -1028,6 +1061,53 @@ fn cancel_session(inner: &Arc) { schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); } +#[cfg(target_os = "windows")] +fn restore_prepared_windows_ime_session(inner: &Arc) { + if let Some(prepared) = inner.prepared_windows_ime_session.lock().take() { + inner.windows_ime.restore_session(prepared); + } +} + +#[cfg(not(target_os = "windows"))] +fn restore_prepared_windows_ime_session(_inner: &Arc) {} + +#[cfg(target_os = "windows")] +async fn insert_with_windows_ime_first( + inner: &Arc, + polished: &str, + restore_clipboard: bool, +) -> InsertStatus { + let prepared = inner.prepared_windows_ime_session.lock().take(); + let Some(prepared) = prepared else { + return inner + .inserter + .insert_via_clipboard_fallback(polished, restore_clipboard); + }; + + let request = crate::windows_ime_ipc::ImeSubmitRequest { + session_id: Uuid::new_v4().to_string(), + text: polished.to_string(), + created_at: Utc::now().to_rfc3339(), + }; + + let ime_status = match inner.windows_ime.submit_prepared(&prepared, request).await { + Ok(status) => status, + Err(error) => { + log::warn!("[windows-ime] TSF submit failed, falling back to clipboard: {error}"); + InsertStatus::CopiedFallback + } + }; + inner.windows_ime.restore_session(prepared); + + if ime_status == InsertStatus::Inserted { + ime_status + } else { + inner + .inserter + .insert_via_clipboard_fallback(polished, restore_clipboard) + } +} + // ─────────────────────────── helpers ─────────────────────────── #[cfg(any(debug_assertions, test))] diff --git a/openless-all/app/src-tauri/src/insertion.rs b/openless-all/app/src-tauri/src/insertion.rs index bbb84f14..014636af 100644 --- a/openless-all/app/src-tauri/src/insertion.rs +++ b/openless-all/app/src-tauri/src/insertion.rs @@ -35,6 +35,15 @@ impl TextInserter { insert_with_clipboard_restore(text, restore_clipboard_after_paste) } + #[cfg(not(target_os = "macos"))] + pub fn insert_via_clipboard_fallback( + &self, + text: &str, + restore_clipboard_after_paste: bool, + ) -> InsertStatus { + self.insert(text, restore_clipboard_after_paste) + } + /// Insert `text` at the current cursor position. #[cfg(target_os = "macos")] pub fn insert(&self, text: &str, _restore_clipboard_after_paste: bool) -> InsertStatus { diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 9bc92e98..a41a30e9 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -23,6 +23,7 @@ mod types; mod windows_ime_ipc; mod windows_ime_profile; mod windows_ime_protocol; +mod windows_ime_session; #[cfg(target_os = "macos")] use std::sync::mpsc; diff --git a/openless-all/app/src-tauri/src/windows_ime_session.rs b/openless-all/app/src-tauri/src/windows_ime_session.rs new file mode 100644 index 00000000..b7e70027 --- /dev/null +++ b/openless-all/app/src-tauri/src/windows_ime_session.rs @@ -0,0 +1,169 @@ +use crate::types::InsertStatus; +use crate::windows_ime_ipc::{ImeSubmitRequest, WindowsImeIpcServer}; +use crate::windows_ime_profile::{ + restore_decision, ImeProfileSnapshot, ProfileRestoreDecision, WindowsImeProfileManager, +}; +use crate::windows_ime_protocol::ImeSubmitStatus; + +#[derive(Debug)] +pub enum WindowsImeSessionError { + Profile(String), + Ipc(String), +} + +impl std::fmt::Display for WindowsImeSessionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Profile(message) | Self::Ipc(message) => write!(f, "{message}"), + } + } +} + +impl std::error::Error for WindowsImeSessionError {} + +pub fn map_ime_status_to_insert_status(status: ImeSubmitStatus) -> InsertStatus { + match status { + ImeSubmitStatus::Committed => InsertStatus::Inserted, + ImeSubmitStatus::Rejected | ImeSubmitStatus::Failed => InsertStatus::CopiedFallback, + } +} + +pub fn should_fallback_after_ime_result(status: ImeSubmitStatus) -> bool { + !matches!(status, ImeSubmitStatus::Committed) +} + +#[derive(Debug)] +pub struct PreparedWindowsImeSession { + saved_profile: Option, + openless_activated: bool, +} + +impl PreparedWindowsImeSession { + pub fn unavailable() -> Self { + Self { + saved_profile: None, + openless_activated: false, + } + } + + pub fn is_ready_for_tsf_submit(&self) -> bool { + self.saved_profile.is_some() && self.openless_activated + } +} + +pub struct WindowsImeSessionController { + profile_manager: WindowsImeProfileManager, + ipc_server: WindowsImeIpcServer, +} + +impl WindowsImeSessionController { + pub fn new() -> Self { + Self { + profile_manager: WindowsImeProfileManager::new(), + ipc_server: WindowsImeIpcServer::new(), + } + } + + pub fn prepare_session(&self) -> PreparedWindowsImeSession { + #[cfg(target_os = "windows")] + { + let saved_profile = match self.profile_manager.capture_active_profile() { + Ok(snapshot) => snapshot, + Err(error) => { + let error = WindowsImeSessionError::Profile(error.to_string()); + log::warn!("[windows-ime] capture active profile failed: {error}"); + return PreparedWindowsImeSession::unavailable(); + } + }; + + match self.profile_manager.activate_openless_profile() { + Ok(()) => PreparedWindowsImeSession { + saved_profile: Some(saved_profile), + openless_activated: true, + }, + Err(error) => { + let error = WindowsImeSessionError::Profile(error.to_string()); + log::warn!("[windows-ime] activate OpenLess profile failed: {error}"); + PreparedWindowsImeSession::unavailable() + } + } + } + + #[cfg(not(target_os = "windows"))] + { + PreparedWindowsImeSession::unavailable() + } + } + + pub async fn submit_prepared( + &self, + prepared: &PreparedWindowsImeSession, + request: ImeSubmitRequest, + ) -> Result { + if !prepared.is_ready_for_tsf_submit() { + return Ok(InsertStatus::CopiedFallback); + } + + let status = self + .ipc_server + .submit_text(request) + .await + .map_err(|error| WindowsImeSessionError::Ipc(error.to_string()))?; + if should_fallback_after_ime_result(status) { + log::warn!("[windows-ime] TSF submit returned {status:?}; falling back to clipboard"); + } + Ok(map_ime_status_to_insert_status(status)) + } + + pub fn restore_session(&self, prepared: PreparedWindowsImeSession) { + let should_restore = match self.profile_manager.is_openless_profile_active() { + Ok(openless_active) => { + restore_decision(prepared.saved_profile.as_ref(), openless_active) + } + Err(error) => { + log::warn!("[windows-ime] check active profile before restore failed: {error}"); + ProfileRestoreDecision::KeepCurrentProfile + } + }; + + if should_restore != ProfileRestoreDecision::RestoreSavedProfile { + return; + } + + let Some(saved_profile) = prepared.saved_profile.as_ref() else { + return; + }; + + if let Err(error) = self.profile_manager.restore_profile(saved_profile) { + log::warn!("[windows-ime] restore saved profile failed: {error}"); + } + } +} + +impl Default for WindowsImeSessionController { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn committed_ime_result_maps_to_inserted() { + assert_eq!( + map_ime_status_to_insert_status(ImeSubmitStatus::Committed), + InsertStatus::Inserted + ); + } + + #[test] + fn rejected_ime_result_requests_fallback() { + assert!(should_fallback_after_ime_result(ImeSubmitStatus::Rejected)); + assert!(should_fallback_after_ime_result(ImeSubmitStatus::Failed)); + assert!(!should_fallback_after_ime_result( + ImeSubmitStatus::Committed + )); + } +} From 49b55f1a416f9110b3ce43d116b0fadad3b15a8e Mon Sep 17 00:00:00 2001 From: millionart Date: Fri, 1 May 2026 19:58:56 +0800 Subject: [PATCH 12/43] fix: report inactive Windows IME session --- .../app/src-tauri/src/windows_ime_session.rs | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/openless-all/app/src-tauri/src/windows_ime_session.rs b/openless-all/app/src-tauri/src/windows_ime_session.rs index b7e70027..38bcc6b8 100644 --- a/openless-all/app/src-tauri/src/windows_ime_session.rs +++ b/openless-all/app/src-tauri/src/windows_ime_session.rs @@ -53,14 +53,14 @@ impl PreparedWindowsImeSession { pub struct WindowsImeSessionController { profile_manager: WindowsImeProfileManager, - ipc_server: WindowsImeIpcServer, + ipc: WindowsImeIpcServer, } impl WindowsImeSessionController { pub fn new() -> Self { Self { profile_manager: WindowsImeProfileManager::new(), - ipc_server: WindowsImeIpcServer::new(), + ipc: WindowsImeIpcServer::new(), } } @@ -101,11 +101,13 @@ impl WindowsImeSessionController { request: ImeSubmitRequest, ) -> Result { if !prepared.is_ready_for_tsf_submit() { - return Ok(InsertStatus::CopiedFallback); + return Err(WindowsImeSessionError::Ipc( + "OpenLess IME session is not active".to_string(), + )); } let status = self - .ipc_server + .ipc .submit_text(request) .await .map_err(|error| WindowsImeSessionError::Ipc(error.to_string()))?; @@ -166,4 +168,23 @@ mod tests { ImeSubmitStatus::Committed )); } + + #[tokio::test] + async fn submit_prepared_reports_unavailable_session() { + let controller = WindowsImeSessionController::new(); + let result = controller + .submit_prepared( + &PreparedWindowsImeSession::unavailable(), + ImeSubmitRequest { + session_id: "session-1".to_string(), + text: "hello".to_string(), + created_at: "2026-05-01T12:00:00Z".to_string(), + }, + ) + .await; + + assert!( + matches!(result, Err(WindowsImeSessionError::Ipc(message)) if message == "OpenLess IME session is not active") + ); + } } From a1bc871c801b125092aa9436ea405ed0475e7bb7 Mon Sep 17 00:00:00 2001 From: millionart Date: Fri, 1 May 2026 20:08:23 +0800 Subject: [PATCH 13/43] fix: scope Windows IME session restore by dictation --- openless-all/app/src-tauri/src/coordinator.rs | 158 ++++++++++++------ .../app/src-tauri/src/windows_ime_session.rs | 35 +++- 2 files changed, 143 insertions(+), 50 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 93937a0e..c736e25d 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -94,7 +94,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>, @@ -108,6 +108,13 @@ struct Inner { translation_modifier_seen: AtomicBool, } +#[cfg(target_os = "windows")] +#[derive(Debug)] +struct PreparedWindowsImeSessionSlot { + session_id: u64, + prepared: PreparedWindowsImeSession, +} + impl Coordinator { pub fn new() -> Self { let history = HistoryStore::new().unwrap_or_else(|e| { @@ -460,7 +467,7 @@ fn window_key_matches_trigger(trigger: crate::types::HotkeyTrigger, key: &str, c // ─────────────────────────── session lifecycle ─────────────────────────── async fn begin_session(inner: &Arc) -> Result<(), String> { - { + let current_session_id = { let mut state = inner.state.lock(); if state.phase != SessionPhase::Idle { return Ok(()); @@ -474,11 +481,15 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { // 自增 session_id;spawn 出去的 recorder error monitor 会捕获这个值, // 如果迟到错误到达时 id 已不匹配就 drop,不会误中止后续 session。 state.session_id = state.session_id.wrapping_add(1); - } + state.session_id + }; #[cfg(target_os = "windows")] { let prepared = inner.windows_ime.prepare_session(); - *inner.prepared_windows_ime_session.lock() = Some(prepared); + *inner.prepared_windows_ime_session.lock() = Some(PreparedWindowsImeSessionSlot { + session_id: current_session_id, + prepared, + }); } // 翻译模式标志重置;hotkey 监听器在 Shift down 时再 set true。 inner.translation_modifier_seen.store(false, Ordering::SeqCst); @@ -501,7 +512,7 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { Some(message.clone()), None, ); - restore_prepared_windows_ime_session(inner); + restore_prepared_windows_ime_session(inner, current_session_id); inner.state.lock().phase = SessionPhase::Idle; return Err(message); } @@ -516,7 +527,7 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { Some(message.clone()), None, ); - restore_prepared_windows_ime_session(inner); + restore_prepared_windows_ime_session(inner, current_session_id); inner.state.lock().phase = SessionPhase::Idle; schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); return Err(message); @@ -531,7 +542,8 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { let whisper = Arc::new(WhisperBatchASR::new(api_key, base_url, model)); *inner.asr.lock() = Some(ActiveAsr::Whisper(Arc::clone(&whisper))); let consumer: Arc = whisper; - start_recorder_and_enter_listening(inner, &active_asr, consumer).await?; + start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer) + .await?; } else { let hotwords = enabled_hotwords(inner); let creds = read_volc_credentials(); @@ -539,7 +551,7 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { let bridge = Arc::new(DeferredAsrBridge::new()); let consumer: Arc = bridge.clone(); *inner.asr.lock() = Some(ActiveAsr::Volcengine(Arc::clone(&asr))); - start_recorder_for_starting(inner, &active_asr, consumer)?; + start_recorder_for_starting(inner, current_session_id, &active_asr, consumer)?; if let Err(e) = asr.open_session().await { log::error!("[coord] open ASR session failed: {e}"); @@ -553,7 +565,7 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { } } if cancel_raced_during_starting(inner) { - restore_prepared_windows_ime_session(inner); + restore_prepared_windows_ime_session(inner, current_session_id); inner.state.lock().phase = SessionPhase::Idle; return Ok(()); } @@ -565,7 +577,7 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { Some(format!("ASR 连接失败: {e}")), None, ); - restore_prepared_windows_ime_session(inner); + restore_prepared_windows_ime_session(inner, current_session_id); inner.state.lock().phase = SessionPhase::Idle; schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); return Err(e.to_string()); @@ -579,14 +591,14 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { if let Some(rec) = inner.recorder.lock().take() { rec.stop(); } - restore_prepared_windows_ime_session(inner); + restore_prepared_windows_ime_session(inner, current_session_id); inner.state.lock().phase = SessionPhase::Idle; return Ok(()); } let target: Arc = asr; let flushed_bytes = bridge.attach(target); log::info!("[coord] ASR connected; flushed {flushed_bytes} deferred audio bytes"); - finish_starting_session(inner).await; + finish_starting_session(inner, current_session_id).await; } Ok(()) @@ -594,6 +606,7 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { fn start_recorder_for_starting( inner: &Arc, + session_id: u64, active_asr: &str, consumer: Arc, ) -> Result<(), String> { @@ -657,7 +670,7 @@ fn start_recorder_for_starting( Some(format!("录音启动失败: {e}")), None, ); - restore_prepared_windows_ime_session(inner); + restore_prepared_windows_ime_session(inner, session_id); inner.state.lock().phase = SessionPhase::Idle; schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); return Err(e.to_string()); @@ -694,7 +707,7 @@ fn spawn_recorder_error_monitor(inner: &Arc, rx: mpsc::Receiver, message: String) { - let elapsed = { + let (elapsed, session_id) = { let mut state = inner.state.lock(); if state.cancelled || !matches!( @@ -706,7 +719,10 @@ fn abort_recording_with_error(inner: &Arc, message: String) { } state.cancelled = true; state.phase = SessionPhase::Idle; - state.started_at.elapsed().as_millis() as u64 + ( + state.started_at.elapsed().as_millis() as u64, + state.session_id, + ) }; if let Some(rec) = inner.recorder.lock().take() { @@ -718,7 +734,7 @@ fn abort_recording_with_error(inner: &Arc, message: String) { ActiveAsr::Whisper(w) => w.cancel(), } } - restore_prepared_windows_ime_session(inner); + restore_prepared_windows_ime_session(inner, session_id); emit_capsule( inner, @@ -733,15 +749,16 @@ fn abort_recording_with_error(inner: &Arc, message: String) { async fn start_recorder_and_enter_listening( inner: &Arc, + session_id: u64, active_asr: &str, consumer: Arc, ) -> Result<(), String> { - start_recorder_for_starting(inner, active_asr, consumer)?; - finish_starting_session(inner).await; + start_recorder_for_starting(inner, session_id, active_asr, consumer)?; + finish_starting_session(inner, session_id).await; Ok(()) } -async fn finish_starting_session(inner: &Arc) { +async fn finish_starting_session(inner: &Arc, session_id: u64) { // audit HIGH #1:转 Listening 之前在同一 lock 内检查 cancel race。 // 之前是无条件 phase=Listening,会把 cancel_session 在 await 期间设的 Idle // 反向覆盖回 Listening → 用户的 cancel 边沿被吞掉。 @@ -771,7 +788,7 @@ async fn finish_starting_session(inner: &Arc) { ActiveAsr::Whisper(w) => w.cancel(), } } - restore_prepared_windows_ime_session(inner); + restore_prepared_windows_ime_session(inner, session_id); inner.state.lock().phase = SessionPhase::Idle; } BeginOutcome::Started | BeginOutcome::PendingStop => { @@ -785,13 +802,14 @@ async fn finish_starting_session(inner: &Arc) { } async fn end_session(inner: &Arc) -> Result<(), String> { - { + let current_session_id = { let mut state = inner.state.lock(); if state.phase != SessionPhase::Listening { return Ok(()); } state.phase = SessionPhase::Processing; - } + state.session_id + }; let elapsed = inner.state.lock().started_at.elapsed().as_millis() as u64; emit_capsule(inner, CapsuleState::Transcribing, 0.0, elapsed, None, None); @@ -804,7 +822,7 @@ async fn end_session(inner: &Arc) -> Result<(), String> { let asr = match asr_opt { Some(a) => a, None => { - restore_prepared_windows_ime_session(inner); + restore_prepared_windows_ime_session(inner, current_session_id); inner.state.lock().phase = SessionPhase::Idle; return Ok(()); } @@ -827,7 +845,7 @@ async fn end_session(inner: &Arc) -> Result<(), String> { Some(format!("识别失败: {e}")), None, ); - restore_prepared_windows_ime_session(inner); + restore_prepared_windows_ime_session(inner, current_session_id); inner.state.lock().phase = SessionPhase::Idle; schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); return Err(e.to_string()); @@ -846,7 +864,7 @@ async fn end_session(inner: &Arc) -> Result<(), String> { Some(format!("识别失败: {e}")), None, ); - restore_prepared_windows_ime_session(inner); + restore_prepared_windows_ime_session(inner, current_session_id); inner.state.lock().phase = SessionPhase::Idle; schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); return Err(e.to_string()); @@ -858,7 +876,7 @@ async fn end_session(inner: &Arc) -> Result<(), String> { // 优先级高于 empty 检查 — 用户取消 → 静默丢弃,不写失败历史也不弹错误胶囊。 if inner.state.lock().cancelled { log::info!("[coord] cancel detected after ASR — discarding transcript"); - restore_prepared_windows_ime_session(inner); + restore_prepared_windows_ime_session(inner, current_session_id); inner.state.lock().phase = SessionPhase::Idle; return Ok(()); } @@ -890,7 +908,7 @@ async fn end_session(inner: &Arc) -> Result<(), String> { Some("ASR returned empty transcript".to_string()), None, ); - restore_prepared_windows_ime_session(inner); + restore_prepared_windows_ime_session(inner, current_session_id); inner.state.lock().phase = SessionPhase::Idle; schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); return Err("ASR returned empty transcript".to_string()); @@ -935,7 +953,7 @@ async fn end_session(inner: &Arc) -> Result<(), String> { "[coord] cancel detected before insert — discarding output (chars={})", polished.chars().count() ); - restore_prepared_windows_ime_session(inner); + restore_prepared_windows_ime_session(inner, current_session_id); return Ok(()); } @@ -943,7 +961,9 @@ async fn end_session(inner: &Arc) -> Result<(), String> { restore_focus_target_if_possible(focus_target); let restore_clipboard = inner.prefs.get().restore_clipboard_after_paste; #[cfg(target_os = "windows")] - let status = insert_with_windows_ime_first(inner, &polished, restore_clipboard).await; + let status = + insert_with_windows_ime_first(inner, current_session_id, &polished, restore_clipboard) + .await; #[cfg(not(target_os = "windows"))] let status = inner.inserter.insert(&polished, restore_clipboard); let inserted_chars = polished.chars().count() as u32; @@ -1024,20 +1044,24 @@ async fn end_session(inner: &Arc) -> Result<(), String> { } fn cancel_session(inner: &Arc) { - let phase = inner.state.lock().phase; - if phase == SessionPhase::Idle { - return; - } - // Inserting 阶段已经过了最后一次 cancel 检查 + 锁内转换,inserter.insert 即将 - // 或正在执行 → Cmd+V 已发出无法撤销。这里硬设 cancelled=true 只会让 UI 显示 - // "已取消" 但文本仍被插入,与用户预期相反。直接拒绝,让本次 session 走完。 - if phase == SessionPhase::Inserting { - log::info!("[coord] cancel ignored — already in Inserting phase, can't undo paste"); - return; - } - // Processing 阶段 cancel 不能直接干掉 in-flight polish task(已经 await 了), - // 但可以打 cancelled 标记,让 end_session 在插入前检查并丢弃结果。 - inner.state.lock().cancelled = true; + let (phase, session_id) = { + let mut state = inner.state.lock(); + let phase = state.phase; + if phase == SessionPhase::Idle { + return; + } + // Inserting 阶段已经过了最后一次 cancel 检查 + 锁内转换,inserter.insert 即将 + // 或正在执行 → Cmd+V 已发出无法撤销。这里硬设 cancelled=true 只会让 UI 显示 + // "已取消" 但文本仍被插入,与用户预期相反。直接拒绝,让本次 session 走完。 + if phase == SessionPhase::Inserting { + log::info!("[coord] cancel ignored — already in Inserting phase, can't undo paste"); + return; + } + // Processing 阶段 cancel 不能直接干掉 in-flight polish task(已经 await 了), + // 但可以打 cancelled 标记,让 end_session 在插入前检查并丢弃结果。 + state.cancelled = true; + (phase, state.session_id) + }; if let Some(rec) = inner.recorder.lock().take() { rec.stop(); @@ -1048,7 +1072,7 @@ fn cancel_session(inner: &Arc) { ActiveAsr::Whisper(w) => w.cancel(), } } - restore_prepared_windows_ime_session(inner); + restore_prepared_windows_ime_session(inner, session_id); // Processing 阶段保持 phase=Processing 让 end_session 自己走完检查 + 收尾; // 其他阶段直接转 Idle。 if phase != SessionPhase::Processing { @@ -1062,22 +1086,45 @@ fn cancel_session(inner: &Arc) { } #[cfg(target_os = "windows")] -fn restore_prepared_windows_ime_session(inner: &Arc) { - if let Some(prepared) = inner.prepared_windows_ime_session.lock().take() { +fn take_matching_prepared_windows_ime_session( + slot: &mut Option, + 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 +} + +#[cfg(target_os = "windows")] +fn restore_prepared_windows_ime_session(inner: &Arc, session_id: u64) { + let prepared = { + let mut slot = inner.prepared_windows_ime_session.lock(); + take_matching_prepared_windows_ime_session(&mut slot, session_id) + }; + if let Some(prepared) = prepared { inner.windows_ime.restore_session(prepared); } } #[cfg(not(target_os = "windows"))] -fn restore_prepared_windows_ime_session(_inner: &Arc) {} +fn restore_prepared_windows_ime_session(_inner: &Arc, _session_id: u64) {} #[cfg(target_os = "windows")] async fn insert_with_windows_ime_first( inner: &Arc, + session_id: u64, polished: &str, restore_clipboard: bool, ) -> InsertStatus { - let prepared = inner.prepared_windows_ime_session.lock().take(); + let prepared = { + let mut slot = inner.prepared_windows_ime_session.lock(); + take_matching_prepared_windows_ime_session(&mut slot, session_id) + }; let Some(prepared) = prepared else { return inner .inserter @@ -1451,6 +1498,21 @@ mod tests { ); assert!(coordinator.inner.hotkey_trigger_held.load(Ordering::SeqCst)); } + + #[test] + #[cfg(target_os = "windows")] + fn prepared_windows_ime_slot_is_taken_only_for_matching_session() { + let mut slot = Some(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 slot, 2).is_some()); + assert!(slot.is_none()); + } } fn enabled_phrases(inner: &Arc) -> Vec { diff --git a/openless-all/app/src-tauri/src/windows_ime_session.rs b/openless-all/app/src-tauri/src/windows_ime_session.rs index 38bcc6b8..544fcff8 100644 --- a/openless-all/app/src-tauri/src/windows_ime_session.rs +++ b/openless-all/app/src-tauri/src/windows_ime_session.rs @@ -49,6 +49,18 @@ impl PreparedWindowsImeSession { pub fn is_ready_for_tsf_submit(&self) -> bool { self.saved_profile.is_some() && self.openless_activated } + + pub fn has_saved_profile(&self) -> bool { + self.saved_profile.is_some() + } + + pub fn openless_was_activated(&self) -> bool { + self.openless_activated + } + + pub fn should_restore_when_active_profile_check_fails(&self) -> bool { + self.has_saved_profile() && self.openless_was_activated() + } } pub struct WindowsImeSessionController { @@ -123,8 +135,15 @@ impl WindowsImeSessionController { restore_decision(prepared.saved_profile.as_ref(), openless_active) } Err(error) => { - log::warn!("[windows-ime] check active profile before restore failed: {error}"); - ProfileRestoreDecision::KeepCurrentProfile + if prepared.should_restore_when_active_profile_check_fails() { + log::warn!( + "[windows-ime] check active profile before restore failed: {error}; attempting restore" + ); + ProfileRestoreDecision::RestoreSavedProfile + } else { + log::warn!("[windows-ime] check active profile before restore failed: {error}"); + ProfileRestoreDecision::KeepCurrentProfile + } } }; @@ -187,4 +206,16 @@ mod tests { matches!(result, Err(WindowsImeSessionError::Ipc(message)) if message == "OpenLess IME session is not active") ); } + + #[test] + fn active_profile_check_failure_restores_only_prepared_active_session() { + let prepared = PreparedWindowsImeSession { + saved_profile: Some(ImeProfileSnapshot::keyboard_layout(0x0409, 0x0409_0409)), + openless_activated: true, + }; + + assert!(prepared.should_restore_when_active_profile_check_fails()); + assert!(!PreparedWindowsImeSession::unavailable() + .should_restore_when_active_profile_check_fails()); + } } From 39d766d44dfaf521a535e6a32eb814d84cb6f3be Mon Sep 17 00:00:00 2001 From: millionart Date: Fri, 1 May 2026 20:13:37 +0800 Subject: [PATCH 14/43] fix: ignore stale dictation startup continuations --- openless-all/app/src-tauri/src/coordinator.rs | 109 +++++++++++++++--- 1 file changed, 91 insertions(+), 18 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index c736e25d..7a396e2c 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -555,6 +555,23 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { if let Err(e) = asr.open_session().await { log::error!("[coord] open ASR session failed: {e}"); + match startup_race_status_for_starting(inner, current_session_id) { + StartupRaceStatus::StaleContinuation => { + log::info!( + "[coord] stale ASR open_session error from session {current_session_id} — ignoring" + ); + asr.cancel(); + restore_prepared_windows_ime_session(inner, current_session_id); + return Ok(()); + } + StartupRaceStatus::CancelRaced => { + asr.cancel(); + restore_prepared_windows_ime_session(inner, current_session_id); + set_phase_idle_if_session_matches(inner, current_session_id); + return Ok(()); + } + StartupRaceStatus::ActiveStarting => {} + } if let Some(rec) = inner.recorder.lock().take() { rec.stop(); } @@ -564,11 +581,6 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { ActiveAsr::Whisper(w) => w.cancel(), } } - if cancel_raced_during_starting(inner) { - restore_prepared_windows_ime_session(inner, current_session_id); - inner.state.lock().phase = SessionPhase::Idle; - return Ok(()); - } emit_capsule( inner, CapsuleState::Error, @@ -578,22 +590,33 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { None, ); restore_prepared_windows_ime_session(inner, current_session_id); - inner.state.lock().phase = SessionPhase::Idle; + set_phase_idle_if_session_matches(inner, current_session_id); schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); return Err(e.to_string()); } // open_session.await 期间用户可能按了 Esc / 改变心意。如果 cancel_session // 已触发(cancelled=true 或 phase 被改回 Idle),别再装 ASR,直接善后。 // audit HIGH #1。 - if cancel_raced_during_starting(inner) { - log::info!("[coord] cancel raced during ASR open_session — aborting begin"); - asr.cancel(); - if let Some(rec) = inner.recorder.lock().take() { - rec.stop(); + match startup_race_status_for_starting(inner, current_session_id) { + StartupRaceStatus::ActiveStarting => {} + StartupRaceStatus::CancelRaced => { + log::info!("[coord] cancel raced during ASR open_session — aborting begin"); + asr.cancel(); + if let Some(rec) = inner.recorder.lock().take() { + rec.stop(); + } + restore_prepared_windows_ime_session(inner, current_session_id); + set_phase_idle_if_session_matches(inner, current_session_id); + return Ok(()); + } + StartupRaceStatus::StaleContinuation => { + log::info!( + "[coord] stale ASR open_session continuation from session {current_session_id} — ignoring" + ); + asr.cancel(); + restore_prepared_windows_ime_session(inner, current_session_id); + return Ok(()); } - restore_prepared_windows_ime_session(inner, current_session_id); - inner.state.lock().phase = SessionPhase::Idle; - return Ok(()); } let target: Arc = asr; let flushed_bytes = bridge.attach(target); @@ -764,7 +787,9 @@ async fn finish_starting_session(inner: &Arc, session_id: u64) { // 反向覆盖回 Listening → 用户的 cancel 边沿被吞掉。 let outcome = { let mut state = inner.state.lock(); - if state.cancelled || state.phase != SessionPhase::Starting { + if state.session_id != session_id { + BeginOutcome::StaleContinuation + } else if state.cancelled || state.phase != SessionPhase::Starting { BeginOutcome::CancelRaced } else { state.phase = SessionPhase::Listening; @@ -777,6 +802,12 @@ async fn finish_starting_session(inner: &Arc, session_id: u64) { } }; match outcome { + BeginOutcome::StaleContinuation => { + log::info!( + "[coord] stale recorder/ASR startup continuation from session {session_id} — ignoring" + ); + restore_prepared_windows_ime_session(inner, session_id); + } BeginOutcome::CancelRaced => { log::info!("[coord] cancel raced during recorder/ASR startup — aborting begin"); if let Some(rec) = inner.recorder.lock().take() { @@ -789,7 +820,7 @@ async fn finish_starting_session(inner: &Arc, session_id: u64) { } } restore_prepared_windows_ime_session(inner, session_id); - inner.state.lock().phase = SessionPhase::Idle; + set_phase_idle_if_session_matches(inner, session_id); } BeginOutcome::Started | BeginOutcome::PendingStop => { log::info!("[coord] session started"); @@ -1513,6 +1544,19 @@ mod tests { assert!(take_matching_prepared_windows_ime_session(&mut slot, 2).is_some()); assert!(slot.is_none()); } + + #[test] + fn startup_race_check_treats_newer_session_as_stale() { + let mut state = SessionState::default(); + state.phase = SessionPhase::Starting; + state.cancelled = false; + state.session_id = 2; + + assert_eq!( + startup_race_status(&state, 1), + StartupRaceStatus::StaleContinuation + ); + } } fn enabled_phrases(inner: &Arc) -> Vec { @@ -1532,6 +1576,8 @@ const CAPSULE_AUTO_HIDE_DELAY_MS: u64 = 2000; /// begin_session 中各 await 之间的 cancel race 检查结果。 enum BeginOutcome { + /// 启动 continuation 属于旧 session;不能改动当前 session 状态。 + StaleContinuation, /// 正常进入 Listening。 Started, /// Starting 阶段积累了 pending_stop 边沿,应立即 end_session(hold 快速松开 / toggle 快速双击)。 @@ -1541,12 +1587,39 @@ enum BeginOutcome { CancelRaced, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum StartupRaceStatus { + ActiveStarting, + CancelRaced, + StaleContinuation, +} + +fn startup_race_status(state: &SessionState, captured_session_id: u64) -> StartupRaceStatus { + if state.session_id != captured_session_id { + StartupRaceStatus::StaleContinuation + } else if state.cancelled || state.phase != SessionPhase::Starting { + StartupRaceStatus::CancelRaced + } else { + StartupRaceStatus::ActiveStarting + } +} + /// 检查 begin_session 的 await 间隙是否被 cancel_session 打断。 /// 必须在持有 state lock 的瞬间读,结果一拿就过期,所以用 helper 名字提醒只在 /// 「准备做下一步副作用前」用。 -fn cancel_raced_during_starting(inner: &Arc) -> bool { +fn startup_race_status_for_starting( + inner: &Arc, + captured_session_id: u64, +) -> StartupRaceStatus { let state = inner.state.lock(); - state.cancelled || state.phase != SessionPhase::Starting + startup_race_status(&state, captured_session_id) +} + +fn set_phase_idle_if_session_matches(inner: &Arc, session_id: u64) { + let mut state = inner.state.lock(); + if state.session_id == session_id { + state.phase = SessionPhase::Idle; + } } fn schedule_capsule_idle(inner: &Arc, delay_ms: u64) { From e950d1ddc78822bdf8fdaaf169692d0477e95669 Mon Sep 17 00:00:00 2001 From: millionart Date: Fri, 1 May 2026 20:20:25 +0800 Subject: [PATCH 15/43] feat: scaffold OpenLess TSF IME DLL --- openless-all/app/windows-ime/OpenLessIme.sln | 21 +++ .../app/windows-ime/OpenLessIme.vcxproj | 91 +++++++++ .../app/windows-ime/src/class_factory.cpp | 75 ++++++++ .../app/windows-ime/src/class_factory.h | 20 ++ openless-all/app/windows-ime/src/dllmain.cpp | 54 ++++++ openless-all/app/windows-ime/src/guids.h | 21 +++ openless-all/app/windows-ime/src/registry.cpp | 176 ++++++++++++++++++ openless-all/app/windows-ime/src/registry.h | 6 + openless-all/app/windows-ime/src/resource.rc | 33 ++++ .../app/windows-ime/src/text_service.cpp | 95 ++++++++++ .../app/windows-ime/src/text_service.h | 34 ++++ 11 files changed, 626 insertions(+) create mode 100644 openless-all/app/windows-ime/OpenLessIme.sln create mode 100644 openless-all/app/windows-ime/OpenLessIme.vcxproj create mode 100644 openless-all/app/windows-ime/src/class_factory.cpp create mode 100644 openless-all/app/windows-ime/src/class_factory.h create mode 100644 openless-all/app/windows-ime/src/dllmain.cpp create mode 100644 openless-all/app/windows-ime/src/guids.h create mode 100644 openless-all/app/windows-ime/src/registry.cpp create mode 100644 openless-all/app/windows-ime/src/registry.h create mode 100644 openless-all/app/windows-ime/src/resource.rc create mode 100644 openless-all/app/windows-ime/src/text_service.cpp create mode 100644 openless-all/app/windows-ime/src/text_service.h diff --git a/openless-all/app/windows-ime/OpenLessIme.sln b/openless-all/app/windows-ime/OpenLessIme.sln new file mode 100644 index 00000000..a2fc9f08 --- /dev/null +++ b/openless-all/app/windows-ime/OpenLessIme.sln @@ -0,0 +1,21 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{BC8A1FFA-BEE3-4634-8014-F334798102B3}") = "OpenLessIme", "OpenLessIme.vcxproj", "{F45287CB-1F31-4D7C-9B62-5748C2F8C9B0}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|x64 = Debug|x64 + Release|x64 = Release|x64 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {F45287CB-1F31-4D7C-9B62-5748C2F8C9B0}.Debug|x64.ActiveCfg = Debug|x64 + {F45287CB-1F31-4D7C-9B62-5748C2F8C9B0}.Debug|x64.Build.0 = Debug|x64 + {F45287CB-1F31-4D7C-9B62-5748C2F8C9B0}.Release|x64.ActiveCfg = Release|x64 + {F45287CB-1F31-4D7C-9B62-5748C2F8C9B0}.Release|x64.Build.0 = Release|x64 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/openless-all/app/windows-ime/OpenLessIme.vcxproj b/openless-all/app/windows-ime/OpenLessIme.vcxproj new file mode 100644 index 00000000..0275bfae --- /dev/null +++ b/openless-all/app/windows-ime/OpenLessIme.vcxproj @@ -0,0 +1,91 @@ + + + + + Debug + x64 + + + Release + x64 + + + + 17.0 + Win32Proj + {F45287CB-1F31-4D7C-9B62-5748C2F8C9B0} + OpenLessIme + 10.0 + + + + DynamicLibrary + true + v143 + Unicode + + + DynamicLibrary + false + v143 + true + Unicode + + + + + + + + + + + + + + Level4 + true + WIN32;_WINDOWS;_USRDLL;OPENLESSIME_EXPORTS;%(PreprocessorDefinitions) + true + stdcpp17 + + + Windows + msctf.lib;ole32.lib;uuid.lib;advapi32.lib;%(AdditionalDependencies) + + + + + Level4 + true + true + true + WIN32;NDEBUG;_WINDOWS;_USRDLL;OPENLESSIME_EXPORTS;%(PreprocessorDefinitions) + true + stdcpp17 + + + Windows + true + true + msctf.lib;ole32.lib;uuid.lib;advapi32.lib;%(AdditionalDependencies) + + + + + + + + + + + + + + + + + + + + diff --git a/openless-all/app/windows-ime/src/class_factory.cpp b/openless-all/app/windows-ime/src/class_factory.cpp new file mode 100644 index 00000000..7cfb095e --- /dev/null +++ b/openless-all/app/windows-ime/src/class_factory.cpp @@ -0,0 +1,75 @@ +#include "class_factory.h" + +#include +#include + +#include "text_service.h" + +extern LONG g_lock_count; +extern LONG g_object_count; + +OpenLessClassFactory::OpenLessClassFactory() { + InterlockedIncrement(&g_object_count); +} + +OpenLessClassFactory::~OpenLessClassFactory() { + InterlockedDecrement(&g_object_count); +} + +STDMETHODIMP OpenLessClassFactory::QueryInterface(REFIID iid, void** object) { + if (object == nullptr) { + return E_POINTER; + } + *object = nullptr; + + if (iid == IID_IUnknown || iid == IID_IClassFactory) { + *object = static_cast(this); + AddRef(); + return S_OK; + } + + return E_NOINTERFACE; +} + +STDMETHODIMP_(ULONG) OpenLessClassFactory::AddRef() { + return static_cast(InterlockedIncrement(&ref_count_)); +} + +STDMETHODIMP_(ULONG) OpenLessClassFactory::Release() { + const ULONG count = static_cast(InterlockedDecrement(&ref_count_)); + if (count == 0) { + delete this; + } + return count; +} + +STDMETHODIMP OpenLessClassFactory::CreateInstance(IUnknown* outer, + REFIID iid, + void** object) { + if (object == nullptr) { + return E_POINTER; + } + *object = nullptr; + + if (outer != nullptr) { + return CLASS_E_NOAGGREGATION; + } + + auto* service = new (std::nothrow) OpenLessTextService(); + if (service == nullptr) { + return E_OUTOFMEMORY; + } + + const HRESULT hr = service->QueryInterface(iid, object); + service->Release(); + return hr; +} + +STDMETHODIMP OpenLessClassFactory::LockServer(BOOL lock) { + if (lock) { + InterlockedIncrement(&g_lock_count); + } else { + InterlockedDecrement(&g_lock_count); + } + return S_OK; +} diff --git a/openless-all/app/windows-ime/src/class_factory.h b/openless-all/app/windows-ime/src/class_factory.h new file mode 100644 index 00000000..bb2de3e9 --- /dev/null +++ b/openless-all/app/windows-ime/src/class_factory.h @@ -0,0 +1,20 @@ +#pragma once + +#include + +class OpenLessClassFactory final : public IClassFactory { + public: + OpenLessClassFactory(); + OpenLessClassFactory(const OpenLessClassFactory&) = delete; + OpenLessClassFactory& operator=(const OpenLessClassFactory&) = delete; + ~OpenLessClassFactory() override; + + STDMETHODIMP QueryInterface(REFIID iid, void** object) override; + STDMETHODIMP_(ULONG) AddRef() override; + STDMETHODIMP_(ULONG) Release() override; + STDMETHODIMP CreateInstance(IUnknown* outer, REFIID iid, void** object) override; + STDMETHODIMP LockServer(BOOL lock) override; + + private: + LONG ref_count_ = 1; +}; diff --git a/openless-all/app/windows-ime/src/dllmain.cpp b/openless-all/app/windows-ime/src/dllmain.cpp new file mode 100644 index 00000000..84476ba2 --- /dev/null +++ b/openless-all/app/windows-ime/src/dllmain.cpp @@ -0,0 +1,54 @@ +#include + +#include + +#include "class_factory.h" +#include "guids.h" +#include "registry.h" + +HINSTANCE g_module = nullptr; +LONG g_lock_count = 0; +LONG g_object_count = 0; + +BOOL APIENTRY DllMain(HINSTANCE instance, DWORD reason, LPVOID reserved) { + UNREFERENCED_PARAMETER(reserved); + + if (reason == DLL_PROCESS_ATTACH) { + g_module = instance; + DisableThreadLibraryCalls(instance); + } + + return TRUE; +} + +STDAPI DllCanUnloadNow() { + return (g_lock_count == 0 && g_object_count == 0) ? S_OK : S_FALSE; +} + +STDAPI DllGetClassObject(REFCLSID clsid, REFIID iid, void** object) { + if (object == nullptr) { + return E_POINTER; + } + *object = nullptr; + + if (clsid != CLSID_OpenLessTextService) { + return CLASS_E_CLASSNOTAVAILABLE; + } + + auto* factory = new (std::nothrow) OpenLessClassFactory(); + if (factory == nullptr) { + return E_OUTOFMEMORY; + } + + const HRESULT hr = factory->QueryInterface(iid, object); + factory->Release(); + return hr; +} + +STDAPI DllRegisterServer() { + return RegisterOpenLessTextService(g_module); +} + +STDAPI DllUnregisterServer() { + return UnregisterOpenLessTextService(); +} diff --git a/openless-all/app/windows-ime/src/guids.h b/openless-all/app/windows-ime/src/guids.h new file mode 100644 index 00000000..a22782bf --- /dev/null +++ b/openless-all/app/windows-ime/src/guids.h @@ -0,0 +1,21 @@ +#pragma once + +#include +#include + +inline constexpr CLSID CLSID_OpenLessTextService = { + 0x6b9f3f4f, + 0x5ee7, + 0x42d6, + {0x9c, 0x61, 0x9f, 0x80, 0xb0, 0x3a, 0x5d, 0x7d}, +}; + +inline constexpr GUID GUID_OpenLessProfile = { + 0x9b5f5e04, + 0x23f6, + 0x47da, + {0x9a, 0x26, 0xd2, 0x21, 0xf6, 0xc3, 0xf0, 0x2e}, +}; + +inline constexpr wchar_t kOpenLessImeName[] = L"OpenLess Voice Input"; +inline constexpr LANGID kOpenLessLangId = 0x0804; diff --git a/openless-all/app/windows-ime/src/registry.cpp b/openless-all/app/windows-ime/src/registry.cpp new file mode 100644 index 00000000..6a5e5656 --- /dev/null +++ b/openless-all/app/windows-ime/src/registry.cpp @@ -0,0 +1,176 @@ +#include "registry.h" + +#include +#include + +#include "guids.h" + +namespace { + +constexpr wchar_t kClsidKey[] = + L"Software\\Classes\\CLSID\\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}"; +constexpr wchar_t kInprocServer32Key[] = + L"Software\\Classes\\CLSID\\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\\InprocServer32"; + +HRESULT HResultFromWin32Error(LSTATUS status) { + return status == ERROR_SUCCESS ? S_OK : HRESULT_FROM_WIN32(status); +} + +HRESULT SetStringValue(HKEY key, const wchar_t* name, const wchar_t* value) { + const auto byte_count = + static_cast((wcslen(value) + 1) * sizeof(wchar_t)); + return HResultFromWin32Error( + RegSetValueExW(key, name, 0, REG_SZ, + reinterpret_cast(value), byte_count)); +} + +HRESULT RegisterComServer(HINSTANCE module) { + wchar_t module_path[MAX_PATH] = {}; + const DWORD path_len = + GetModuleFileNameW(module, module_path, ARRAYSIZE(module_path)); + if (path_len == 0) { + return HRESULT_FROM_WIN32(GetLastError()); + } + if (path_len == ARRAYSIZE(module_path)) { + return HRESULT_FROM_WIN32(ERROR_INSUFFICIENT_BUFFER); + } + + HKEY clsid_key = nullptr; + LSTATUS status = + RegCreateKeyExW(HKEY_CURRENT_USER, kClsidKey, 0, nullptr, + REG_OPTION_NON_VOLATILE, KEY_WRITE, nullptr, &clsid_key, + nullptr); + HRESULT hr = HResultFromWin32Error(status); + if (FAILED(hr)) { + return hr; + } + + hr = SetStringValue(clsid_key, nullptr, kOpenLessImeName); + RegCloseKey(clsid_key); + if (FAILED(hr)) { + return hr; + } + + HKEY inproc_key = nullptr; + status = RegCreateKeyExW(HKEY_CURRENT_USER, kInprocServer32Key, 0, nullptr, + REG_OPTION_NON_VOLATILE, KEY_WRITE, nullptr, + &inproc_key, nullptr); + hr = HResultFromWin32Error(status); + if (FAILED(hr)) { + return hr; + } + + hr = SetStringValue(inproc_key, nullptr, module_path); + if (SUCCEEDED(hr)) { + hr = SetStringValue(inproc_key, L"ThreadingModel", L"Apartment"); + } + RegCloseKey(inproc_key); + return hr; +} + +HRESULT DeleteComServerRegistration() { + const LSTATUS status = RegDeleteTreeW(HKEY_CURRENT_USER, kClsidKey); + if (status == ERROR_FILE_NOT_FOUND) { + return S_OK; + } + return HResultFromWin32Error(status); +} + +class ScopedComInit { + public: + ScopedComInit() : hr_(CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED)) {} + ScopedComInit(const ScopedComInit&) = delete; + ScopedComInit& operator=(const ScopedComInit&) = delete; + ~ScopedComInit() { + if (hr_ == S_OK || hr_ == S_FALSE) { + CoUninitialize(); + } + } + + HRESULT hr() const { + return hr_ == RPC_E_CHANGED_MODE ? S_OK : hr_; + } + + private: + HRESULT hr_; +}; + +HRESULT CreateProfiles(ITfInputProcessorProfiles** profiles) { + return CoCreateInstance(CLSID_TF_InputProcessorProfiles, nullptr, + CLSCTX_INPROC_SERVER, + IID_ITfInputProcessorProfiles, + reinterpret_cast(profiles)); +} + +HRESULT RegisterLanguageProfile() { + ScopedComInit com; + HRESULT hr = com.hr(); + if (FAILED(hr)) { + return hr; + } + + ITfInputProcessorProfiles* profiles = nullptr; + hr = CreateProfiles(&profiles); + if (FAILED(hr)) { + return hr; + } + + hr = profiles->Register(CLSID_OpenLessTextService); + if (SUCCEEDED(hr)) { + hr = profiles->AddLanguageProfile( + CLSID_OpenLessTextService, kOpenLessLangId, GUID_OpenLessProfile, + const_cast(kOpenLessImeName), + static_cast(ARRAYSIZE(kOpenLessImeName) - 1), nullptr, 0, 0); + } + if (SUCCEEDED(hr)) { + hr = profiles->EnableLanguageProfile(CLSID_OpenLessTextService, + kOpenLessLangId, GUID_OpenLessProfile, + TRUE); + } + + profiles->Release(); + return hr; +} + +HRESULT UnregisterLanguageProfile() { + ScopedComInit com; + HRESULT hr = com.hr(); + if (FAILED(hr)) { + return hr; + } + + ITfInputProcessorProfiles* profiles = nullptr; + hr = CreateProfiles(&profiles); + if (FAILED(hr)) { + return hr; + } + + hr = profiles->Unregister(CLSID_OpenLessTextService); + profiles->Release(); + return hr; +} + +} // namespace + +HRESULT RegisterOpenLessTextService(HINSTANCE module) { + if (module == nullptr) { + return E_INVALIDARG; + } + + HRESULT hr = RegisterComServer(module); + if (FAILED(hr)) { + return hr; + } + + hr = RegisterLanguageProfile(); + if (FAILED(hr)) { + DeleteComServerRegistration(); + } + return hr; +} + +HRESULT UnregisterOpenLessTextService() { + const HRESULT profile_hr = UnregisterLanguageProfile(); + const HRESULT registry_hr = DeleteComServerRegistration(); + return FAILED(profile_hr) ? profile_hr : registry_hr; +} diff --git a/openless-all/app/windows-ime/src/registry.h b/openless-all/app/windows-ime/src/registry.h new file mode 100644 index 00000000..74e4ab8d --- /dev/null +++ b/openless-all/app/windows-ime/src/registry.h @@ -0,0 +1,6 @@ +#pragma once + +#include + +HRESULT RegisterOpenLessTextService(HINSTANCE module); +HRESULT UnregisterOpenLessTextService(); diff --git a/openless-all/app/windows-ime/src/resource.rc b/openless-all/app/windows-ime/src/resource.rc new file mode 100644 index 00000000..c27e46d2 --- /dev/null +++ b/openless-all/app/windows-ime/src/resource.rc @@ -0,0 +1,33 @@ +#include + +1 VERSIONINFO +FILEVERSION 0,1,0,0 +PRODUCTVERSION 0,1,0,0 +FILEFLAGSMASK 0x3fL +#ifdef _DEBUG +FILEFLAGS 0x1L +#else +FILEFLAGS 0x0L +#endif +FILEOS 0x40004L +FILETYPE 0x2L +FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904b0" + BEGIN + VALUE "CompanyName", "OpenLess" + VALUE "FileDescription", "OpenLess Voice Input TSF IME" + VALUE "FileVersion", "0.1.0.0" + VALUE "InternalName", "OpenLessIme.dll" + VALUE "OriginalFilename", "OpenLessIme.dll" + VALUE "ProductName", "OpenLess Voice Input" + VALUE "ProductVersion", "0.1.0.0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1200 + END +END diff --git a/openless-all/app/windows-ime/src/text_service.cpp b/openless-all/app/windows-ime/src/text_service.cpp new file mode 100644 index 00000000..7a0caa79 --- /dev/null +++ b/openless-all/app/windows-ime/src/text_service.cpp @@ -0,0 +1,95 @@ +#include "text_service.h" + +extern LONG g_object_count; + +OpenLessTextService::OpenLessTextService() { + InterlockedIncrement(&g_object_count); +} + +OpenLessTextService::~OpenLessTextService() { + Deactivate(); + InterlockedDecrement(&g_object_count); +} + +STDMETHODIMP OpenLessTextService::QueryInterface(REFIID iid, void** object) { + if (object == nullptr) { + return E_POINTER; + } + *object = nullptr; + + if (iid == IID_IUnknown || iid == IID_ITfTextInputProcessor || + iid == IID_ITfTextInputProcessorEx) { + *object = static_cast(this); + AddRef(); + return S_OK; + } + + return E_NOINTERFACE; +} + +STDMETHODIMP_(ULONG) OpenLessTextService::AddRef() { + return static_cast(InterlockedIncrement(&ref_count_)); +} + +STDMETHODIMP_(ULONG) OpenLessTextService::Release() { + const ULONG count = static_cast(InterlockedDecrement(&ref_count_)); + if (count == 0) { + delete this; + } + return count; +} + +STDMETHODIMP OpenLessTextService::Activate(ITfThreadMgr* thread_mgr, + TfClientId client_id) { + return ActivateEx(thread_mgr, client_id, 0); +} + +STDMETHODIMP OpenLessTextService::ActivateEx(ITfThreadMgr* thread_mgr, + TfClientId client_id, + DWORD flags) { + UNREFERENCED_PARAMETER(flags); + + if (thread_mgr == nullptr) { + return E_INVALIDARG; + } + + Deactivate(); + + thread_mgr_ = thread_mgr; + thread_mgr_->AddRef(); + client_id_ = client_id; + + const HRESULT hr = StartIpcServer(); + if (FAILED(hr)) { + Deactivate(); + return hr; + } + + return S_OK; +} + +STDMETHODIMP OpenLessTextService::Deactivate() { + StopIpcServer(); + + if (thread_mgr_ != nullptr) { + thread_mgr_->Release(); + thread_mgr_ = nullptr; + } + client_id_ = TF_CLIENTID_NULL; + + return S_OK; +} + +HRESULT OpenLessTextService::SubmitTextFromPipe( + const std::wstring& session_id, + const std::wstring& text) { + UNREFERENCED_PARAMETER(session_id); + UNREFERENCED_PARAMETER(text); + return E_NOTIMPL; +} + +HRESULT OpenLessTextService::StartIpcServer() { + return S_OK; +} + +void OpenLessTextService::StopIpcServer() {} diff --git a/openless-all/app/windows-ime/src/text_service.h b/openless-all/app/windows-ime/src/text_service.h new file mode 100644 index 00000000..ac1d8576 --- /dev/null +++ b/openless-all/app/windows-ime/src/text_service.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include +#include + +class OpenLessTextService final : public ITfTextInputProcessorEx { + public: + OpenLessTextService(); + OpenLessTextService(const OpenLessTextService&) = delete; + OpenLessTextService& operator=(const OpenLessTextService&) = delete; + ~OpenLessTextService() override; + + STDMETHODIMP QueryInterface(REFIID iid, void** object) override; + STDMETHODIMP_(ULONG) AddRef() override; + STDMETHODIMP_(ULONG) Release() override; + + STDMETHODIMP Activate(ITfThreadMgr* thread_mgr, TfClientId client_id) override; + STDMETHODIMP Deactivate() override; + STDMETHODIMP ActivateEx(ITfThreadMgr* thread_mgr, + TfClientId client_id, + DWORD flags) override; + + HRESULT SubmitTextFromPipe(const std::wstring& session_id, + const std::wstring& text); + + private: + HRESULT StartIpcServer(); + void StopIpcServer(); + + LONG ref_count_ = 1; + ITfThreadMgr* thread_mgr_ = nullptr; + TfClientId client_id_ = TF_CLIENTID_NULL; +}; From 4208deb4a88cce56d9f734363dba096cfa85908c Mon Sep 17 00:00:00 2001 From: millionart Date: Fri, 1 May 2026 20:25:05 +0800 Subject: [PATCH 16/43] fix: export OpenLess IME COM entry points --- openless-all/app/windows-ime/OpenLessIme.vcxproj | 5 +++++ openless-all/app/windows-ime/src/OpenLessIme.def | 6 ++++++ 2 files changed, 11 insertions(+) create mode 100644 openless-all/app/windows-ime/src/OpenLessIme.def diff --git a/openless-all/app/windows-ime/OpenLessIme.vcxproj b/openless-all/app/windows-ime/OpenLessIme.vcxproj index 0275bfae..ec863ef7 100644 --- a/openless-all/app/windows-ime/OpenLessIme.vcxproj +++ b/openless-all/app/windows-ime/OpenLessIme.vcxproj @@ -52,6 +52,7 @@ Windows msctf.lib;ole32.lib;uuid.lib;advapi32.lib;%(AdditionalDependencies) + src\OpenLessIme.def @@ -69,6 +70,7 @@ true true msctf.lib;ole32.lib;uuid.lib;advapi32.lib;%(AdditionalDependencies) + src\OpenLessIme.def @@ -86,6 +88,9 @@ + + + diff --git a/openless-all/app/windows-ime/src/OpenLessIme.def b/openless-all/app/windows-ime/src/OpenLessIme.def new file mode 100644 index 00000000..082239aa --- /dev/null +++ b/openless-all/app/windows-ime/src/OpenLessIme.def @@ -0,0 +1,6 @@ +LIBRARY "OpenLessIme" +EXPORTS + DllCanUnloadNow PRIVATE + DllGetClassObject PRIVATE + DllRegisterServer PRIVATE + DllUnregisterServer PRIVATE From 1ab6f7b7ab4821eda696d1cba21f6e5691b2381c Mon Sep 17 00:00:00 2001 From: millionart Date: Fri, 1 May 2026 20:29:47 +0800 Subject: [PATCH 17/43] fix: register OpenLess IME keyboard category --- openless-all/app/windows-ime/src/registry.cpp | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/openless-all/app/windows-ime/src/registry.cpp b/openless-all/app/windows-ime/src/registry.cpp index 6a5e5656..61c99887 100644 --- a/openless-all/app/windows-ime/src/registry.cpp +++ b/openless-all/app/windows-ime/src/registry.cpp @@ -102,6 +102,12 @@ HRESULT CreateProfiles(ITfInputProcessorProfiles** profiles) { reinterpret_cast(profiles)); } +HRESULT CreateCategoryManager(ITfCategoryMgr** category_mgr) { + return CoCreateInstance(CLSID_TF_CategoryMgr, nullptr, CLSCTX_INPROC_SERVER, + IID_ITfCategoryMgr, + reinterpret_cast(category_mgr)); +} + HRESULT RegisterLanguageProfile() { ScopedComInit com; HRESULT hr = com.hr(); @@ -132,6 +138,26 @@ HRESULT RegisterLanguageProfile() { return hr; } +HRESULT RegisterKeyboardCategory() { + ScopedComInit com; + HRESULT hr = com.hr(); + if (FAILED(hr)) { + return hr; + } + + ITfCategoryMgr* category_mgr = nullptr; + hr = CreateCategoryManager(&category_mgr); + if (FAILED(hr)) { + return hr; + } + + hr = category_mgr->RegisterCategory(CLSID_OpenLessTextService, + GUID_TFCAT_TIP_KEYBOARD, + CLSID_OpenLessTextService); + category_mgr->Release(); + return hr; +} + HRESULT UnregisterLanguageProfile() { ScopedComInit com; HRESULT hr = com.hr(); @@ -150,6 +176,26 @@ HRESULT UnregisterLanguageProfile() { return hr; } +HRESULT UnregisterKeyboardCategory() { + ScopedComInit com; + HRESULT hr = com.hr(); + if (FAILED(hr)) { + return hr; + } + + ITfCategoryMgr* category_mgr = nullptr; + hr = CreateCategoryManager(&category_mgr); + if (FAILED(hr)) { + return hr; + } + + hr = category_mgr->UnregisterCategory(CLSID_OpenLessTextService, + GUID_TFCAT_TIP_KEYBOARD, + CLSID_OpenLessTextService); + category_mgr->Release(); + return hr; +} + } // namespace HRESULT RegisterOpenLessTextService(HINSTANCE module) { @@ -165,12 +211,23 @@ HRESULT RegisterOpenLessTextService(HINSTANCE module) { hr = RegisterLanguageProfile(); if (FAILED(hr)) { DeleteComServerRegistration(); + return hr; + } + + hr = RegisterKeyboardCategory(); + if (FAILED(hr)) { + UnregisterLanguageProfile(); + DeleteComServerRegistration(); } return hr; } HRESULT UnregisterOpenLessTextService() { + const HRESULT category_hr = UnregisterKeyboardCategory(); const HRESULT profile_hr = UnregisterLanguageProfile(); const HRESULT registry_hr = DeleteComServerRegistration(); + if (FAILED(category_hr)) { + return category_hr; + } return FAILED(profile_hr) ? profile_hr : registry_hr; } From 0f28bb1a3e04b6bee051f20ee621ffb05c2052ce Mon Sep 17 00:00:00 2001 From: millionart Date: Fri, 1 May 2026 20:34:15 +0800 Subject: [PATCH 18/43] feat: commit dictated text through TSF edit sessions --- .../app/windows-ime/OpenLessIme.vcxproj | 2 + .../app/windows-ime/src/edit_session.cpp | 81 +++++++++++++++++++ .../app/windows-ime/src/edit_session.h | 23 ++++++ .../app/windows-ime/src/text_service.cpp | 47 ++++++++++- 4 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 openless-all/app/windows-ime/src/edit_session.cpp create mode 100644 openless-all/app/windows-ime/src/edit_session.h diff --git a/openless-all/app/windows-ime/OpenLessIme.vcxproj b/openless-all/app/windows-ime/OpenLessIme.vcxproj index ec863ef7..d27520d7 100644 --- a/openless-all/app/windows-ime/OpenLessIme.vcxproj +++ b/openless-all/app/windows-ime/OpenLessIme.vcxproj @@ -76,11 +76,13 @@ + + diff --git a/openless-all/app/windows-ime/src/edit_session.cpp b/openless-all/app/windows-ime/src/edit_session.cpp new file mode 100644 index 00000000..1e4210b1 --- /dev/null +++ b/openless-all/app/windows-ime/src/edit_session.cpp @@ -0,0 +1,81 @@ +#include "edit_session.h" + +#include + +OpenLessEditSession::OpenLessEditSession(ITfContext* context, + std::wstring text) + : context_(context), text_(std::move(text)) { + if (context_ != nullptr) { + context_->AddRef(); + } +} + +OpenLessEditSession::~OpenLessEditSession() { + if (context_ != nullptr) { + context_->Release(); + context_ = nullptr; + } +} + +STDMETHODIMP OpenLessEditSession::QueryInterface(REFIID iid, void** object) { + if (object == nullptr) { + return E_POINTER; + } + *object = nullptr; + + if (iid == IID_IUnknown || iid == IID_ITfEditSession) { + *object = static_cast(this); + AddRef(); + return S_OK; + } + + return E_NOINTERFACE; +} + +STDMETHODIMP_(ULONG) OpenLessEditSession::AddRef() { + return static_cast(InterlockedIncrement(&ref_count_)); +} + +STDMETHODIMP_(ULONG) OpenLessEditSession::Release() { + const ULONG count = static_cast(InterlockedDecrement(&ref_count_)); + if (count == 0) { + delete this; + } + return count; +} + +STDMETHODIMP OpenLessEditSession::DoEditSession(TfEditCookie edit_cookie) { + if (context_ == nullptr) { + return E_UNEXPECTED; + } + + ITfInsertAtSelection* insert_at_selection = nullptr; + HRESULT hr = context_->QueryInterface(IID_ITfInsertAtSelection, + reinterpret_cast( + &insert_at_selection)); + if (FAILED(hr)) { + return hr; + } + + ITfRange* query_range = nullptr; + hr = insert_at_selection->InsertTextAtSelection( + edit_cookie, TF_IAS_QUERYONLY, text_.c_str(), + static_cast(text_.size()), &query_range); + if (query_range != nullptr) { + query_range->Release(); + query_range = nullptr; + } + + if (SUCCEEDED(hr)) { + ITfRange* committed_range = nullptr; + hr = insert_at_selection->InsertTextAtSelection( + edit_cookie, 0, text_.c_str(), static_cast(text_.size()), + &committed_range); + if (committed_range != nullptr) { + committed_range->Release(); + } + } + + insert_at_selection->Release(); + return hr; +} diff --git a/openless-all/app/windows-ime/src/edit_session.h b/openless-all/app/windows-ime/src/edit_session.h new file mode 100644 index 00000000..5ab96bbd --- /dev/null +++ b/openless-all/app/windows-ime/src/edit_session.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include +#include + +class OpenLessEditSession final : public ITfEditSession { + public: + OpenLessEditSession(ITfContext* context, std::wstring text); + OpenLessEditSession(const OpenLessEditSession&) = delete; + OpenLessEditSession& operator=(const OpenLessEditSession&) = delete; + ~OpenLessEditSession() override; + + STDMETHODIMP QueryInterface(REFIID iid, void** object) override; + STDMETHODIMP_(ULONG) AddRef() override; + STDMETHODIMP_(ULONG) Release() override; + STDMETHODIMP DoEditSession(TfEditCookie edit_cookie) override; + + private: + LONG ref_count_ = 1; + ITfContext* context_ = nullptr; + std::wstring text_; +}; diff --git a/openless-all/app/windows-ime/src/text_service.cpp b/openless-all/app/windows-ime/src/text_service.cpp index 7a0caa79..ed911832 100644 --- a/openless-all/app/windows-ime/src/text_service.cpp +++ b/openless-all/app/windows-ime/src/text_service.cpp @@ -1,5 +1,9 @@ #include "text_service.h" +#include + +#include "edit_session.h" + extern LONG g_object_count; OpenLessTextService::OpenLessTextService() { @@ -84,8 +88,47 @@ HRESULT OpenLessTextService::SubmitTextFromPipe( const std::wstring& session_id, const std::wstring& text) { UNREFERENCED_PARAMETER(session_id); - UNREFERENCED_PARAMETER(text); - return E_NOTIMPL; + + if (thread_mgr_ == nullptr || client_id_ == TF_CLIENTID_NULL) { + return E_UNEXPECTED; + } + + ITfDocumentMgr* document_mgr = nullptr; + HRESULT hr = thread_mgr_->GetFocus(&document_mgr); + if (FAILED(hr)) { + return hr; + } + if (document_mgr == nullptr) { + return E_FAIL; + } + + ITfContext* context = nullptr; + hr = document_mgr->GetTop(&context); + document_mgr->Release(); + document_mgr = nullptr; + if (FAILED(hr)) { + return hr; + } + if (context == nullptr) { + return E_FAIL; + } + + auto* session = new (std::nothrow) OpenLessEditSession(context, text); + if (session == nullptr) { + context->Release(); + return E_OUTOFMEMORY; + } + + HRESULT edit_result = S_OK; + hr = context->RequestEditSession(client_id_, session, + TF_ES_SYNC | TF_ES_READWRITE, &edit_result); + session->Release(); + context->Release(); + + if (FAILED(hr)) { + return hr; + } + return edit_result; } HRESULT OpenLessTextService::StartIpcServer() { From b34d450071d900e4f16fc5129450a1690a57f695 Mon Sep 17 00:00:00 2001 From: millionart Date: Fri, 1 May 2026 20:36:33 +0800 Subject: [PATCH 19/43] fix: remove COM destructor override specifiers --- openless-all/app/windows-ime/src/class_factory.h | 2 +- openless-all/app/windows-ime/src/edit_session.h | 2 +- openless-all/app/windows-ime/src/text_service.h | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/openless-all/app/windows-ime/src/class_factory.h b/openless-all/app/windows-ime/src/class_factory.h index bb2de3e9..db6e3aa2 100644 --- a/openless-all/app/windows-ime/src/class_factory.h +++ b/openless-all/app/windows-ime/src/class_factory.h @@ -7,7 +7,7 @@ class OpenLessClassFactory final : public IClassFactory { OpenLessClassFactory(); OpenLessClassFactory(const OpenLessClassFactory&) = delete; OpenLessClassFactory& operator=(const OpenLessClassFactory&) = delete; - ~OpenLessClassFactory() override; + ~OpenLessClassFactory(); STDMETHODIMP QueryInterface(REFIID iid, void** object) override; STDMETHODIMP_(ULONG) AddRef() override; diff --git a/openless-all/app/windows-ime/src/edit_session.h b/openless-all/app/windows-ime/src/edit_session.h index 5ab96bbd..dbe11890 100644 --- a/openless-all/app/windows-ime/src/edit_session.h +++ b/openless-all/app/windows-ime/src/edit_session.h @@ -9,7 +9,7 @@ class OpenLessEditSession final : public ITfEditSession { OpenLessEditSession(ITfContext* context, std::wstring text); OpenLessEditSession(const OpenLessEditSession&) = delete; OpenLessEditSession& operator=(const OpenLessEditSession&) = delete; - ~OpenLessEditSession() override; + ~OpenLessEditSession(); STDMETHODIMP QueryInterface(REFIID iid, void** object) override; STDMETHODIMP_(ULONG) AddRef() override; diff --git a/openless-all/app/windows-ime/src/text_service.h b/openless-all/app/windows-ime/src/text_service.h index ac1d8576..7ef32466 100644 --- a/openless-all/app/windows-ime/src/text_service.h +++ b/openless-all/app/windows-ime/src/text_service.h @@ -9,7 +9,7 @@ class OpenLessTextService final : public ITfTextInputProcessorEx { OpenLessTextService(); OpenLessTextService(const OpenLessTextService&) = delete; OpenLessTextService& operator=(const OpenLessTextService&) = delete; - ~OpenLessTextService() override; + ~OpenLessTextService(); STDMETHODIMP QueryInterface(REFIID iid, void** object) override; STDMETHODIMP_(ULONG) AddRef() override; From e714fd2c7d30cba876b2c1328dc533362905416e Mon Sep 17 00:00:00 2001 From: millionart Date: Fri, 1 May 2026 20:38:59 +0800 Subject: [PATCH 20/43] fix: build OpenLess IME DLL with installed SDK libs --- .gitignore | 4 ++++ openless-all/app/windows-ime/OpenLessIme.vcxproj | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 2a2095e5..72f7592e 100644 --- a/.gitignore +++ b/.gitignore @@ -25,5 +25,9 @@ promo-openless/ docs/old-promo/ .worktrees/ +# Windows TSF IME local build outputs +openless-all/app/windows-ime/OpenLessIme/ +openless-all/app/windows-ime/x64/ + # Planning docs are kept local only, not published to the public repo. docs/plans/ diff --git a/openless-all/app/windows-ime/OpenLessIme.vcxproj b/openless-all/app/windows-ime/OpenLessIme.vcxproj index d27520d7..32c1ba4f 100644 --- a/openless-all/app/windows-ime/OpenLessIme.vcxproj +++ b/openless-all/app/windows-ime/OpenLessIme.vcxproj @@ -51,7 +51,7 @@ Windows - msctf.lib;ole32.lib;uuid.lib;advapi32.lib;%(AdditionalDependencies) + ole32.lib;uuid.lib;advapi32.lib;%(AdditionalDependencies) src\OpenLessIme.def @@ -69,7 +69,7 @@ Windows true true - msctf.lib;ole32.lib;uuid.lib;advapi32.lib;%(AdditionalDependencies) + ole32.lib;uuid.lib;advapi32.lib;%(AdditionalDependencies) src\OpenLessIme.def From 52f7de5d855009d209591bcd773f848e37f294eb Mon Sep 17 00:00:00 2001 From: millionart Date: Fri, 1 May 2026 20:47:12 +0800 Subject: [PATCH 21/43] feat: receive OpenLess IME submissions over a named pipe --- .../app/windows-ime/OpenLessIme.vcxproj | 2 + .../app/windows-ime/src/ipc_client.cpp | 486 ++++++++++++++++++ openless-all/app/windows-ime/src/ipc_client.h | 36 ++ .../app/windows-ime/src/text_service.cpp | 127 ++++- .../app/windows-ime/src/text_service.h | 14 + 5 files changed, 660 insertions(+), 5 deletions(-) create mode 100644 openless-all/app/windows-ime/src/ipc_client.cpp create mode 100644 openless-all/app/windows-ime/src/ipc_client.h diff --git a/openless-all/app/windows-ime/OpenLessIme.vcxproj b/openless-all/app/windows-ime/OpenLessIme.vcxproj index 32c1ba4f..af417141 100644 --- a/openless-all/app/windows-ime/OpenLessIme.vcxproj +++ b/openless-all/app/windows-ime/OpenLessIme.vcxproj @@ -77,6 +77,7 @@ + @@ -84,6 +85,7 @@ + diff --git a/openless-all/app/windows-ime/src/ipc_client.cpp b/openless-all/app/windows-ime/src/ipc_client.cpp new file mode 100644 index 00000000..d34b528e --- /dev/null +++ b/openless-all/app/windows-ime/src/ipc_client.cpp @@ -0,0 +1,486 @@ +#include "ipc_client.h" + +#include +#include + +#include "text_service.h" + +namespace { + +constexpr wchar_t kPipeName[] = L"\\\\.\\pipe\\OpenLessImeSubmit"; +constexpr DWORD kPipeBufferSize = 4096; +constexpr size_t kMaxJsonLineBytes = 64 * 1024; + +struct SubmitMessage { + std::wstring type; + std::wstring session_id; + std::wstring text; + int protocol_version = 0; + bool has_type = false; + bool has_session_id = false; + bool has_text = false; + bool has_protocol_version = false; +}; + +bool AppendUtf8AsWide(const char* data, + int length, + std::wstring* output) { + if (length == 0) { + return true; + } + + const int required = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, data, + length, nullptr, 0); + if (required <= 0) { + return false; + } + + const size_t old_size = output->size(); + output->resize(old_size + static_cast(required)); + return MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, data, length, + output->data() + old_size, required) == required; +} + +bool WideToUtf8(const std::wstring& value, std::string* output) { + output->clear(); + if (value.empty()) { + return true; + } + + const int required = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, + value.c_str(), + static_cast(value.size()), + nullptr, 0, nullptr, nullptr); + if (required <= 0) { + return false; + } + + output->resize(static_cast(required)); + return WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, value.c_str(), + static_cast(value.size()), output->data(), + required, nullptr, nullptr) == required; +} + +void SkipWhitespace(const std::string& json, size_t* pos) { + while (*pos < json.size()) { + const char c = json[*pos]; + if (c != ' ' && c != '\t' && c != '\r' && c != '\n') { + return; + } + ++(*pos); + } +} + +int HexDigit(char c) { + if (c >= '0' && c <= '9') { + return c - '0'; + } + if (c >= 'a' && c <= 'f') { + return c - 'a' + 10; + } + if (c >= 'A' && c <= 'F') { + return c - 'A' + 10; + } + return -1; +} + +bool ParseJsonString(const std::string& json, + size_t* pos, + std::wstring* value) { + value->clear(); + if (*pos >= json.size() || json[*pos] != '"') { + return false; + } + ++(*pos); + + size_t segment_start = *pos; + while (*pos < json.size()) { + const char c = json[*pos]; + if (static_cast(c) < 0x20) { + return false; + } + + if (c == '"') { + if (!AppendUtf8AsWide(json.data() + segment_start, + static_cast(*pos - segment_start), value)) { + return false; + } + ++(*pos); + return true; + } + + if (c != '\\') { + ++(*pos); + continue; + } + + if (!AppendUtf8AsWide(json.data() + segment_start, + static_cast(*pos - segment_start), value)) { + return false; + } + + ++(*pos); + if (*pos >= json.size()) { + return false; + } + + const char escaped = json[*pos]; + switch (escaped) { + case '"': + case '\\': + case '/': + value->push_back(static_cast(escaped)); + ++(*pos); + break; + case 'b': + value->push_back(L'\b'); + ++(*pos); + break; + case 'f': + value->push_back(L'\f'); + ++(*pos); + break; + case 'n': + value->push_back(L'\n'); + ++(*pos); + break; + case 'r': + value->push_back(L'\r'); + ++(*pos); + break; + case 't': + value->push_back(L'\t'); + ++(*pos); + break; + case 'u': { + if (*pos + 4 >= json.size()) { + return false; + } + uint32_t code_unit = 0; + for (int i = 1; i <= 4; ++i) { + const int digit = HexDigit(json[*pos + static_cast(i)]); + if (digit < 0) { + return false; + } + code_unit = (code_unit << 4) | static_cast(digit); + } + value->push_back(static_cast(code_unit)); + *pos += 5; + break; + } + default: + return false; + } + + segment_start = *pos; + } + + return false; +} + +bool ParseJsonInteger(const std::string& json, size_t* pos, int* value) { + if (*pos >= json.size() || json[*pos] < '0' || json[*pos] > '9') { + return false; + } + + int parsed = 0; + while (*pos < json.size() && json[*pos] >= '0' && json[*pos] <= '9') { + parsed = parsed * 10 + (json[*pos] - '0'); + ++(*pos); + } + + *value = parsed; + return true; +} + +bool SkipJsonValue(const std::string& json, size_t* pos) { + std::wstring ignored; + if (*pos >= json.size()) { + return false; + } + if (json[*pos] == '"') { + return ParseJsonString(json, pos, &ignored); + } + while (*pos < json.size() && json[*pos] != ',' && json[*pos] != '}') { + ++(*pos); + } + return true; +} + +bool ParseSubmitMessage(const std::string& json, SubmitMessage* message) { + size_t pos = 0; + SkipWhitespace(json, &pos); + if (pos >= json.size() || json[pos] != '{') { + return false; + } + ++pos; + + while (true) { + SkipWhitespace(json, &pos); + if (pos < json.size() && json[pos] == '}') { + ++pos; + break; + } + + std::wstring key; + if (!ParseJsonString(json, &pos, &key)) { + return false; + } + + SkipWhitespace(json, &pos); + if (pos >= json.size() || json[pos] != ':') { + return false; + } + ++pos; + SkipWhitespace(json, &pos); + + if (key == L"type") { + message->has_type = ParseJsonString(json, &pos, &message->type); + if (!message->has_type) { + return false; + } + } else if (key == L"sessionId") { + message->has_session_id = + ParseJsonString(json, &pos, &message->session_id); + if (!message->has_session_id) { + return false; + } + } else if (key == L"text") { + message->has_text = ParseJsonString(json, &pos, &message->text); + if (!message->has_text) { + return false; + } + } else if (key == L"protocolVersion") { + message->has_protocol_version = + ParseJsonInteger(json, &pos, &message->protocol_version); + if (!message->has_protocol_version) { + return false; + } + } else if (!SkipJsonValue(json, &pos)) { + return false; + } + + SkipWhitespace(json, &pos); + if (pos < json.size() && json[pos] == ',') { + ++pos; + continue; + } + if (pos < json.size() && json[pos] == '}') { + ++pos; + break; + } + return false; + } + + SkipWhitespace(json, &pos); + return pos == json.size(); +} + +std::wstring EscapeJsonString(const std::wstring& value) { + std::wstring escaped; + for (wchar_t ch : value) { + switch (ch) { + case L'"': + escaped += L"\\\""; + break; + case L'\\': + escaped += L"\\\\"; + break; + case L'\b': + escaped += L"\\b"; + break; + case L'\f': + escaped += L"\\f"; + break; + case L'\n': + escaped += L"\\n"; + break; + case L'\r': + escaped += L"\\r"; + break; + case L'\t': + escaped += L"\\t"; + break; + default: + if (ch < 0x20) { + wchar_t buffer[7] = {}; + swprintf_s(buffer, L"\\u%04X", static_cast(ch)); + escaped += buffer; + } else { + escaped.push_back(ch); + } + break; + } + } + return escaped; +} + +} // namespace + +OpenLessPipeServer::OpenLessPipeServer() = default; + +OpenLessPipeServer::~OpenLessPipeServer() { + Stop(); +} + +void OpenLessPipeServer::Start(OpenLessTextService* service) { + if (service == nullptr || thread_.joinable()) { + return; + } + + stop_requested_.store(false); + service_ = service; + service_->AddRef(); + thread_ = std::thread(&OpenLessPipeServer::Run, this); +} + +void OpenLessPipeServer::Stop() { + stop_requested_.store(true); + if (thread_.joinable()) { + CancelSynchronousIo(thread_.native_handle()); + WakePipe(); + thread_.join(); + } + + if (service_ != nullptr) { + service_->Release(); + service_ = nullptr; + } +} + +void OpenLessPipeServer::Run() { + while (!stop_requested_.load()) { + HANDLE pipe = CreateNamedPipeW( + kPipeName, PIPE_ACCESS_DUPLEX, + PIPE_TYPE_MESSAGE | PIPE_READMODE_BYTE | PIPE_WAIT, 1, + kPipeBufferSize, kPipeBufferSize, 0, nullptr); + if (pipe == INVALID_HANDLE_VALUE) { + return; + } + + { + std::lock_guard lock(pipe_mutex_); + pipe_handle_ = pipe; + } + + const BOOL connected = + ConnectNamedPipe(pipe, nullptr) + ? TRUE + : (GetLastError() == ERROR_PIPE_CONNECTED ? TRUE : FALSE); + + if (connected && !stop_requested_.load()) { + std::string line; + if (ReadJsonLine(pipe, &line)) { + HandleSubmitLine(pipe, line); + } + } + + FlushFileBuffers(pipe); + DisconnectNamedPipe(pipe); + CloseHandle(pipe); + + { + std::lock_guard lock(pipe_mutex_); + if (pipe_handle_ == pipe) { + pipe_handle_ = INVALID_HANDLE_VALUE; + } + } + } +} + +bool OpenLessPipeServer::ReadJsonLine(HANDLE pipe, std::string* line) { + line->clear(); + char buffer[1024] = {}; + + while (!stop_requested_.load() && line->size() < kMaxJsonLineBytes) { + DWORD bytes_read = 0; + if (!ReadFile(pipe, buffer, sizeof(buffer), &bytes_read, nullptr)) { + const DWORD error = GetLastError(); + if (error == ERROR_MORE_DATA && bytes_read > 0) { + line->append(buffer, buffer + bytes_read); + continue; + } + return !line->empty(); + } + + if (bytes_read == 0) { + return !line->empty(); + } + + for (DWORD i = 0; i < bytes_read; ++i) { + if (buffer[i] == '\n') { + return true; + } + line->push_back(buffer[i]); + if (line->size() >= kMaxJsonLineBytes) { + return true; + } + } + } + + return !line->empty(); +} + +void OpenLessPipeServer::HandleSubmitLine(HANDLE pipe, const std::string& line) { + SubmitMessage message; + if (!ParseSubmitMessage(line, &message) || !message.has_type || + !message.has_protocol_version || !message.has_session_id || + !message.has_text || message.protocol_version != 1 || + message.type != L"submitText") { + WriteResult(pipe, message.session_id, L"failed", L"protocolError"); + return; + } + + if (service_ == nullptr) { + WriteResult(pipe, message.session_id, L"failed", L"serviceUnavailable"); + return; + } + + const HRESULT hr = + service_->SubmitTextFromPipe(message.session_id, message.text); + if (SUCCEEDED(hr)) { + WriteResult(pipe, message.session_id, L"committed", nullptr); + } else { + WriteResult(pipe, message.session_id, L"rejected", L"commitRejected"); + } +} + +bool OpenLessPipeServer::WriteResult(HANDLE pipe, + const std::wstring& session_id, + const wchar_t* status, + const wchar_t* error_code) { + std::wstring response = L"{\"type\":\"submitResult\",\"protocolVersion\":1,"; + response += L"\"sessionId\":\""; + response += EscapeJsonString(session_id); + response += L"\",\"status\":\""; + response += status; + response += L"\",\"errorCode\":"; + if (error_code == nullptr) { + response += L"null"; + } else { + response += L"\""; + response += error_code; + response += L"\""; + } + response += L"}\n"; + + std::string utf8_response; + if (!WideToUtf8(response, &utf8_response)) { + return false; + } + + DWORD bytes_written = 0; + return WriteFile(pipe, utf8_response.data(), + static_cast(utf8_response.size()), &bytes_written, + nullptr) && + bytes_written == utf8_response.size(); +} + +void OpenLessPipeServer::WakePipe() { + HANDLE pipe = + CreateFileW(kPipeName, GENERIC_READ | GENERIC_WRITE, 0, nullptr, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + if (pipe != INVALID_HANDLE_VALUE) { + CloseHandle(pipe); + } +} diff --git a/openless-all/app/windows-ime/src/ipc_client.h b/openless-all/app/windows-ime/src/ipc_client.h new file mode 100644 index 00000000..e0c0e799 --- /dev/null +++ b/openless-all/app/windows-ime/src/ipc_client.h @@ -0,0 +1,36 @@ +#pragma once + +#include +#include +#include +#include +#include + +class OpenLessTextService; + +class OpenLessPipeServer { + public: + OpenLessPipeServer(); + OpenLessPipeServer(const OpenLessPipeServer&) = delete; + OpenLessPipeServer& operator=(const OpenLessPipeServer&) = delete; + ~OpenLessPipeServer(); + + void Start(OpenLessTextService* service); + void Stop(); + + private: + void Run(); + bool ReadJsonLine(HANDLE pipe, std::string* line); + void HandleSubmitLine(HANDLE pipe, const std::string& line); + bool WriteResult(HANDLE pipe, + const std::wstring& session_id, + const wchar_t* status, + const wchar_t* error_code); + void WakePipe(); + + std::atomic stop_requested_{false}; + std::thread thread_; + std::mutex pipe_mutex_; + HANDLE pipe_handle_ = INVALID_HANDLE_VALUE; + OpenLessTextService* service_ = nullptr; +}; diff --git a/openless-all/app/windows-ime/src/text_service.cpp b/openless-all/app/windows-ime/src/text_service.cpp index ed911832..7c4369e3 100644 --- a/openless-all/app/windows-ime/src/text_service.cpp +++ b/openless-all/app/windows-ime/src/text_service.cpp @@ -5,6 +5,21 @@ #include "edit_session.h" extern LONG g_object_count; +extern HINSTANCE g_module; + +namespace { + +constexpr wchar_t kMessageWindowClassName[] = L"OpenLessImeMessageWindow"; +constexpr UINT kSubmitTextMessage = WM_APP + 1; +constexpr UINT kSubmitTextTimeoutMs = 3000; + +struct SubmitTextRequest { + const std::wstring* session_id = nullptr; + const std::wstring* text = nullptr; + HRESULT result = E_UNEXPECTED; +}; + +} // namespace OpenLessTextService::OpenLessTextService() { InterlockedIncrement(&g_object_count); @@ -59,11 +74,19 @@ STDMETHODIMP OpenLessTextService::ActivateEx(ITfThreadMgr* thread_mgr, Deactivate(); + owner_thread_id_ = GetCurrentThreadId(); + thread_mgr_ = thread_mgr; thread_mgr_->AddRef(); client_id_ = client_id; - const HRESULT hr = StartIpcServer(); + HRESULT hr = EnsureMessageWindow(); + if (FAILED(hr)) { + Deactivate(); + return hr; + } + + hr = StartIpcServer(); if (FAILED(hr)) { Deactivate(); return hr; @@ -74,12 +97,14 @@ STDMETHODIMP OpenLessTextService::ActivateEx(ITfThreadMgr* thread_mgr, STDMETHODIMP OpenLessTextService::Deactivate() { StopIpcServer(); + DestroyMessageWindow(); if (thread_mgr_ != nullptr) { thread_mgr_->Release(); thread_mgr_ = nullptr; } client_id_ = TF_CLIENTID_NULL; + owner_thread_id_ = 0; return S_OK; } @@ -87,6 +112,74 @@ STDMETHODIMP OpenLessTextService::Deactivate() { HRESULT OpenLessTextService::SubmitTextFromPipe( const std::wstring& session_id, const std::wstring& text) { + if (GetCurrentThreadId() == owner_thread_id_) { + return CommitTextOnOwnerThread(session_id, text); + } + + if (message_window_ == nullptr) { + return E_UNEXPECTED; + } + + SubmitTextRequest request{&session_id, &text, E_UNEXPECTED}; + DWORD_PTR message_result = 0; + const LRESULT sent = SendMessageTimeoutW( + message_window_, kSubmitTextMessage, 0, + reinterpret_cast(&request), SMTO_ABORTIFHUNG, + kSubmitTextTimeoutMs, &message_result); + if (sent == 0) { + const DWORD error = GetLastError(); + return HRESULT_FROM_WIN32(error != ERROR_SUCCESS ? error : ERROR_TIMEOUT); + } + + return request.result; +} + +HRESULT OpenLessTextService::StartIpcServer() { + pipe_server_.Start(this); + return S_OK; +} + +void OpenLessTextService::StopIpcServer() { + pipe_server_.Stop(); +} + +HRESULT OpenLessTextService::EnsureMessageWindow() { + if (message_window_ != nullptr) { + return S_OK; + } + + WNDCLASSW window_class = {}; + window_class.lpfnWndProc = OpenLessTextService::MessageWindowProc; + window_class.hInstance = g_module; + window_class.lpszClassName = kMessageWindowClassName; + + if (!RegisterClassW(&window_class)) { + const DWORD error = GetLastError(); + if (error != ERROR_CLASS_ALREADY_EXISTS) { + return HRESULT_FROM_WIN32(error); + } + } + + message_window_ = + CreateWindowExW(0, kMessageWindowClassName, L"", 0, 0, 0, 0, 0, + HWND_MESSAGE, nullptr, g_module, this); + if (message_window_ == nullptr) { + return HRESULT_FROM_WIN32(GetLastError()); + } + + return S_OK; +} + +void OpenLessTextService::DestroyMessageWindow() { + if (message_window_ != nullptr) { + DestroyWindow(message_window_); + message_window_ = nullptr; + } +} + +HRESULT OpenLessTextService::CommitTextOnOwnerThread( + const std::wstring& session_id, + const std::wstring& text) { UNREFERENCED_PARAMETER(session_id); if (thread_mgr_ == nullptr || client_id_ == TF_CLIENTID_NULL) { @@ -131,8 +224,32 @@ HRESULT OpenLessTextService::SubmitTextFromPipe( return edit_result; } -HRESULT OpenLessTextService::StartIpcServer() { - return S_OK; -} +LRESULT CALLBACK OpenLessTextService::MessageWindowProc(HWND window, + UINT message, + WPARAM wparam, + LPARAM lparam) { + UNREFERENCED_PARAMETER(wparam); + + if (message == WM_NCCREATE) { + const auto* create = reinterpret_cast(lparam); + SetWindowLongPtrW(window, GWLP_USERDATA, + reinterpret_cast(create->lpCreateParams)); + return TRUE; + } -void OpenLessTextService::StopIpcServer() {} + auto* service = reinterpret_cast( + GetWindowLongPtrW(window, GWLP_USERDATA)); + if (message == kSubmitTextMessage && service != nullptr) { + auto* request = reinterpret_cast(lparam); + if (request == nullptr || request->session_id == nullptr || + request->text == nullptr) { + return 0; + } + + request->result = + service->CommitTextOnOwnerThread(*request->session_id, *request->text); + return 1; + } + + return DefWindowProcW(window, message, wparam, lparam); +} diff --git a/openless-all/app/windows-ime/src/text_service.h b/openless-all/app/windows-ime/src/text_service.h index 7ef32466..d6402a6a 100644 --- a/openless-all/app/windows-ime/src/text_service.h +++ b/openless-all/app/windows-ime/src/text_service.h @@ -4,6 +4,8 @@ #include #include +#include "ipc_client.h" + class OpenLessTextService final : public ITfTextInputProcessorEx { public: OpenLessTextService(); @@ -27,8 +29,20 @@ class OpenLessTextService final : public ITfTextInputProcessorEx { private: HRESULT StartIpcServer(); void StopIpcServer(); + HRESULT EnsureMessageWindow(); + void DestroyMessageWindow(); + HRESULT CommitTextOnOwnerThread(const std::wstring& session_id, + const std::wstring& text); + + static LRESULT CALLBACK MessageWindowProc(HWND window, + UINT message, + WPARAM wparam, + LPARAM lparam); LONG ref_count_ = 1; ITfThreadMgr* thread_mgr_ = nullptr; TfClientId client_id_ = TF_CLIENTID_NULL; + DWORD owner_thread_id_ = 0; + HWND message_window_ = nullptr; + OpenLessPipeServer pipe_server_; }; From 7225caa12bfab57c22c6f7fa7a9c2165b2efd228 Mon Sep 17 00:00:00 2001 From: millionart Date: Fri, 1 May 2026 20:53:50 +0800 Subject: [PATCH 22/43] feat: add Windows IME build and registration scripts --- .../app/scripts/windows-ime-build.ps1 | 55 +++++++++++++++++++ .../app/scripts/windows-ime-register.ps1 | 29 ++++++++++ .../app/scripts/windows-ime-unregister.ps1 | 30 ++++++++++ .../app/scripts/windows-preflight.ps1 | 52 +++++++++++++++++- 4 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 openless-all/app/scripts/windows-ime-build.ps1 create mode 100644 openless-all/app/scripts/windows-ime-register.ps1 create mode 100644 openless-all/app/scripts/windows-ime-unregister.ps1 diff --git a/openless-all/app/scripts/windows-ime-build.ps1 b/openless-all/app/scripts/windows-ime-build.ps1 new file mode 100644 index 00000000..33aa38a5 --- /dev/null +++ b/openless-all/app/scripts/windows-ime-build.ps1 @@ -0,0 +1,55 @@ +param( + [ValidateSet("Debug", "Release")] + [string]$Configuration = "Release" +) + +$ErrorActionPreference = "Stop" + +function Find-MSBuild { + $cmd = Get-Command MSBuild.exe -ErrorAction SilentlyContinue + if ($cmd) { + return $cmd.Source + } + + $vswhere = Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio\Installer\vswhere.exe" + if (Test-Path $vswhere) { + $found = & $vswhere -latest -products * -requires Microsoft.Component.MSBuild -find "MSBuild\Current\Bin\MSBuild.exe" 2>$null | + Select-Object -First 1 + if ($found -and (Test-Path $found)) { + return $found + } + } + + $candidates = @( + "${env:ProgramFiles}\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe", + "${env:ProgramFiles}\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\MSBuild.exe" + ) + foreach ($candidate in $candidates) { + if (Test-Path $candidate) { + return $candidate + } + } + + throw "MSBuild.exe not found. Install Visual Studio Build Tools or run from Developer PowerShell." +} + +$appRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$solution = Join-Path $appRoot "windows-ime\OpenLessIme.sln" +$dll = Join-Path $appRoot "windows-ime\x64\$Configuration\OpenLessIme.dll" +$msbuild = Find-MSBuild + +if (-not (Test-Path $solution)) { + throw "Solution not found: $solution" +} + +Write-Host "[build] $Configuration x64" +& $msbuild $solution /p:Configuration=$Configuration /p:Platform=x64 +if ($LASTEXITCODE -ne 0) { + throw "OpenLessIme build failed with exit code $LASTEXITCODE" +} + +if (-not (Test-Path $dll)) { + throw "OpenLessIme.dll was not produced: $dll" +} + +Write-Host "[ok] $dll" diff --git a/openless-all/app/scripts/windows-ime-register.ps1 b/openless-all/app/scripts/windows-ime-register.ps1 new file mode 100644 index 00000000..ce012685 --- /dev/null +++ b/openless-all/app/scripts/windows-ime-register.ps1 @@ -0,0 +1,29 @@ +param( + [ValidateSet("Debug", "Release")] + [string]$Configuration = "Release" +) + +$ErrorActionPreference = "Stop" + +function Get-Regsvr32x64 { + $sysnative = Join-Path $env:WINDIR "Sysnative\regsvr32.exe" + if (Test-Path $sysnative) { + return $sysnative + } + return (Join-Path $env:WINDIR "System32\regsvr32.exe") +} + +$appRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$dll = Join-Path $appRoot "windows-ime\x64\$Configuration\OpenLessIme.dll" + +if (-not (Test-Path $dll)) { + & (Join-Path $PSScriptRoot "windows-ime-build.ps1") -Configuration $Configuration +} + +$regsvr32 = Get-Regsvr32x64 +& $regsvr32 /s $dll +if ($LASTEXITCODE -ne 0) { + throw "regsvr32 failed with exit code $LASTEXITCODE" +} + +Write-Host "[ok] OpenLess TSF IME registered" diff --git a/openless-all/app/scripts/windows-ime-unregister.ps1 b/openless-all/app/scripts/windows-ime-unregister.ps1 new file mode 100644 index 00000000..1e2a9532 --- /dev/null +++ b/openless-all/app/scripts/windows-ime-unregister.ps1 @@ -0,0 +1,30 @@ +param( + [ValidateSet("Debug", "Release")] + [string]$Configuration = "Release" +) + +$ErrorActionPreference = "Stop" + +function Get-Regsvr32x64 { + $sysnative = Join-Path $env:WINDIR "Sysnative\regsvr32.exe" + if (Test-Path $sysnative) { + return $sysnative + } + return (Join-Path $env:WINDIR "System32\regsvr32.exe") +} + +$appRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$dll = Join-Path $appRoot "windows-ime\x64\$Configuration\OpenLessIme.dll" + +if (-not (Test-Path $dll)) { + Write-Host "[skip] OpenLessIme.dll not found: $dll" + exit 0 +} + +$regsvr32 = Get-Regsvr32x64 +& $regsvr32 /u /s $dll +if ($LASTEXITCODE -ne 0) { + throw "regsvr32 /u failed with exit code $LASTEXITCODE" +} + +Write-Host "[ok] OpenLess TSF IME unregistered" diff --git a/openless-all/app/scripts/windows-preflight.ps1 b/openless-all/app/scripts/windows-preflight.ps1 index edee735e..1176f335 100644 --- a/openless-all/app/scripts/windows-preflight.ps1 +++ b/openless-all/app/scripts/windows-preflight.ps1 @@ -1,5 +1,5 @@ param( - [ValidateSet("all", "msvc", "gnu")] + [ValidateSet("all", "msvc", "gnu", "ime")] [string]$Toolchain = "all" ) @@ -17,6 +17,34 @@ function Test-Command($Name) { return $false } +function Find-MSBuild { + $cmd = Get-Command MSBuild.exe -ErrorAction SilentlyContinue + if ($cmd) { + return $cmd.Source + } + + $vswhere = Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio\Installer\vswhere.exe" + if (Test-Path $vswhere) { + $found = & $vswhere -latest -products * -requires Microsoft.Component.MSBuild -find "MSBuild\Current\Bin\MSBuild.exe" 2>$null | + Select-Object -First 1 + if ($found -and (Test-Path $found)) { + return $found + } + } + + $candidates = @( + "${env:ProgramFiles}\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe", + "${env:ProgramFiles}\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\MSBuild.exe" + ) + foreach ($candidate in $candidates) { + if (Test-Path $candidate) { + return $candidate + } + } + + return $null +} + function Find-Kernel32Lib { $kitsRoot = Join-Path ${env:ProgramFiles(x86)} "Windows Kits\10\Lib" if (-not (Test-Path $kitsRoot)) { @@ -100,6 +128,28 @@ if ($Toolchain -eq "all" -or $Toolchain -eq "gnu") { } } +if ($Toolchain -eq "all" -or $Toolchain -eq "ime") { + Write-Host "" + Write-Host "== Windows IME route ==" + $msbuild = Find-MSBuild + if ($msbuild) { + Write-Host "[ok] MSBuild.exe -> $msbuild" + } else { + Write-Host "[missing] MSBuild.exe" + Write-Host "[hint] Install Visual Studio Build Tools workload 'Desktop development with C++'." + $failed = $true + } + + $kernel32 = Find-Kernel32Lib + if ($kernel32) { + Write-Host "[ok] kernel32.lib -> $kernel32" + } else { + Write-Host "[missing] kernel32.lib" + Write-Host "[hint] Install a Windows 10/11 SDK with x64 libraries." + $failed = $true + } +} + if ($failed) { exit 1 } From f5624f702d8d784214ad3a7437ab7173b90321a4 Mon Sep 17 00:00:00 2001 From: millionart Date: Fri, 1 May 2026 21:04:47 +0800 Subject: [PATCH 23/43] feat: show Windows TSF IME backend status --- openless-all/app/src-tauri/src/commands.rs | 7 +- openless-all/app/src-tauri/src/lib.rs | 1 + openless-all/app/src-tauri/src/types.rs | 18 +++ .../app/src-tauri/src/windows_ime_profile.rs | 110 +++++++++++++++++- openless-all/app/src/i18n/en.ts | 10 ++ openless-all/app/src/i18n/zh-CN.ts | 10 ++ openless-all/app/src/lib/ipc.ts | 12 ++ openless-all/app/src/lib/types.ts | 13 +++ openless-all/app/src/pages/Settings.tsx | 35 ++++++ 9 files changed, 213 insertions(+), 3 deletions(-) diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index ef74c7a7..3d05e7ac 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -9,7 +9,7 @@ use crate::permissions::{self, PermissionStatus}; use crate::persistence::{CredentialAccount, CredentialsSnapshot, CredentialsVault}; use crate::types::{ CredentialsStatus, DictationSession, DictionaryEntry, HotkeyCapability, HotkeyStatus, - PolishMode, UserPreferences, + PolishMode, UserPreferences, WindowsImeStatus, }; type CoordinatorState<'a> = State<'a, Arc>; @@ -38,6 +38,11 @@ pub fn get_hotkey_capability(coord: CoordinatorState<'_>) -> HotkeyCapability { coord.hotkey_capability() } +#[tauri::command] +pub fn get_windows_ime_status() -> WindowsImeStatus { + crate::windows_ime_profile::get_windows_ime_status() +} + #[tauri::command] pub fn get_credentials() -> CredentialsStatus { let snap = CredentialsVault::snapshot(); diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index a41a30e9..39b85fe2 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -152,6 +152,7 @@ pub fn run() { commands::set_settings, commands::get_hotkey_status, commands::get_hotkey_capability, + commands::get_windows_ime_status, commands::get_credentials, commands::set_credential, commands::list_history, diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index e5622378..da3d3955 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -282,6 +282,24 @@ pub struct HotkeyStatus { pub last_error: Option, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum WindowsImeInstallState { + Installed, + NotInstalled, + RegistrationBroken, + NotWindows, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WindowsImeStatus { + pub state: WindowsImeInstallState, + pub using_tsf_backend: bool, + pub message: String, + pub dll_path: Option, +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub enum HotkeyStatusState { 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 78fb4325..6a1948dc 100644 --- a/openless-all/app/src-tauri/src/windows_ime_profile.rs +++ b/openless-all/app/src-tauri/src/windows_ime_profile.rs @@ -2,6 +2,8 @@ pub const OPENLESS_TSF_LANG_ID: u16 = 0x0804; pub const OPENLESS_TEXT_SERVICE_CLSID_BRACED: &str = "{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}"; pub const OPENLESS_PROFILE_GUID_BRACED: &str = "{9B5F5E04-23F6-47DA-9A26-D221F6C3F02E}"; +use crate::types::{WindowsImeInstallState, WindowsImeStatus}; + #[cfg(target_os = "windows")] fn parse_guid(value: &str) -> WindowsImeProfileResult { uuid::Uuid::parse_str(value) @@ -101,6 +103,23 @@ impl std::error::Error for WindowsImeProfileError {} pub type WindowsImeProfileResult = Result; +pub fn get_windows_ime_status() -> WindowsImeStatus { + #[cfg(target_os = "windows")] + { + windows_impl::get_windows_ime_status() + } + + #[cfg(not(target_os = "windows"))] + { + WindowsImeStatus { + state: WindowsImeInstallState::NotWindows, + using_tsf_backend: false, + message: "Windows TSF IME backend is only available on Windows".to_string(), + dll_path: None, + } + } +} + #[cfg(target_os = "windows")] pub struct WindowsImeProfileManager; @@ -163,6 +182,7 @@ impl WindowsImeProfileManager { mod windows_impl { use super::*; use std::ffi::c_void; + use std::path::Path; use std::ptr; use windows::core::GUID; use windows::Win32::System::Com::{ @@ -171,10 +191,16 @@ mod windows_impl { }; use windows::Win32::UI::Input::KeyboardAndMouse::HKL; use windows::Win32::UI::TextServices::{ - CLSID_TF_InputProcessorProfiles, ITfInputProcessorProfileMgr, TF_INPUTPROCESSORPROFILE, - GUID_TFCAT_TIP_KEYBOARD, TF_IPPMF_FORPROCESS, TF_PROFILETYPE_INPUTPROCESSOR, + CLSID_TF_InputProcessorProfiles, ITfInputProcessorProfileMgr, GUID_TFCAT_TIP_KEYBOARD, + TF_INPUTPROCESSORPROFILE, TF_IPPMF_FORPROCESS, TF_PROFILETYPE_INPUTPROCESSOR, TF_PROFILETYPE_KEYBOARDLAYOUT, }; + use winreg::enums::HKEY_CURRENT_USER; + use winreg::RegKey; + + const OPENLESS_COM_INPROC_KEY: &str = + r"Software\Classes\CLSID\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\InprocServer32"; + const OPENLESS_TSF_PROFILE_KEY: &str = r"Software\Microsoft\CTF\TIP\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\LanguageProfile\0x00000804\{9B5F5E04-23F6-47DA-9A26-D221F6C3F02E}"; struct ComApartment; @@ -284,6 +310,86 @@ mod windows_impl { == Some(OPENLESS_PROFILE_GUID_BRACED)) } + pub fn get_windows_ime_status() -> WindowsImeStatus { + match inspect_windows_ime_registration() { + RegistrationInspection::Installed { dll_path } => WindowsImeStatus { + state: WindowsImeInstallState::Installed, + using_tsf_backend: true, + message: "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(), + dll_path: None, + }, + RegistrationInspection::Broken { dll_path, reason } => WindowsImeStatus { + state: WindowsImeInstallState::RegistrationBroken, + using_tsf_backend: false, + message: reason, + dll_path, + }, + } + } + + enum RegistrationInspection { + Installed { + dll_path: String, + }, + NotInstalled, + Broken { + dll_path: Option, + reason: String, + }, + } + + fn inspect_windows_ime_registration() -> RegistrationInspection { + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + let com_key = hkcu.open_subkey(OPENLESS_COM_INPROC_KEY); + let tip_key_exists = hkcu.open_subkey(OPENLESS_TSF_PROFILE_KEY).is_ok(); + + if com_key.is_err() && !tip_key_exists { + return RegistrationInspection::NotInstalled; + } + + let com_key = match com_key { + Ok(key) => key, + Err(_) => { + return RegistrationInspection::Broken { + dll_path: None, + reason: "OpenLess COM registration is missing".to_string(), + }; + } + }; + + let dll_path: String = match com_key.get_value::("") { + Ok(value) if !value.trim().is_empty() => value, + _ => { + return RegistrationInspection::Broken { + dll_path: None, + reason: "OpenLess COM DLL path is missing".to_string(), + }; + } + }; + + if !Path::new(&dll_path).is_file() { + return RegistrationInspection::Broken { + dll_path: Some(dll_path), + reason: "OpenLess COM DLL path does not exist".to_string(), + }; + } + + if !tip_key_exists { + return RegistrationInspection::Broken { + dll_path: Some(dll_path), + reason: "OpenLess TSF language profile registration is missing".to_string(), + }; + } + + RegistrationInspection::Installed { dll_path } + } + fn with_profile_manager( operation: impl FnOnce(&ITfInputProcessorProfileMgr) -> windows::core::Result, ) -> WindowsImeProfileResult { diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index fa335452..d411dc80 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -283,6 +283,16 @@ export const en: typeof zhCN = { hotkeyInstalled: 'Installed', 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.', + windowsImeInstalled: 'Installed', + windowsImeUnavailable: 'Unavailable', + 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.', + notWindows: 'Only available on Windows.', + }, }, language: { title: 'Interface language', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index cf98f614..b532d103 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -281,6 +281,16 @@ export const zhCN = { hotkeyInstalled: '已安装', hotkeyStarting: '安装中…', hotkeyFailed: '监听失败', + windowsImeLabel: 'Windows 输入法后端', + windowsImeDesc: '用于在语音会话期间临时切换到 OpenLess TSF 输入法,避免剪贴板插入限制。', + windowsImeInstalled: '已安装', + windowsImeUnavailable: '不可用', + windowsIme: { + installed: '已安装。语音输入时会临时切换到 OpenLess 输入法。', + notInstalled: '未安装。OpenLess 正在使用剪贴板 / WM_PASTE 兜底。', + registrationBroken: '注册已损坏。请重新安装 OpenLess 输入法。', + notWindows: '仅 Windows 可用。', + }, }, language: { title: '界面语言', diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index a1cabd6b..7a50b7e6 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -11,6 +11,7 @@ import type { PermissionStatus, PolishMode, UserPreferences, + WindowsImeStatus, } from './types'; import { OL_DATA } from './mockData'; @@ -70,6 +71,13 @@ const mockHotkeyStatus: HotkeyStatus = { lastError: null, }; +const mockWindowsImeStatus: WindowsImeStatus = { + state: 'installed', + usingTsfBackend: true, + message: 'OpenLess TSF IME registration is present', + dllPath: 'C:\\Program Files\\OpenLess\\OpenLessIme.dll', +}; + const mockHistory: DictationSession[] = OL_DATA.history.map((h, i) => ({ id: `mock-${i}`, createdAt: new Date().toISOString(), @@ -110,6 +118,10 @@ export function getHotkeyCapability(): Promise { return invokeOrMock('get_hotkey_capability', undefined, () => mockHotkeyCapability); } +export function getWindowsImeStatus(): Promise { + return invokeOrMock('get_windows_ime_status', undefined, () => mockWindowsImeStatus); +} + // ── Credentials ──────────────────────────────────────────────────────── export function getCredentials(): Promise { return invokeOrMock('get_credentials', undefined, () => mockCredentialsStatus); diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 8f350564..dfdb9e51 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -71,6 +71,19 @@ export interface HotkeyStatus { lastError: HotkeyInstallError | null; } +export type WindowsImeInstallState = + | 'installed' + | 'notInstalled' + | 'registrationBroken' + | 'notWindows'; + +export interface WindowsImeStatus { + state: WindowsImeInstallState; + usingTsfBackend: boolean; + message: string; + dllPath: string | null; +} + export interface UserPreferences { hotkey: HotkeyBinding; defaultMode: PolishMode; diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 08f70b28..9812b13c 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -13,6 +13,7 @@ import { checkAccessibilityPermission, checkMicrophonePermission, getHotkeyStatus, + getWindowsImeStatus, openExternal, openSystemSettings, readCredential, @@ -28,6 +29,7 @@ import type { HotkeyStatus, HotkeyTrigger, PermissionStatus, + WindowsImeStatus, } from '../lib/types'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; import i18n, { @@ -624,6 +626,7 @@ function PermissionsSection() { const [accessibility, setAccessibility] = useState('loading'); const [microphone, setMicrophone] = useState('loading'); const [hotkey, setHotkey] = useState(null); + const [windowsIme, setWindowsIme] = useState(null); const { capability } = useHotkeySettings(); const refreshPermissions = async () => { @@ -639,15 +642,21 @@ function PermissionsSection() { setHotkey(await getHotkeyStatus()); }; + const refreshWindowsIme = async () => { + setWindowsIme(await getWindowsImeStatus()); + }; + useEffect(() => { refreshPermissions(); refreshHotkey(); + refreshWindowsIme(); const hotkeyId = window.setInterval(refreshHotkey, 1000); // 麦克风检查会短暂打开输入流,避免每秒探测导致隐私指示器频繁闪烁。 const permissionId = window.setInterval(refreshPermissions, 10000); const onFocus = () => { refreshPermissions(); refreshHotkey(); + refreshWindowsIme(); }; window.addEventListener('focus', onFocus); return () => { @@ -721,6 +730,21 @@ function PermissionsSection() { )} + {windowsIme?.state !== 'notWindows' && ( + +
+ + {windowsIme && ( + + {t(`settings.permissions.windowsIme.${windowsIme.state}`)} + + )} +
+
+ )} {t('settings.permissions.networkOk')} @@ -880,6 +904,17 @@ function HotkeyStatusPill({ status }: { status: HotkeyStatus | null }) { return {t('settings.permissions.hotkeyFailed')}; } +function WindowsImeStatusPill({ status }: { status: WindowsImeStatus | null }) { + const { t } = useTranslation(); + if (!status) { + return {t('settings.permissions.checking')}; + } + if (status.state === 'installed') { + return {t('settings.permissions.windowsImeInstalled')}; + } + return {t('settings.permissions.windowsImeUnavailable')}; +} + function adapterDisplayName(adapter: HotkeyCapability['adapter'] | HotkeyStatus['adapter']) { if (adapter === 'macEventTap') return i18n.t('hotkey.adapter.macEventTap'); if (adapter === 'windowsLowLevel') return i18n.t('hotkey.adapter.windowsLowLevel'); From 35c2bbb2616f17a5cd39095b37321fcdf1875139 Mon Sep 17 00:00:00 2001 From: millionart Date: Fri, 1 May 2026 21:09:05 +0800 Subject: [PATCH 24/43] fix: mark browser IME mock as non-Windows --- openless-all/app/src/lib/ipc.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index 7a50b7e6..6efafd84 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -72,10 +72,10 @@ const mockHotkeyStatus: HotkeyStatus = { }; const mockWindowsImeStatus: WindowsImeStatus = { - state: 'installed', - usingTsfBackend: true, - message: 'OpenLess TSF IME registration is present', - dllPath: 'C:\\Program Files\\OpenLess\\OpenLessIme.dll', + state: 'notWindows', + usingTsfBackend: false, + message: 'Browser dev mock', + dllPath: null, }; const mockHistory: DictationSession[] = OL_DATA.history.map((h, i) => ({ From e8bc4af8ebef6229c517c10e68daef0977c985ab Mon Sep 17 00:00:00 2001 From: millionart Date: Fri, 1 May 2026 21:16:21 +0800 Subject: [PATCH 25/43] fix: check TSF profile registration in HKLM --- openless-all/app/src-tauri/src/windows_ime_profile.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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 6a1948dc..dfddde06 100644 --- a/openless-all/app/src-tauri/src/windows_ime_profile.rs +++ b/openless-all/app/src-tauri/src/windows_ime_profile.rs @@ -195,7 +195,7 @@ mod windows_impl { TF_INPUTPROCESSORPROFILE, TF_IPPMF_FORPROCESS, TF_PROFILETYPE_INPUTPROCESSOR, TF_PROFILETYPE_KEYBOARDLAYOUT, }; - use winreg::enums::HKEY_CURRENT_USER; + use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, KEY_READ, KEY_WOW64_64KEY}; use winreg::RegKey; const OPENLESS_COM_INPROC_KEY: &str = @@ -346,8 +346,11 @@ mod windows_impl { fn inspect_windows_ime_registration() -> RegistrationInspection { let hkcu = RegKey::predef(HKEY_CURRENT_USER); - let com_key = hkcu.open_subkey(OPENLESS_COM_INPROC_KEY); - let tip_key_exists = hkcu.open_subkey(OPENLESS_TSF_PROFILE_KEY).is_ok(); + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + let com_key = hkcu.open_subkey_with_flags(OPENLESS_COM_INPROC_KEY, KEY_READ); + let tip_key_exists = hklm + .open_subkey_with_flags(OPENLESS_TSF_PROFILE_KEY, KEY_READ | KEY_WOW64_64KEY) + .is_ok(); if com_key.is_err() && !tip_key_exists { return RegistrationInspection::NotInstalled; From 57a562c253fc76461d97c68146f35a4249a43a8d Mon Sep 17 00:00:00 2001 From: millionart Date: Fri, 1 May 2026 21:23:24 +0800 Subject: [PATCH 26/43] fix: activate Windows IME profile for session scope --- openless-all/app/src-tauri/src/windows_ime_ipc.rs | 4 +++- .../app/src-tauri/src/windows_ime_profile.rs | 12 +++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/openless-all/app/src-tauri/src/windows_ime_ipc.rs b/openless-all/app/src-tauri/src/windows_ime_ipc.rs index 3fe8b0c3..ac7d4c06 100644 --- a/openless-all/app/src-tauri/src/windows_ime_ipc.rs +++ b/openless-all/app/src-tauri/src/windows_ime_ipc.rs @@ -3,7 +3,9 @@ use std::time::Duration; use crate::windows_ime_protocol::ImeSubmitStatus; pub const IME_CLIENT_WAIT_TIMEOUT: Duration = Duration::from_millis(700); -pub const IME_SUBMIT_TIMEOUT: Duration = Duration::from_millis(900); +// Must exceed the IME DLL owner-thread commit timeout (3000 ms), otherwise Rust +// can fall back while the DLL later commits and duplicates insertion. +pub const IME_SUBMIT_TIMEOUT: Duration = Duration::from_millis(3800); const IME_PIPE_RETRY_INTERVAL: Duration = Duration::from_millis(25); const ERROR_FILE_NOT_FOUND: u32 = 2; 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 dfddde06..2b3444b7 100644 --- a/openless-all/app/src-tauri/src/windows_ime_profile.rs +++ b/openless-all/app/src-tauri/src/windows_ime_profile.rs @@ -192,8 +192,8 @@ mod windows_impl { use windows::Win32::UI::Input::KeyboardAndMouse::HKL; use windows::Win32::UI::TextServices::{ CLSID_TF_InputProcessorProfiles, ITfInputProcessorProfileMgr, GUID_TFCAT_TIP_KEYBOARD, - TF_INPUTPROCESSORPROFILE, TF_IPPMF_FORPROCESS, TF_PROFILETYPE_INPUTPROCESSOR, - TF_PROFILETYPE_KEYBOARDLAYOUT, + TF_INPUTPROCESSORPROFILE, TF_IPPMF_DONTCARECURRENTINPUTLANGUAGE, TF_IPPMF_FORSESSION, + TF_PROFILETYPE_INPUTPROCESSOR, TF_PROFILETYPE_KEYBOARDLAYOUT, }; use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, KEY_READ, KEY_WOW64_64KEY}; use winreg::RegKey; @@ -201,6 +201,8 @@ mod windows_impl { const OPENLESS_COM_INPROC_KEY: &str = r"Software\Classes\CLSID\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\InprocServer32"; const OPENLESS_TSF_PROFILE_KEY: &str = r"Software\Microsoft\CTF\TIP\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\LanguageProfile\0x00000804\{9B5F5E04-23F6-47DA-9A26-D221F6C3F02E}"; + const PROFILE_ACTIVATION_FLAGS: u32 = + TF_IPPMF_FORSESSION | TF_IPPMF_DONTCARECURRENTINPUTLANGUAGE; struct ComApartment; @@ -255,7 +257,7 @@ mod windows_impl { &clsid, &profile_guid, null_hkl(), - TF_IPPMF_FORPROCESS, + PROFILE_ACTIVATION_FLAGS, ) }) } @@ -274,7 +276,7 @@ mod windows_impl { &clsid, &profile_guid, null_hkl(), - TF_IPPMF_FORPROCESS, + PROFILE_ACTIVATION_FLAGS, ) }) } @@ -289,7 +291,7 @@ mod windows_impl { &zero_guid, &zero_guid, hkl, - TF_IPPMF_FORPROCESS, + PROFILE_ACTIVATION_FLAGS, ) }) } From 300d01ebbaf74fdf3e768622da8c87f38ce8c297 Mon Sep 17 00:00:00 2001 From: millionart Date: Fri, 1 May 2026 21:44:26 +0800 Subject: [PATCH 27/43] fix: require admin for Windows IME registration --- .../app/scripts/windows-ime-register.ps1 | 16 +++++++++++++--- .../app/scripts/windows-ime-unregister.ps1 | 16 +++++++++++++--- openless-all/app/scripts/windows-preflight.ps1 | 12 ++++++++++++ 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/openless-all/app/scripts/windows-ime-register.ps1 b/openless-all/app/scripts/windows-ime-register.ps1 index ce012685..9dbe5e5f 100644 --- a/openless-all/app/scripts/windows-ime-register.ps1 +++ b/openless-all/app/scripts/windows-ime-register.ps1 @@ -13,6 +13,12 @@ function Get-Regsvr32x64 { return (Join-Path $env:WINDIR "System32\regsvr32.exe") } +function Test-IsAdministrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + $appRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path $dll = Join-Path $appRoot "windows-ime\x64\$Configuration\OpenLessIme.dll" @@ -20,10 +26,14 @@ if (-not (Test-Path $dll)) { & (Join-Path $PSScriptRoot "windows-ime-build.ps1") -Configuration $Configuration } +if (-not (Test-IsAdministrator)) { + throw "Registering the OpenLess TSF IME requires an elevated Administrator PowerShell." +} + $regsvr32 = Get-Regsvr32x64 -& $regsvr32 /s $dll -if ($LASTEXITCODE -ne 0) { - throw "regsvr32 failed with exit code $LASTEXITCODE" +$process = Start-Process -FilePath $regsvr32 -ArgumentList @("/s", $dll) -Wait -PassThru +if ($process.ExitCode -ne 0) { + throw "regsvr32 failed with exit code $($process.ExitCode)" } Write-Host "[ok] OpenLess TSF IME registered" diff --git a/openless-all/app/scripts/windows-ime-unregister.ps1 b/openless-all/app/scripts/windows-ime-unregister.ps1 index 1e2a9532..6e13476a 100644 --- a/openless-all/app/scripts/windows-ime-unregister.ps1 +++ b/openless-all/app/scripts/windows-ime-unregister.ps1 @@ -13,6 +13,12 @@ function Get-Regsvr32x64 { return (Join-Path $env:WINDIR "System32\regsvr32.exe") } +function Test-IsAdministrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + $appRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path $dll = Join-Path $appRoot "windows-ime\x64\$Configuration\OpenLessIme.dll" @@ -21,10 +27,14 @@ if (-not (Test-Path $dll)) { exit 0 } +if (-not (Test-IsAdministrator)) { + throw "Unregistering the OpenLess TSF IME requires an elevated Administrator PowerShell." +} + $regsvr32 = Get-Regsvr32x64 -& $regsvr32 /u /s $dll -if ($LASTEXITCODE -ne 0) { - throw "regsvr32 /u failed with exit code $LASTEXITCODE" +$process = Start-Process -FilePath $regsvr32 -ArgumentList @("/u", "/s", $dll) -Wait -PassThru +if ($process.ExitCode -ne 0) { + throw "regsvr32 /u failed with exit code $($process.ExitCode)" } Write-Host "[ok] OpenLess TSF IME unregistered" diff --git a/openless-all/app/scripts/windows-preflight.ps1 b/openless-all/app/scripts/windows-preflight.ps1 index 1176f335..bf6f0df9 100644 --- a/openless-all/app/scripts/windows-preflight.ps1 +++ b/openless-all/app/scripts/windows-preflight.ps1 @@ -60,6 +60,12 @@ function Find-Kernel32Lib { } } +function Test-IsAdministrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + function Test-WebView2Runtime { $paths = @( "HKLM:\SOFTWARE\Microsoft\EdgeUpdate\Clients\{F1E7FBD4-9C4C-41A4-AB01-7C0F7A947F1A}", @@ -148,6 +154,12 @@ if ($Toolchain -eq "all" -or $Toolchain -eq "ime") { Write-Host "[hint] Install a Windows 10/11 SDK with x64 libraries." $failed = $true } + + if (Test-IsAdministrator) { + Write-Host "[ok] Administrator shell for TSF registration" + } else { + Write-Host "[warn] Registering/unregistering the TSF IME requires an elevated Administrator PowerShell." + } } if ($failed) { From 0e4747df8e95ecbf4059e695c18af4cc0aa8925b Mon Sep 17 00:00:00 2001 From: millionart Date: Fri, 1 May 2026 21:52:45 +0800 Subject: [PATCH 28/43] fix: register Windows IME COM server system-wide --- .../app/src-tauri/src/windows_ime_profile.rs | 6 +++--- openless-all/app/windows-ime/src/registry.cpp | 19 +++++++++++++------ 2 files changed, 16 insertions(+), 9 deletions(-) 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 2b3444b7..ed1430f0 100644 --- a/openless-all/app/src-tauri/src/windows_ime_profile.rs +++ b/openless-all/app/src-tauri/src/windows_ime_profile.rs @@ -195,7 +195,7 @@ mod windows_impl { TF_INPUTPROCESSORPROFILE, TF_IPPMF_DONTCARECURRENTINPUTLANGUAGE, TF_IPPMF_FORSESSION, TF_PROFILETYPE_INPUTPROCESSOR, TF_PROFILETYPE_KEYBOARDLAYOUT, }; - use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, KEY_READ, KEY_WOW64_64KEY}; + use winreg::enums::{HKEY_LOCAL_MACHINE, KEY_READ, KEY_WOW64_64KEY}; use winreg::RegKey; const OPENLESS_COM_INPROC_KEY: &str = @@ -347,9 +347,9 @@ mod windows_impl { } fn inspect_windows_ime_registration() -> RegistrationInspection { - let hkcu = RegKey::predef(HKEY_CURRENT_USER); let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); - let com_key = hkcu.open_subkey_with_flags(OPENLESS_COM_INPROC_KEY, KEY_READ); + let com_key = + hklm.open_subkey_with_flags(OPENLESS_COM_INPROC_KEY, KEY_READ | KEY_WOW64_64KEY); let tip_key_exists = hklm .open_subkey_with_flags(OPENLESS_TSF_PROFILE_KEY, KEY_READ | KEY_WOW64_64KEY) .is_ok(); diff --git a/openless-all/app/windows-ime/src/registry.cpp b/openless-all/app/windows-ime/src/registry.cpp index 61c99887..e403a0c9 100644 --- a/openless-all/app/windows-ime/src/registry.cpp +++ b/openless-all/app/windows-ime/src/registry.cpp @@ -11,6 +11,7 @@ constexpr wchar_t kClsidKey[] = L"Software\\Classes\\CLSID\\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}"; constexpr wchar_t kInprocServer32Key[] = L"Software\\Classes\\CLSID\\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\\InprocServer32"; +constexpr REGSAM kRegistryWriteAccess = KEY_WRITE | KEY_WOW64_64KEY; HRESULT HResultFromWin32Error(LSTATUS status) { return status == ERROR_SUCCESS ? S_OK : HRESULT_FROM_WIN32(status); @@ -37,9 +38,9 @@ HRESULT RegisterComServer(HINSTANCE module) { HKEY clsid_key = nullptr; LSTATUS status = - RegCreateKeyExW(HKEY_CURRENT_USER, kClsidKey, 0, nullptr, - REG_OPTION_NON_VOLATILE, KEY_WRITE, nullptr, &clsid_key, - nullptr); + RegCreateKeyExW(HKEY_LOCAL_MACHINE, kClsidKey, 0, nullptr, + REG_OPTION_NON_VOLATILE, kRegistryWriteAccess, nullptr, + &clsid_key, nullptr); HRESULT hr = HResultFromWin32Error(status); if (FAILED(hr)) { return hr; @@ -52,8 +53,8 @@ HRESULT RegisterComServer(HINSTANCE module) { } HKEY inproc_key = nullptr; - status = RegCreateKeyExW(HKEY_CURRENT_USER, kInprocServer32Key, 0, nullptr, - REG_OPTION_NON_VOLATILE, KEY_WRITE, nullptr, + status = RegCreateKeyExW(HKEY_LOCAL_MACHINE, kInprocServer32Key, 0, nullptr, + REG_OPTION_NON_VOLATILE, kRegistryWriteAccess, nullptr, &inproc_key, nullptr); hr = HResultFromWin32Error(status); if (FAILED(hr)) { @@ -69,7 +70,7 @@ HRESULT RegisterComServer(HINSTANCE module) { } HRESULT DeleteComServerRegistration() { - const LSTATUS status = RegDeleteTreeW(HKEY_CURRENT_USER, kClsidKey); + const LSTATUS status = RegDeleteTreeW(HKEY_LOCAL_MACHINE, kClsidKey); if (status == ERROR_FILE_NOT_FOUND) { return S_OK; } @@ -121,6 +122,8 @@ HRESULT RegisterLanguageProfile() { return hr; } + profiles->Unregister(CLSID_OpenLessTextService); + hr = profiles->Register(CLSID_OpenLessTextService); if (SUCCEEDED(hr)) { hr = profiles->AddLanguageProfile( @@ -151,6 +154,10 @@ HRESULT RegisterKeyboardCategory() { return hr; } + category_mgr->UnregisterCategory(CLSID_OpenLessTextService, + GUID_TFCAT_TIP_KEYBOARD, + CLSID_OpenLessTextService); + hr = category_mgr->RegisterCategory(CLSID_OpenLessTextService, GUID_TFCAT_TIP_KEYBOARD, CLSID_OpenLessTextService); From c3b59329cd59d7a536d303f30f4e1cd36f368332 Mon Sep 17 00:00:00 2001 From: millionart Date: Fri, 1 May 2026 23:17:04 +0800 Subject: [PATCH 29/43] fix: avoid clipboard fallback for Windows voice insertion --- .../windows-real-asr-insertion-smoke.ps1 | 73 +++++++++++++++-- .../app/scripts/windows-smoke-suite.ps1 | 4 +- openless-all/app/src-tauri/src/coordinator.rs | 78 +++++++++++++++++-- openless-all/app/src-tauri/src/hotkey.rs | 25 ++++-- openless-all/app/src-tauri/src/insertion.rs | 56 +++++++++++++ .../app/src-tauri/src/windows_ime_ipc.rs | 30 +++++-- .../app/src-tauri/src/windows_ime_profile.rs | 38 +++++++-- .../app/src-tauri/src/windows_ime_protocol.rs | 14 +++- .../app/src-tauri/src/windows_ime_session.rs | 5 +- .../app/windows-ime/src/ipc_client.cpp | 33 +++++++- openless-all/app/windows-ime/src/ipc_client.h | 1 + openless-all/app/windows-ime/src/registry.cpp | 33 ++++++++ 12 files changed, 345 insertions(+), 45 deletions(-) diff --git a/openless-all/app/scripts/windows-real-asr-insertion-smoke.ps1 b/openless-all/app/scripts/windows-real-asr-insertion-smoke.ps1 index 0ce1dcd6..c30ebcd8 100644 --- a/openless-all/app/scripts/windows-real-asr-insertion-smoke.ps1 +++ b/openless-all/app/scripts/windows-real-asr-insertion-smoke.ps1 @@ -1,10 +1,12 @@ param( [string]$ExePath = "", - [ValidateSet("notepad", "browser")] + [ValidateSet("notepad", "browser", "win32edit")] [string]$Target = "notepad", [string]$Phrase = "OpenLess Windows real regression", [int]$TimeoutSeconds = 120, [int]$VirtualKey = 0xA3, + [switch]$AllowClipboardFallback, + [switch]$RequireJsonCredentials, [switch]$DebugHotkeyEvents ) @@ -263,6 +265,48 @@ function New-BrowserInputFixture { return $path } +function New-Win32EditHost { + $sourcePath = Join-Path $env:TEMP "OpenLessWin32EditHost.cs" + $exePath = Join-Path $env:TEMP "OpenLessWin32EditHost.exe" + $source = @" +using System; +using System.Windows.Forms; + +public static class OpenLessWin32EditHost { + [STAThread] + public static void Main() { + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + var form = new Form(); + form.Text = "OpenLess Win32 Edit Host"; + form.Width = 820; + form.Height = 320; + var box = new TextBox(); + box.Multiline = true; + box.AcceptsReturn = true; + box.AcceptsTab = true; + box.Dock = DockStyle.Fill; + box.Font = new System.Drawing.Font("Consolas", 18); + form.Controls.Add(box); + form.Shown += (sender, args) => box.Focus(); + Application.Run(form); + } +} +"@ + $needsBuild = $true + if ((Test-Path $exePath) -and (Test-Path $sourcePath)) { + $needsBuild = (Get-Item $sourcePath).LastWriteTimeUtc -gt (Get-Item $exePath).LastWriteTimeUtc + } + if ($needsBuild) { + [System.IO.File]::WriteAllText($sourcePath, $source, [System.Text.UTF8Encoding]::new($false)) + Add-Type -TypeDefinition $source ` + -ReferencedAssemblies @("System.Windows.Forms", "System.Drawing") ` + -OutputAssembly $exePath ` + -OutputType WindowsApplication + } + return $exePath +} + function Stop-BrowserProfileProcesses($ProfilePath) { if ([string]::IsNullOrWhiteSpace($ProfilePath)) { return @@ -285,6 +329,15 @@ function Start-InputTarget($TargetName) { } return [pscustomobject]@{ Process = $process; FixturePath = $null; ProfilePath = $null } } + if ($TargetName -eq "win32edit") { + $hostExe = New-Win32EditHost + Start-Process -FilePath $hostExe | Out-Null + $process = Wait-ProcessWindow "OpenLessWin32EditHost" $startedAt 15 + if (-not (Focus-Window $process)) { + throw "Win32 edit host window could not be focused." + } + return [pscustomobject]@{ Process = $process; FixturePath = $null; ProfilePath = $null } + } $browserPath = Resolve-BrowserPath $fixture = New-BrowserInputFixture @@ -327,9 +380,12 @@ function Speak-TestPhrase($Text) { } $credentialStatus = Get-OpenLessCredentialStatus -if (-not $credentialStatus.VolcengineConfigured -or -not $credentialStatus.ArkConfigured) { +if ($RequireJsonCredentials -and (-not $credentialStatus.VolcengineConfigured -or -not $credentialStatus.ArkConfigured)) { throw "Real ASR regression requires configured Volcengine ASR and Ark LLM credentials." } +if (-not $credentialStatus.VolcengineConfigured -or -not $credentialStatus.ArkConfigured) { + Write-Warning "Legacy credentials.json is incomplete; continuing because the app may use the OS credential vault." +} $logPath = Join-Path $env:LOCALAPPDATA "OpenLess\Logs\openless.log" $historyPath = Join-Path $env:APPDATA "OpenLess\history.json" @@ -340,7 +396,7 @@ $previousPreferences = Set-HoldHotkeyPreference $preferencesPath Get-Process openless -ErrorAction SilentlyContinue | Stop-Process -Force Remove-Item -LiteralPath $logPath -Force -ErrorAction SilentlyContinue -Write-Host "== Real ASR + insertion fallback smoke ($Target) ==" +Write-Host "== Real ASR + direct insertion smoke ($Target) ==" $env:OPENLESS_SHOW_MAIN_ON_START = "1" $env:OPENLESS_ACCEPT_SYNTHETIC_HOTKEY_EVENTS = "1" if ($DebugHotkeyEvents) { @@ -356,7 +412,7 @@ try { $inputTarget = $null try { - if (-not (Wait-LogPattern $logPath "WH_KEYBOARD_LL installed" 20)) { + if (-not (Wait-LogPattern $logPath "hotkey listener installed" 20)) { throw "Windows low-level keyboard hook was not installed." } @@ -388,8 +444,11 @@ try { if ([string]::IsNullOrWhiteSpace($latest.rawTranscript) -or [string]::IsNullOrWhiteSpace($latest.finalText)) { throw "Latest history item is missing rawTranscript or finalText." } - if (@("copiedFallback", "pasteSent") -notcontains $latest.insertStatus) { - throw "Expected Windows insertStatus copiedFallback or pasteSent, got '$($latest.insertStatus)'." + if ($latest.insertStatus -ne "inserted") { + if (-not $AllowClipboardFallback -or @("copiedFallback", "pasteSent") -notcontains $latest.insertStatus) { + throw "Expected Windows insertStatus inserted, got '$($latest.insertStatus)'." + } + Write-Warning "Clipboard fallback was allowed for this run. insertStatus=$($latest.insertStatus)" } Focus-Window $inputTarget.Process | Out-Null @@ -430,4 +489,4 @@ try { } } -Write-Host "Real ASR + insertion fallback smoke ($Target) passed." +Write-Host "Real ASR + direct insertion smoke ($Target) passed." diff --git a/openless-all/app/scripts/windows-smoke-suite.ps1 b/openless-all/app/scripts/windows-smoke-suite.ps1 index 9e37be51..55797fed 100644 --- a/openless-all/app/scripts/windows-smoke-suite.ps1 +++ b/openless-all/app/scripts/windows-smoke-suite.ps1 @@ -1,7 +1,7 @@ param( [string]$ExePath = "", [string]$Phrase = "OpenLess Windows regression suite phrase. OpenLess Windows regression suite phrase.", - [ValidateSet("notepad", "browser")] + [ValidateSet("notepad", "browser", "win32edit")] [string[]]$Targets = @("notepad", "browser"), [switch]$Build, [switch]$SkipRuntime, @@ -105,7 +105,7 @@ try { if (-not $SkipRealAsr) { foreach ($target in $Targets) { - Invoke-Step "Real ASR insertion fallback: $target" { + Invoke-Step "Real ASR direct insertion: $target" { $parameters = @{ ExePath = $ExePath Target = $target diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 7a396e2c..5af7f550 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -32,6 +32,8 @@ use crate::types::{ HotkeyStatusState, InsertStatus, PolishMode, }; #[cfg(target_os = "windows")] +use crate::windows_ime_ipc::ImeSubmitTarget; +#[cfg(target_os = "windows")] use crate::windows_ime_session::{PreparedWindowsImeSession, WindowsImeSessionController}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -492,7 +494,9 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { }); } // 翻译模式标志重置;hotkey 监听器在 Shift down 时再 set true。 - inner.translation_modifier_seen.store(false, Ordering::SeqCst); + inner + .translation_modifier_seen + .store(false, Ordering::SeqCst); #[cfg(any(debug_assertions, test))] if hotkey_injection_dry_run_enabled() { @@ -952,8 +956,8 @@ async fn end_session(inner: &Arc) -> Result<(), String> { let hotword_strs = enabled_phrases(inner); let working_languages = prefs.working_languages.clone(); let translation_target = prefs.translation_target_language.trim().to_string(); - let translation_active = inner.translation_modifier_seen.load(Ordering::SeqCst) - && !translation_target.is_empty(); + let translation_active = + inner.translation_modifier_seen.load(Ordering::SeqCst) && !translation_target.is_empty(); let (polished, polish_error) = if translation_active { log::info!( "[coord] translation mode → target=\u{300C}{}\u{300D} working={:?}", @@ -992,9 +996,16 @@ async fn end_session(inner: &Arc) -> Result<(), String> { restore_focus_target_if_possible(focus_target); let restore_clipboard = inner.prefs.get().restore_clipboard_after_paste; #[cfg(target_os = "windows")] - let status = - insert_with_windows_ime_first(inner, current_session_id, &polished, restore_clipboard) - .await; + let ime_target = capture_ime_submit_target(); + #[cfg(target_os = "windows")] + let status = insert_with_windows_ime_first( + inner, + current_session_id, + &polished, + restore_clipboard, + ime_target, + ) + .await; #[cfg(not(target_os = "windows"))] let status = inner.inserter.insert(&polished, restore_clipboard); let inserted_chars = polished.chars().count() as u32; @@ -1151,6 +1162,7 @@ async fn insert_with_windows_ime_first( session_id: u64, polished: &str, restore_clipboard: bool, + ime_target: Option, ) -> InsertStatus { let prepared = { let mut slot = inner.prepared_windows_ime_session.lock(); @@ -1166,12 +1178,15 @@ async fn insert_with_windows_ime_first( session_id: Uuid::new_v4().to_string(), text: polished.to_string(), created_at: Utc::now().to_rfc3339(), + target: ime_target, }; let ime_status = match inner.windows_ime.submit_prepared(&prepared, request).await { Ok(status) => status, Err(error) => { - log::warn!("[windows-ime] TSF submit failed, falling back to clipboard: {error}"); + log::warn!( + "[windows-ime] TSF submit failed, falling back to non-TSF insertion: {error}" + ); InsertStatus::CopiedFallback } }; @@ -1179,6 +1194,9 @@ async fn insert_with_windows_ime_first( if ime_status == InsertStatus::Inserted { ime_status + } else if inner.inserter.insert_via_unicode_keystrokes(polished) == InsertStatus::Inserted { + log::info!("[windows-ime] TSF unavailable; inserted via Unicode SendInput"); + InsertStatus::Inserted } else { inner .inserter @@ -1293,7 +1311,9 @@ async fn polish_text( let config = OpenAICompatibleConfig::new("ark", "Doubao Ark", base_url, api_key, model); let provider = OpenAICompatibleLLMProvider::new(config); - Ok(provider.polish(raw, mode, hotwords, working_languages).await?) + Ok(provider + .polish(raw, mode, hotwords, working_languages) + .await?) } /// 翻译路径——和 polish 一样失败时返回原文 + 失败原因,避免"不丢字"约定被违反(CLAUDE.md)。 @@ -1684,6 +1704,48 @@ fn restore_focus_target_if_possible(target: Option) { #[cfg(not(target_os = "windows"))] fn restore_focus_target_if_possible(_target: Option) {} +#[cfg(target_os = "windows")] +fn capture_ime_submit_target() -> Option { + use windows::Win32::UI::WindowsAndMessaging::{ + GetForegroundWindow, GetGUIThreadInfo, GetWindowThreadProcessId, GUITHREADINFO, + }; + + let foreground = unsafe { GetForegroundWindow() }; + if foreground.0.is_null() { + return None; + } + + let mut foreground_process_id = 0; + let foreground_thread_id = + unsafe { GetWindowThreadProcessId(foreground, Some(&mut foreground_process_id)) }; + if foreground_thread_id == 0 { + return None; + } + + let mut gui_info = GUITHREADINFO { + cbSize: std::mem::size_of::() as u32, + ..Default::default() + }; + let target_window = if unsafe { GetGUIThreadInfo(foreground_thread_id, &mut gui_info).is_ok() } + && !gui_info.hwndFocus.0.is_null() + { + gui_info.hwndFocus + } else { + foreground + }; + + let mut process_id = 0; + let thread_id = unsafe { GetWindowThreadProcessId(target_window, Some(&mut process_id)) }; + if process_id == 0 || thread_id == 0 { + return None; + } + + Some(ImeSubmitTarget { + process_id, + thread_id, + }) +} + #[cfg(target_os = "windows")] fn show_capsule_window_no_activate() -> bool { use std::iter::once; diff --git a/openless-all/app/src-tauri/src/hotkey.rs b/openless-all/app/src-tauri/src/hotkey.rs index 8eb27b25..8cb87703 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -349,10 +349,14 @@ mod platform { let shift_active = (flags & FLAG_MASK_SHIFT) != 0; let shift_was_held = ctx.shared.translation_modifier_held.load(Ordering::SeqCst); if shift_active && !shift_was_held { - ctx.shared.translation_modifier_held.store(true, Ordering::SeqCst); + ctx.shared + .translation_modifier_held + .store(true, Ordering::SeqCst); send_or_log(&ctx.tx, HotkeyEvent::TranslationModifierPressed); } else if !shift_active && shift_was_held { - ctx.shared.translation_modifier_held.store(false, Ordering::SeqCst); + ctx.shared + .translation_modifier_held + .store(false, Ordering::SeqCst); } let keycode = unsafe { CGEventGetIntegerValueField(event, KEYBOARD_EVENT_KEYCODE) }; @@ -582,13 +586,18 @@ mod platform { if matches!(vk_code, VK_SHIFT | VK_LSHIFT | VK_RSHIFT) { match message { WM_KEYDOWN | WM_SYSKEYDOWN => { - let was_held = ctx.shared.translation_modifier_held.swap(true, Ordering::SeqCst); + let was_held = ctx + .shared + .translation_modifier_held + .swap(true, Ordering::SeqCst); if !was_held { send_or_log(&ctx.tx, HotkeyEvent::TranslationModifierPressed); } } WM_KEYUP | WM_SYSKEYUP => { - ctx.shared.translation_modifier_held.store(false, Ordering::SeqCst); + ctx.shared + .translation_modifier_held + .store(false, Ordering::SeqCst); } _ => {} } @@ -724,7 +733,9 @@ mod platform { } // Shift(任一侧)= 翻译模式修饰键。详见 issue #4。 if matches!(key, Key::ShiftLeft | Key::ShiftRight) { - let was_held = shared.translation_modifier_held.swap(true, Ordering::SeqCst); + let was_held = shared + .translation_modifier_held + .swap(true, Ordering::SeqCst); if !was_held { let _ = tx.send(HotkeyEvent::TranslationModifierPressed); } @@ -739,7 +750,9 @@ mod platform { } EventType::KeyRelease(key) => { if matches!(key, Key::ShiftLeft | Key::ShiftRight) { - shared.translation_modifier_held.store(false, Ordering::SeqCst); + shared + .translation_modifier_held + .store(false, Ordering::SeqCst); return; } if key == trigger_to_rdev_key(trigger) { diff --git a/openless-all/app/src-tauri/src/insertion.rs b/openless-all/app/src-tauri/src/insertion.rs index 014636af..63688e4c 100644 --- a/openless-all/app/src-tauri/src/insertion.rs +++ b/openless-all/app/src-tauri/src/insertion.rs @@ -44,6 +44,20 @@ impl TextInserter { self.insert(text, restore_clipboard_after_paste) } + #[cfg(target_os = "windows")] + pub fn insert_via_unicode_keystrokes(&self, text: &str) -> InsertStatus { + if text.is_empty() { + return InsertStatus::CopiedFallback; + } + match windows_unicode::send_text(text) { + Ok(()) => InsertStatus::Inserted, + Err(err) => { + log::warn!("[insertion] Unicode SendInput failed: {err}"); + InsertStatus::CopiedFallback + } + } + } + /// Insert `text` at the current cursor position. #[cfg(target_os = "macos")] pub fn insert(&self, text: &str, _restore_clipboard_after_paste: bool) -> InsertStatus { @@ -218,6 +232,48 @@ fn insertion_success_status() -> InsertStatus { InsertStatus::PasteSent } +#[cfg(target_os = "windows")] +mod windows_unicode { + use windows::Win32::UI::Input::KeyboardAndMouse::{ + SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYBD_EVENT_FLAGS, KEYEVENTF_KEYUP, + KEYEVENTF_UNICODE, VIRTUAL_KEY, + }; + + pub fn send_text(text: &str) -> Result<(), String> { + for unit in text.encode_utf16() { + send_utf16_unit(unit, false)?; + send_utf16_unit(unit, true)?; + } + Ok(()) + } + + fn send_utf16_unit(unit: u16, key_up: bool) -> Result<(), String> { + let flags = if key_up { + KEYEVENTF_UNICODE | KEYEVENTF_KEYUP + } else { + KEYEVENTF_UNICODE + }; + let input = INPUT { + r#type: INPUT_KEYBOARD, + Anonymous: INPUT_0 { + ki: KEYBDINPUT { + wVk: VIRTUAL_KEY(0), + wScan: unit, + dwFlags: KEYBD_EVENT_FLAGS(flags.0), + time: 0, + dwExtraInfo: 0, + }, + }, + }; + let sent = unsafe { SendInput(&[input], std::mem::size_of::() as i32) }; + if sent == 1 { + Ok(()) + } else { + Err(std::io::Error::last_os_error().to_string()) + } + } +} + // ─────────────────────────── macOS native CGEvent paste ─────────────────────────── #[cfg(target_os = "macos")] diff --git a/openless-all/app/src-tauri/src/windows_ime_ipc.rs b/openless-all/app/src-tauri/src/windows_ime_ipc.rs index ac7d4c06..1a206dad 100644 --- a/openless-all/app/src-tauri/src/windows_ime_ipc.rs +++ b/openless-all/app/src-tauri/src/windows_ime_ipc.rs @@ -96,6 +96,13 @@ pub struct ImeSubmitRequest { pub session_id: String, pub text: String, pub created_at: String, + pub target: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ImeSubmitTarget { + pub process_id: u32, + pub thread_id: u32, } #[derive(Clone)] @@ -171,7 +178,7 @@ mod windows_pipe { IME_CLIENT_WAIT_TIMEOUT, IME_PIPE_RETRY_INTERVAL, IME_SUBMIT_TIMEOUT, }; use crate::windows_ime_protocol::{ - decode_message, encode_message, ImePipeMessage, OPENLESS_IME_PIPE_NAME, + decode_message, encode_message, ime_pipe_name_for_target, ImePipeMessage, OPENLESS_IME_PROTOCOL_VERSION, }; @@ -182,8 +189,10 @@ mod windows_pipe { pub async fn submit_text_over_pipe( request: ImeSubmitRequest, ) -> WindowsImeIpcResult { + let target = request.target.ok_or(WindowsImeIpcError::NoReadyClient)?; let mut pending = PendingImeSubmit::new(request.session_id.clone()); - let pipe = open_pipe_with_retry().await?; + let pipe_name = ime_pipe_name_for_target(target.process_id, target.thread_id); + let pipe = open_pipe_with_retry(&pipe_name).await?; let (read_half, mut write_half) = tokio::io::split(pipe); let mut reader = BufReader::new(read_half); @@ -230,8 +239,13 @@ mod windows_pipe { protocol_version, session_id, status, - .. + error_code, } if protocol_version == OPENLESS_IME_PROTOCOL_VERSION => { + if status != crate::windows_ime_protocol::ImeSubmitStatus::Committed { + log::warn!( + "[windows-ime] submit result status={status:?} error_code={error_code:?}" + ); + } pending.accept_result(&session_id, status) } ImePipeMessage::SubmitResult { @@ -245,12 +259,12 @@ mod windows_pipe { } } - async fn open_pipe_with_retry() -> WindowsImeIpcResult { + async fn open_pipe_with_retry(pipe_name: &str) -> WindowsImeIpcResult { let deadline = Instant::now() + IME_CLIENT_WAIT_TIMEOUT; loop { - let retry_error = match wait_for_pipe_client() { - Ok(()) => match ClientOptions::new().open(OPENLESS_IME_PIPE_NAME) { + let retry_error = match wait_for_pipe_client(pipe_name) { + Ok(()) => match ClientOptions::new().open(pipe_name) { Ok(pipe) => return Ok(pipe), Err(error) => { let error_code = error.raw_os_error().map(|code| code as u32); @@ -275,8 +289,8 @@ mod windows_pipe { } } - fn wait_for_pipe_client() -> WindowsImeIpcResult<()> { - let pipe_name = OsStr::new(OPENLESS_IME_PIPE_NAME) + fn wait_for_pipe_client(pipe_name: &str) -> WindowsImeIpcResult<()> { + let pipe_name = OsStr::new(pipe_name) .encode_wide() .chain(std::iter::once(0)) .collect::>(); 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 ed1430f0..1d7eac82 100644 --- a/openless-all/app/src-tauri/src/windows_ime_profile.rs +++ b/openless-all/app/src-tauri/src/windows_ime_profile.rs @@ -191,9 +191,10 @@ mod windows_impl { }; use windows::Win32::UI::Input::KeyboardAndMouse::HKL; use windows::Win32::UI::TextServices::{ - CLSID_TF_InputProcessorProfiles, ITfInputProcessorProfileMgr, GUID_TFCAT_TIP_KEYBOARD, - TF_INPUTPROCESSORPROFILE, TF_IPPMF_DONTCARECURRENTINPUTLANGUAGE, TF_IPPMF_FORSESSION, - TF_PROFILETYPE_INPUTPROCESSOR, TF_PROFILETYPE_KEYBOARDLAYOUT, + CLSID_TF_InputProcessorProfiles, ITfInputProcessorProfileMgr, ITfInputProcessorProfiles, + GUID_TFCAT_TIP_KEYBOARD, TF_INPUTPROCESSORPROFILE, TF_IPPMF_DONTCARECURRENTINPUTLANGUAGE, + TF_IPPMF_ENABLEPROFILE, TF_IPPMF_FORSESSION, TF_PROFILETYPE_INPUTPROCESSOR, + TF_PROFILETYPE_KEYBOARDLAYOUT, }; use winreg::enums::{HKEY_LOCAL_MACHINE, KEY_READ, KEY_WOW64_64KEY}; use winreg::RegKey; @@ -201,8 +202,9 @@ mod windows_impl { const OPENLESS_COM_INPROC_KEY: &str = r"Software\Classes\CLSID\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\InprocServer32"; const OPENLESS_TSF_PROFILE_KEY: &str = r"Software\Microsoft\CTF\TIP\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\LanguageProfile\0x00000804\{9B5F5E04-23F6-47DA-9A26-D221F6C3F02E}"; - const PROFILE_ACTIVATION_FLAGS: u32 = - TF_IPPMF_FORSESSION | TF_IPPMF_DONTCARECURRENTINPUTLANGUAGE; + const OPENLESS_PROFILE_ACTIVATION_FLAGS: u32 = + TF_IPPMF_FORSESSION | TF_IPPMF_DONTCARECURRENTINPUTLANGUAGE | TF_IPPMF_ENABLEPROFILE; + const PROFILE_RESTORE_FLAGS: u32 = TF_IPPMF_FORSESSION | TF_IPPMF_DONTCARECURRENTINPUTLANGUAGE; struct ComApartment; @@ -250,6 +252,12 @@ mod windows_impl { let clsid = parse_guid(OPENLESS_TEXT_SERVICE_CLSID_BRACED)?; let profile_guid = parse_guid(OPENLESS_PROFILE_GUID_BRACED)?; + with_input_processor_profiles(|profiles| unsafe { + profiles.EnableLanguageProfile(&clsid, OPENLESS_TSF_LANG_ID, &profile_guid, true)?; + profiles.ChangeCurrentLanguage(OPENLESS_TSF_LANG_ID)?; + profiles.ActivateLanguageProfile(&clsid, OPENLESS_TSF_LANG_ID, &profile_guid) + })?; + with_profile_manager(|manager| unsafe { manager.ActivateProfile( TF_PROFILETYPE_INPUTPROCESSOR, @@ -257,7 +265,7 @@ mod windows_impl { &clsid, &profile_guid, null_hkl(), - PROFILE_ACTIVATION_FLAGS, + OPENLESS_PROFILE_ACTIVATION_FLAGS, ) }) } @@ -276,7 +284,7 @@ mod windows_impl { &clsid, &profile_guid, null_hkl(), - PROFILE_ACTIVATION_FLAGS, + PROFILE_RESTORE_FLAGS, ) }) } @@ -291,7 +299,7 @@ mod windows_impl { &zero_guid, &zero_guid, hkl, - PROFILE_ACTIVATION_FLAGS, + PROFILE_RESTORE_FLAGS, ) }) } @@ -409,6 +417,20 @@ mod windows_impl { operation(&manager).map_err(windows_api_error("ITfInputProcessorProfileMgr operation")) } + fn with_input_processor_profiles( + operation: impl FnOnce(&ITfInputProcessorProfiles) -> windows::core::Result, + ) -> WindowsImeProfileResult { + let _com = ComApartment::initialize()?; + let profiles: ITfInputProcessorProfiles = unsafe { + CoCreateInstance(&CLSID_TF_InputProcessorProfiles, None, CLSCTX_INPROC_SERVER) + } + .map_err(windows_api_error( + "CoCreateInstance ITfInputProcessorProfiles", + ))?; + + operation(&profiles).map_err(windows_api_error("ITfInputProcessorProfiles operation")) + } + fn parse_required_guid(label: &str, value: Option<&str>) -> WindowsImeProfileResult { parse_guid(value.ok_or_else(|| { WindowsImeProfileError::WindowsApi(format!("missing {label} in saved IME profile")) diff --git a/openless-all/app/src-tauri/src/windows_ime_protocol.rs b/openless-all/app/src-tauri/src/windows_ime_protocol.rs index adf6c93b..86f3f282 100644 --- a/openless-all/app/src-tauri/src/windows_ime_protocol.rs +++ b/openless-all/app/src-tauri/src/windows_ime_protocol.rs @@ -1,7 +1,11 @@ use serde::{Deserialize, Serialize}; pub const OPENLESS_IME_PROTOCOL_VERSION: u32 = 1; -pub const OPENLESS_IME_PIPE_NAME: &str = r"\\.\pipe\OpenLessImeSubmit"; +pub const OPENLESS_IME_PIPE_NAME_PREFIX: &str = r"\\.\pipe\OpenLessImeSubmit"; + +pub fn ime_pipe_name_for_target(process_id: u32, thread_id: u32) -> String { + format!("{OPENLESS_IME_PIPE_NAME_PREFIX}-{process_id}-{thread_id}") +} #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde( @@ -93,6 +97,14 @@ mod tests { assert_eq!(decoded, message); } + #[test] + fn ime_pipe_name_includes_target_process_and_thread() { + assert_eq!( + ime_pipe_name_for_target(1234, 5678), + r"\\.\pipe\OpenLessImeSubmit-1234-5678" + ); + } + #[test] fn stale_submit_result_is_rejected() { let result = ImePipeMessage::SubmitResult { diff --git a/openless-all/app/src-tauri/src/windows_ime_session.rs b/openless-all/app/src-tauri/src/windows_ime_session.rs index 544fcff8..e43dafb3 100644 --- a/openless-all/app/src-tauri/src/windows_ime_session.rs +++ b/openless-all/app/src-tauri/src/windows_ime_session.rs @@ -124,7 +124,9 @@ impl WindowsImeSessionController { .await .map_err(|error| WindowsImeSessionError::Ipc(error.to_string()))?; if should_fallback_after_ime_result(status) { - log::warn!("[windows-ime] TSF submit returned {status:?}; falling back to clipboard"); + log::warn!( + "[windows-ime] TSF submit returned {status:?}; falling back to non-TSF insertion" + ); } Ok(map_ime_status_to_insert_status(status)) } @@ -198,6 +200,7 @@ mod tests { session_id: "session-1".to_string(), text: "hello".to_string(), created_at: "2026-05-01T12:00:00Z".to_string(), + target: None, }, ) .await; diff --git a/openless-all/app/windows-ime/src/ipc_client.cpp b/openless-all/app/windows-ime/src/ipc_client.cpp index d34b528e..55c42d15 100644 --- a/openless-all/app/windows-ime/src/ipc_client.cpp +++ b/openless-all/app/windows-ime/src/ipc_client.cpp @@ -7,7 +7,7 @@ namespace { -constexpr wchar_t kPipeName[] = L"\\\\.\\pipe\\OpenLessImeSubmit"; +constexpr wchar_t kPipeNamePrefix[] = L"\\\\.\\pipe\\OpenLessImeSubmit"; constexpr DWORD kPipeBufferSize = 4096; constexpr size_t kMaxJsonLineBytes = 64 * 1024; @@ -22,6 +22,25 @@ struct SubmitMessage { bool has_protocol_version = false; }; +std::wstring HResultErrorCode(HRESULT hr) { + constexpr wchar_t kHexDigits[] = L"0123456789ABCDEF"; + auto value = static_cast(hr); + std::wstring code = L"hresult:0x"; + for (int shift = 28; shift >= 0; shift -= 4) { + code.push_back(kHexDigits[(value >> shift) & 0xF]); + } + return code; +} + +std::wstring PipeNameForCurrentThread() { + std::wstring name = kPipeNamePrefix; + name += L"-"; + name += std::to_wstring(GetCurrentProcessId()); + name += L"-"; + name += std::to_wstring(GetCurrentThreadId()); + return name; +} + bool AppendUtf8AsWide(const char* data, int length, std::wstring* output) { @@ -329,6 +348,7 @@ void OpenLessPipeServer::Start(OpenLessTextService* service) { } stop_requested_.store(false); + pipe_name_ = PipeNameForCurrentThread(); service_ = service; service_->AddRef(); thread_ = std::thread(&OpenLessPipeServer::Run, this); @@ -349,9 +369,10 @@ void OpenLessPipeServer::Stop() { } void OpenLessPipeServer::Run() { + const std::wstring pipe_name = pipe_name_; while (!stop_requested_.load()) { HANDLE pipe = CreateNamedPipeW( - kPipeName, PIPE_ACCESS_DUPLEX, + pipe_name.c_str(), PIPE_ACCESS_DUPLEX, PIPE_TYPE_MESSAGE | PIPE_READMODE_BYTE | PIPE_WAIT, 1, kPipeBufferSize, kPipeBufferSize, 0, nullptr); if (pipe == INVALID_HANDLE_VALUE) { @@ -441,7 +462,8 @@ void OpenLessPipeServer::HandleSubmitLine(HANDLE pipe, const std::string& line) if (SUCCEEDED(hr)) { WriteResult(pipe, message.session_id, L"committed", nullptr); } else { - WriteResult(pipe, message.session_id, L"rejected", L"commitRejected"); + const std::wstring error_code = HResultErrorCode(hr); + WriteResult(pipe, message.session_id, L"rejected", error_code.c_str()); } } @@ -477,8 +499,11 @@ bool OpenLessPipeServer::WriteResult(HANDLE pipe, } void OpenLessPipeServer::WakePipe() { + if (pipe_name_.empty()) { + return; + } HANDLE pipe = - CreateFileW(kPipeName, GENERIC_READ | GENERIC_WRITE, 0, nullptr, + CreateFileW(pipe_name_.c_str(), GENERIC_READ | GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); if (pipe != INVALID_HANDLE_VALUE) { CloseHandle(pipe); diff --git a/openless-all/app/windows-ime/src/ipc_client.h b/openless-all/app/windows-ime/src/ipc_client.h index e0c0e799..909be569 100644 --- a/openless-all/app/windows-ime/src/ipc_client.h +++ b/openless-all/app/windows-ime/src/ipc_client.h @@ -32,5 +32,6 @@ class OpenLessPipeServer { std::thread thread_; std::mutex pipe_mutex_; HANDLE pipe_handle_ = INVALID_HANDLE_VALUE; + std::wstring pipe_name_; OpenLessTextService* service_ = nullptr; }; diff --git a/openless-all/app/windows-ime/src/registry.cpp b/openless-all/app/windows-ime/src/registry.cpp index e403a0c9..ba250f89 100644 --- a/openless-all/app/windows-ime/src/registry.cpp +++ b/openless-all/app/windows-ime/src/registry.cpp @@ -103,6 +103,13 @@ HRESULT CreateProfiles(ITfInputProcessorProfiles** profiles) { reinterpret_cast(profiles)); } +HRESULT CreateProfileManager(ITfInputProcessorProfileMgr** manager) { + return CoCreateInstance(CLSID_TF_InputProcessorProfiles, nullptr, + CLSCTX_INPROC_SERVER, + IID_ITfInputProcessorProfileMgr, + reinterpret_cast(manager)); +} + HRESULT CreateCategoryManager(ITfCategoryMgr** category_mgr) { return CoCreateInstance(CLSID_TF_CategoryMgr, nullptr, CLSCTX_INPROC_SERVER, IID_ITfCategoryMgr, @@ -138,6 +145,24 @@ HRESULT RegisterLanguageProfile() { } profiles->Release(); + + if (FAILED(hr)) { + return hr; + } + + ITfInputProcessorProfileMgr* manager = nullptr; + hr = CreateProfileManager(&manager); + if (FAILED(hr)) { + return hr; + } + + manager->UnregisterProfile(CLSID_OpenLessTextService, kOpenLessLangId, + GUID_OpenLessProfile, 0); + hr = manager->RegisterProfile( + CLSID_OpenLessTextService, kOpenLessLangId, GUID_OpenLessProfile, + kOpenLessImeName, static_cast(ARRAYSIZE(kOpenLessImeName) - 1), + nullptr, 0, 0, nullptr, 0, TRUE, 0); + manager->Release(); return hr; } @@ -178,6 +203,14 @@ HRESULT UnregisterLanguageProfile() { return hr; } + ITfInputProcessorProfileMgr* manager = nullptr; + hr = CreateProfileManager(&manager); + if (SUCCEEDED(hr)) { + manager->UnregisterProfile(CLSID_OpenLessTextService, kOpenLessLangId, + GUID_OpenLessProfile, 0); + manager->Release(); + } + hr = profiles->Unregister(CLSID_OpenLessTextService); profiles->Release(); return hr; From a6e49b965dab2dc55d26b6b2899a6e6f6a6df3a8 Mon Sep 17 00:00:00 2001 From: millionart Date: Sun, 3 May 2026 16:30:20 +0800 Subject: [PATCH 30/43] feat: add Windows TSF-only insertion toggle --- openless-all/app/src-tauri/src/coordinator.rs | 86 ++++++++++++++++--- openless-all/app/src-tauri/src/types.rs | 24 ++++++ openless-all/app/src/i18n/en.ts | 2 + openless-all/app/src/i18n/zh-CN.ts | 2 + openless-all/app/src/lib/ipc.ts | 1 + openless-all/app/src/lib/types.ts | 2 + openless-all/app/src/pages/Settings.tsx | 13 +++ 7 files changed, 119 insertions(+), 11 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 5af7f550..e1becdae 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -994,7 +994,9 @@ async fn end_session(inner: &Arc) -> Result<(), String> { let focus_target = inner.state.lock().focus_target; restore_focus_target_if_possible(focus_target); - let restore_clipboard = inner.prefs.get().restore_clipboard_after_paste; + 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; #[cfg(target_os = "windows")] let ime_target = capture_ime_submit_target(); #[cfg(target_os = "windows")] @@ -1003,6 +1005,7 @@ async fn end_session(inner: &Arc) -> Result<(), String> { current_session_id, &polished, restore_clipboard, + allow_non_tsf_insertion_fallback, ime_target, ) .await; @@ -1029,7 +1032,14 @@ async fn end_session(inner: &Arc) -> Result<(), String> { // polish 失败时在 history 里标记 polishFailed,让用户能在历史详情看到为什么这次输出 // 不是预期的 mode 风格。即使失败也不丢词 — final_text 仍是原文(保留"用户的话不丢"语义)。 - let error_code = polish_error.as_ref().map(|_| "polishFailed".to_string()); + let tsf_required_insert_failed = cfg!(target_os = "windows") + && !allow_non_tsf_insertion_fallback + && status == InsertStatus::Failed; + let error_code = if tsf_required_insert_failed { + Some("windowsImeTsfRequired".to_string()) + } else { + polish_error.as_ref().map(|_| "polishFailed".to_string()) + }; let session = DictationSession { id: Uuid::new_v4().to_string(), @@ -1050,7 +1060,9 @@ async fn end_session(inner: &Arc) -> Result<(), String> { log::error!("[coord] history append failed: {e}"); } - let done_message = if polish_error.is_some() { + let done_message = if tsf_required_insert_failed { + Some("TSF 未上屏,已禁止非 TSF 兜底".to_string()) + } else if polish_error.is_some() { // polish 失败优先告知用户,即使 insert 成功也要让用户知道这版是原文 Some("润色失败,已插入原文".to_string()) } else { @@ -1162,6 +1174,7 @@ async fn insert_with_windows_ime_first( session_id: u64, polished: &str, restore_clipboard: bool, + allow_non_tsf_insertion_fallback: bool, ime_target: Option, ) -> InsertStatus { let prepared = { @@ -1169,9 +1182,15 @@ async fn insert_with_windows_ime_first( take_matching_prepared_windows_ime_session(&mut slot, session_id) }; let Some(prepared) = prepared else { - return inner - .inserter - .insert_via_clipboard_fallback(polished, restore_clipboard); + log::warn!("[windows-ime] no prepared TSF session for this dictation"); + if should_try_non_tsf_insertion_fallback( + allow_non_tsf_insertion_fallback, + InsertStatus::Failed, + ) { + return insert_via_non_tsf_fallback(inner, polished, restore_clipboard); + } + log::warn!("[windows-ime] non-TSF insertion fallback is disabled; failing insert"); + return InsertStatus::Failed; }; let request = crate::windows_ime_ipc::ImeSubmitRequest { @@ -1184,17 +1203,37 @@ async fn insert_with_windows_ime_first( let ime_status = match inner.windows_ime.submit_prepared(&prepared, request).await { Ok(status) => status, Err(error) => { - log::warn!( - "[windows-ime] TSF submit failed, falling back to non-TSF insertion: {error}" - ); - InsertStatus::CopiedFallback + log::warn!("[windows-ime] TSF submit failed: {error}"); + InsertStatus::Failed } }; inner.windows_ime.restore_session(prepared); if ime_status == InsertStatus::Inserted { ime_status - } else if inner.inserter.insert_via_unicode_keystrokes(polished) == InsertStatus::Inserted { + } else if should_try_non_tsf_insertion_fallback(allow_non_tsf_insertion_fallback, ime_status) { + insert_via_non_tsf_fallback(inner, polished, restore_clipboard) + } else { + log::warn!("[windows-ime] TSF did not insert; non-TSF insertion fallback is disabled"); + InsertStatus::Failed + } +} + +#[cfg(target_os = "windows")] +fn should_try_non_tsf_insertion_fallback( + allow_non_tsf_insertion_fallback: bool, + ime_status: InsertStatus, +) -> bool { + allow_non_tsf_insertion_fallback && ime_status != InsertStatus::Inserted +} + +#[cfg(target_os = "windows")] +fn insert_via_non_tsf_fallback( + inner: &Arc, + polished: &str, + restore_clipboard: bool, +) -> InsertStatus { + if inner.inserter.insert_via_unicode_keystrokes(polished) == InsertStatus::Inserted { log::info!("[windows-ime] TSF unavailable; inserted via Unicode SendInput"); InsertStatus::Inserted } else { @@ -1565,6 +1604,31 @@ mod tests { assert!(slot.is_none()); } + #[test] + #[cfg(target_os = "windows")] + fn non_tsf_insertion_fallback_gate_blocks_only_when_disabled() { + assert!(should_try_non_tsf_insertion_fallback( + true, + InsertStatus::CopiedFallback + )); + assert!(should_try_non_tsf_insertion_fallback( + true, + InsertStatus::Failed + )); + assert!(!should_try_non_tsf_insertion_fallback( + true, + InsertStatus::Inserted + )); + assert!(!should_try_non_tsf_insertion_fallback( + false, + InsertStatus::CopiedFallback + )); + assert!(!should_try_non_tsf_insertion_fallback( + false, + InsertStatus::Failed + )); + } + #[test] fn startup_race_check_treats_newer_session_as_stale() { let mut state = SessionState::default(); diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index da3d3955..db20c3e9 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -90,6 +90,10 @@ pub struct UserPreferences { /// 关掉就把听写文本留在剪贴板,让 simulate_paste 实际没生效时用户能 Ctrl+V 找回。 /// macOS 走 AX 直写,不受这个开关影响。详见 issue #111。 pub restore_clipboard_after_paste: bool, + /// Windows: 是否允许 TSF 失败后继续使用 SendInput / 粘贴类非 TSF 兜底。 + /// 默认开启以保持可用性;关闭后可验证文本是否真正由 TSF 上屏。 + #[serde(default = "default_true")] + pub allow_non_tsf_insertion_fallback: bool, /// 用户的工作语言(多选,原生名)。会作为前提注入 LLM polish/translate 的 system prompt 头部, /// 让模型知道该用户在哪些语言间工作。详见 issue #4。 #[serde(default = "default_working_languages")] @@ -120,6 +124,7 @@ impl Default for UserPreferences { active_asr_provider: "volcengine".into(), active_llm_provider: "ark".into(), restore_clipboard_after_paste: true, + allow_non_tsf_insertion_fallback: true, working_languages: default_working_languages(), translation_target_language: String::new(), } @@ -382,3 +387,22 @@ pub struct TodayMetrics { pub avg_latency_ms: u64, pub total_duration_ms: u64, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn non_tsf_insertion_fallback_defaults_to_enabled() { + let prefs = UserPreferences::default(); + + assert!(prefs.allow_non_tsf_insertion_fallback); + } + + #[test] + fn missing_non_tsf_insertion_fallback_pref_defaults_to_enabled() { + let prefs: UserPreferences = serde_json::from_str("{}").unwrap(); + + assert!(prefs.allow_non_tsf_insertion_fallback); + } +} diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index d411dc80..a182e65e 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -219,6 +219,8 @@ export const en: typeof zhCN = { capsuleDesc: 'Show a translucent capsule at the bottom of the screen while recording / transcribing.', restoreClipboardLabel: 'Restore clipboard after insert', restoreClipboardDesc: 'Windows / Linux only: restore your original clipboard after a successful paste (default on). Turn off to keep the dictation text in the clipboard so you can manually Ctrl+V if the simulated paste did not actually land. See issue #111.', + allowNonTsfFallbackLabel: 'Allow non-TSF fallback', + allowNonTsfFallbackDesc: 'Windows only: if direct TSF insertion fails, allow Unicode SendInput, shortcut paste, or WM_PASTE. Turn off to verify that insertion is really coming from TSF.', }, providers: { llmTitle: 'LLM (polishing)', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index b532d103..c9c46a63 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -217,6 +217,8 @@ export const zhCN = { capsuleDesc: '录音 / 转写时在屏幕底部显示半透明胶囊。', restoreClipboardLabel: '插入后恢复剪贴板', restoreClipboardDesc: '仅 Windows / Linux:粘贴成功后恢复你原来的剪贴板内容(默认开)。关掉就把听写文本留在剪贴板,模拟粘贴没真正落地时可以手动 Ctrl+V 找回。详见 issue #111。', + allowNonTsfFallbackLabel: '允许非 TSF 兜底', + allowNonTsfFallbackDesc: '仅 Windows:TSF 直接上屏失败后,允许改用 Unicode SendInput、快捷键粘贴或 WM_PASTE。关闭后可验证是否真实使用 TSF 输入。', }, providers: { llmTitle: 'LLM 模型(润色)', diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index 6efafd84..0e5d2a5d 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -45,6 +45,7 @@ const mockSettings: UserPreferences = { activeAsrProvider: 'volcengine', activeLlmProvider: 'ark', restoreClipboardAfterPaste: true, + allowNonTsfInsertionFallback: true, workingLanguages: ['简体中文'], translationTargetLanguage: '', }; diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index dfdb9e51..d7414084 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -94,6 +94,8 @@ export interface UserPreferences { activeLlmProvider: string; /** 仅 Windows/Linux:粘贴成功后是否恢复用户原剪贴板。默认 true。详见 issue #111。 */ restoreClipboardAfterPaste: boolean; + /** Windows:TSF 失败后是否允许 SendInput / 粘贴类非 TSF 兜底。关闭后可验证是否真实 TSF 上屏。 */ + allowNonTsfInsertionFallback: boolean; /** 用户的工作语言(多选,原生名);作为前提注入 LLM polish/translate prompt 头部。 */ workingLanguages: string[]; /** 翻译模式目标语言(单选,原生名);空串 = 不启用 Shift 翻译。详见 issue #4。 */ diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 9812b13c..b59e0c43 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -154,6 +154,8 @@ function RecordingSection() { savePrefs({ ...prefs, showCapsule }); const onRestoreClipboardChange = (restoreClipboardAfterPaste: boolean) => savePrefs({ ...prefs, restoreClipboardAfterPaste }); + const onAllowNonTsfFallbackChange = (allowNonTsfInsertionFallback: boolean) => + savePrefs({ ...prefs, allowNonTsfInsertionFallback }); const choices: Array<[HotkeyMode, string]> = [ ['toggle', t('settings.recording.modeToggle')], @@ -231,6 +233,17 @@ function RecordingSection() { > + {capability.adapter === 'windowsLowLevel' && ( + + + + )} {capability.statusHint && (
{capability.statusHint} From 865e432fb7a4fd5a9f8a96ae0030e9c06b9aa58e Mon Sep 17 00:00:00 2001 From: millionart Date: Sun, 3 May 2026 19:03:43 +0800 Subject: [PATCH 31/43] fix: package and register Windows TSF IME --- .../app/scripts/windows-ime-build.ps1 | 28 +- .../app/scripts/windows-package-msvc.cmd | 8 + .../app/scripts/windows-package-msvc.ps1 | 300 ++++++++++++++++++ .../app/scripts/windows-package-msvc.test.mjs | 67 ++++ .../app/src-tauri/src/windows_ime_ipc.rs | 73 +++-- .../app/src-tauri/src/windows_ime_profile.rs | 38 ++- .../app/src-tauri/src/windows_ime_protocol.rs | 49 +++ openless-all/app/src-tauri/tauri.conf.json | 10 + .../app/src-tauri/wix/openless-ime.wxs | 31 ++ openless-all/app/windows-ime/src/registry.cpp | 42 ++- 10 files changed, 619 insertions(+), 27 deletions(-) create mode 100644 openless-all/app/scripts/windows-package-msvc.cmd create mode 100644 openless-all/app/scripts/windows-package-msvc.ps1 create mode 100644 openless-all/app/scripts/windows-package-msvc.test.mjs create mode 100644 openless-all/app/src-tauri/wix/openless-ime.wxs diff --git a/openless-all/app/scripts/windows-ime-build.ps1 b/openless-all/app/scripts/windows-ime-build.ps1 index 33aa38a5..34932bd3 100644 --- a/openless-all/app/scripts/windows-ime-build.ps1 +++ b/openless-all/app/scripts/windows-ime-build.ps1 @@ -1,6 +1,8 @@ param( [ValidateSet("Debug", "Release")] - [string]$Configuration = "Release" + [string]$Configuration = "Release", + [string]$OutputDirectory = "", + [string]$IntermediateDirectory = "" ) $ErrorActionPreference = "Stop" @@ -37,13 +39,35 @@ $appRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path $solution = Join-Path $appRoot "windows-ime\OpenLessIme.sln" $dll = Join-Path $appRoot "windows-ime\x64\$Configuration\OpenLessIme.dll" $msbuild = Find-MSBuild +$msbuildArgs = @($solution, "/p:Configuration=$Configuration", "/p:Platform=x64") + +function Get-FullPathWithTrailingSlash($Path) { + $fullPath = [System.IO.Path]::GetFullPath($Path) + if (-not $fullPath.EndsWith("\")) { + return "$fullPath\" + } + return $fullPath +} if (-not (Test-Path $solution)) { throw "Solution not found: $solution" } Write-Host "[build] $Configuration x64" -& $msbuild $solution /p:Configuration=$Configuration /p:Platform=x64 +if (-not [string]::IsNullOrWhiteSpace($OutputDirectory)) { + $outputDirectoryPath = Get-FullPathWithTrailingSlash $OutputDirectory + New-Item -ItemType Directory -Force -Path $outputDirectoryPath | Out-Null + $msbuildArgs += "/p:OutDir=$outputDirectoryPath" + $dll = Join-Path $outputDirectoryPath "OpenLessIme.dll" +} + +if (-not [string]::IsNullOrWhiteSpace($IntermediateDirectory)) { + $intermediateDirectoryPath = Get-FullPathWithTrailingSlash $IntermediateDirectory + New-Item -ItemType Directory -Force -Path $intermediateDirectoryPath | Out-Null + $msbuildArgs += "/p:IntDir=$intermediateDirectoryPath" +} + +& $msbuild @msbuildArgs if ($LASTEXITCODE -ne 0) { throw "OpenLessIme build failed with exit code $LASTEXITCODE" } diff --git a/openless-all/app/scripts/windows-package-msvc.cmd b/openless-all/app/scripts/windows-package-msvc.cmd new file mode 100644 index 00000000..9ef31bf9 --- /dev/null +++ b/openless-all/app/scripts/windows-package-msvc.cmd @@ -0,0 +1,8 @@ +@echo off +setlocal + +set "SCRIPT_DIR=%~dp0" +set "SUPPLIED_ARGS=%*" + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%windows-package-msvc.ps1" %SUPPLIED_ARGS% +exit /b %ERRORLEVEL% diff --git a/openless-all/app/scripts/windows-package-msvc.ps1 b/openless-all/app/scripts/windows-package-msvc.ps1 new file mode 100644 index 00000000..bd096f41 --- /dev/null +++ b/openless-all/app/scripts/windows-package-msvc.ps1 @@ -0,0 +1,300 @@ +param( + [string]$ArtifactsRoot = "", + [switch]$SkipRustInstall, + [switch]$SkipNpmCi, + [switch]$CleanArtifacts +) + +$ErrorActionPreference = "Stop" + +$appRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$releaseRoot = Join-Path $appRoot "src-tauri\target\x86_64-pc-windows-msvc\release" +$imeBuildRoot = Join-Path $appRoot "src-tauri\target\windows-ime-msvc" +if ([string]::IsNullOrWhiteSpace($ArtifactsRoot)) { + $ArtifactsRoot = Join-Path $appRoot ".artifacts\windows-msvc" +} + +function Add-PathEntry($PathEntry) { + if ([string]::IsNullOrWhiteSpace($PathEntry) -or -not (Test-Path $PathEntry)) { + return + } + $entries = $env:PATH -split ";" + if ($entries -notcontains $PathEntry) { + $env:PATH = "$PathEntry;$env:PATH" + } +} + +function Test-Command($Name) { + return $null -ne (Get-Command $Name -ErrorAction SilentlyContinue) +} + +function Install-RustMsvcToolchain { + $cargoBin = Join-Path $env:USERPROFILE ".cargo\bin" + Add-PathEntry $cargoBin + + $hasRustup = Test-Command "rustup" + $hasCargo = Test-Command "cargo" + $hasRustc = Test-Command "rustc" + $hasToolchain = $false + if ($hasRustup) { + $toolchains = & cmd.exe /d /c "rustup toolchain list 2>nul" + $hasToolchain = $LASTEXITCODE -eq 0 -and $toolchains -match "stable-x86_64-pc-windows-msvc" + } + + if ($hasRustup -and $hasCargo -and $hasRustc -and $hasToolchain) { + Write-Host "[ok] Rust MSVC toolchain already installed" + return + } + + if ($SkipRustInstall) { + throw "Rust MSVC toolchain is missing. Re-run without -SkipRustInstall to install it automatically." + } + + Write-Host "[info] Installing Rust stable-x86_64-pc-windows-msvc" + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + $rustupInit = Join-Path $env:TEMP "rustup-init-x86_64-pc-windows-msvc.exe" + Invoke-WebRequest -Uri "https://win.rustup.rs/x86_64" -OutFile $rustupInit + & $rustupInit -y --default-toolchain stable-x86_64-pc-windows-msvc + + Add-PathEntry $cargoBin + & rustup toolchain install stable-x86_64-pc-windows-msvc + & rustup default stable-x86_64-pc-windows-msvc + + if (-not (Test-Command "cargo") -or -not (Test-Command "rustc")) { + throw "Rust installation finished, but cargo/rustc is still not available in PATH." + } +} + +function Find-VsDevCmd { + $candidates = @() + + $vswhere = Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio\Installer\vswhere.exe" + if (Test-Path $vswhere) { + $installPath = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath 2>$null + if (-not [string]::IsNullOrWhiteSpace($installPath)) { + $candidates += (Join-Path $installPath "Common7\Tools\VsDevCmd.bat") + } + } + + $candidates += @( + (Join-Path $env:ProgramFiles "Microsoft Visual Studio\2022\Community\Common7\Tools\VsDevCmd.bat"), + (Join-Path $env:ProgramFiles "Microsoft Visual Studio\2022\Professional\Common7\Tools\VsDevCmd.bat"), + (Join-Path $env:ProgramFiles "Microsoft Visual Studio\2022\Enterprise\Common7\Tools\VsDevCmd.bat"), + (Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio\2022\BuildTools\Common7\Tools\VsDevCmd.bat") + ) + + foreach ($candidate in $candidates) { + if (Test-Path $candidate) { + return (Resolve-Path $candidate).Path + } + } + + throw "VsDevCmd.bat not found. Install Visual Studio 2022 Build Tools with the Desktop development with C++ workload." +} + +function Find-WixTool($Name) { + $tauriWixTool = Join-Path $env:LOCALAPPDATA "tauri\WixTools314\$Name" + if (Test-Path $tauriWixTool) { + return (Resolve-Path $tauriWixTool).Path + } + + $cmd = Get-Command $Name -ErrorAction SilentlyContinue + if ($cmd) { + return $cmd.Source + } + + throw "$Name not found. Run the Tauri MSI build once so the WiX tools are installed." +} + +function Get-PackageVersion { + $packageJson = Get-Content -LiteralPath (Join-Path $appRoot "package.json") -Raw | ConvertFrom-Json + return $packageJson.version +} + +function Get-MsiName { + return "OpenLess_$(Get-PackageVersion)_x64_en-US.msi" +} + +function Get-MsiPath { + return Join-Path $releaseRoot "bundle\msi\$(Get-MsiName)" +} + +function Test-WebView2Runtime { + $paths = @( + "HKLM:\SOFTWARE\Microsoft\EdgeUpdate\Clients\{F1E7FBD4-9C4C-41A4-AB01-7C0F7A947F1A}", + "HKLM:\SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F1E7FBD4-9C4C-41A4-AB01-7C0F7A947F1A}" + ) + foreach ($path in $paths) { + if (Test-Path $path) { + Write-Host "[ok] WebView2 Runtime registry key found" + return + } + } + Write-Warning "WebView2 Runtime registry key not found. Install Evergreen runtime if the app window is blank." +} + +function Invoke-MsvcBuild { + param( + [string]$VsDevCmd, + [string]$CargoBin + ) + + if ([string]::IsNullOrWhiteSpace($env:OPENLESS_IME_DLL) -or -not (Test-Path $env:OPENLESS_IME_DLL)) { + throw "OPENLESS_IME_DLL must point to the built OpenLessIme.dll before the MSI build." + } + + $msiPath = Get-MsiPath + Remove-Item -LiteralPath $msiPath -Force -ErrorAction SilentlyContinue + + $buildCommand = "call `"$VsDevCmd`" -arch=x64 -host_arch=x64 && set `"PATH=$CargoBin;%PATH%`" && set `"OPENLESS_IME_DLL=$env:OPENLESS_IME_DLL`" && npm.cmd run tauri build -- --target x86_64-pc-windows-msvc --bundles msi" + & cmd.exe /d /c $buildCommand + if ($LASTEXITCODE -ne 0) { + Write-Warning "Tauri Windows MSI build returned exit code $LASTEXITCODE. Trying to finish MSI linking from generated WiX objects." + Repair-TauriMsiBundle + } +} + +function Repair-TauriMsiBundle { + $wixRoot = Join-Path $releaseRoot "wix\x64" + $mainObject = Join-Path $wixRoot "main.wixobj" + $imeObject = Join-Path $wixRoot "openless-ime.wixobj" + $locale = Join-Path $wixRoot "locale.wxl" + $msiPath = Get-MsiPath + + foreach ($requiredPath in @($mainObject, $imeObject, $locale, $env:OPENLESS_IME_DLL)) { + if ([string]::IsNullOrWhiteSpace($requiredPath) -or -not (Test-Path $requiredPath)) { + throw "Cannot repair Tauri MSI bundle because a required file is missing: $requiredPath" + } + } + + $bundleDir = Split-Path -Parent $msiPath + New-Item -ItemType Directory -Force -Path $bundleDir | Out-Null + Remove-Item -LiteralPath $msiPath -Force -ErrorAction SilentlyContinue + + $light = Find-WixTool "light.exe" + & $light -nologo -ext WixUIExtension -ext WixUtilExtension -loc $locale -out $msiPath $mainObject $imeObject + if ($LASTEXITCODE -ne 0) { + throw "WiX light.exe failed with exit code $LASTEXITCODE." + } + if (-not (Test-Path $msiPath)) { + throw "WiX light.exe finished but MSI was not produced: $msiPath" + } + + Write-Host "[ok] MSI linked from generated WiX objects -> $msiPath" +} + +function Invoke-OpenLessImeBuild { + $buildScript = Join-Path $PSScriptRoot "windows-ime-build.ps1" + $imeOutDir = Join-Path $imeBuildRoot "x64\Release" + $imeIntDir = Join-Path $imeBuildRoot "obj\x64\Release" + + if (-not (Test-Path $buildScript)) { + throw "OpenLess IME build script not found: $buildScript" + } + + & $buildScript -Configuration Release -OutputDirectory $imeOutDir -IntermediateDirectory $imeIntDir + if ($LASTEXITCODE -ne 0) { + throw "OpenLessIme build failed with exit code $LASTEXITCODE." + } + + $imeDll = Join-Path $imeOutDir "OpenLessIme.dll" + if (-not (Test-Path $imeDll)) { + throw "OpenLessIme.dll was not produced: $imeDll" + } + + $env:OPENLESS_IME_DLL = (Resolve-Path $imeDll).Path + Write-Host "[ok] OPENLESS_IME_DLL -> $env:OPENLESS_IME_DLL" +} + +function Reset-ArtifactsRoot { + if (-not $CleanArtifacts) { + New-Item -ItemType Directory -Force -Path $ArtifactsRoot | Out-Null + return + } + + $resolvedAppRoot = (Resolve-Path $appRoot).Path + if (Test-Path $ArtifactsRoot) { + $resolvedArtifactsRoot = (Resolve-Path $ArtifactsRoot).Path + if (-not $resolvedArtifactsRoot.StartsWith($resolvedAppRoot, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "-CleanArtifacts refuses to delete output outside the app root: $resolvedArtifactsRoot" + } + Remove-Item -LiteralPath $resolvedArtifactsRoot -Recurse -Force + } + New-Item -ItemType Directory -Force -Path $ArtifactsRoot | Out-Null +} + +function Copy-WindowsArtifacts { + $version = Get-PackageVersion + $msiName = Get-MsiName + $msiPath = Get-MsiPath + $exePath = Join-Path $releaseRoot "openless.exe" + $webView2Loader = Get-ChildItem -Path (Join-Path $releaseRoot "build") -Recurse -Filter "WebView2Loader.dll" -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match "\\out\\x64\\WebView2Loader\.dll$" } | + Select-Object -First 1 + + if (-not (Test-Path $msiPath)) { + throw "MSI not found: $msiPath" + } + if (-not (Test-Path $exePath)) { + throw "Release exe not found: $exePath" + } + if ($null -eq $webView2Loader) { + throw "WebView2Loader.dll x64 not found under $releaseRoot\build" + } + if ([string]::IsNullOrWhiteSpace($env:OPENLESS_IME_DLL) -or -not (Test-Path $env:OPENLESS_IME_DLL)) { + throw "OpenLessIme.dll not found for portable package: $env:OPENLESS_IME_DLL" + } + + Reset-ArtifactsRoot + Copy-Item -LiteralPath $msiPath -Destination (Join-Path $ArtifactsRoot $msiName) -Force + + $portableName = "OpenLess_${version}_x64_portable" + $portableRoot = Join-Path $ArtifactsRoot $portableName + $portableImeRoot = Join-Path $portableRoot "windows-ime" + New-Item -ItemType Directory -Force -Path $portableRoot | Out-Null + New-Item -ItemType Directory -Force -Path $portableImeRoot | Out-Null + Copy-Item -LiteralPath $exePath -Destination (Join-Path $portableRoot "openless.exe") -Force + Copy-Item -LiteralPath $webView2Loader.FullName -Destination (Join-Path $portableRoot "WebView2Loader.dll") -Force + Copy-Item -LiteralPath $env:OPENLESS_IME_DLL -Destination (Join-Path $portableImeRoot "OpenLessIme.dll") -Force + + $zipPath = Join-Path $ArtifactsRoot "$portableName.zip" + Remove-Item -LiteralPath $zipPath -Force -ErrorAction SilentlyContinue + Compress-Archive -LiteralPath $portableRoot -DestinationPath $zipPath -CompressionLevel Optimal + + Write-Host "" + Write-Host "Windows artifacts:" + Get-ChildItem -File -LiteralPath $ArtifactsRoot | Select-Object Name,Length,LastWriteTime | Format-Table -AutoSize + + Write-Host "SHA256:" + Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $ArtifactsRoot $msiName), $zipPath | Select-Object Path,Hash | Format-List +} + +Push-Location $appRoot +try { + Write-Host "[info] App root: $appRoot" + Install-RustMsvcToolchain + Test-WebView2Runtime + + $vsDevCmd = Find-VsDevCmd + Write-Host "[ok] VsDevCmd.bat -> $vsDevCmd" + + if (-not (Test-Command "node") -or -not (Test-Command "npm.cmd")) { + throw "Node.js/npm.cmd not found. Install Node.js before packaging." + } + + if ($SkipNpmCi) { + if (-not (Test-Path (Join-Path $appRoot "node_modules"))) { + throw "-SkipNpmCi was set, but node_modules does not exist." + } + Write-Host "[info] Skipping npm.cmd ci" + } else { + npm.cmd ci + } + + $cargoBin = Join-Path $env:USERPROFILE ".cargo\bin" + Invoke-OpenLessImeBuild + Invoke-MsvcBuild -VsDevCmd $vsDevCmd -CargoBin $cargoBin + Copy-WindowsArtifacts +} finally { + Pop-Location +} diff --git a/openless-all/app/scripts/windows-package-msvc.test.mjs b/openless-all/app/scripts/windows-package-msvc.test.mjs new file mode 100644 index 00000000..f6b6dc49 --- /dev/null +++ b/openless-all/app/scripts/windows-package-msvc.test.mjs @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptsDir = dirname(fileURLToPath(import.meta.url)); +const appRoot = join(scriptsDir, ".."); +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 tauriConfigPath = join(appRoot, "src-tauri", "tauri.conf.json"); +const wixFragmentPath = join(appRoot, "src-tauri", "wix", "openless-ime.wxs"); + +const script = readFileSync(scriptPath, "utf8"); +const launcher = readFileSync(launcherPath, "utf8"); +const imeBuild = readFileSync(imeBuildPath, "utf8"); +const tauriConfig = JSON.parse(readFileSync(tauriConfigPath, "utf8")); +const wixFragment = readFileSync(wixFragmentPath, "utf8"); + +const requiredFragments = [ + "Install-RustMsvcToolchain", + "https://win.rustup.rs/x86_64", + "stable-x86_64-pc-windows-msvc", + "Find-VsDevCmd", + "VsDevCmd.bat", + "npm.cmd ci", + "windows-ime-build.ps1", + "OPENLESS_IME_DLL", + "OpenLessIme.dll", + "tauri build -- --target x86_64-pc-windows-msvc --bundles msi", + "Repair-TauriMsiBundle", + "light.exe", + "main.wixobj", + "openless-ime.wixobj", + "locale.wxl", + "WebView2Loader.dll", + "Compress-Archive", + "Get-FileHash -Algorithm SHA256", +]; + +for (const fragment of requiredFragments) { + assert.match(script, new RegExp(fragment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), `missing ${fragment}`); +} + +assert.match(script, /\[switch\]\$SkipRustInstall/, "script should support opting out of Rust installation"); +assert.match(script, /\[switch\]\$SkipNpmCi/, "script should support reusing existing node_modules"); +assert.match(script, /\[switch\]\$CleanArtifacts/, "script should support cleaning the output directory"); + +assert.match(imeBuild, /\[string\]\$OutputDirectory/, "IME build should support a package-specific output directory"); +assert.match(imeBuild, /\[string\]\$IntermediateDirectory/, "IME build should support a package-specific intermediate directory"); +assert.match(imeBuild, /\/p:OutDir=/, "IME build should pass OutDir to MSBuild"); +assert.match(imeBuild, /\/p:IntDir=/, "IME build should pass IntDir to MSBuild"); + +assert.deepEqual(tauriConfig.bundle.windows.wix.fragmentPaths, ["wix/openless-ime.wxs"]); +assert.deepEqual(tauriConfig.bundle.windows.wix.componentRefs, ["OpenLessImeDllComponent"]); + +assert.match(wixFragment, /DirectoryRef Id="INSTALLDIR"/, "WiX fragment should install into the app directory"); +assert.match(wixFragment, /Component Id="OpenLessImeDllComponent"/, "WiX fragment should define the TSF DLL component"); +assert.match(wixFragment, /Source="src-tauri\\target\\windows-ime-msvc\\x64\\Release\\OpenLessIme\.dll"/, "WiX fragment should consume the package-built IME DLL"); +assert.match(wixFragment, /regsvr32\.exe/, "MSI should register and unregister the TSF DLL"); +assert.match(wixFragment, /RegisterOpenLessIme/, "MSI should register OpenLess IME during install"); +assert.match(wixFragment, /UnregisterOpenLessIme/, "MSI should unregister OpenLess IME during uninstall"); + +assert.match(launcher, /powershell\.exe/, "launcher should call powershell.exe"); +assert.match(launcher, /-ExecutionPolicy Bypass/, "launcher should bypass execution policy for this process"); +assert.match(launcher, /windows-package-msvc\.ps1/, "launcher should invoke the packaging script"); +assert.match(launcher, /%SUPPLIED_ARGS%/, "launcher should forward user arguments"); diff --git a/openless-all/app/src-tauri/src/windows_ime_ipc.rs b/openless-all/app/src-tauri/src/windows_ime_ipc.rs index 1a206dad..27944a59 100644 --- a/openless-all/app/src-tauri/src/windows_ime_ipc.rs +++ b/openless-all/app/src-tauri/src/windows_ime_ipc.rs @@ -178,8 +178,8 @@ mod windows_pipe { IME_CLIENT_WAIT_TIMEOUT, IME_PIPE_RETRY_INTERVAL, IME_SUBMIT_TIMEOUT, }; use crate::windows_ime_protocol::{ - decode_message, encode_message, ime_pipe_name_for_target, ImePipeMessage, - OPENLESS_IME_PROTOCOL_VERSION, + decode_message, encode_message, ime_pipe_candidate_names_for_target, + ime_pipe_name_for_target, ImePipeMessage, OPENLESS_IME_PROTOCOL_VERSION, }; extern "system" { @@ -191,8 +191,7 @@ mod windows_pipe { ) -> WindowsImeIpcResult { let target = request.target.ok_or(WindowsImeIpcError::NoReadyClient)?; let mut pending = PendingImeSubmit::new(request.session_id.clone()); - let pipe_name = ime_pipe_name_for_target(target.process_id, target.thread_id); - let pipe = open_pipe_with_retry(&pipe_name).await?; + let (pipe_name, pipe) = open_pipe_with_retry(target).await?; let (read_half, mut write_half) = tokio::io::split(pipe); let mut reader = BufReader::new(read_half); @@ -206,6 +205,7 @@ mod windows_pipe { .map_err(|error| WindowsImeIpcError::Protocol(error.to_string()))?; let response = tokio::time::timeout(IME_SUBMIT_TIMEOUT, async { + log::debug!("[windows-ime] submitting text over pipe {pipe_name}"); write_half .write_all(line.as_bytes()) .await @@ -259,28 +259,42 @@ mod windows_pipe { } } - async fn open_pipe_with_retry(pipe_name: &str) -> WindowsImeIpcResult { + async fn open_pipe_with_retry( + target: super::ImeSubmitTarget, + ) -> WindowsImeIpcResult<(String, NamedPipeClient)> { let deadline = Instant::now() + IME_CLIENT_WAIT_TIMEOUT; + let exact_pipe_name = ime_pipe_name_for_target(target.process_id, target.thread_id); loop { - let retry_error = match wait_for_pipe_client(pipe_name) { - Ok(()) => match ClientOptions::new().open(pipe_name) { - Ok(pipe) => return Ok(pipe), + let mut retry_error = WindowsImeIpcError::NoReadyClient; + + for pipe_name in pipe_names_for_target(target) { + retry_error = match wait_for_pipe_client(&pipe_name) { + Ok(()) => match ClientOptions::new().open(&pipe_name) { + Ok(pipe) => { + if pipe_name != exact_pipe_name { + log::info!( + "[windows-ime] exact target pipe {exact_pipe_name} was not ready; using same-process pipe {pipe_name}" + ); + } + return Ok((pipe_name, pipe)); + } + Err(error) => { + let error_code = error.raw_os_error().map(|code| code as u32); + if !super::is_retryable_pipe_error(error_code) { + return Err(WindowsImeIpcError::Io(error.to_string())); + } + super::map_wait_named_pipe_error(error_code) + } + }, Err(error) => { - let error_code = error.raw_os_error().map(|code| code as u32); - if !super::is_retryable_pipe_error(error_code) { - return Err(WindowsImeIpcError::Io(error.to_string())); + if !is_retryable_wait_error(&error) { + return Err(error); } - super::map_wait_named_pipe_error(error_code) - } - }, - Err(error) => { - if !is_retryable_wait_error(&error) { - return Err(error); + error } - error - } - }; + }; + } if Instant::now() >= deadline { return Err(retry_error); @@ -289,6 +303,25 @@ mod windows_pipe { } } + fn pipe_names_for_target(target: super::ImeSubmitTarget) -> Vec { + ime_pipe_candidate_names_for_target( + target.process_id, + target.thread_id, + available_pipe_names(), + ) + } + + fn available_pipe_names() -> Vec { + let Ok(entries) = std::fs::read_dir(r"\\.\pipe\") else { + return Vec::new(); + }; + + entries + .filter_map(Result::ok) + .map(|entry| format!(r"\\.\pipe\{}", entry.file_name().to_string_lossy())) + .collect() + } + fn wait_for_pipe_client(pipe_name: &str) -> WindowsImeIpcResult<()> { let pipe_name = OsStr::new(pipe_name) .encode_wide() 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 1d7eac82..0dc2356f 100644 --- a/openless-all/app/src-tauri/src/windows_ime_profile.rs +++ b/openless-all/app/src-tauri/src/windows_ime_profile.rs @@ -202,6 +202,9 @@ mod windows_impl { const OPENLESS_COM_INPROC_KEY: &str = r"Software\Classes\CLSID\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\InprocServer32"; const OPENLESS_TSF_PROFILE_KEY: &str = r"Software\Microsoft\CTF\TIP\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\LanguageProfile\0x00000804\{9B5F5E04-23F6-47DA-9A26-D221F6C3F02E}"; + const OPENLESS_TSF_KEYBOARD_CATEGORY_KEY: &str = r"Software\Microsoft\CTF\TIP\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\Category\Category\{34745C63-B2F0-4784-8B67-5E12C8701A31}\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}"; + const OPENLESS_TSF_IMMERSIVE_CATEGORY_KEY: &str = r"Software\Microsoft\CTF\TIP\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\Category\Category\{13A016DF-560B-46CD-947A-4C3AF1E0E35D}\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}"; + const OPENLESS_TSF_SYSTRAY_CATEGORY_KEY: &str = r"Software\Microsoft\CTF\TIP\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\Category\Category\{25504FB4-7BAB-4BC1-9C69-CF81890F0EF5}\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}"; const OPENLESS_PROFILE_ACTIVATION_FLAGS: u32 = TF_IPPMF_FORSESSION | TF_IPPMF_DONTCARECURRENTINPUTLANGUAGE | TF_IPPMF_ENABLEPROFILE; const PROFILE_RESTORE_FLAGS: u32 = TF_IPPMF_FORSESSION | TF_IPPMF_DONTCARECURRENTINPUTLANGUAGE; @@ -361,8 +364,26 @@ mod windows_impl { let tip_key_exists = hklm .open_subkey_with_flags(OPENLESS_TSF_PROFILE_KEY, KEY_READ | KEY_WOW64_64KEY) .is_ok(); + let keyboard_category_exists = hklm + .open_subkey_with_flags( + OPENLESS_TSF_KEYBOARD_CATEGORY_KEY, + KEY_READ | KEY_WOW64_64KEY, + ) + .is_ok(); + let immersive_category_exists = hklm + .open_subkey_with_flags( + OPENLESS_TSF_IMMERSIVE_CATEGORY_KEY, + KEY_READ | KEY_WOW64_64KEY, + ) + .is_ok(); + let systray_category_exists = hklm + .open_subkey_with_flags( + OPENLESS_TSF_SYSTRAY_CATEGORY_KEY, + KEY_READ | KEY_WOW64_64KEY, + ) + .is_ok(); - if com_key.is_err() && !tip_key_exists { + if com_key.is_err() && !tip_key_exists && !keyboard_category_exists { return RegistrationInspection::NotInstalled; } @@ -400,6 +421,21 @@ mod windows_impl { }; } + if !keyboard_category_exists { + return RegistrationInspection::Broken { + dll_path: Some(dll_path), + reason: "OpenLess TSF keyboard category registration is missing".to_string(), + }; + } + + if !immersive_category_exists || !systray_category_exists { + return RegistrationInspection::Broken { + dll_path: Some(dll_path), + reason: "OpenLess TSF immersive support registration is missing; reinstall the IME" + .to_string(), + }; + } + RegistrationInspection::Installed { dll_path } } diff --git a/openless-all/app/src-tauri/src/windows_ime_protocol.rs b/openless-all/app/src-tauri/src/windows_ime_protocol.rs index 86f3f282..be9a8786 100644 --- a/openless-all/app/src-tauri/src/windows_ime_protocol.rs +++ b/openless-all/app/src-tauri/src/windows_ime_protocol.rs @@ -7,6 +7,36 @@ pub fn ime_pipe_name_for_target(process_id: u32, thread_id: u32) -> String { format!("{OPENLESS_IME_PIPE_NAME_PREFIX}-{process_id}-{thread_id}") } +pub fn ime_pipe_candidate_names_for_target( + process_id: u32, + thread_id: u32, + available_pipe_names: I, +) -> Vec +where + I: IntoIterator, +{ + let exact_pipe_name = ime_pipe_name_for_target(process_id, thread_id); + let process_pipe_prefix = format!("{OPENLESS_IME_PIPE_NAME_PREFIX}-{process_id}-"); + let mut candidates = vec![exact_pipe_name.clone()]; + let mut same_process_pipe_names = available_pipe_names + .into_iter() + .filter(|pipe_name| pipe_name != &exact_pipe_name) + .filter(|pipe_name| { + pipe_name + .strip_prefix(&process_pipe_prefix) + .is_some_and(|thread_suffix| { + !thread_suffix.is_empty() + && thread_suffix.bytes().all(|byte| byte.is_ascii_digit()) + }) + }) + .collect::>(); + + same_process_pipe_names.sort(); + same_process_pipe_names.dedup(); + candidates.extend(same_process_pipe_names); + candidates +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde( tag = "type", @@ -105,6 +135,25 @@ mod tests { ); } + #[test] + fn ime_pipe_candidates_include_same_process_clients_after_exact_target() { + let available = vec![ + r"\\.\pipe\OtherPipe".to_string(), + r"\\.\pipe\OpenLessImeSubmit-4321-1111".to_string(), + r"\\.\pipe\OpenLessImeSubmit-1234-9999".to_string(), + r"\\.\pipe\OpenLessImeSubmit-1234-5678".to_string(), + r"\\.\pipe\OpenLessImeSubmit-1234-bad".to_string(), + ]; + + assert_eq!( + ime_pipe_candidate_names_for_target(1234, 5678, available), + vec![ + r"\\.\pipe\OpenLessImeSubmit-1234-5678".to_string(), + r"\\.\pipe\OpenLessImeSubmit-1234-9999".to_string(), + ] + ); + } + #[test] fn stale_submit_result_is_rejected() { let result = ImePipeMessage::SubmitResult { diff --git a/openless-all/app/src-tauri/tauri.conf.json b/openless-all/app/src-tauri/tauri.conf.json index 2dc524fb..ed21a6e5 100644 --- a/openless-all/app/src-tauri/tauri.conf.json +++ b/openless-all/app/src-tauri/tauri.conf.json @@ -65,6 +65,16 @@ "minimumSystemVersion": "12.0", "infoPlist": "Info.plist", "entitlements": "Entitlements.plist" + }, + "windows": { + "wix": { + "fragmentPaths": [ + "wix/openless-ime.wxs" + ], + "componentRefs": [ + "OpenLessImeDllComponent" + ] + } } }, "plugins": { diff --git a/openless-all/app/src-tauri/wix/openless-ime.wxs b/openless-all/app/src-tauri/wix/openless-ime.wxs new file mode 100644 index 00000000..47464946 --- /dev/null +++ b/openless-all/app/src-tauri/wix/openless-ime.wxs @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + diff --git a/openless-all/app/windows-ime/src/registry.cpp b/openless-all/app/windows-ime/src/registry.cpp index ba250f89..ff7eb79b 100644 --- a/openless-all/app/windows-ime/src/registry.cpp +++ b/openless-all/app/windows-ime/src/registry.cpp @@ -36,6 +36,8 @@ HRESULT RegisterComServer(HINSTANCE module) { return HRESULT_FROM_WIN32(ERROR_INSUFFICIENT_BUFFER); } + RegDeleteTreeW(HKEY_CURRENT_USER, kClsidKey); + HKEY clsid_key = nullptr; LSTATUS status = RegCreateKeyExW(HKEY_LOCAL_MACHINE, kClsidKey, 0, nullptr, @@ -70,11 +72,16 @@ HRESULT RegisterComServer(HINSTANCE module) { } HRESULT DeleteComServerRegistration() { - const LSTATUS status = RegDeleteTreeW(HKEY_LOCAL_MACHINE, kClsidKey); - if (status == ERROR_FILE_NOT_FOUND) { + const LSTATUS user_status = RegDeleteTreeW(HKEY_CURRENT_USER, kClsidKey); + if (user_status != ERROR_SUCCESS && user_status != ERROR_FILE_NOT_FOUND) { + return HResultFromWin32Error(user_status); + } + + const LSTATUS machine_status = RegDeleteTreeW(HKEY_LOCAL_MACHINE, kClsidKey); + if (machine_status == ERROR_FILE_NOT_FOUND) { return S_OK; } - return HResultFromWin32Error(status); + return HResultFromWin32Error(machine_status); } class ScopedComInit { @@ -161,7 +168,8 @@ HRESULT RegisterLanguageProfile() { hr = manager->RegisterProfile( CLSID_OpenLessTextService, kOpenLessLangId, GUID_OpenLessProfile, kOpenLessImeName, static_cast(ARRAYSIZE(kOpenLessImeName) - 1), - nullptr, 0, 0, nullptr, 0, TRUE, 0); + nullptr, 0, 0, nullptr, 0, TRUE, + TF_IPP_CAPS_IMMERSIVESUPPORT | TF_IPP_CAPS_SYSTRAYSUPPORT); manager->Release(); return hr; } @@ -182,10 +190,26 @@ HRESULT RegisterKeyboardCategory() { category_mgr->UnregisterCategory(CLSID_OpenLessTextService, GUID_TFCAT_TIP_KEYBOARD, CLSID_OpenLessTextService); + category_mgr->UnregisterCategory(CLSID_OpenLessTextService, + GUID_TFCAT_TIPCAP_IMMERSIVESUPPORT, + CLSID_OpenLessTextService); + category_mgr->UnregisterCategory(CLSID_OpenLessTextService, + GUID_TFCAT_TIPCAP_SYSTRAYSUPPORT, + CLSID_OpenLessTextService); hr = category_mgr->RegisterCategory(CLSID_OpenLessTextService, GUID_TFCAT_TIP_KEYBOARD, CLSID_OpenLessTextService); + if (SUCCEEDED(hr)) { + hr = category_mgr->RegisterCategory(CLSID_OpenLessTextService, + GUID_TFCAT_TIPCAP_IMMERSIVESUPPORT, + CLSID_OpenLessTextService); + } + if (SUCCEEDED(hr)) { + hr = category_mgr->RegisterCategory(CLSID_OpenLessTextService, + GUID_TFCAT_TIPCAP_SYSTRAYSUPPORT, + CLSID_OpenLessTextService); + } category_mgr->Release(); return hr; } @@ -232,6 +256,16 @@ HRESULT UnregisterKeyboardCategory() { hr = category_mgr->UnregisterCategory(CLSID_OpenLessTextService, GUID_TFCAT_TIP_KEYBOARD, CLSID_OpenLessTextService); + if (SUCCEEDED(hr)) { + hr = category_mgr->UnregisterCategory(CLSID_OpenLessTextService, + GUID_TFCAT_TIPCAP_IMMERSIVESUPPORT, + CLSID_OpenLessTextService); + } + if (SUCCEEDED(hr)) { + hr = category_mgr->UnregisterCategory(CLSID_OpenLessTextService, + GUID_TFCAT_TIPCAP_SYSTRAYSUPPORT, + CLSID_OpenLessTextService); + } category_mgr->Release(); return hr; } From ab7d1bc7b560eab8d86fa94d08c5f1d5579d726b Mon Sep 17 00:00:00 2001 From: millionart Date: Sun, 3 May 2026 20:40:51 +0800 Subject: [PATCH 32/43] fix: restore IME profile after skipped insertion --- openless-all/app/src-tauri/src/coordinator.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index a4728e31..c62db45f 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -1474,6 +1474,7 @@ async fn end_session(inner: &Arc) -> Result<(), String> { InsertStatus::Failed } }; + restore_prepared_windows_ime_session(inner, current_session_id); let inserted_chars = polished.chars().count() as u32; // 累计每条 enabled 词条在最终文本中的命中次数。 From cb10e0d39c4654fd2354c2c65376e114b714ea41 Mon Sep 17 00:00:00 2001 From: millionart Date: Sun, 3 May 2026 22:59:38 +0800 Subject: [PATCH 33/43] fix: register 32-bit Windows IME --- .gitignore | 3 + .../app/scripts/windows-ime-build.ps1 | 31 +++++----- .../app/scripts/windows-ime-register.ps1 | 45 ++++++++++---- .../app/scripts/windows-ime-unregister.ps1 | 47 +++++++++++---- .../app/scripts/windows-package-msvc.ps1 | 56 +++++++++++------- .../app/scripts/windows-package-msvc.test.mjs | 32 ++++++++-- .../app/src-tauri/src/windows_ime_profile.rs | 28 ++++++++- openless-all/app/src-tauri/tauri.conf.json | 3 +- .../app/src-tauri/wix/openless-ime.wxs | 41 ++++++++++--- openless-all/app/windows-ime/OpenLessIme.sln | 6 ++ .../app/windows-ime/OpenLessIme.vcxproj | 59 +++++++++++++++++++ openless-all/app/windows-ime/src/registry.cpp | 2 +- 12 files changed, 280 insertions(+), 73 deletions(-) diff --git a/.gitignore b/.gitignore index 72f7592e..a2d5ad09 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,9 @@ docs/old-promo/ # Windows TSF IME local build outputs openless-all/app/windows-ime/OpenLessIme/ openless-all/app/windows-ime/x64/ +openless-all/app/windows-ime/Win32/ +openless-all/app/windows-ime/Release/ +openless-all/app/windows-ime/obj/ # Planning docs are kept local only, not published to the public repo. docs/plans/ diff --git a/openless-all/app/scripts/windows-ime-build.ps1 b/openless-all/app/scripts/windows-ime-build.ps1 index 34932bd3..6f2c4395 100644 --- a/openless-all/app/scripts/windows-ime-build.ps1 +++ b/openless-all/app/scripts/windows-ime-build.ps1 @@ -1,6 +1,8 @@ param( [ValidateSet("Debug", "Release")] [string]$Configuration = "Release", + [ValidateSet("x64", "Win32")] + [string]$Platform = "x64", [string]$OutputDirectory = "", [string]$IntermediateDirectory = "" ) @@ -37,9 +39,12 @@ function Find-MSBuild { $appRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path $solution = Join-Path $appRoot "windows-ime\OpenLessIme.sln" -$dll = Join-Path $appRoot "windows-ime\x64\$Configuration\OpenLessIme.dll" +$defaultPlatformFolder = if ($Platform -eq "Win32") { "Win32" } else { $Platform } +$defaultOutputDirectory = Join-Path $appRoot "windows-ime\$defaultPlatformFolder\$Configuration" +$defaultIntermediateDirectory = Join-Path $appRoot "windows-ime\obj\$defaultPlatformFolder\$Configuration" +$dll = Join-Path $defaultOutputDirectory "OpenLessIme.dll" $msbuild = Find-MSBuild -$msbuildArgs = @($solution, "/p:Configuration=$Configuration", "/p:Platform=x64") +$msbuildArgs = @($solution, "/p:Configuration=$Configuration", "/p:Platform=$Platform") function Get-FullPathWithTrailingSlash($Path) { $fullPath = [System.IO.Path]::GetFullPath($Path) @@ -53,19 +58,17 @@ if (-not (Test-Path $solution)) { throw "Solution not found: $solution" } -Write-Host "[build] $Configuration x64" -if (-not [string]::IsNullOrWhiteSpace($OutputDirectory)) { - $outputDirectoryPath = Get-FullPathWithTrailingSlash $OutputDirectory - New-Item -ItemType Directory -Force -Path $outputDirectoryPath | Out-Null - $msbuildArgs += "/p:OutDir=$outputDirectoryPath" - $dll = Join-Path $outputDirectoryPath "OpenLessIme.dll" -} +Write-Host "[build] $Configuration $Platform" +$outputDirectory = if ([string]::IsNullOrWhiteSpace($OutputDirectory)) { $defaultOutputDirectory } else { $OutputDirectory } +$outputDirectoryPath = Get-FullPathWithTrailingSlash $outputDirectory +New-Item -ItemType Directory -Force -Path $outputDirectoryPath | Out-Null +$msbuildArgs += "/p:OutDir=$outputDirectoryPath" +$dll = Join-Path $outputDirectoryPath "OpenLessIme.dll" -if (-not [string]::IsNullOrWhiteSpace($IntermediateDirectory)) { - $intermediateDirectoryPath = Get-FullPathWithTrailingSlash $IntermediateDirectory - New-Item -ItemType Directory -Force -Path $intermediateDirectoryPath | Out-Null - $msbuildArgs += "/p:IntDir=$intermediateDirectoryPath" -} +$intermediateDirectory = if ([string]::IsNullOrWhiteSpace($IntermediateDirectory)) { $defaultIntermediateDirectory } else { $IntermediateDirectory } +$intermediateDirectoryPath = Get-FullPathWithTrailingSlash $intermediateDirectory +New-Item -ItemType Directory -Force -Path $intermediateDirectoryPath | Out-Null +$msbuildArgs += "/p:IntDir=$intermediateDirectoryPath" & $msbuild @msbuildArgs if ($LASTEXITCODE -ne 0) { diff --git a/openless-all/app/scripts/windows-ime-register.ps1 b/openless-all/app/scripts/windows-ime-register.ps1 index 9dbe5e5f..3740cd2c 100644 --- a/openless-all/app/scripts/windows-ime-register.ps1 +++ b/openless-all/app/scripts/windows-ime-register.ps1 @@ -5,11 +5,25 @@ param( $ErrorActionPreference = "Stop" -function Get-Regsvr32x64 { +function Get-Regsvr32ForPlatform { + param( + [ValidateSet("x64", "Win32")] + [string]$Platform + ) + + if ($Platform -eq "Win32") { + $syswow64 = Join-Path $env:WINDIR "SysWOW64\regsvr32.exe" + if (Test-Path $syswow64) { + return $syswow64 + } + return (Join-Path $env:WINDIR "System32\regsvr32.exe") + } + $sysnative = Join-Path $env:WINDIR "Sysnative\regsvr32.exe" if (Test-Path $sysnative) { return $sysnative } + return (Join-Path $env:WINDIR "System32\regsvr32.exe") } @@ -20,20 +34,31 @@ function Test-IsAdministrator { } $appRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path -$dll = Join-Path $appRoot "windows-ime\x64\$Configuration\OpenLessIme.dll" -if (-not (Test-Path $dll)) { - & (Join-Path $PSScriptRoot "windows-ime-build.ps1") -Configuration $Configuration +function Get-DllPath { + param( + [ValidateSet("x64", "Win32")] + [string]$Platform + ) + + $folder = if ($Platform -eq "Win32") { "Win32" } else { $Platform } + return Join-Path $appRoot "windows-ime\$folder\$Configuration\OpenLessIme.dll" } if (-not (Test-IsAdministrator)) { throw "Registering the OpenLess TSF IME requires an elevated Administrator PowerShell." } -$regsvr32 = Get-Regsvr32x64 -$process = Start-Process -FilePath $regsvr32 -ArgumentList @("/s", $dll) -Wait -PassThru -if ($process.ExitCode -ne 0) { - throw "regsvr32 failed with exit code $($process.ExitCode)" -} +foreach ($platform in @("x64", "Win32")) { + $dll = Get-DllPath $platform + if (-not (Test-Path $dll)) { + & (Join-Path $PSScriptRoot "windows-ime-build.ps1") -Configuration $Configuration -Platform $platform + } -Write-Host "[ok] OpenLess TSF IME registered" + $regsvr32 = Get-Regsvr32ForPlatform $platform + $process = Start-Process -FilePath $regsvr32 -ArgumentList @("/s", $dll) -Wait -PassThru + if ($process.ExitCode -ne 0) { + throw "$platform regsvr32 failed with exit code $($process.ExitCode)" + } + 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 6e13476a..4d51a004 100644 --- a/openless-all/app/scripts/windows-ime-unregister.ps1 +++ b/openless-all/app/scripts/windows-ime-unregister.ps1 @@ -5,11 +5,25 @@ param( $ErrorActionPreference = "Stop" -function Get-Regsvr32x64 { +function Get-Regsvr32ForPlatform { + param( + [ValidateSet("x64", "Win32")] + [string]$Platform + ) + + if ($Platform -eq "Win32") { + $syswow64 = Join-Path $env:WINDIR "SysWOW64\regsvr32.exe" + if (Test-Path $syswow64) { + return $syswow64 + } + return (Join-Path $env:WINDIR "System32\regsvr32.exe") + } + $sysnative = Join-Path $env:WINDIR "Sysnative\regsvr32.exe" if (Test-Path $sysnative) { return $sysnative } + return (Join-Path $env:WINDIR "System32\regsvr32.exe") } @@ -20,21 +34,32 @@ function Test-IsAdministrator { } $appRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path -$dll = Join-Path $appRoot "windows-ime\x64\$Configuration\OpenLessIme.dll" -if (-not (Test-Path $dll)) { - Write-Host "[skip] OpenLessIme.dll not found: $dll" - exit 0 +function Get-DllPath { + param( + [ValidateSet("x64", "Win32")] + [string]$Platform + ) + + $folder = if ($Platform -eq "Win32") { "Win32" } else { $Platform } + return Join-Path $appRoot "windows-ime\$folder\$Configuration\OpenLessIme.dll" } if (-not (Test-IsAdministrator)) { throw "Unregistering the OpenLess TSF IME requires an elevated Administrator PowerShell." } -$regsvr32 = Get-Regsvr32x64 -$process = Start-Process -FilePath $regsvr32 -ArgumentList @("/u", "/s", $dll) -Wait -PassThru -if ($process.ExitCode -ne 0) { - throw "regsvr32 /u failed with exit code $($process.ExitCode)" -} +foreach ($platform in @("x64", "Win32")) { + $dll = Get-DllPath $platform + if (-not (Test-Path $dll)) { + Write-Host "[skip] OpenLessIme.dll not found ($platform): $dll" + continue + } -Write-Host "[ok] OpenLess TSF IME unregistered" + $regsvr32 = Get-Regsvr32ForPlatform $platform + $process = Start-Process -FilePath $regsvr32 -ArgumentList @("/u", "/s", $dll) -Wait -PassThru + if ($process.ExitCode -ne 0) { + throw "$platform regsvr32 /u failed with exit code $($process.ExitCode)" + } + Write-Host "[ok] OpenLess TSF IME unregistered ($platform)" +} diff --git a/openless-all/app/scripts/windows-package-msvc.ps1 b/openless-all/app/scripts/windows-package-msvc.ps1 index bd096f41..ea9e84f0 100644 --- a/openless-all/app/scripts/windows-package-msvc.ps1 +++ b/openless-all/app/scripts/windows-package-msvc.ps1 @@ -139,14 +139,17 @@ function Invoke-MsvcBuild { [string]$CargoBin ) - if ([string]::IsNullOrWhiteSpace($env:OPENLESS_IME_DLL) -or -not (Test-Path $env:OPENLESS_IME_DLL)) { - throw "OPENLESS_IME_DLL must point to the built OpenLessIme.dll before the MSI build." + if ([string]::IsNullOrWhiteSpace($env:OPENLESS_IME_DLL_X64) -or -not (Test-Path $env:OPENLESS_IME_DLL_X64)) { + throw "OPENLESS_IME_DLL_X64 must point to the built x64 OpenLessIme.dll before the MSI build." + } + if ([string]::IsNullOrWhiteSpace($env:OPENLESS_IME_DLL_X86) -or -not (Test-Path $env:OPENLESS_IME_DLL_X86)) { + throw "OPENLESS_IME_DLL_X86 must point to the built x86 OpenLessIme.dll before the MSI build." } $msiPath = Get-MsiPath Remove-Item -LiteralPath $msiPath -Force -ErrorAction SilentlyContinue - $buildCommand = "call `"$VsDevCmd`" -arch=x64 -host_arch=x64 && set `"PATH=$CargoBin;%PATH%`" && set `"OPENLESS_IME_DLL=$env:OPENLESS_IME_DLL`" && npm.cmd run tauri build -- --target x86_64-pc-windows-msvc --bundles msi" + $buildCommand = "call `"$VsDevCmd`" -arch=x64 -host_arch=x64 && set `"PATH=$CargoBin;%PATH%`" && set `"OPENLESS_IME_DLL_X64=$env:OPENLESS_IME_DLL_X64`" && set `"OPENLESS_IME_DLL_X86=$env:OPENLESS_IME_DLL_X86`" && npm.cmd run tauri build -- --target x86_64-pc-windows-msvc --bundles msi" & cmd.exe /d /c $buildCommand if ($LASTEXITCODE -ne 0) { Write-Warning "Tauri Windows MSI build returned exit code $LASTEXITCODE. Trying to finish MSI linking from generated WiX objects." @@ -161,7 +164,7 @@ function Repair-TauriMsiBundle { $locale = Join-Path $wixRoot "locale.wxl" $msiPath = Get-MsiPath - foreach ($requiredPath in @($mainObject, $imeObject, $locale, $env:OPENLESS_IME_DLL)) { + foreach ($requiredPath in @($mainObject, $imeObject, $locale, $env:OPENLESS_IME_DLL_X64, $env:OPENLESS_IME_DLL_X86)) { if ([string]::IsNullOrWhiteSpace($requiredPath) -or -not (Test-Path $requiredPath)) { throw "Cannot repair Tauri MSI bundle because a required file is missing: $requiredPath" } @@ -185,25 +188,31 @@ function Repair-TauriMsiBundle { function Invoke-OpenLessImeBuild { $buildScript = Join-Path $PSScriptRoot "windows-ime-build.ps1" - $imeOutDir = Join-Path $imeBuildRoot "x64\Release" - $imeIntDir = Join-Path $imeBuildRoot "obj\x64\Release" if (-not (Test-Path $buildScript)) { throw "OpenLess IME build script not found: $buildScript" } - & $buildScript -Configuration Release -OutputDirectory $imeOutDir -IntermediateDirectory $imeIntDir - if ($LASTEXITCODE -ne 0) { - throw "OpenLessIme build failed with exit code $LASTEXITCODE." - } + $targets = @( + @{ Platform = "x64"; Folder = "x64"; EnvName = "OPENLESS_IME_DLL_X64" }, + @{ Platform = "Win32"; Folder = "x86"; EnvName = "OPENLESS_IME_DLL_X86" } + ) + foreach ($target in $targets) { + $imeOutDir = Join-Path $imeBuildRoot "$($target.Folder)\Release" + $imeIntDir = Join-Path $imeBuildRoot "obj\$($target.Folder)\Release" + & $buildScript -Configuration Release -Platform $target.Platform -OutputDirectory $imeOutDir -IntermediateDirectory $imeIntDir + if ($LASTEXITCODE -ne 0) { + throw "OpenLessIme $($target.Platform) build failed with exit code $LASTEXITCODE." + } - $imeDll = Join-Path $imeOutDir "OpenLessIme.dll" - if (-not (Test-Path $imeDll)) { - throw "OpenLessIme.dll was not produced: $imeDll" - } + $imeDll = Join-Path $imeOutDir "OpenLessIme.dll" + if (-not (Test-Path $imeDll)) { + throw "OpenLessIme.dll was not produced: $imeDll" + } - $env:OPENLESS_IME_DLL = (Resolve-Path $imeDll).Path - Write-Host "[ok] OPENLESS_IME_DLL -> $env:OPENLESS_IME_DLL" + Set-Item -Path "Env:$($target.EnvName)" -Value (Resolve-Path $imeDll).Path + Write-Host "[ok] $($target.EnvName) -> $((Get-Item -Path "Env:$($target.EnvName)").Value)" + } } function Reset-ArtifactsRoot { @@ -241,8 +250,11 @@ function Copy-WindowsArtifacts { if ($null -eq $webView2Loader) { throw "WebView2Loader.dll x64 not found under $releaseRoot\build" } - if ([string]::IsNullOrWhiteSpace($env:OPENLESS_IME_DLL) -or -not (Test-Path $env:OPENLESS_IME_DLL)) { - throw "OpenLessIme.dll not found for portable package: $env:OPENLESS_IME_DLL" + if ([string]::IsNullOrWhiteSpace($env:OPENLESS_IME_DLL_X64) -or -not (Test-Path $env:OPENLESS_IME_DLL_X64)) { + throw "x64 OpenLessIme.dll not found for portable package: $env:OPENLESS_IME_DLL_X64" + } + if ([string]::IsNullOrWhiteSpace($env:OPENLESS_IME_DLL_X86) -or -not (Test-Path $env:OPENLESS_IME_DLL_X86)) { + throw "x86 OpenLessIme.dll not found for portable package: $env:OPENLESS_IME_DLL_X86" } Reset-ArtifactsRoot @@ -251,11 +263,15 @@ function Copy-WindowsArtifacts { $portableName = "OpenLess_${version}_x64_portable" $portableRoot = Join-Path $ArtifactsRoot $portableName $portableImeRoot = Join-Path $portableRoot "windows-ime" + $portableImeX64Root = Join-Path $portableImeRoot "x64" + $portableImeX86Root = Join-Path $portableImeRoot "x86" New-Item -ItemType Directory -Force -Path $portableRoot | Out-Null - New-Item -ItemType Directory -Force -Path $portableImeRoot | Out-Null + New-Item -ItemType Directory -Force -Path $portableImeX64Root | Out-Null + New-Item -ItemType Directory -Force -Path $portableImeX86Root | Out-Null Copy-Item -LiteralPath $exePath -Destination (Join-Path $portableRoot "openless.exe") -Force Copy-Item -LiteralPath $webView2Loader.FullName -Destination (Join-Path $portableRoot "WebView2Loader.dll") -Force - Copy-Item -LiteralPath $env:OPENLESS_IME_DLL -Destination (Join-Path $portableImeRoot "OpenLessIme.dll") -Force + Copy-Item -LiteralPath $env:OPENLESS_IME_DLL_X64 -Destination (Join-Path $portableImeX64Root "OpenLessIme.dll") -Force + Copy-Item -LiteralPath $env:OPENLESS_IME_DLL_X86 -Destination (Join-Path $portableImeX86Root "OpenLessIme.dll") -Force $zipPath = Join-Path $ArtifactsRoot "$portableName.zip" Remove-Item -LiteralPath $zipPath -Force -ErrorAction SilentlyContinue diff --git a/openless-all/app/scripts/windows-package-msvc.test.mjs b/openless-all/app/scripts/windows-package-msvc.test.mjs index f6b6dc49..5a302829 100644 --- a/openless-all/app/scripts/windows-package-msvc.test.mjs +++ b/openless-all/app/scripts/windows-package-msvc.test.mjs @@ -8,12 +8,16 @@ const appRoot = join(scriptsDir, ".."); 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 imeSolutionPath = join(appRoot, "windows-ime", "OpenLessIme.sln"); +const imeProjectPath = join(appRoot, "windows-ime", "OpenLessIme.vcxproj"); const tauriConfigPath = join(appRoot, "src-tauri", "tauri.conf.json"); const wixFragmentPath = join(appRoot, "src-tauri", "wix", "openless-ime.wxs"); const script = readFileSync(scriptPath, "utf8"); const launcher = readFileSync(launcherPath, "utf8"); const imeBuild = readFileSync(imeBuildPath, "utf8"); +const imeSolution = readFileSync(imeSolutionPath, "utf8"); +const imeProject = readFileSync(imeProjectPath, "utf8"); const tauriConfig = JSON.parse(readFileSync(tauriConfigPath, "utf8")); const wixFragment = readFileSync(wixFragmentPath, "utf8"); @@ -25,7 +29,8 @@ const requiredFragments = [ "VsDevCmd.bat", "npm.cmd ci", "windows-ime-build.ps1", - "OPENLESS_IME_DLL", + "OPENLESS_IME_DLL_X64", + "OPENLESS_IME_DLL_X86", "OpenLessIme.dll", "tauri build -- --target x86_64-pc-windows-msvc --bundles msi", "Repair-TauriMsiBundle", @@ -48,18 +53,33 @@ assert.match(script, /\[switch\]\$CleanArtifacts/, "script should support cleani assert.match(imeBuild, /\[string\]\$OutputDirectory/, "IME build should support a package-specific output directory"); assert.match(imeBuild, /\[string\]\$IntermediateDirectory/, "IME build should support a package-specific intermediate directory"); +assert.match(imeBuild, /\[ValidateSet\("x64", "Win32"\)\]/, "IME build should support x64 and Win32 platforms"); +assert.match(imeBuild, /\/p:Platform=\$Platform/, "IME build should pass Platform to MSBuild"); +assert.match(imeBuild, /\$defaultOutputDirectory = Join-Path \$appRoot "windows-ime\\\$defaultPlatformFolder\\\$Configuration"/, "IME build should force stable default OutDir per platform"); assert.match(imeBuild, /\/p:OutDir=/, "IME build should pass OutDir to MSBuild"); assert.match(imeBuild, /\/p:IntDir=/, "IME build should pass IntDir to MSBuild"); assert.deepEqual(tauriConfig.bundle.windows.wix.fragmentPaths, ["wix/openless-ime.wxs"]); -assert.deepEqual(tauriConfig.bundle.windows.wix.componentRefs, ["OpenLessImeDllComponent"]); +assert.deepEqual(tauriConfig.bundle.windows.wix.componentRefs, [ + "OpenLessImeDllX64Component", + "OpenLessImeDllX86Component", +]); + +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"); assert.match(wixFragment, /DirectoryRef Id="INSTALLDIR"/, "WiX fragment should install into the app directory"); -assert.match(wixFragment, /Component Id="OpenLessImeDllComponent"/, "WiX fragment should define the TSF DLL component"); -assert.match(wixFragment, /Source="src-tauri\\target\\windows-ime-msvc\\x64\\Release\\OpenLessIme\.dll"/, "WiX fragment should consume the package-built IME DLL"); +assert.match(wixFragment, /Component Id="OpenLessImeDllX64Component"/, "WiX fragment should define the x64 TSF DLL component"); +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, /RegisterOpenLessIme/, "MSI should register OpenLess IME during install"); -assert.match(wixFragment, /UnregisterOpenLessIme/, "MSI should unregister OpenLess IME during uninstall"); +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(launcher, /powershell\.exe/, "launcher should call powershell.exe"); assert.match(launcher, /-ExecutionPolicy Bypass/, "launcher should bypass execution policy for this process"); 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 0dc2356f..253af515 100644 --- a/openless-all/app/src-tauri/src/windows_ime_profile.rs +++ b/openless-all/app/src-tauri/src/windows_ime_profile.rs @@ -196,7 +196,7 @@ mod windows_impl { TF_IPPMF_ENABLEPROFILE, TF_IPPMF_FORSESSION, TF_PROFILETYPE_INPUTPROCESSOR, TF_PROFILETYPE_KEYBOARDLAYOUT, }; - use winreg::enums::{HKEY_LOCAL_MACHINE, KEY_READ, KEY_WOW64_64KEY}; + use winreg::enums::{HKEY_LOCAL_MACHINE, KEY_READ, KEY_WOW64_32KEY, KEY_WOW64_64KEY}; use winreg::RegKey; const OPENLESS_COM_INPROC_KEY: &str = @@ -414,6 +414,22 @@ mod windows_impl { }; } + let x86_dll_path = match read_com_dll_path(&hklm, KEY_READ | KEY_WOW64_32KEY, "32-bit") { + Ok(path) => path, + Err(reason) => { + return RegistrationInspection::Broken { + dll_path: Some(dll_path), + reason, + }; + } + }; + if !Path::new(&x86_dll_path).is_file() { + return RegistrationInspection::Broken { + dll_path: Some(x86_dll_path), + reason: "OpenLess 32-bit COM DLL path does not exist".to_string(), + }; + } + if !tip_key_exists { return RegistrationInspection::Broken { dll_path: Some(dll_path), @@ -439,6 +455,16 @@ mod windows_impl { RegistrationInspection::Installed { dll_path } } + fn read_com_dll_path(hklm: &RegKey, flags: u32, label: &str) -> Result { + let com_key = hklm + .open_subkey_with_flags(OPENLESS_COM_INPROC_KEY, flags) + .map_err(|_| format!("OpenLess {label} COM registration is missing"))?; + match com_key.get_value::("") { + Ok(value) if !value.trim().is_empty() => Ok(value), + _ => Err(format!("OpenLess {label} COM DLL path is missing")), + } + } + fn with_profile_manager( operation: impl FnOnce(&ITfInputProcessorProfileMgr) -> windows::core::Result, ) -> WindowsImeProfileResult { diff --git a/openless-all/app/src-tauri/tauri.conf.json b/openless-all/app/src-tauri/tauri.conf.json index f8523723..d80ab3c7 100644 --- a/openless-all/app/src-tauri/tauri.conf.json +++ b/openless-all/app/src-tauri/tauri.conf.json @@ -90,7 +90,8 @@ "wix/openless-ime.wxs" ], "componentRefs": [ - "OpenLessImeDllComponent" + "OpenLessImeDllX64Component", + "OpenLessImeDllX86Component" ] } } diff --git a/openless-all/app/src-tauri/wix/openless-ime.wxs b/openless-all/app/src-tauri/wix/openless-ime.wxs index 47464946..f7571a1b 100644 --- a/openless-all/app/src-tauri/wix/openless-ime.wxs +++ b/openless-all/app/src-tauri/wix/openless-ime.wxs @@ -3,29 +3,52 @@ - - - + + + + + + + + + + + + - - + + + + diff --git a/openless-all/app/windows-ime/OpenLessIme.sln b/openless-all/app/windows-ime/OpenLessIme.sln index a2fc9f08..86c2de06 100644 --- a/openless-all/app/windows-ime/OpenLessIme.sln +++ b/openless-all/app/windows-ime/OpenLessIme.sln @@ -6,12 +6,18 @@ Project("{BC8A1FFA-BEE3-4634-8014-F334798102B3}") = "OpenLessIme", "OpenLessIme. EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Win32 = Debug|Win32 Debug|x64 = Debug|x64 + Release|Win32 = Release|Win32 Release|x64 = Release|x64 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution + {F45287CB-1F31-4D7C-9B62-5748C2F8C9B0}.Debug|Win32.ActiveCfg = Debug|Win32 + {F45287CB-1F31-4D7C-9B62-5748C2F8C9B0}.Debug|Win32.Build.0 = Debug|Win32 {F45287CB-1F31-4D7C-9B62-5748C2F8C9B0}.Debug|x64.ActiveCfg = Debug|x64 {F45287CB-1F31-4D7C-9B62-5748C2F8C9B0}.Debug|x64.Build.0 = Debug|x64 + {F45287CB-1F31-4D7C-9B62-5748C2F8C9B0}.Release|Win32.ActiveCfg = Release|Win32 + {F45287CB-1F31-4D7C-9B62-5748C2F8C9B0}.Release|Win32.Build.0 = Release|Win32 {F45287CB-1F31-4D7C-9B62-5748C2F8C9B0}.Release|x64.ActiveCfg = Release|x64 {F45287CB-1F31-4D7C-9B62-5748C2F8C9B0}.Release|x64.Build.0 = Release|x64 EndGlobalSection diff --git a/openless-all/app/windows-ime/OpenLessIme.vcxproj b/openless-all/app/windows-ime/OpenLessIme.vcxproj index af417141..2bbe9feb 100644 --- a/openless-all/app/windows-ime/OpenLessIme.vcxproj +++ b/openless-all/app/windows-ime/OpenLessIme.vcxproj @@ -1,10 +1,18 @@ + + Debug + Win32 + Debug x64 + + Release + Win32 + Release x64 @@ -18,12 +26,25 @@ 10.0 + + DynamicLibrary + true + v143 + Unicode + DynamicLibrary true v143 Unicode + + DynamicLibrary + false + v143 + true + Unicode + DynamicLibrary false @@ -34,13 +55,33 @@ + + + + + + + + + Level4 + true + WIN32;_WINDOWS;_USRDLL;OPENLESSIME_EXPORTS;%(PreprocessorDefinitions) + true + stdcpp17 + + + Windows + ole32.lib;uuid.lib;advapi32.lib;%(AdditionalDependencies) + src\OpenLessIme.def + + Level4 @@ -55,6 +96,24 @@ src\OpenLessIme.def + + + Level4 + true + true + true + WIN32;NDEBUG;_WINDOWS;_USRDLL;OPENLESSIME_EXPORTS;%(PreprocessorDefinitions) + true + stdcpp17 + + + Windows + true + true + ole32.lib;uuid.lib;advapi32.lib;%(AdditionalDependencies) + src\OpenLessIme.def + + Level4 diff --git a/openless-all/app/windows-ime/src/registry.cpp b/openless-all/app/windows-ime/src/registry.cpp index ff7eb79b..45d74c32 100644 --- a/openless-all/app/windows-ime/src/registry.cpp +++ b/openless-all/app/windows-ime/src/registry.cpp @@ -11,7 +11,7 @@ constexpr wchar_t kClsidKey[] = L"Software\\Classes\\CLSID\\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}"; constexpr wchar_t kInprocServer32Key[] = L"Software\\Classes\\CLSID\\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\\InprocServer32"; -constexpr REGSAM kRegistryWriteAccess = KEY_WRITE | KEY_WOW64_64KEY; +constexpr REGSAM kRegistryWriteAccess = KEY_WRITE; HRESULT HResultFromWin32Error(LSTATUS status) { return status == ERROR_SUCCESS ? S_OK : HRESULT_FROM_WIN32(status); From 152e38c8437c747cd67f2a99814a213a7acf9281 Mon Sep 17 00:00:00 2001 From: millionart Date: Sun, 3 May 2026 23:38:35 +0800 Subject: [PATCH 34/43] fix: support async Word IME commits --- .../app/scripts/windows-ime-register.ps1 | 23 +++-- .../app/scripts/windows-package-msvc.test.mjs | 15 ++++ .../app/windows-ime/src/edit_session.cpp | 39 +++++++- .../app/windows-ime/src/edit_session.h | 22 ++++- .../app/windows-ime/src/text_service.cpp | 89 +++++++++++++++++-- .../app/windows-ime/src/text_service.h | 10 ++- 6 files changed, 181 insertions(+), 17 deletions(-) diff --git a/openless-all/app/scripts/windows-ime-register.ps1 b/openless-all/app/scripts/windows-ime-register.ps1 index 3740cd2c..47edd880 100644 --- a/openless-all/app/scripts/windows-ime-register.ps1 +++ b/openless-all/app/scripts/windows-ime-register.ps1 @@ -34,6 +34,7 @@ function Test-IsAdministrator { } $appRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$stagingRoot = Join-Path $appRoot "src-tauri\target\windows-ime-register" function Get-DllPath { param( @@ -41,8 +42,18 @@ function Get-DllPath { [string]$Platform ) - $folder = if ($Platform -eq "Win32") { "Win32" } else { $Platform } - return Join-Path $appRoot "windows-ime\$folder\$Configuration\OpenLessIme.dll" + $folder = if ($Platform -eq "Win32") { "x86" } else { $Platform } + return Join-Path $stagingRoot "$folder\$Configuration\OpenLessIme.dll" +} + +function Get-IntermediateDirectory { + param( + [ValidateSet("x64", "Win32")] + [string]$Platform + ) + + $folder = if ($Platform -eq "Win32") { "x86" } else { $Platform } + return Join-Path $stagingRoot "obj\$folder\$Configuration" } if (-not (Test-IsAdministrator)) { @@ -51,9 +62,11 @@ if (-not (Test-IsAdministrator)) { foreach ($platform in @("x64", "Win32")) { $dll = Get-DllPath $platform - if (-not (Test-Path $dll)) { - & (Join-Path $PSScriptRoot "windows-ime-build.ps1") -Configuration $Configuration -Platform $platform - } + & (Join-Path $PSScriptRoot "windows-ime-build.ps1") ` + -Configuration $Configuration ` + -Platform $platform ` + -OutputDirectory (Split-Path $dll -Parent) ` + -IntermediateDirectory (Get-IntermediateDirectory $platform) $regsvr32 = Get-Regsvr32ForPlatform $platform $process = Start-Process -FilePath $regsvr32 -ArgumentList @("/s", $dll) -Wait -PassThru diff --git a/openless-all/app/scripts/windows-package-msvc.test.mjs b/openless-all/app/scripts/windows-package-msvc.test.mjs index 5a302829..7c578601 100644 --- a/openless-all/app/scripts/windows-package-msvc.test.mjs +++ b/openless-all/app/scripts/windows-package-msvc.test.mjs @@ -8,16 +8,22 @@ const appRoot = join(scriptsDir, ".."); 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 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"); +const imeTextServicePath = join(appRoot, "windows-ime", "src", "text_service.cpp"); const tauriConfigPath = join(appRoot, "src-tauri", "tauri.conf.json"); const wixFragmentPath = join(appRoot, "src-tauri", "wix", "openless-ime.wxs"); const script = readFileSync(scriptPath, "utf8"); const launcher = readFileSync(launcherPath, "utf8"); const imeBuild = readFileSync(imeBuildPath, "utf8"); +const imeRegister = readFileSync(imeRegisterPath, "utf8"); const imeSolution = readFileSync(imeSolutionPath, "utf8"); const imeProject = readFileSync(imeProjectPath, "utf8"); +const imeEditSession = readFileSync(imeEditSessionPath, "utf8"); +const imeTextService = readFileSync(imeTextServicePath, "utf8"); const tauriConfig = JSON.parse(readFileSync(tauriConfigPath, "utf8")); const wixFragment = readFileSync(wixFragmentPath, "utf8"); @@ -58,6 +64,11 @@ assert.match(imeBuild, /\/p:Platform=\$Platform/, "IME build should pass Platfor assert.match(imeBuild, /\$defaultOutputDirectory = Join-Path \$appRoot "windows-ime\\\$defaultPlatformFolder\\\$Configuration"/, "IME build should force stable default OutDir per platform"); assert.match(imeBuild, /\/p:OutDir=/, "IME build should pass OutDir to MSBuild"); assert.match(imeBuild, /\/p:IntDir=/, "IME build should pass IntDir to MSBuild"); +assert.match(imeRegister, /windows-ime-build\.ps1/, "IME register should build before registering"); +assert.doesNotMatch(imeRegister, /if \(-not \(Test-Path \$dll\)\)/, "IME register must rebuild stale DLLs, not only missing DLLs"); +assert.match(imeRegister, /windows-ime-register/, "IME register should use a side-by-side staging output to avoid locked registered DLLs"); +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.deepEqual(tauriConfig.bundle.windows.wix.fragmentPaths, ["wix/openless-ime.wxs"]); assert.deepEqual(tauriConfig.bundle.windows.wix.componentRefs, [ @@ -67,6 +78,10 @@ assert.deepEqual(tauriConfig.bundle.windows.wix.componentRefs, [ 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"); +assert.match(imeTextService, /TF_E_SYNCHRONOUS/, "IME should detect hosts like Word that reject synchronous edit sessions"); +assert.match(imeTextService, /TF_ES_ASYNC \| TF_ES_READWRITE/, "IME should retry Word-hosted commits with an async edit session"); +assert.match(imeTextService, /WaitForSingleObject/, "IME pipe submit should wait for async edit-session completion"); +assert.match(imeEditSession, /SetEvent/, "IME edit session should signal async completion back to the pipe submitter"); assert.match(wixFragment, /DirectoryRef Id="INSTALLDIR"/, "WiX fragment should install into the app directory"); assert.match(wixFragment, /Component Id="OpenLessImeDllX64Component"/, "WiX fragment should define the x64 TSF DLL component"); diff --git a/openless-all/app/windows-ime/src/edit_session.cpp b/openless-all/app/windows-ime/src/edit_session.cpp index 1e4210b1..79261886 100644 --- a/openless-all/app/windows-ime/src/edit_session.cpp +++ b/openless-all/app/windows-ime/src/edit_session.cpp @@ -2,9 +2,31 @@ #include -OpenLessEditSession::OpenLessEditSession(ITfContext* context, - std::wstring text) - : context_(context), text_(std::move(text)) { +OpenLessAsyncEditState::OpenLessAsyncEditState() + : event(CreateEventW(nullptr, TRUE, FALSE, nullptr)) { + if (event == nullptr) { + create_error = GetLastError(); + } +} + +OpenLessAsyncEditState::~OpenLessAsyncEditState() { + if (event != nullptr) { + CloseHandle(event); + event = nullptr; + } +} + +bool OpenLessAsyncEditState::IsValid() const { + return event != nullptr; +} + +OpenLessEditSession::OpenLessEditSession( + ITfContext* context, + std::wstring text, + std::shared_ptr async_state) + : context_(context), + text_(std::move(text)), + async_state_(std::move(async_state)) { if (context_ != nullptr) { context_->AddRef(); } @@ -45,6 +67,17 @@ STDMETHODIMP_(ULONG) OpenLessEditSession::Release() { } STDMETHODIMP OpenLessEditSession::DoEditSession(TfEditCookie edit_cookie) { + const HRESULT hr = InsertText(edit_cookie); + if (async_state_) { + async_state_->result = hr; + if (async_state_->event != nullptr) { + SetEvent(async_state_->event); + } + } + return hr; +} + +HRESULT OpenLessEditSession::InsertText(TfEditCookie edit_cookie) { if (context_ == nullptr) { return E_UNEXPECTED; } diff --git a/openless-all/app/windows-ime/src/edit_session.h b/openless-all/app/windows-ime/src/edit_session.h index dbe11890..0185b6d9 100644 --- a/openless-all/app/windows-ime/src/edit_session.h +++ b/openless-all/app/windows-ime/src/edit_session.h @@ -1,12 +1,29 @@ #pragma once #include +#include #include #include +struct OpenLessAsyncEditState { + OpenLessAsyncEditState(); + OpenLessAsyncEditState(const OpenLessAsyncEditState&) = delete; + OpenLessAsyncEditState& operator=(const OpenLessAsyncEditState&) = delete; + ~OpenLessAsyncEditState(); + + bool IsValid() const; + + HANDLE event = nullptr; + DWORD create_error = ERROR_SUCCESS; + HRESULT result = E_UNEXPECTED; +}; + class OpenLessEditSession final : public ITfEditSession { public: - OpenLessEditSession(ITfContext* context, std::wstring text); + OpenLessEditSession( + ITfContext* context, + std::wstring text, + std::shared_ptr async_state = nullptr); OpenLessEditSession(const OpenLessEditSession&) = delete; OpenLessEditSession& operator=(const OpenLessEditSession&) = delete; ~OpenLessEditSession(); @@ -17,7 +34,10 @@ class OpenLessEditSession final : public ITfEditSession { STDMETHODIMP DoEditSession(TfEditCookie edit_cookie) override; private: + HRESULT InsertText(TfEditCookie edit_cookie); + LONG ref_count_ = 1; ITfContext* context_ = nullptr; std::wstring text_; + std::shared_ptr async_state_; }; diff --git a/openless-all/app/windows-ime/src/text_service.cpp b/openless-all/app/windows-ime/src/text_service.cpp index 7c4369e3..443a41c6 100644 --- a/openless-all/app/windows-ime/src/text_service.cpp +++ b/openless-all/app/windows-ime/src/text_service.cpp @@ -1,5 +1,6 @@ #include "text_service.h" +#include #include #include "edit_session.h" @@ -16,9 +17,30 @@ constexpr UINT kSubmitTextTimeoutMs = 3000; struct SubmitTextRequest { const std::wstring* session_id = nullptr; const std::wstring* text = nullptr; + std::shared_ptr async_completion; + bool wait_for_async_completion = false; HRESULT result = E_UNEXPECTED; }; +HRESULT WaitForAsyncEditCompletion( + const std::shared_ptr& completion) { + if (!completion || !completion->IsValid()) { + return HRESULT_FROM_WIN32(completion && completion->create_error != ERROR_SUCCESS + ? completion->create_error + : ERROR_INVALID_HANDLE); + } + + const DWORD wait_result = + WaitForSingleObject(completion->event, kSubmitTextTimeoutMs); + if (wait_result == WAIT_OBJECT_0) { + return completion->result; + } + if (wait_result == WAIT_TIMEOUT) { + return HRESULT_FROM_WIN32(ERROR_TIMEOUT); + } + return HRESULT_FROM_WIN32(GetLastError()); +} + } // namespace OpenLessTextService::OpenLessTextService() { @@ -113,14 +135,16 @@ HRESULT OpenLessTextService::SubmitTextFromPipe( const std::wstring& session_id, const std::wstring& text) { if (GetCurrentThreadId() == owner_thread_id_) { - return CommitTextOnOwnerThread(session_id, text); + return CommitTextOnOwnerThread(session_id, text, nullptr, nullptr); } if (message_window_ == nullptr) { return E_UNEXPECTED; } - SubmitTextRequest request{&session_id, &text, E_UNEXPECTED}; + SubmitTextRequest request; + request.session_id = &session_id; + request.text = &text; DWORD_PTR message_result = 0; const LRESULT sent = SendMessageTimeoutW( message_window_, kSubmitTextMessage, 0, @@ -131,6 +155,10 @@ HRESULT OpenLessTextService::SubmitTextFromPipe( return HRESULT_FROM_WIN32(error != ERROR_SUCCESS ? error : ERROR_TIMEOUT); } + if (request.wait_for_async_completion) { + return WaitForAsyncEditCompletion(request.async_completion); + } + return request.result; } @@ -179,7 +207,9 @@ void OpenLessTextService::DestroyMessageWindow() { HRESULT OpenLessTextService::CommitTextOnOwnerThread( const std::wstring& session_id, - const std::wstring& text) { + const std::wstring& text, + std::shared_ptr* async_completion, + bool* wait_for_async_completion) { UNREFERENCED_PARAMETER(session_id); if (thread_mgr_ == nullptr || client_id_ == TF_CLIENTID_NULL) { @@ -216,12 +246,58 @@ HRESULT OpenLessTextService::CommitTextOnOwnerThread( hr = context->RequestEditSession(client_id_, session, TF_ES_SYNC | TF_ES_READWRITE, &edit_result); session->Release(); + + const bool synchronous_rejected = + hr == TF_E_SYNCHRONOUS || + (SUCCEEDED(hr) && edit_result == TF_E_SYNCHRONOUS); + if (!synchronous_rejected) { + context->Release(); + if (FAILED(hr)) { + return hr; + } + return edit_result; + } + + if (async_completion == nullptr || wait_for_async_completion == nullptr) { + context->Release(); + if (FAILED(hr)) { + return hr; + } + return edit_result; + } + + auto completion = std::make_shared(); + if (!completion->IsValid()) { + context->Release(); + return HRESULT_FROM_WIN32(completion->create_error != ERROR_SUCCESS + ? completion->create_error + : ERROR_INVALID_HANDLE); + } + + auto* async_session = + new (std::nothrow) OpenLessEditSession(context, text, completion); + if (async_session == nullptr) { + context->Release(); + return E_OUTOFMEMORY; + } + + HRESULT async_edit_result = S_OK; + hr = context->RequestEditSession(client_id_, async_session, + TF_ES_ASYNC | TF_ES_READWRITE, + &async_edit_result); + async_session->Release(); context->Release(); if (FAILED(hr)) { return hr; } - return edit_result; + if (FAILED(async_edit_result)) { + return async_edit_result; + } + + *async_completion = std::move(completion); + *wait_for_async_completion = true; + return S_OK; } LRESULT CALLBACK OpenLessTextService::MessageWindowProc(HWND window, @@ -246,8 +322,9 @@ LRESULT CALLBACK OpenLessTextService::MessageWindowProc(HWND window, return 0; } - request->result = - service->CommitTextOnOwnerThread(*request->session_id, *request->text); + request->result = service->CommitTextOnOwnerThread( + *request->session_id, *request->text, &request->async_completion, + &request->wait_for_async_completion); return 1; } diff --git a/openless-all/app/windows-ime/src/text_service.h b/openless-all/app/windows-ime/src/text_service.h index d6402a6a..c69190d0 100644 --- a/openless-all/app/windows-ime/src/text_service.h +++ b/openless-all/app/windows-ime/src/text_service.h @@ -1,11 +1,14 @@ #pragma once #include +#include #include #include #include "ipc_client.h" +struct OpenLessAsyncEditState; + class OpenLessTextService final : public ITfTextInputProcessorEx { public: OpenLessTextService(); @@ -31,8 +34,11 @@ class OpenLessTextService final : public ITfTextInputProcessorEx { void StopIpcServer(); HRESULT EnsureMessageWindow(); void DestroyMessageWindow(); - HRESULT CommitTextOnOwnerThread(const std::wstring& session_id, - const std::wstring& text); + HRESULT CommitTextOnOwnerThread( + const std::wstring& session_id, + const std::wstring& text, + std::shared_ptr* async_completion, + bool* wait_for_async_completion); static LRESULT CALLBACK MessageWindowProc(HWND window, UINT message, From 8cb289ea8c460bd0bbd490e9507572ee2db61d97 Mon Sep 17 00:00:00 2001 From: millionart Date: Sun, 3 May 2026 23:53:29 +0800 Subject: [PATCH 35/43] fix: move Word caret after IME commit --- openless-all/app/scripts/windows-ime-register.ps1 | 3 ++- .../app/scripts/windows-package-msvc.test.mjs | 5 +++++ openless-all/app/windows-ime/src/edit_session.cpp | 11 +++++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/openless-all/app/scripts/windows-ime-register.ps1 b/openless-all/app/scripts/windows-ime-register.ps1 index 47edd880..f252ccb0 100644 --- a/openless-all/app/scripts/windows-ime-register.ps1 +++ b/openless-all/app/scripts/windows-ime-register.ps1 @@ -34,7 +34,8 @@ function Test-IsAdministrator { } $appRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path -$stagingRoot = 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" function Get-DllPath { param( diff --git a/openless-all/app/scripts/windows-package-msvc.test.mjs b/openless-all/app/scripts/windows-package-msvc.test.mjs index 7c578601..e5b4ef3d 100644 --- a/openless-all/app/scripts/windows-package-msvc.test.mjs +++ b/openless-all/app/scripts/windows-package-msvc.test.mjs @@ -67,6 +67,8 @@ assert.match(imeBuild, /\/p:IntDir=/, "IME build should pass IntDir to MSBuild") assert.match(imeRegister, /windows-ime-build\.ps1/, "IME register should build before registering"); assert.doesNotMatch(imeRegister, /if \(-not \(Test-Path \$dll\)\)/, "IME register must rebuild stale DLLs, not only missing DLLs"); assert.match(imeRegister, /windows-ime-register/, "IME register should use a side-by-side staging output to avoid locked registered DLLs"); +assert.match(imeRegister, /Get-Date/, "IME register should create a fresh staging output for each registration run"); +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"); @@ -82,6 +84,9 @@ assert.match(imeTextService, /TF_E_SYNCHRONOUS/, "IME should detect hosts like W assert.match(imeTextService, /TF_ES_ASYNC \| TF_ES_READWRITE/, "IME should retry Word-hosted commits with an async edit session"); assert.match(imeTextService, /WaitForSingleObject/, "IME pipe submit should wait for async edit-session completion"); assert.match(imeEditSession, /SetEvent/, "IME edit session should signal async completion back to the pipe submitter"); +assert.match(imeEditSession, /Collapse\(edit_cookie, TF_ANCHOR_END\)/, "IME should collapse the committed range to its end after insertion"); +assert.match(imeEditSession, /SetSelection\(edit_cookie, 1, &selection\)/, "IME should move the caret to the end of inserted text"); +assert.match(imeEditSession, /TF_AE_END/, "IME should make the end of the committed text the active selection end"); assert.match(wixFragment, /DirectoryRef Id="INSTALLDIR"/, "WiX fragment should install into the app directory"); assert.match(wixFragment, /Component Id="OpenLessImeDllX64Component"/, "WiX fragment should define the x64 TSF DLL component"); diff --git a/openless-all/app/windows-ime/src/edit_session.cpp b/openless-all/app/windows-ime/src/edit_session.cpp index 79261886..6e1a32f1 100644 --- a/openless-all/app/windows-ime/src/edit_session.cpp +++ b/openless-all/app/windows-ime/src/edit_session.cpp @@ -105,6 +105,17 @@ HRESULT OpenLessEditSession::InsertText(TfEditCookie edit_cookie) { edit_cookie, 0, text_.c_str(), static_cast(text_.size()), &committed_range); if (committed_range != nullptr) { + if (SUCCEEDED(hr)) { + const HRESULT collapse_hr = + committed_range->Collapse(edit_cookie, TF_ANCHOR_END); + if (SUCCEEDED(collapse_hr)) { + TF_SELECTION selection = {}; + selection.range = committed_range; + selection.style.ase = TF_AE_END; + selection.style.fInterimChar = FALSE; + (void)context_->SetSelection(edit_cookie, 1, &selection); + } + } committed_range->Release(); } } From f533f6ea05132a679bf3d6b9bc66fa4a10f7ab31 Mon Sep 17 00:00:00 2001 From: millionart Date: Mon, 4 May 2026 00:14:56 +0800 Subject: [PATCH 36/43] fix: address Windows IME review cleanup --- openless-all/app/src-tauri/src/coordinator.rs | 189 +++++++++++++----- .../app/src-tauri/src/windows_ime_profile.rs | 30 ++- 2 files changed, 162 insertions(+), 57 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index c62db45f..773d9556 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -56,6 +56,24 @@ enum ActiveAsr { Whisper(Arc), } +struct SessionResource { + session_id: u64, + resource: T, +} + +impl SessionResource { + fn new(session_id: u64, resource: T) -> Self { + Self { + session_id, + resource, + } + } + + fn into_inner(self) -> T { + self.resource + } +} + struct SessionState { phase: SessionPhase, started_at: Instant, @@ -104,8 +122,8 @@ struct Inner { #[cfg(target_os = "windows")] prepared_windows_ime_session: Arc>>, state: Mutex, - asr: Mutex>, - recorder: Mutex>, + asr: Mutex>>, + recorder: Mutex>>, hotkey: Mutex>, hotkey_status: Mutex, hotkey_trigger_held: AtomicBool, @@ -755,15 +773,72 @@ fn request_stop_during_starting(inner: &Arc, reason: &str) { stop_recorder_if_pending_start_stop(inner); } +fn take_session_resource(slot: &mut Option>, session_id: u64) -> Option { + if slot + .as_ref() + .map(|resource| resource.session_id == session_id) + .unwrap_or(false) + { + slot.take().map(SessionResource::into_inner) + } else { + None + } +} + +fn store_asr_for_session(inner: &Arc, session_id: u64, asr: ActiveAsr) { + *inner.asr.lock() = Some(SessionResource::new(session_id, asr)); +} + +fn take_asr_for_session(inner: &Arc, session_id: u64) -> Option { + let mut slot = inner.asr.lock(); + take_session_resource(&mut slot, session_id) +} + +fn cancel_active_asr(asr: ActiveAsr) { + match asr { + ActiveAsr::Volcengine(v) => v.cancel(), + ActiveAsr::Whisper(w) => w.cancel(), + } +} + +fn cancel_asr_for_session(inner: &Arc, session_id: u64) { + if let Some(asr) = take_asr_for_session(inner, session_id) { + cancel_active_asr(asr); + } +} + +fn store_recorder_for_session(inner: &Arc, session_id: u64, recorder: Recorder) { + *inner.recorder.lock() = Some(SessionResource::new(session_id, recorder)); +} + +fn take_recorder_for_session(inner: &Arc, session_id: u64) -> Option { + let mut slot = inner.recorder.lock(); + take_session_resource(&mut slot, session_id) +} + +fn stop_recorder_for_session(inner: &Arc, session_id: u64) { + if let Some(recorder) = take_recorder_for_session(inner, session_id) { + recorder.stop(); + } +} + +fn discard_startup_resources_for_session(inner: &Arc, session_id: u64) { + stop_recorder_for_session(inner, session_id); + cancel_asr_for_session(inner, session_id); +} + fn stop_recorder_if_pending_start_stop(inner: &Arc) { - let should_stop = { + let (should_stop, session_id) = { let state = inner.state.lock(); - state.phase == SessionPhase::Starting && state.pending_stop + ( + state.phase == SessionPhase::Starting && state.pending_stop, + state.session_id, + ) }; if !should_stop { return; } - if let Some(rec) = inner.recorder.lock().take() { + if let Some(rec) = take_recorder_for_session(inner, session_id) { rec.stop(); let elapsed = inner.state.lock().started_at.elapsed().as_millis() as u64; emit_capsule(inner, CapsuleState::Transcribing, 0.0, elapsed, None, None); @@ -938,7 +1013,11 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { if active_asr == "whisper" { let (api_key, base_url, model) = read_whisper_credentials(); let whisper = Arc::new(WhisperBatchASR::new(api_key, base_url, model)); - *inner.asr.lock() = Some(ActiveAsr::Whisper(Arc::clone(&whisper))); + store_asr_for_session( + inner, + current_session_id, + ActiveAsr::Whisper(Arc::clone(&whisper)), + ); let consumer: Arc = whisper; start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer) .await?; @@ -948,7 +1027,11 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { let asr = Arc::new(VolcengineStreamingASR::new(creds, hotwords)); let bridge = Arc::new(DeferredAsrBridge::new()); let consumer: Arc = bridge.clone(); - *inner.asr.lock() = Some(ActiveAsr::Volcengine(Arc::clone(&asr))); + store_asr_for_session( + inner, + current_session_id, + ActiveAsr::Volcengine(Arc::clone(&asr)), + ); start_recorder_for_starting(inner, current_session_id, &active_asr, consumer)?; if let Err(e) = asr.open_session().await { @@ -959,26 +1042,20 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { "[coord] stale ASR open_session error from session {current_session_id} — ignoring" ); asr.cancel(); + discard_startup_resources_for_session(inner, current_session_id); restore_prepared_windows_ime_session(inner, current_session_id); return Ok(()); } StartupRaceStatus::CancelRaced => { asr.cancel(); + discard_startup_resources_for_session(inner, current_session_id); restore_prepared_windows_ime_session(inner, current_session_id); set_phase_idle_if_session_matches(inner, current_session_id); return Ok(()); } StartupRaceStatus::ActiveStarting => {} } - if let Some(rec) = inner.recorder.lock().take() { - rec.stop(); - } - if let Some(asr) = inner.asr.lock().take() { - match asr { - ActiveAsr::Volcengine(v) => v.cancel(), - ActiveAsr::Whisper(w) => w.cancel(), - } - } + discard_startup_resources_for_session(inner, current_session_id); emit_capsule( inner, CapsuleState::Error, @@ -1000,9 +1077,7 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { StartupRaceStatus::CancelRaced => { log::info!("[coord] cancel raced during ASR open_session — aborting begin"); asr.cancel(); - if let Some(rec) = inner.recorder.lock().take() { - rec.stop(); - } + discard_startup_resources_for_session(inner, current_session_id); restore_prepared_windows_ime_session(inner, current_session_id); set_phase_idle_if_session_matches(inner, current_session_id); return Ok(()); @@ -1012,6 +1087,7 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { "[coord] stale ASR open_session continuation from session {current_session_id} — ignoring" ); asr.cancel(); + discard_startup_resources_for_session(inner, current_session_id); restore_prepared_windows_ime_session(inner, current_session_id); return Ok(()); } @@ -1070,19 +1146,14 @@ fn start_recorder_for_starting( match Recorder::start(consumer, level_handler) { Ok((rec, runtime_errors)) => { - *inner.recorder.lock() = Some(rec); + store_recorder_for_session(inner, session_id, rec); spawn_recorder_error_monitor(inner, runtime_errors); stop_recorder_if_pending_start_stop(inner); log::info!("[coord] recorder started (asr={active_asr}, phase=Starting)"); } Err(e) => { log::error!("[coord] recorder start failed: {e}"); - if let Some(asr) = inner.asr.lock().take() { - match asr { - ActiveAsr::Volcengine(v) => v.cancel(), - ActiveAsr::Whisper(w) => w.cancel(), - } - } + cancel_asr_for_session(inner, session_id); emit_capsule( inner, CapsuleState::Error, @@ -1173,15 +1244,7 @@ fn abort_recording_with_error(inner: &Arc, message: String) { ) }; - if let Some(rec) = inner.recorder.lock().take() { - rec.stop(); - } - if let Some(asr) = inner.asr.lock().take() { - match asr { - ActiveAsr::Volcengine(v) => v.cancel(), - ActiveAsr::Whisper(w) => w.cancel(), - } - } + discard_startup_resources_for_session(inner, session_id); restore_prepared_windows_ime_session(inner, session_id); emit_capsule( @@ -1231,19 +1294,12 @@ async fn finish_starting_session(inner: &Arc, session_id: u64) { log::info!( "[coord] stale recorder/ASR startup continuation from session {session_id} — ignoring" ); + discard_startup_resources_for_session(inner, session_id); restore_prepared_windows_ime_session(inner, session_id); } BeginOutcome::CancelRaced => { log::info!("[coord] cancel raced during recorder/ASR startup — aborting begin"); - if let Some(rec) = inner.recorder.lock().take() { - rec.stop(); - } - if let Some(asr) = inner.asr.lock().take() { - match asr { - ActiveAsr::Volcengine(v) => v.cancel(), - ActiveAsr::Whisper(w) => w.cancel(), - } - } + discard_startup_resources_for_session(inner, session_id); restore_prepared_windows_ime_session(inner, session_id); set_phase_idle_if_session_matches(inner, session_id); } @@ -1270,11 +1326,11 @@ async fn end_session(inner: &Arc) -> Result<(), String> { let elapsed = inner.state.lock().started_at.elapsed().as_millis() as u64; emit_capsule(inner, CapsuleState::Transcribing, 0.0, elapsed, None, None); - if let Some(rec) = inner.recorder.lock().take() { + if let Some(rec) = take_recorder_for_session(inner, current_session_id) { rec.stop(); } - let asr_opt = inner.asr.lock().take(); + let asr_opt = take_asr_for_session(inner, current_session_id); let asr = match asr_opt { Some(a) => a, None => { @@ -1581,15 +1637,8 @@ fn cancel_session(inner: &Arc) { (phase, state.session_id) }; - if let Some(rec) = inner.recorder.lock().take() { - rec.stop(); - } - if let Some(asr) = inner.asr.lock().take() { - match asr { - ActiveAsr::Volcengine(v) => v.cancel(), - ActiveAsr::Whisper(w) => w.cancel(), - } - } + stop_recorder_for_session(inner, session_id); + cancel_asr_for_session(inner, session_id); restore_prepared_windows_ime_session(inner, session_id); // Processing 阶段保持 phase=Processing 让 end_session 自己走完检查 + 收尾; // 其他阶段直接转 Idle。 @@ -2622,6 +2671,36 @@ mod tests { StartupRaceStatus::StaleContinuation ); } + + #[test] + fn stale_startup_cleanup_keeps_newer_asr_resource() { + let coordinator = Coordinator::new(); + let newer_asr = Arc::new(WhisperBatchASR::new( + "key".to_string(), + "http://localhost".to_string(), + "model".to_string(), + )); + *coordinator.inner.asr.lock() = Some(SessionResource::new( + 2, + ActiveAsr::Whisper(Arc::clone(&newer_asr)), + )); + + discard_startup_resources_for_session(&coordinator.inner, 1); + + assert_eq!( + coordinator + .inner + .asr + .lock() + .as_ref() + .map(|resource| resource.session_id), + Some(2) + ); + + discard_startup_resources_for_session(&coordinator.inner, 2); + + assert!(coordinator.inner.asr.lock().is_none()); + } } fn enabled_phrases(inner: &Arc) -> Vec { 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 253af515..57d55c5f 100644 --- a/openless-all/app/src-tauri/src/windows_ime_profile.rs +++ b/openless-all/app/src-tauri/src/windows_ime_profile.rs @@ -243,14 +243,31 @@ mod windows_impl { if profile.dwProfileType == TF_PROFILETYPE_INPUTPROCESSOR { Ok(ImeProfileSnapshot::text_service( profile.langid, - format!("{:?}", profile.clsid), - format!("{:?}", profile.guidProfile), + guid_to_braced_string(profile.clsid), + guid_to_braced_string(profile.guidProfile), )) } else { keyboard_layout_snapshot_from_tsf(profile.langid, profile.hkl) } } + pub(super) fn guid_to_braced_string(guid: GUID) -> String { + format!( + "{{{:08X}-{:04X}-{:04X}-{:02X}{:02X}-{:02X}{:02X}{:02X}{:02X}{:02X}{:02X}}}", + guid.data1, + guid.data2, + guid.data3, + guid.data4[0], + guid.data4[1], + guid.data4[2], + guid.data4[3], + guid.data4[4], + guid.data4[5], + guid.data4[6], + guid.data4[7], + ) + } + pub fn activate_openless_profile() -> WindowsImeProfileResult<()> { let clsid = parse_guid(OPENLESS_TEXT_SERVICE_CLSID_BRACED)?; let profile_guid = parse_guid(OPENLESS_PROFILE_GUID_BRACED)?; @@ -657,4 +674,13 @@ mod windows_tests { .to_string() .contains("active keyboard layout profile has no HKL")); } + + #[test] + fn guid_snapshot_strings_are_canonical_and_parseable() { + let guid = windows::core::GUID::from_u128(0x6b9f3f4f_5ee7_42d6_9c61_9f80b03a5d7d); + let formatted = windows_impl::guid_to_braced_string(guid); + + assert_eq!(formatted, "{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}"); + assert!(parse_guid(&formatted).is_ok()); + } } From 31045f7871668cf216048ae667be0dfb54ca3cf3 Mon Sep 17 00:00:00 2001 From: millionart Date: Mon, 4 May 2026 00:18:26 +0800 Subject: [PATCH 37/43] fix: preserve IME snapshot on activation failure --- .../app/src-tauri/src/windows_ime_session.rs | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/openless-all/app/src-tauri/src/windows_ime_session.rs b/openless-all/app/src-tauri/src/windows_ime_session.rs index e43dafb3..85334ffd 100644 --- a/openless-all/app/src-tauri/src/windows_ime_session.rs +++ b/openless-all/app/src-tauri/src/windows_ime_session.rs @@ -46,8 +46,15 @@ impl PreparedWindowsImeSession { } } + pub fn activation_failed(saved_profile: ImeProfileSnapshot) -> Self { + Self { + saved_profile: Some(saved_profile), + openless_activated: false, + } + } + pub fn is_ready_for_tsf_submit(&self) -> bool { - self.saved_profile.is_some() && self.openless_activated + self.has_saved_profile() && self.openless_was_activated() } pub fn has_saved_profile(&self) -> bool { @@ -59,7 +66,7 @@ impl PreparedWindowsImeSession { } pub fn should_restore_when_active_profile_check_fails(&self) -> bool { - self.has_saved_profile() && self.openless_was_activated() + self.has_saved_profile() } } @@ -96,7 +103,7 @@ impl WindowsImeSessionController { Err(error) => { let error = WindowsImeSessionError::Profile(error.to_string()); log::warn!("[windows-ime] activate OpenLess profile failed: {error}"); - PreparedWindowsImeSession::unavailable() + PreparedWindowsImeSession::activation_failed(saved_profile) } } } @@ -211,14 +218,29 @@ mod tests { } #[test] - fn active_profile_check_failure_restores_only_prepared_active_session() { + fn active_profile_check_failure_restores_any_session_with_saved_profile() { let prepared = PreparedWindowsImeSession { saved_profile: Some(ImeProfileSnapshot::keyboard_layout(0x0409, 0x0409_0409)), openless_activated: true, }; + let activation_failed = PreparedWindowsImeSession::activation_failed( + ImeProfileSnapshot::keyboard_layout(0x0409, 0x0409_0409), + ); assert!(prepared.should_restore_when_active_profile_check_fails()); + assert!(activation_failed.should_restore_when_active_profile_check_fails()); assert!(!PreparedWindowsImeSession::unavailable() .should_restore_when_active_profile_check_fails()); } + + #[test] + fn activation_failed_session_keeps_snapshot_but_cannot_submit() { + let prepared = PreparedWindowsImeSession::activation_failed( + ImeProfileSnapshot::keyboard_layout(0x0409, 0x0409_0409), + ); + + assert!(prepared.has_saved_profile()); + assert!(!prepared.openless_was_activated()); + assert!(!prepared.is_ready_for_tsf_submit()); + } } From 2554259d8057ae39f766cf321043fb0724f8807c Mon Sep 17 00:00:00 2001 From: millionart Date: Mon, 4 May 2026 10:17:56 +0800 Subject: [PATCH 38/43] 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); + } } From e040caebf9a4d5424e8ba0d316586b510c52461f Mon Sep 17 00:00:00 2001 From: millionart Date: Mon, 4 May 2026 10:48:23 +0800 Subject: [PATCH 39/43] fix: extend Windows IME submit timeout --- .../app/src-tauri/src/windows_ime_ipc.rs | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/openless-all/app/src-tauri/src/windows_ime_ipc.rs b/openless-all/app/src-tauri/src/windows_ime_ipc.rs index 27944a59..b5585bf0 100644 --- a/openless-all/app/src-tauri/src/windows_ime_ipc.rs +++ b/openless-all/app/src-tauri/src/windows_ime_ipc.rs @@ -3,9 +3,17 @@ use std::time::Duration; use crate::windows_ime_protocol::ImeSubmitStatus; pub const IME_CLIENT_WAIT_TIMEOUT: Duration = Duration::from_millis(700); -// Must exceed the IME DLL owner-thread commit timeout (3000 ms), otherwise Rust -// can fall back while the DLL later commits and duplicates insertion. -pub const IME_SUBMIT_TIMEOUT: Duration = Duration::from_millis(3800); +const IME_OWNER_THREAD_MESSAGE_TIMEOUT_MS: u64 = 3000; +const IME_ASYNC_EDIT_SESSION_TIMEOUT_MS: u64 = 3000; +const IME_SUBMIT_TIMEOUT_MARGIN_MS: u64 = 1000; +const IME_NATIVE_ASYNC_COMMIT_TIMEOUT_MS: u64 = + IME_OWNER_THREAD_MESSAGE_TIMEOUT_MS + IME_ASYNC_EDIT_SESSION_TIMEOUT_MS; + +// Must exceed the IME DLL owner-thread SendMessageTimeoutW wait plus the +// async edit session wait, otherwise Rust can fall back while the DLL later +// commits and duplicates insertion. +pub const IME_SUBMIT_TIMEOUT: Duration = + Duration::from_millis(IME_NATIVE_ASYNC_COMMIT_TIMEOUT_MS + IME_SUBMIT_TIMEOUT_MARGIN_MS); const IME_PIPE_RETRY_INTERVAL: Duration = Duration::from_millis(25); const ERROR_FILE_NOT_FOUND: u32 = 2; @@ -382,6 +390,14 @@ mod tests { .is_err()); } + #[test] + fn submit_timeout_covers_native_async_commit_path() { + assert!( + IME_SUBMIT_TIMEOUT + > Duration::from_millis(IME_NATIVE_ASYNC_COMMIT_TIMEOUT_MS) + ); + } + #[test] fn wait_pipe_error_mapping_treats_missing_or_busy_pipe_as_no_ready_client() { assert_eq!( From 77eb569468440a42751cafcc13e6bd2363a0ae74 Mon Sep 17 00:00:00 2001 From: millionart Date: Mon, 4 May 2026 11:11:21 +0800 Subject: [PATCH 40/43] fix: restore IME profile after activation failure --- .../app/src-tauri/src/windows_ime_profile.rs | 17 +++++++++++++---- .../app/src-tauri/src/windows_ime_session.rs | 13 ++++++++++--- 2 files changed, 23 insertions(+), 7 deletions(-) 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 f2160975..beb3c9f0 100644 --- a/openless-all/app/src-tauri/src/windows_ime_profile.rs +++ b/openless-all/app/src-tauri/src/windows_ime_profile.rs @@ -77,8 +77,9 @@ pub enum ProfileRestoreDecision { pub fn restore_decision( saved: Option<&ImeProfileSnapshot>, openless_profile_is_current: bool, + openless_activation_failed: bool, ) -> ProfileRestoreDecision { - if saved.is_some() && openless_profile_is_current { + if saved.is_some() && (openless_profile_is_current || openless_activation_failed) { ProfileRestoreDecision::RestoreSavedProfile } else { ProfileRestoreDecision::KeepCurrentProfile @@ -628,7 +629,15 @@ mod tests { #[test] fn restore_is_required_when_openless_is_active_and_snapshot_exists() { assert_eq!( - restore_decision(Some(&text_service_snapshot()), true), + restore_decision(Some(&text_service_snapshot()), true, false), + ProfileRestoreDecision::RestoreSavedProfile + ); + } + + #[test] + fn restore_is_required_after_activation_failure_with_snapshot() { + assert_eq!( + restore_decision(Some(&text_service_snapshot()), false, true), ProfileRestoreDecision::RestoreSavedProfile ); } @@ -636,7 +645,7 @@ mod tests { #[test] fn restore_is_skipped_when_snapshot_is_missing() { assert_eq!( - restore_decision(None, true), + restore_decision(None, true, true), ProfileRestoreDecision::KeepCurrentProfile ); } @@ -644,7 +653,7 @@ mod tests { #[test] fn restore_is_skipped_when_user_already_changed_away_from_openless() { assert_eq!( - restore_decision(Some(&text_service_snapshot()), false), + restore_decision(Some(&text_service_snapshot()), false, false), ProfileRestoreDecision::KeepCurrentProfile ); } diff --git a/openless-all/app/src-tauri/src/windows_ime_session.rs b/openless-all/app/src-tauri/src/windows_ime_session.rs index 85334ffd..8c82798a 100644 --- a/openless-all/app/src-tauri/src/windows_ime_session.rs +++ b/openless-all/app/src-tauri/src/windows_ime_session.rs @@ -68,6 +68,10 @@ impl PreparedWindowsImeSession { pub fn should_restore_when_active_profile_check_fails(&self) -> bool { self.has_saved_profile() } + + pub fn activation_failed_with_saved_profile(&self) -> bool { + self.has_saved_profile() && !self.openless_was_activated() + } } pub struct WindowsImeSessionController { @@ -140,9 +144,11 @@ impl WindowsImeSessionController { pub fn restore_session(&self, prepared: PreparedWindowsImeSession) { let should_restore = match self.profile_manager.is_openless_profile_active() { - Ok(openless_active) => { - restore_decision(prepared.saved_profile.as_ref(), openless_active) - } + Ok(openless_active) => restore_decision( + prepared.saved_profile.as_ref(), + openless_active, + prepared.activation_failed_with_saved_profile(), + ), Err(error) => { if prepared.should_restore_when_active_profile_check_fails() { log::warn!( @@ -242,5 +248,6 @@ mod tests { assert!(prepared.has_saved_profile()); assert!(!prepared.openless_was_activated()); assert!(!prepared.is_ready_for_tsf_submit()); + assert!(prepared.activation_failed_with_saved_profile()); } } From 1de10d32aed9a00121f30d61dd13512f38781062 Mon Sep 17 00:00:00 2001 From: millionart Date: Mon, 4 May 2026 11:58:18 +0800 Subject: [PATCH 41/43] fix: skip stale Windows IME session restores --- openless-all/app/src-tauri/src/coordinator.rs | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 9cea8bd8..69246fb3 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -1674,11 +1674,30 @@ fn take_matching_prepared_windows_ime_session( Some(slots.remove(index).prepared) } +#[cfg(target_os = "windows")] +fn take_current_prepared_windows_ime_session_for_restore( + slots: &mut Vec, + session_id: u64, + current_session_id: u64, +) -> Option { + let prepared = take_matching_prepared_windows_ime_session(slots, session_id)?; + if current_session_id == session_id { + Some(prepared) + } else { + None + } +} + #[cfg(target_os = "windows")] fn restore_prepared_windows_ime_session(inner: &Arc, session_id: u64) { + let state = inner.state.lock(); let prepared = { let mut slot = inner.prepared_windows_ime_session.lock(); - take_matching_prepared_windows_ime_session(&mut slot, session_id) + take_current_prepared_windows_ime_session_for_restore( + &mut slot, + session_id, + state.session_id, + ) }; if let Some(prepared) = prepared { inner.windows_ime.restore_session(prepared); @@ -2690,6 +2709,20 @@ mod tests { ); } + #[test] + #[cfg(target_os = "windows")] + fn stale_prepared_windows_ime_restore_discards_old_snapshot_without_restoring() { + 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!(take_current_prepared_windows_ime_session_for_restore(&mut slots, 1, 2).is_none()); + assert_eq!( + slots.iter().map(|slot| slot.session_id).collect::>(), + vec![2] + ); + } + #[test] #[cfg(target_os = "windows")] fn non_tsf_insertion_fallback_gate_blocks_only_when_disabled() { From 9e2c79598aa96b35c8074fa1f585ae5d9cb13160 Mon Sep 17 00:00:00 2001 From: millionart Date: Mon, 4 May 2026 12:37:56 +0800 Subject: [PATCH 42/43] fix: classify Windows IME focus failures --- openless-all/app/src-tauri/src/coordinator.rs | 71 ++++++++++++++++--- 1 file changed, 61 insertions(+), 10 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 69246fb3..9a0ee515 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -1550,14 +1550,14 @@ async fn end_session(inner: &Arc) -> Result<(), String> { // polish 失败时在 history 里标记 polishFailed,让用户能在历史详情看到为什么这次输出 // 不是预期的 mode 风格。即使失败也不丢词 — final_text 仍是原文(保留"用户的话不丢"语义)。 - let tsf_required_insert_failed = cfg!(target_os = "windows") - && !allow_non_tsf_insertion_fallback - && status == InsertStatus::Failed; - let error_code = if tsf_required_insert_failed { - Some("windowsImeTsfRequired".to_string()) - } else { - polish_error.as_ref().map(|_| "polishFailed".to_string()) - }; + let error_code = dictation_error_code( + status, + polish_error.is_some(), + focus_ready_for_paste, + allow_non_tsf_insertion_fallback, + ) + .map(str::to_string); + let tsf_required_insert_failed = error_code.as_deref() == Some("windowsImeTsfRequired"); let session = DictationSession { id: Uuid::new_v4().to_string(), @@ -1615,6 +1615,27 @@ async fn end_session(inner: &Arc) -> Result<(), String> { Ok(()) } +fn dictation_error_code( + status: InsertStatus, + polish_failed: bool, + focus_ready_for_paste: bool, + allow_non_tsf_insertion_fallback: bool, +) -> Option<&'static str> { + if !focus_ready_for_paste && status == InsertStatus::Failed { + Some("focusRestoreFailed") + } else if cfg!(target_os = "windows") + && focus_ready_for_paste + && !allow_non_tsf_insertion_fallback + && status == InsertStatus::Failed + { + Some("windowsImeTsfRequired") + } else if polish_failed { + Some("polishFailed") + } else { + None + } +} + fn cancel_session(inner: &Arc) { let (phase, session_id) = { let mut state = inner.state.lock(); @@ -2748,6 +2769,31 @@ mod tests { )); } + #[test] + fn focus_restore_failure_uses_specific_error_code_when_insert_fails() { + assert_eq!( + dictation_error_code(InsertStatus::Failed, false, false, false), + Some("focusRestoreFailed") + ); + } + + #[test] + #[cfg(target_os = "windows")] + fn missing_windows_hwnd_is_not_present() { + use windows::Win32::Foundation::HWND; + + assert!(!windows_hwnd_is_present(HWND::default())); + } + + #[test] + #[cfg(target_os = "windows")] + fn tsf_required_failure_keeps_tsf_error_when_focus_was_ready() { + assert_eq!( + dictation_error_code(InsertStatus::Failed, false, true, false), + Some("windowsImeTsfRequired") + ); + } + #[test] fn startup_race_check_treats_newer_session_as_stale() { let mut state = SessionState::default(); @@ -3017,6 +3063,11 @@ fn restore_focus_target_if_possible(_target: Option) -> bool { true } +#[cfg(target_os = "windows")] +fn windows_hwnd_is_present(hwnd: windows::Win32::Foundation::HWND) -> bool { + hwnd != windows::Win32::Foundation::HWND::default() +} + #[cfg(target_os = "windows")] fn capture_ime_submit_target() -> Option { use windows::Win32::UI::WindowsAndMessaging::{ @@ -3024,7 +3075,7 @@ fn capture_ime_submit_target() -> Option { }; let foreground = unsafe { GetForegroundWindow() }; - if foreground.0.is_null() { + if !windows_hwnd_is_present(foreground) { return None; } @@ -3040,7 +3091,7 @@ fn capture_ime_submit_target() -> Option { ..Default::default() }; let target_window = if unsafe { GetGUIThreadInfo(foreground_thread_id, &mut gui_info).is_ok() } - && !gui_info.hwndFocus.0.is_null() + && windows_hwnd_is_present(gui_info.hwndFocus) { gui_info.hwndFocus } else { From ec16958d4dc840e7d121e3d0de7978509f7496c9 Mon Sep 17 00:00:00 2001 From: millionart Date: Mon, 4 May 2026 13:14:00 +0800 Subject: [PATCH 43/43] fix: restore IME before abort idle --- openless-all/app/src-tauri/src/coordinator.rs | 90 +++++++++++++++---- 1 file changed, 72 insertions(+), 18 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 9a0ee515..a2867b61 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -1224,38 +1224,58 @@ fn spawn_qa_recorder_error_monitor(inner: &Arc, rx: mpsc::Receiver, message: String) { - let (elapsed, session_id) = { + let Some(abort) = ({ let mut state = inner.state.lock(); - if state.cancelled - || !matches!( - state.phase, - SessionPhase::Starting | SessionPhase::Listening - ) - { - return; - } - state.cancelled = true; - state.phase = SessionPhase::Idle; - ( - state.started_at.elapsed().as_millis() as u64, - state.session_id, - ) + begin_recording_abort_before_restore(&mut state) + }) else { + return; }; - discard_startup_resources_for_session(inner, session_id); - restore_prepared_windows_ime_session(inner, session_id); + discard_startup_resources_for_session(inner, abort.session_id); + restore_prepared_windows_ime_session(inner, abort.session_id); + { + let mut state = inner.state.lock(); + publish_abort_idle_after_restore(&mut state, abort.session_id); + } emit_capsule( inner, CapsuleState::Error, 0.0, - elapsed, + abort.elapsed, Some(message), None, ); schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); } +struct RecordingAbort { + elapsed: u64, + session_id: u64, +} + +fn begin_recording_abort_before_restore(state: &mut SessionState) -> Option { + if state.cancelled + || !matches!( + state.phase, + SessionPhase::Starting | SessionPhase::Listening + ) + { + return None; + } + state.cancelled = true; + Some(RecordingAbort { + elapsed: state.started_at.elapsed().as_millis() as u64, + session_id: state.session_id, + }) +} + +fn publish_abort_idle_after_restore(state: &mut SessionState, session_id: u64) { + if state.session_id == session_id { + state.phase = SessionPhase::Idle; + } +} + async fn start_recorder_and_enter_listening( inner: &Arc, session_id: u64, @@ -2656,6 +2676,40 @@ mod tests { assert!(coordinator.inner.asr.lock().is_none()); } + #[test] + fn abort_recording_keeps_session_non_idle_until_restore_can_run() { + let mut state = SessionState::default(); + state.phase = SessionPhase::Listening; + state.cancelled = false; + state.session_id = 7; + + let abort = begin_recording_abort_before_restore(&mut state).unwrap(); + + assert_eq!(abort.session_id, 7); + assert!(state.cancelled); + assert_eq!(state.phase, SessionPhase::Listening); + + publish_abort_idle_after_restore(&mut state, abort.session_id); + + assert_eq!(state.phase, SessionPhase::Idle); + } + + #[tokio::test] + async fn pressed_edge_during_inserting_does_not_start_new_session() { + let coordinator = Coordinator::new(); + { + let mut state = coordinator.inner.state.lock(); + state.phase = SessionPhase::Inserting; + state.session_id = 41; + } + + handle_pressed_edge(&coordinator.inner).await; + + let state = coordinator.inner.state.lock(); + assert_eq!(state.phase, SessionPhase::Inserting); + assert_eq!(state.session_id, 41); + } + #[tokio::test] async fn repeated_pressed_edge_during_hold_session_does_not_restart() { let coordinator = Coordinator::new();