merge Windows TSF IME support
This commit is contained in:
commit
f497cd4eb5
47 files changed
+6761
-92
No files matched your search
@@ -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");
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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='"[System64Folder]regsvr32.exe" /s "[#OpenLessImeDll]"'
|
||||
Return="check" />
|
||||
<CustomAction
|
||||
Id="UnregisterOpenLessIme"
|
||||
Directory="INSTALLDIR"
|
||||
Execute="deferred"
|
||||
Impersonate="no"
|
||||
ExeCommand='"[System64Folder]regsvr32.exe" /s /u "[#OpenLessImeDll]"'
|
||||
Return="ignore" />
|
||||
|
||||
<InstallExecuteSequence>
|
||||
<Custom Action="UnregisterOpenLessIme" Before="RemoveFiles"><![CDATA[REMOVE="ALL"]]></Custom>
|
||||
<Custom Action="RegisterOpenLessIme" After="InstallFiles"><![CDATA[NOT REMOVE]]></Custom>
|
||||
</InstallExecuteSequence>
|
||||
</Fragment>
|
||||
</Wix>
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '界面语言',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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。 */
|
||||
|
||||
@@ -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_;
|
||||
};
|
||||
@@ -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_;
|
||||
};
|
||||
Reference in new issue
Block a user