merge Windows TSF IME support

This commit is contained in:
millionart committed 2026-05-03 19:24:21 +08:00
commit f497cd4eb5
47 files changed
+6761 -92

No files matched your search

+5
View File
@@ -23,6 +23,11 @@ apps/
promo/
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/
File diff suppressed because it is too large. Load diff
@@ -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`
@@ -0,0 +1,79 @@
param(
[ValidateSet("Debug", "Release")]
[string]$Configuration = "Release",
[string]$OutputDirectory = "",
[string]$IntermediateDirectory = ""
)
$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
$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"
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"
}
if (-not (Test-Path $dll)) {
throw "OpenLessIme.dll was not produced: $dll"
}
Write-Host "[ok] $dll"
@@ -0,0 +1,39 @@
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")
}
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"
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
$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"
@@ -0,0 +1,40 @@
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")
}
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"
if (-not (Test-Path $dll)) {
Write-Host "[skip] OpenLessIme.dll not found: $dll"
exit 0
}
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)"
}
Write-Host "[ok] OpenLess TSF IME unregistered"
@@ -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%
@@ -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
}
@@ -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");
+63 -1
View File
@@ -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)) {
@@ -32,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}",
@@ -100,6 +134,34 @@ 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 (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) {
exit 1
}
@@ -1,13 +1,15 @@
param(
[string]$ExePath = "",
[ValidateSet("notepad", "browser", "wt-cmd", "wt-powershell")]
[ValidateSet("notepad", "browser", "wt-cmd", "wt-powershell", "win32edit")]
[string]$Target = "notepad",
[string]$Phrase = "OpenLess Windows real regression",
[int]$TimeoutSeconds = 120,
[int]$VirtualKey = 0xA2,
[int]$VirtualKey = 0xA3,
[string]$InjectedTranscriptText = "",
[int]$ManualSpeechSeconds = 8,
[switch]$ManualSpeech,
[switch]$AllowClipboardFallback,
[switch]$RequireJsonCredentials,
[switch]$DebugHotkeyEvents
)
@@ -296,6 +298,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
@@ -402,6 +446,15 @@ raise SystemExit(1)
TargetKind = "terminal"
}
}
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
@@ -508,6 +561,16 @@ raise SystemExit(1)
}
}
if ($TargetName -eq "win32edit") {
Focus-Window $TargetInfo.Process | Out-Null
Start-Sleep -Milliseconds 400
Send-CtrlChord 0x41
Start-Sleep -Milliseconds 200
Send-CtrlChord 0x43
Start-Sleep -Milliseconds 400
return Get-Clipboard -Raw -ErrorAction SilentlyContinue
}
return $null
}
@@ -530,9 +593,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"
@@ -551,7 +617,7 @@ if (-not [string]::IsNullOrWhiteSpace($InjectedTranscriptText)) {
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) {
@@ -617,8 +683,11 @@ try {
if ([string]::IsNullOrWhiteSpace($latest.rawTranscript) -or [string]::IsNullOrWhiteSpace($latest.finalText)) {
throw "Latest history item is missing rawTranscript or finalText."
}
if ($latest.insertStatus -ne "pasteSent") {
throw "Expected Windows insertStatus pasteSent after guarded foreground restore, 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)"
}
$targetText = Read-TargetContent $inputTarget $Target
@@ -663,4 +732,4 @@ try {
}
}
Write-Host "Real ASR + insertion fallback smoke ($Target) passed."
Write-Host "Real ASR + direct insertion smoke ($Target) passed."
@@ -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
+8 -3
View File
@@ -56,12 +56,17 @@ objc2-app-kit = "0.2"
raw-window-handle = "0.6"
windows = { version = "0.58", features = [
"Win32_Foundation",
"Win32_Globalization",
"Win32_Graphics_Dwm",
"Win32_Graphics_Gdi",
"Win32_UI_Shell",
"Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_WindowsAndMessaging",
"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"
+6 -1
View File
@@ -13,7 +13,7 @@ use crate::persistence::{CredentialAccount, CredentialsSnapshot, CredentialsVaul
use crate::polish::{LLMError, OpenAICompatibleConfig, OpenAICompatibleLLMProvider};
use crate::types::{
CredentialsStatus, DictationSession, DictionaryEntry, HotkeyCapability, HotkeyStatus,
PolishMode, QaHotkeyBinding, UserPreferences,
PolishMode, QaHotkeyBinding, UserPreferences, WindowsImeStatus,
};
type CoordinatorState<'a> = State<'a, Arc<Coordinator>>;
@@ -81,6 +81,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();
+390 -47
View File
@@ -33,6 +33,10 @@ use crate::types::{
CapsulePayload, CapsuleState, DictationSession, HotkeyCapability, HotkeyMode, HotkeyStatus,
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)]
enum SessionPhase {
@@ -95,6 +99,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<Mutex<Option<PreparedWindowsImeSessionSlot>>>,
state: Mutex<SessionState>,
asr: Mutex<Option<ActiveAsr>>,
recorder: Mutex<Option<Recorder>>,
@@ -163,6 +171,13 @@ impl Default for QaSessionState {
}
}
#[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| {
@@ -179,6 +194,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),
@@ -840,7 +859,7 @@ fn window_key_matches_trigger(trigger: crate::types::HotkeyTrigger, key: &str, c
// ─────────────────────────── session lifecycle ───────────────────────────
async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
{
let current_session_id = {
let mut state = inner.state.lock();
if state.phase != SessionPhase::Idle {
return Ok(());
@@ -858,6 +877,15 @@ async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
if let Some(label) = state.front_app.as_deref() {
log::info!("[coord] front_app captured: {label}");
}
state.session_id
};
#[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,
});
}
// 翻译模式标志重置;hotkey 监听器在 Shift down 时再 set true。
inner
@@ -882,6 +910,7 @@ async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
Some(message.clone()),
None,
);
restore_prepared_windows_ime_session(inner, current_session_id);
inner.state.lock().phase = SessionPhase::Idle;
return Err(message);
}
@@ -896,6 +925,7 @@ async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
Some(message.clone()),
None,
);
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);
@@ -910,7 +940,8 @@ async fn begin_session(inner: &Arc<Inner>) -> 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<dyn crate::recorder::AudioConsumer> = 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();
@@ -918,10 +949,27 @@ async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
let bridge = Arc::new(DeferredAsrBridge::new());
let consumer: Arc<dyn crate::recorder::AudioConsumer> = 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}");
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();
}
@@ -931,10 +979,6 @@ async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
ActiveAsr::Whisper(w) => w.cancel(),
}
}
if cancel_raced_during_starting(inner) {
inner.state.lock().phase = SessionPhase::Idle;
return Ok(());
}
emit_capsule(
inner,
CapsuleState::Error,
@@ -943,26 +987,39 @@ async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
Some(format!("ASR 连接失败: {e}")),
None,
);
inner.state.lock().phase = SessionPhase::Idle;
restore_prepared_windows_ime_session(inner, current_session_id);
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(());
}
inner.state.lock().phase = SessionPhase::Idle;
return Ok(());
}
let target: Arc<dyn crate::asr::AudioConsumer> = 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(())
@@ -970,6 +1027,7 @@ async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
fn start_recorder_for_starting(
inner: &Arc<Inner>,
session_id: u64,
active_asr: &str,
consumer: Arc<dyn crate::recorder::AudioConsumer>,
) -> Result<(), String> {
@@ -1033,6 +1091,7 @@ fn start_recorder_for_starting(
Some(format!("录音启动失败: {e}")),
None,
);
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());
@@ -1096,7 +1155,7 @@ fn spawn_qa_recorder_error_monitor(inner: &Arc<Inner>, rx: mpsc::Receiver<Record
}
fn abort_recording_with_error(inner: &Arc<Inner>, message: String) {
let elapsed = {
let (elapsed, session_id) = {
let mut state = inner.state.lock();
if state.cancelled
|| !matches!(
@@ -1108,7 +1167,10 @@ fn abort_recording_with_error(inner: &Arc<Inner>, 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() {
@@ -1120,6 +1182,7 @@ fn abort_recording_with_error(inner: &Arc<Inner>, message: String) {
ActiveAsr::Whisper(w) => w.cancel(),
}
}
restore_prepared_windows_ime_session(inner, session_id);
emit_capsule(
inner,
@@ -1134,21 +1197,24 @@ fn abort_recording_with_error(inner: &Arc<Inner>, message: String) {
async fn start_recorder_and_enter_listening(
inner: &Arc<Inner>,
session_id: u64,
active_asr: &str,
consumer: Arc<dyn crate::recorder::AudioConsumer>,
) -> 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<Inner>) {
async fn finish_starting_session(inner: &Arc<Inner>, session_id: u64) {
// audit HIGH #1:转 Listening 之前在同一 lock 内检查 cancel race。
// 之前是无条件 phase=Listening,会把 cancel_session 在 await 期间设的 Idle
// 反向覆盖回 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;
@@ -1161,6 +1227,12 @@ async fn finish_starting_session(inner: &Arc<Inner>) {
}
};
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() {
@@ -1172,7 +1244,8 @@ async fn finish_starting_session(inner: &Arc<Inner>) {
ActiveAsr::Whisper(w) => w.cancel(),
}
}
inner.state.lock().phase = SessionPhase::Idle;
restore_prepared_windows_ime_session(inner, session_id);
set_phase_idle_if_session_matches(inner, session_id);
}
BeginOutcome::Started | BeginOutcome::PendingStop => {
log::info!("[coord] session started");
@@ -1185,13 +1258,14 @@ async fn finish_starting_session(inner: &Arc<Inner>) {
}
async fn end_session(inner: &Arc<Inner>) -> 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);
@@ -1204,6 +1278,7 @@ async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
let asr = match asr_opt {
Some(a) => a,
None => {
restore_prepared_windows_ime_session(inner, current_session_id);
inner.state.lock().phase = SessionPhase::Idle;
return Ok(());
}
@@ -1226,6 +1301,7 @@ async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
Some(format!("识别失败: {e}")),
None,
);
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());
@@ -1244,6 +1320,7 @@ async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
Some(format!("识别失败: {e}")),
None,
);
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());
@@ -1255,6 +1332,7 @@ async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
// 优先级高于 empty 检查 — 用户取消 → 静默丢弃,不写失败历史也不弹错误胶囊。
if inner.state.lock().cancelled {
log::info!("[coord] cancel detected after ASR — discarding transcript");
restore_prepared_windows_ime_session(inner, current_session_id);
inner.state.lock().phase = SessionPhase::Idle;
return Ok(());
}
@@ -1299,6 +1377,7 @@ async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
Some("ASR returned empty transcript".to_string()),
None,
);
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());
@@ -1358,19 +1437,42 @@ async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
"[coord] cancel detected before insert — discarding output (chars={})",
polished.chars().count()
);
restore_prepared_windows_ime_session(inner, current_session_id);
return Ok(());
}
let focus_target = inner.state.lock().focus_target;
let focus_ready_for_paste = 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;
let status = if focus_ready_for_paste {
inner.inserter.insert(&polished, restore_clipboard)
#[cfg(target_os = "windows")]
{
let ime_target = capture_ime_submit_target();
insert_with_windows_ime_first(
inner,
current_session_id,
&polished,
restore_clipboard,
allow_non_tsf_insertion_fallback,
ime_target,
)
.await
}
#[cfg(not(target_os = "windows"))]
{
inner.inserter.insert(&polished, restore_clipboard)
}
} else {
log::warn!(
"[coord] original insertion target is not foreground; copied output without paste"
);
inner.inserter.copy_fallback(&polished)
if allow_non_tsf_insertion_fallback {
inner.inserter.copy_fallback(&polished)
} else {
InsertStatus::Failed
}
};
let inserted_chars = polished.chars().count() as u32;
@@ -1393,7 +1495,14 @@ async fn end_session(inner: &Arc<Inner>) -> 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(),
@@ -1414,7 +1523,9 @@ async fn end_session(inner: &Arc<Inner>) -> 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 {
@@ -1450,20 +1561,24 @@ async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
}
fn cancel_session(inner: &Arc<Inner>) {
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();
@@ -1474,6 +1589,7 @@ fn cancel_session(inner: &Arc<Inner>) {
ActiveAsr::Whisper(w) => w.cancel(),
}
}
restore_prepared_windows_ime_session(inner, session_id);
// Processing 阶段保持 phase=Processing 让 end_session 自己走完检查 + 收尾;
// 其他阶段直接转 Idle。
if phase != SessionPhase::Processing {
@@ -1486,6 +1602,110 @@ fn cancel_session(inner: &Arc<Inner>) {
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
}
#[cfg(target_os = "windows")]
fn take_matching_prepared_windows_ime_session(
slot: &mut Option<PreparedWindowsImeSessionSlot>,
session_id: u64,
) -> Option<PreparedWindowsImeSession> {
if slot
.as_ref()
.map(|slot| slot.session_id == session_id)
.unwrap_or(false)
{
return slot.take().map(|slot| slot.prepared);
}
None
}
#[cfg(target_os = "windows")]
fn restore_prepared_windows_ime_session(inner: &Arc<Inner>, 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<Inner>, _session_id: u64) {}
#[cfg(target_os = "windows")]
async fn insert_with_windows_ime_first(
inner: &Arc<Inner>,
session_id: u64,
polished: &str,
restore_clipboard: bool,
allow_non_tsf_insertion_fallback: bool,
ime_target: Option<ImeSubmitTarget>,
) -> InsertStatus {
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 {
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 {
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: {error}");
InsertStatus::Failed
}
};
inner.windows_ime.restore_session(prepared);
if ime_status == InsertStatus::Inserted {
ime_status
} 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<Inner>,
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 {
inner
.inserter
.insert_via_clipboard_fallback(polished, restore_clipboard)
}
}
// ─────────────────────────── helpers ───────────────────────────
#[cfg(any(debug_assertions, test))]
@@ -2348,6 +2568,59 @@ mod tests {
crate::types::HotkeyCapability::current().explicit_fallback_available
);
}
#[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());
}
#[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();
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<Inner>) -> Vec<String> {
@@ -2367,6 +2640,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 快速双击)。
@@ -2376,12 +2651,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<Inner>) -> bool {
fn startup_race_status_for_starting(
inner: &Arc<Inner>,
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<Inner>, 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<Inner>, delay_ms: u64) {
@@ -2504,7 +2806,6 @@ fn capture_frontmost_app() -> Option<String> {
#[cfg(target_os = "windows")]
fn restore_focus_target_if_possible(target: Option<usize>) -> bool {
use std::ffi::c_void;
use std::time::Duration;
use windows::Win32::Foundation::HWND;
use windows::Win32::UI::WindowsAndMessaging::{
GetForegroundWindow, IsIconic, IsWindow, SetForegroundWindow, ShowWindow, SW_RESTORE,
@@ -2547,6 +2848,48 @@ fn restore_focus_target_if_possible(_target: Option<usize>) -> bool {
true
}
#[cfg(target_os = "windows")]
fn capture_ime_submit_target() -> Option<ImeSubmitTarget> {
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::<GUITHREADINFO>() 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<R: tauri::Runtime>(
_app: &AppHandle<R>,
+20 -11
View File
@@ -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);
}
_ => {}
}
@@ -662,11 +671,7 @@ mod platform {
binding: HotkeyBinding,
tx: Sender<HotkeyEvent>,
) -> Result<Box<dyn HotkeyAdapter>, HotkeyInstallError> {
if std::env::var("XDG_SESSION_TYPE")
.ok()
.as_deref()
== Some("wayland")
{
if std::env::var("XDG_SESSION_TYPE").ok().as_deref() == Some("wayland") {
return Err(install_error(
"wayland_unsupported",
"Wayland 暂不支持全局热键,请切到 X11 session 后再试",
@@ -734,7 +739,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);
}
@@ -749,7 +756,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) {
+66 -6
View File
@@ -46,6 +46,29 @@ 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)
}
#[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 {
@@ -178,12 +201,7 @@ fn schedule_clipboard_restore(plan: ClipboardRestorePlan) {
original
};
std::thread::spawn(move || {
restore_clipboard_after_delay(
plan,
original_text,
restore_id,
CLIPBOARD_RESTORE_DELAY,
)
restore_clipboard_after_delay(plan, original_text, restore_id, CLIPBOARD_RESTORE_DELAY)
});
}
@@ -298,6 +316,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::<INPUT>() as i32) };
if sent == 1 {
Ok(())
} else {
Err(std::io::Error::last_os_error().to_string())
}
}
}
// ─────────────────────────── macOS native CGEvent paste ───────────────────────────
#[cfg(target_os = "macos")]
+28 -9
View File
@@ -22,10 +22,14 @@ mod qa_hotkey;
mod recorder;
mod selection;
mod types;
mod windows_ime_ipc;
mod windows_ime_profile;
mod windows_ime_protocol;
mod windows_ime_session;
use std::sync::atomic::{AtomicBool, Ordering};
#[cfg(target_os = "macos")]
use std::sync::mpsc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
#[cfg(target_os = "macos")]
use std::time::Duration;
@@ -181,6 +185,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,
@@ -228,7 +233,11 @@ pub fn run() {
hide_main_window(app);
}
#[cfg(target_os = "windows")]
if matches!(event, tauri::WindowEvent::Resized(_) | tauri::WindowEvent::ScaleFactorChanged { .. }) {
if matches!(
event,
tauri::WindowEvent::Resized(_)
| tauri::WindowEvent::ScaleFactorChanged { .. }
) {
if let Some(main) = app.get_webview_window("main") {
apply_windows_rounded_frame(&main);
}
@@ -583,9 +592,7 @@ fn position_qa_window<R: tauri::Runtime>(window: &tauri::WebviewWindow<R>) -> ta
/// fallback 仍能从原 app 拿到选区)。
pub(crate) fn show_qa_window<R: tauri::Runtime>(app: &AppHandle<R>, content_kind: &str) {
let Some(window) = app.get_webview_window("qa") else {
log::info!(
"[qa] show 跳过:qa 窗口不存在 (content_kind={content_kind})"
);
log::info!("[qa] show 跳过:qa 窗口不存在 (content_kind={content_kind})");
return;
};
// 仅首次 show 时居中;之后保留用户拖动后的位置。
@@ -796,20 +803,32 @@ mod tests {
fn capsule_window_bounds_leave_room_for_windows_shadow() {
let bounds = capsule_window_bounds(false);
#[cfg(target_os = "windows")]
assert_eq!((bounds.width, bounds.height, bounds.bottom_inset), (220.0, 84.0, 12.0));
assert_eq!(
(bounds.width, bounds.height, bounds.bottom_inset),
(220.0, 84.0, 12.0)
);
#[cfg(not(target_os = "windows"))]
assert_eq!((bounds.width, bounds.height, bounds.bottom_inset), (220.0, 110.0, 0.0));
assert_eq!(
(bounds.width, bounds.height, bounds.bottom_inset),
(220.0, 110.0, 0.0)
);
}
#[test]
fn capsule_window_bounds_expand_for_translation_badge() {
let bounds = capsule_window_bounds(true);
#[cfg(target_os = "windows")]
assert_eq!((bounds.width, bounds.height, bounds.bottom_inset), (220.0, 118.0, 12.0));
assert_eq!(
(bounds.width, bounds.height, bounds.bottom_inset),
(220.0, 118.0, 12.0)
);
#[cfg(not(target_os = "windows"))]
assert_eq!((bounds.width, bounds.height, bounds.bottom_inset), (220.0, 110.0, 0.0));
assert_eq!(
(bounds.width, bounds.height, bounds.bottom_inset),
(220.0, 110.0, 0.0)
);
}
#[test]
+4 -3
View File
@@ -330,7 +330,8 @@ mod macos_ax {
unsafe fn cfstring_from_static(bytes_with_nul: &[u8]) -> Option<CFStringRef> {
let cstr = CStr::from_bytes_with_nul(bytes_with_nul).ok()?;
let s = CFStringCreateWithCString(std::ptr::null(), cstr.as_ptr(), K_CF_STRING_ENCODING_UTF8);
let s =
CFStringCreateWithCString(std::ptr::null(), cstr.as_ptr(), K_CF_STRING_ENCODING_UTF8);
if s.is_null() {
None
} else {
@@ -442,8 +443,8 @@ mod macos_paste {
#[cfg(target_os = "windows")]
mod windows_paste {
use windows::Win32::UI::Input::KeyboardAndMouse::{
SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYBD_EVENT_FLAGS,
KEYEVENTF_KEYUP, VIRTUAL_KEY, VK_C, VK_CONTROL,
SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYBD_EVENT_FLAGS, KEYEVENTF_KEYUP,
VIRTUAL_KEY, VK_C, VK_CONTROL,
};
pub fn send_ctrl_c() -> Result<(), String> {
+42 -2
View File
@@ -13,7 +13,6 @@ pub enum PolishMode {
Formal,
}
impl PolishMode {
pub fn display_name(&self) -> &'static str {
match self {
@@ -87,6 +86,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")]
@@ -130,6 +133,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(),
qa_hotkey: default_qa_hotkey(),
@@ -379,6 +383,24 @@ pub struct HotkeyStatus {
pub last_error: Option<HotkeyInstallError>,
}
#[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<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum HotkeyStatusState {
@@ -462,7 +484,6 @@ pub struct TodayMetrics {
pub total_duration_ms: u64,
}
/// 划词追问浮窗里一条对话消息。多轮提问会累积成 Vec<QaChatMessage>,
/// 整段送给 LLM 维持上下文。详见 issue #118 v2。
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -472,3 +493,22 @@ pub struct QaChatMessage {
pub role: String,
pub content: String,
}
#[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);
}
}
@@ -0,0 +1,414 @@
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_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<T> = Result<T, WindowsImeIpcError>;
fn map_wait_named_pipe_error(error_code: Option<u32>) -> 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<u32>) -> 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<ImeSubmitStatus> {
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,
pub target: Option<ImeSubmitTarget>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ImeSubmitTarget {
pub process_id: u32,
pub thread_id: u32,
}
#[derive(Clone)]
pub struct WindowsImeIpcServer {
inner: std::sync::Arc<parking_lot::Mutex<WindowsImeIpcState>>,
}
#[derive(Debug, Default)]
struct WindowsImeIpcState {
ready_client_id: Option<String>,
}
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<ImeSubmitStatus> {
#[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<ImeSubmitStatus> {
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, ime_pipe_candidate_names_for_target,
ime_pipe_name_for_target, ImePipeMessage, OPENLESS_IME_PROTOCOL_VERSION,
};
extern "system" {
fn WaitNamedPipeW(lpNamedPipeName: *const u16, nTimeOut: u32) -> i32;
}
pub async fn submit_text_over_pipe(
request: ImeSubmitRequest,
) -> WindowsImeIpcResult<crate::windows_ime_protocol::ImeSubmitStatus> {
let target = request.target.ok_or(WindowsImeIpcError::NoReadyClient)?;
let mut pending = PendingImeSubmit::new(request.session_id.clone());
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);
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 {
log::debug!("[windows-ime] submitting text over pipe {pipe_name}");
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,
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 {
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(
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 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) => {
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 pipe_names_for_target(target: super::ImeSubmitTarget) -> Vec<String> {
ime_pipe_candidate_names_for_target(
target.process_id,
target.thread_id,
available_pipe_names(),
)
}
fn available_pipe_names() -> Vec<String> {
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()
.chain(std::iter::once(0))
.collect::<Vec<u16>>();
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));
}
}
@@ -0,0 +1,634 @@
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<windows::core::GUID> {
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,
TextService,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImeProfileSnapshot {
kind: ImeProfileKind,
lang_id: u16,
clsid: Option<String>,
profile_guid: Option<String>,
hkl: Option<isize>,
}
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<isize> {
self.hkl
}
}
#[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<T> = Result<T, WindowsImeProfileError>;
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;
#[cfg(target_os = "windows")]
impl WindowsImeProfileManager {
pub fn new() -> Self {
Self
}
pub fn capture_active_profile(&self) -> WindowsImeProfileResult<ImeProfileSnapshot> {
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<bool> {
windows_impl::is_openless_profile_active()
}
}
#[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<ImeProfileSnapshot> {
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<bool> {
Ok(false)
}
}
#[cfg(target_os = "windows")]
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::{
CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_INPROC_SERVER,
COINIT_APARTMENTTHREADED,
};
use windows::Win32::UI::Input::KeyboardAndMouse::HKL;
use windows::Win32::UI::TextServices::{
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;
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;
struct ComApartment;
impl ComApartment {
fn initialize() -> WindowsImeProfileResult<Self> {
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<ImeProfileSnapshot> {
let profile = with_profile_manager(|manager| {
let mut profile = TF_INPUTPROCESSORPROFILE::default();
unsafe {
manager.GetActiveProfile(&active_profile_category_guid(), &mut profile)?;
}
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<()> {
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,
OPENLESS_TSF_LANG_ID,
&clsid,
&profile_guid,
null_hkl(),
OPENLESS_PROFILE_ACTIVATION_FLAGS,
)
})
}
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(),
PROFILE_RESTORE_FLAGS,
)
})
}
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,
PROFILE_RESTORE_FLAGS,
)
})
}
}
}
pub fn is_openless_profile_active() -> WindowsImeProfileResult<bool> {
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))
}
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<String>,
reason: String,
},
}
fn inspect_windows_ime_registration() -> RegistrationInspection {
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
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();
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 && !keyboard_category_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::<String, _>("") {
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(),
};
}
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 }
}
fn with_profile_manager<T>(
operation: impl FnOnce(&ITfInputProcessorProfileMgr) -> windows::core::Result<T>,
) -> WindowsImeProfileResult<T> {
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 with_input_processor_profiles<T>(
operation: impl FnOnce(&ITfInputProcessorProfiles) -> windows::core::Result<T>,
) -> WindowsImeProfileResult<T> {
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<GUID> {
parse_guid(value.ok_or_else(|| {
WindowsImeProfileError::WindowsApi(format!("missing {label} in saved IME profile"))
})?)
}
pub(super) fn active_profile_category_guid() -> GUID {
GUID_TFCAT_TIP_KEYBOARD
}
pub(super) fn keyboard_layout_snapshot_from_tsf(
lang_id: u16,
hkl: HKL,
) -> WindowsImeProfileResult<ImeProfileSnapshot> {
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('}') {
upper
} else {
format!("{{{upper}}}")
}
}
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::*;
fn text_service_snapshot() -> ImeProfileSnapshot {
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]
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
);
}
}
#[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]
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}"
);
}
#[test]
fn active_profile_capture_uses_keyboard_tip_category() {
assert_eq!(
windows_impl::active_profile_category_guid(),
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"));
}
}
@@ -0,0 +1,173 @@
use serde::{Deserialize, Serialize};
pub const OPENLESS_IME_PROTOCOL_VERSION: u32 = 1;
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}")
}
pub fn ime_pipe_candidate_names_for_target<I>(
process_id: u32,
thread_id: u32,
available_pipe_names: I,
) -> Vec<String>
where
I: IntoIterator<Item = String>,
{
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::<Vec<_>>();
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",
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<String>,
},
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<String, serde_json::Error> {
let mut line = serde_json::to_string(message)?;
line.push('\n');
Ok(line)
}
pub fn decode_message(line: &str) -> Result<ImePipeMessage, serde_json::Error> {
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 ime_pipe_name_includes_target_process_and_thread() {
assert_eq!(
ime_pipe_name_for_target(1234, 5678),
r"\\.\pipe\OpenLessImeSubmit-1234-5678"
);
}
#[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 {
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());
}
}
@@ -0,0 +1,224 @@
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<ImeProfileSnapshot>,
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 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 {
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(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<InsertStatus, WindowsImeSessionError> {
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(|error| WindowsImeSessionError::Ipc(error.to_string()))?;
if should_fallback_after_ime_result(status) {
log::warn!(
"[windows-ime] TSF submit returned {status:?}; falling back to non-TSF insertion"
);
}
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) => {
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
}
}
};
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
));
}
#[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(),
target: None,
},
)
.await;
assert!(
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());
}
}
@@ -83,6 +83,16 @@
"minimumSystemVersion": "12.0",
"infoPlist": "Info.plist",
"entitlements": "Entitlements.plist"
},
"windows": {
"wix": {
"fragmentPaths": [
"wix/openless-ime.wxs"
],
"componentRefs": [
"OpenLessImeDllComponent"
]
}
}
},
"plugins": {
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
<Fragment>
<DirectoryRef Id="INSTALLDIR">
<Directory Id="OpenLessImeDirectory" Name="windows-ime">
<Component Id="OpenLessImeDllComponent" Guid="{B787DE6F-DF71-4D68-9DE8-04D3665A26F3}" Win64="yes">
<File Id="OpenLessImeDll" Name="OpenLessIme.dll" Source="src-tauri\target\windows-ime-msvc\x64\Release\OpenLessIme.dll" KeyPath="yes" />
</Component>
</Directory>
</DirectoryRef>
<CustomAction
Id="RegisterOpenLessIme"
Directory="INSTALLDIR"
Execute="deferred"
Impersonate="no"
ExeCommand='&quot;[System64Folder]regsvr32.exe&quot; /s &quot;[#OpenLessImeDll]&quot;'
Return="check" />
<CustomAction
Id="UnregisterOpenLessIme"
Directory="INSTALLDIR"
Execute="deferred"
Impersonate="no"
ExeCommand='&quot;[System64Folder]regsvr32.exe&quot; /s /u &quot;[#OpenLessImeDll]&quot;'
Return="ignore" />
<InstallExecuteSequence>
<Custom Action="UnregisterOpenLessIme" Before="RemoveFiles"><![CDATA[REMOVE="ALL"]]></Custom>
<Custom Action="RegisterOpenLessIme" After="InstallFiles"><![CDATA[NOT REMOVE]]></Custom>
</InstallExecuteSequence>
</Fragment>
</Wix>
+12
View File
@@ -266,6 +266,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)',
@@ -350,6 +352,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',
+12
View File
@@ -264,6 +264,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 模型(润色)',
@@ -348,6 +350,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: '界面语言',
+13
View File
@@ -12,6 +12,7 @@ import type {
PolishMode,
QaHotkeyBinding,
UserPreferences,
WindowsImeStatus,
} from './types';
import { OL_DATA } from './mockData';
@@ -45,6 +46,7 @@ const mockSettings: UserPreferences = {
activeAsrProvider: 'volcengine',
activeLlmProvider: 'ark',
restoreClipboardAfterPaste: true,
allowNonTsfInsertionFallback: true,
workingLanguages: ['简体中文'],
translationTargetLanguage: '',
qaHotkey: { primary: ';', modifiers: ['cmd', 'shift'] },
@@ -81,6 +83,13 @@ const mockHotkeyStatus: HotkeyStatus = {
lastError: null,
};
const mockWindowsImeStatus: WindowsImeStatus = {
state: 'notWindows',
usingTsfBackend: false,
message: 'Browser dev mock',
dllPath: null,
};
const mockHistory: DictationSession[] = OL_DATA.history.map((h, i) => ({
id: `mock-${i}`,
createdAt: new Date().toISOString(),
@@ -121,6 +130,10 @@ export function getHotkeyCapability(): Promise<HotkeyCapability> {
return invokeOrMock('get_hotkey_capability', undefined, () => mockHotkeyCapability);
}
export function getWindowsImeStatus(): Promise<WindowsImeStatus> {
return invokeOrMock('get_windows_ime_status', undefined, () => mockWindowsImeStatus);
}
// ── Credentials ────────────────────────────────────────────────────────
export function getCredentials(): Promise<CredentialsStatus> {
return invokeOrMock('get_credentials', undefined, () => mockCredentialsStatus);
+15
View File
@@ -79,6 +79,19 @@ export interface QaHotkeyBinding {
modifiers: string[];
}
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;
@@ -89,6 +102,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。 */
+48
View File
@@ -13,6 +13,7 @@ import {
checkAccessibilityPermission,
checkMicrophonePermission,
getHotkeyStatus,
getWindowsImeStatus,
openExternal,
openSystemSettings,
listProviderModels,
@@ -30,6 +31,7 @@ import type {
HotkeyStatus,
HotkeyTrigger,
PermissionStatus,
WindowsImeStatus,
} from '../lib/types';
import { useHotkeySettings } from '../state/HotkeySettingsContext';
import i18n, {
@@ -154,6 +156,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 +235,17 @@ function RecordingSection() {
>
<Toggle on={prefs.restoreClipboardAfterPaste} onToggle={onRestoreClipboardChange} />
</SettingRow>
{capability.adapter === 'windowsLowLevel' && (
<SettingRow
label={t('settings.recording.allowNonTsfFallbackLabel')}
desc={t('settings.recording.allowNonTsfFallbackDesc')}
>
<Toggle
on={prefs.allowNonTsfInsertionFallback}
onToggle={onAllowNonTsfFallbackChange}
/>
</SettingRow>
)}
{capability.statusHint && (
<div style={{ marginTop: 6, fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.5 }}>
{capability.statusHint}
@@ -758,6 +773,7 @@ function PermissionsSection() {
const [accessibility, setAccessibility] = useState<PermissionStatus | 'loading'>('loading');
const [microphone, setMicrophone] = useState<PermissionStatus | 'loading'>('loading');
const [hotkey, setHotkey] = useState<HotkeyStatus | null>(null);
const [windowsIme, setWindowsIme] = useState<WindowsImeStatus | null>(null);
const { capability } = useHotkeySettings();
const refreshPermissions = async () => {
@@ -773,15 +789,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 () => {
@@ -855,6 +877,21 @@ function PermissionsSection() {
)}
</div>
</SettingRow>
{windowsIme?.state !== 'notWindows' && (
<SettingRow
label={t('settings.permissions.windowsImeLabel')}
desc={t('settings.permissions.windowsImeDesc')}
>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', minWidth: 0 }}>
<WindowsImeStatusPill status={windowsIme} />
{windowsIme && (
<span style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{t(`settings.permissions.windowsIme.${windowsIme.state}`)}
</span>
)}
</div>
</SettingRow>
)}
<SettingRow label={t('settings.permissions.networkLabel')} desc={t('settings.permissions.networkDesc')}>
<Pill tone="ok"><Icon name="check" size={11} />{t('settings.permissions.networkOk')}</Pill>
</SettingRow>
@@ -1014,6 +1051,17 @@ function HotkeyStatusPill({ status }: { status: HotkeyStatus | null }) {
return <Pill tone="outline">{t('settings.permissions.hotkeyFailed')}</Pill>;
}
function WindowsImeStatusPill({ status }: { status: WindowsImeStatus | null }) {
const { t } = useTranslation();
if (!status) {
return <Pill tone="default">{t('settings.permissions.checking')}</Pill>;
}
if (status.state === 'installed') {
return <Pill tone="ok"><Icon name="check" size={11} />{t('settings.permissions.windowsImeInstalled')}</Pill>;
}
return <Pill tone="outline">{t('settings.permissions.windowsImeUnavailable')}</Pill>;
}
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');
@@ -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
@@ -0,0 +1,100 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup Label="ProjectConfigurations">
<ProjectConfiguration Include="Debug|x64">
<Configuration>Debug</Configuration>
<Platform>x64</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Release|x64">
<Configuration>Release</Configuration>
<Platform>x64</Platform>
</ProjectConfiguration>
</ItemGroup>
<PropertyGroup Label="Globals">
<VCProjectVersion>17.0</VCProjectVersion>
<Keyword>Win32Proj</Keyword>
<ProjectGuid>{F45287CB-1F31-4D7C-9B62-5748C2F8C9B0}</ProjectGuid>
<RootNamespace>OpenLessIme</RootNamespace>
<WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'" Label="Configuration">
<ConfigurationType>DynamicLibrary</ConfigurationType>
<UseDebugLibraries>true</UseDebugLibraries>
<PlatformToolset>v143</PlatformToolset>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'" Label="Configuration">
<ConfigurationType>DynamicLibrary</ConfigurationType>
<UseDebugLibraries>false</UseDebugLibraries>
<PlatformToolset>v143</PlatformToolset>
<WholeProgramOptimization>true</WholeProgramOptimization>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
<ImportGroup Label="ExtensionSettings" />
<ImportGroup Label="Shared" />
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<PropertyGroup Label="UserMacros" />
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
<ClCompile>
<WarningLevel>Level4</WarningLevel>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>WIN32;_WINDOWS;_USRDLL;OPENLESSIME_EXPORTS;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
<LanguageStandard>stdcpp17</LanguageStandard>
</ClCompile>
<Link>
<SubSystem>Windows</SubSystem>
<AdditionalDependencies>ole32.lib;uuid.lib;advapi32.lib;%(AdditionalDependencies)</AdditionalDependencies>
<ModuleDefinitionFile>src\OpenLessIme.def</ModuleDefinitionFile>
</Link>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
<ClCompile>
<WarningLevel>Level4</WarningLevel>
<FunctionLevelLinking>true</FunctionLevelLinking>
<IntrinsicFunctions>true</IntrinsicFunctions>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>WIN32;NDEBUG;_WINDOWS;_USRDLL;OPENLESSIME_EXPORTS;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
<LanguageStandard>stdcpp17</LanguageStandard>
</ClCompile>
<Link>
<SubSystem>Windows</SubSystem>
<EnableCOMDATFolding>true</EnableCOMDATFolding>
<OptimizeReferences>true</OptimizeReferences>
<AdditionalDependencies>ole32.lib;uuid.lib;advapi32.lib;%(AdditionalDependencies)</AdditionalDependencies>
<ModuleDefinitionFile>src\OpenLessIme.def</ModuleDefinitionFile>
</Link>
</ItemDefinitionGroup>
<ItemGroup>
<ClCompile Include="src\class_factory.cpp" />
<ClCompile Include="src\dllmain.cpp" />
<ClCompile Include="src\edit_session.cpp" />
<ClCompile Include="src\ipc_client.cpp" />
<ClCompile Include="src\registry.cpp" />
<ClCompile Include="src\text_service.cpp" />
</ItemGroup>
<ItemGroup>
<ClInclude Include="src\class_factory.h" />
<ClInclude Include="src\edit_session.h" />
<ClInclude Include="src\guids.h" />
<ClInclude Include="src\ipc_client.h" />
<ClInclude Include="src\registry.h" />
<ClInclude Include="src\text_service.h" />
</ItemGroup>
<ItemGroup>
<ResourceCompile Include="src\resource.rc" />
</ItemGroup>
<ItemGroup>
<None Include="src\OpenLessIme.def" />
</ItemGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
<ImportGroup Label="ExtensionTargets" />
</Project>
@@ -0,0 +1,6 @@
LIBRARY "OpenLessIme"
EXPORTS
DllCanUnloadNow PRIVATE
DllGetClassObject PRIVATE
DllRegisterServer PRIVATE
DllUnregisterServer PRIVATE
@@ -0,0 +1,75 @@
#include "class_factory.h"
#include <new>
#include <windows.h>
#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<IClassFactory*>(this);
AddRef();
return S_OK;
}
return E_NOINTERFACE;
}
STDMETHODIMP_(ULONG) OpenLessClassFactory::AddRef() {
return static_cast<ULONG>(InterlockedIncrement(&ref_count_));
}
STDMETHODIMP_(ULONG) OpenLessClassFactory::Release() {
const ULONG count = static_cast<ULONG>(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;
}
@@ -0,0 +1,20 @@
#pragma once
#include <unknwn.h>
class OpenLessClassFactory final : public IClassFactory {
public:
OpenLessClassFactory();
OpenLessClassFactory(const OpenLessClassFactory&) = delete;
OpenLessClassFactory& operator=(const OpenLessClassFactory&) = delete;
~OpenLessClassFactory();
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;
};
@@ -0,0 +1,54 @@
#include <windows.h>
#include <new>
#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();
}
@@ -0,0 +1,81 @@
#include "edit_session.h"
#include <utility>
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<ITfEditSession*>(this);
AddRef();
return S_OK;
}
return E_NOINTERFACE;
}
STDMETHODIMP_(ULONG) OpenLessEditSession::AddRef() {
return static_cast<ULONG>(InterlockedIncrement(&ref_count_));
}
STDMETHODIMP_(ULONG) OpenLessEditSession::Release() {
const ULONG count = static_cast<ULONG>(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<void**>(
&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<LONG>(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<LONG>(text_.size()),
&committed_range);
if (committed_range != nullptr) {
committed_range->Release();
}
}
insert_at_selection->Release();
return hr;
}
@@ -0,0 +1,23 @@
#pragma once
#include <msctf.h>
#include <string>
#include <windows.h>
class OpenLessEditSession final : public ITfEditSession {
public:
OpenLessEditSession(ITfContext* context, std::wstring text);
OpenLessEditSession(const OpenLessEditSession&) = delete;
OpenLessEditSession& operator=(const OpenLessEditSession&) = delete;
~OpenLessEditSession();
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_;
};
+21
View File
@@ -0,0 +1,21 @@
#pragma once
#include <msctf.h>
#include <windows.h>
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;
@@ -0,0 +1,511 @@
#include "ipc_client.h"
#include <cstdint>
#include <string>
#include "text_service.h"
namespace {
constexpr wchar_t kPipeNamePrefix[] = 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;
};
std::wstring HResultErrorCode(HRESULT hr) {
constexpr wchar_t kHexDigits[] = L"0123456789ABCDEF";
auto value = static_cast<unsigned long>(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) {
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<size_t>(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<int>(value.size()),
nullptr, 0, nullptr, nullptr);
if (required <= 0) {
return false;
}
output->resize(static_cast<size_t>(required));
return WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, value.c_str(),
static_cast<int>(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<unsigned char>(c) < 0x20) {
return false;
}
if (c == '"') {
if (!AppendUtf8AsWide(json.data() + segment_start,
static_cast<int>(*pos - segment_start), value)) {
return false;
}
++(*pos);
return true;
}
if (c != '\\') {
++(*pos);
continue;
}
if (!AppendUtf8AsWide(json.data() + segment_start,
static_cast<int>(*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<wchar_t>(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<size_t>(i)]);
if (digit < 0) {
return false;
}
code_unit = (code_unit << 4) | static_cast<uint32_t>(digit);
}
value->push_back(static_cast<wchar_t>(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<unsigned int>(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);
pipe_name_ = PipeNameForCurrentThread();
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() {
const std::wstring pipe_name = pipe_name_;
while (!stop_requested_.load()) {
HANDLE pipe = CreateNamedPipeW(
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) {
return;
}
{
std::lock_guard<std::mutex> 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<std::mutex> 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 {
const std::wstring error_code = HResultErrorCode(hr);
WriteResult(pipe, message.session_id, L"rejected", error_code.c_str());
}
}
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<DWORD>(utf8_response.size()), &bytes_written,
nullptr) &&
bytes_written == utf8_response.size();
}
void OpenLessPipeServer::WakePipe() {
if (pipe_name_.empty()) {
return;
}
HANDLE pipe =
CreateFileW(pipe_name_.c_str(), GENERIC_READ | GENERIC_WRITE, 0, nullptr,
OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
if (pipe != INVALID_HANDLE_VALUE) {
CloseHandle(pipe);
}
}
@@ -0,0 +1,37 @@
#pragma once
#include <atomic>
#include <mutex>
#include <string>
#include <thread>
#include <windows.h>
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<bool> stop_requested_{false};
std::thread thread_;
std::mutex pipe_mutex_;
HANDLE pipe_handle_ = INVALID_HANDLE_VALUE;
std::wstring pipe_name_;
OpenLessTextService* service_ = nullptr;
};
@@ -0,0 +1,307 @@
#include "registry.h"
#include <msctf.h>
#include <strsafe.h>
#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";
constexpr REGSAM kRegistryWriteAccess = KEY_WRITE | KEY_WOW64_64KEY;
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<DWORD>((wcslen(value) + 1) * sizeof(wchar_t));
return HResultFromWin32Error(
RegSetValueExW(key, name, 0, REG_SZ,
reinterpret_cast<const BYTE*>(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);
}
RegDeleteTreeW(HKEY_CURRENT_USER, kClsidKey);
HKEY clsid_key = nullptr;
LSTATUS status =
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;
}
hr = SetStringValue(clsid_key, nullptr, kOpenLessImeName);
RegCloseKey(clsid_key);
if (FAILED(hr)) {
return hr;
}
HKEY inproc_key = nullptr;
status = RegCreateKeyExW(HKEY_LOCAL_MACHINE, kInprocServer32Key, 0, nullptr,
REG_OPTION_NON_VOLATILE, kRegistryWriteAccess, 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 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(machine_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<void**>(profiles));
}
HRESULT CreateProfileManager(ITfInputProcessorProfileMgr** manager) {
return CoCreateInstance(CLSID_TF_InputProcessorProfiles, nullptr,
CLSCTX_INPROC_SERVER,
IID_ITfInputProcessorProfileMgr,
reinterpret_cast<void**>(manager));
}
HRESULT CreateCategoryManager(ITfCategoryMgr** category_mgr) {
return CoCreateInstance(CLSID_TF_CategoryMgr, nullptr, CLSCTX_INPROC_SERVER,
IID_ITfCategoryMgr,
reinterpret_cast<void**>(category_mgr));
}
HRESULT RegisterLanguageProfile() {
ScopedComInit com;
HRESULT hr = com.hr();
if (FAILED(hr)) {
return hr;
}
ITfInputProcessorProfiles* profiles = nullptr;
hr = CreateProfiles(&profiles);
if (FAILED(hr)) {
return hr;
}
profiles->Unregister(CLSID_OpenLessTextService);
hr = profiles->Register(CLSID_OpenLessTextService);
if (SUCCEEDED(hr)) {
hr = profiles->AddLanguageProfile(
CLSID_OpenLessTextService, kOpenLessLangId, GUID_OpenLessProfile,
const_cast<wchar_t*>(kOpenLessImeName),
static_cast<ULONG>(ARRAYSIZE(kOpenLessImeName) - 1), nullptr, 0, 0);
}
if (SUCCEEDED(hr)) {
hr = profiles->EnableLanguageProfile(CLSID_OpenLessTextService,
kOpenLessLangId, GUID_OpenLessProfile,
TRUE);
}
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<ULONG>(ARRAYSIZE(kOpenLessImeName) - 1),
nullptr, 0, 0, nullptr, 0, TRUE,
TF_IPP_CAPS_IMMERSIVESUPPORT | TF_IPP_CAPS_SYSTRAYSUPPORT);
manager->Release();
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;
}
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;
}
HRESULT UnregisterLanguageProfile() {
ScopedComInit com;
HRESULT hr = com.hr();
if (FAILED(hr)) {
return hr;
}
ITfInputProcessorProfiles* profiles = nullptr;
hr = CreateProfiles(&profiles);
if (FAILED(hr)) {
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;
}
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);
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;
}
} // 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;
}
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;
}
@@ -0,0 +1,6 @@
#pragma once
#include <windows.h>
HRESULT RegisterOpenLessTextService(HINSTANCE module);
HRESULT UnregisterOpenLessTextService();
@@ -0,0 +1,33 @@
#include <winver.h>
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
@@ -0,0 +1,255 @@
#include "text_service.h"
#include <new>
#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);
}
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<ITfTextInputProcessorEx*>(this);
AddRef();
return S_OK;
}
return E_NOINTERFACE;
}
STDMETHODIMP_(ULONG) OpenLessTextService::AddRef() {
return static_cast<ULONG>(InterlockedIncrement(&ref_count_));
}
STDMETHODIMP_(ULONG) OpenLessTextService::Release() {
const ULONG count = static_cast<ULONG>(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();
owner_thread_id_ = GetCurrentThreadId();
thread_mgr_ = thread_mgr;
thread_mgr_->AddRef();
client_id_ = client_id;
HRESULT hr = EnsureMessageWindow();
if (FAILED(hr)) {
Deactivate();
return hr;
}
hr = StartIpcServer();
if (FAILED(hr)) {
Deactivate();
return hr;
}
return S_OK;
}
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;
}
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<LPARAM>(&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) {
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;
}
LRESULT CALLBACK OpenLessTextService::MessageWindowProc(HWND window,
UINT message,
WPARAM wparam,
LPARAM lparam) {
UNREFERENCED_PARAMETER(wparam);
if (message == WM_NCCREATE) {
const auto* create = reinterpret_cast<CREATESTRUCTW*>(lparam);
SetWindowLongPtrW(window, GWLP_USERDATA,
reinterpret_cast<LONG_PTR>(create->lpCreateParams));
return TRUE;
}
auto* service = reinterpret_cast<OpenLessTextService*>(
GetWindowLongPtrW(window, GWLP_USERDATA));
if (message == kSubmitTextMessage && service != nullptr) {
auto* request = reinterpret_cast<SubmitTextRequest*>(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);
}
@@ -0,0 +1,48 @@
#pragma once
#include <msctf.h>
#include <string>
#include <windows.h>
#include "ipc_client.h"
class OpenLessTextService final : public ITfTextInputProcessorEx {
public:
OpenLessTextService();
OpenLessTextService(const OpenLessTextService&) = delete;
OpenLessTextService& operator=(const OpenLessTextService&) = delete;
~OpenLessTextService();
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();
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_;
};