fix: register Windows IME from NSIS installer

This commit is contained in:
millionart committed 2026-05-05 17:38:28 +08:00
1 parent c85b1bd4c6
commit 753236dd55
5 files changed
+297

No files matched your search

+12
View File
@@ -298,6 +298,18 @@ jobs:
}
Write-Host "[ok] MSI rebuilt at $msiPath"
- name: Verify Windows installers register TSF IME
if: matrix.platform == 'windows-latest'
shell: pwsh
working-directory: 'openless-all/app'
run: |
$nsis = Get-ChildItem "src-tauri\target\release\bundle\nsis\*.exe" -ErrorAction Stop | Select-Object -First 1
$msi = Get-ChildItem "src-tauri\target\release\bundle\msi\*.msi" -ErrorAction Stop | Select-Object -First 1
if (-not $nsis) { throw "NSIS installer not found." }
if (-not $msi) { throw "MSI installer not found." }
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\windows-ime-install-smoke.ps1 -InstallerPath $nsis.FullName -InstallerKind nsis
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\windows-ime-install-smoke.ps1 -InstallerPath $msi.FullName -InstallerKind msi
# ── Linux:产 deb / rpm / AppImage ──
- name: Build (Linux)
if: matrix.platform == 'ubuntu-22.04'
@@ -0,0 +1,179 @@
param(
[Parameter(Mandatory = $true)]
[string]$InstallerPath,
[Parameter(Mandatory = $true)]
[ValidateSet("nsis", "msi")]
[string]$InstallerKind,
[switch]$SkipUninstall
)
$ErrorActionPreference = "Stop"
$TextServiceClsid = "{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}"
$ProfileGuid = "{9B5F5E04-23F6-47DA-9A26-D221F6C3F02E}"
$LangId = "0x00000804"
$KeyboardCategoryGuid = "{34745C63-B2F0-4784-8B67-5E12C8701A31}"
$ImmersiveCategoryGuid = "{13A016DF-560B-46CD-947A-4C3AF1E0E35D}"
$SystrayCategoryGuid = "{25504FB4-7BAB-4BC1-9C69-CF81890F0EF5}"
# Keep this script aligned with the backend status check and the TSF IPC path
# used by OpenLessImeSubmit-* named pipes.
$ExpectedBackendKeys = @(
"Software\Classes\CLSID\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\InprocServer32",
"Software\WOW6432Node\Classes\CLSID\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\InprocServer32",
"Software\Microsoft\CTF\TIP\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\LanguageProfile\0x00000804\{9B5F5E04-23F6-47DA-9A26-D221F6C3F02E}",
"Software\Microsoft\CTF\TIP\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\Category\Category\{34745C63-B2F0-4784-8B67-5E12C8701A31}\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}",
"Software\Microsoft\CTF\TIP\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\Category\Category\{13A016DF-560B-46CD-947A-4C3AF1E0E35D}\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}",
"Software\Microsoft\CTF\TIP\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\Category\Category\{25504FB4-7BAB-4BC1-9C69-CF81890F0EF5}\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}"
)
function Test-IsAdministrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
function Invoke-CheckedProcess {
param(
[Parameter(Mandatory = $true)]
[string]$FilePath,
[string[]]$ArgumentList = @(),
[Parameter(Mandatory = $true)]
[string]$Label
)
Write-Host "[run] $Label`: $FilePath $($ArgumentList -join ' ')"
$process = Start-Process -FilePath $FilePath -ArgumentList $ArgumentList -Wait -PassThru
if ($process.ExitCode -ne 0) {
throw "$Label failed with exit code $($process.ExitCode)"
}
}
function Open-LocalMachineSubKey {
param(
[Parameter(Mandatory = $true)]
[Microsoft.Win32.RegistryView]$View,
[Parameter(Mandatory = $true)]
[string]$SubKey
)
$baseKey = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, $View)
try {
return $baseKey.OpenSubKey($SubKey)
} finally {
$baseKey.Dispose()
}
}
function Assert-RegistryKey {
param(
[Parameter(Mandatory = $true)]
[Microsoft.Win32.RegistryView]$View,
[Parameter(Mandatory = $true)]
[string]$SubKey,
[Parameter(Mandatory = $true)]
[string]$Label
)
$key = Open-LocalMachineSubKey -View $View -SubKey $SubKey
if ($null -eq $key) {
throw "Missing $Label registry key ($View): HKLM\$SubKey"
}
$key.Close()
Write-Host "[ok] $Label registry key present ($View)"
}
function Get-DefaultRegistryValue {
param(
[Parameter(Mandatory = $true)]
[Microsoft.Win32.RegistryView]$View,
[Parameter(Mandatory = $true)]
[string]$SubKey,
[Parameter(Mandatory = $true)]
[string]$Label
)
$key = Open-LocalMachineSubKey -View $View -SubKey $SubKey
if ($null -eq $key) {
throw "Missing $Label registry key ($View): HKLM\$SubKey"
}
try {
$value = [string]$key.GetValue("")
if ([string]::IsNullOrWhiteSpace($value)) {
throw "$Label default registry value is empty ($View): HKLM\$SubKey"
}
return $value
} finally {
$key.Close()
}
}
function Assert-OpenLessImeInstalled {
$comKey = "Software\Classes\CLSID\$TextServiceClsid\InprocServer32"
$x64Dll = Get-DefaultRegistryValue -View Registry64 -SubKey $comKey -Label "x64 COM"
$x86Dll = Get-DefaultRegistryValue -View Registry32 -SubKey $comKey -Label "x86 COM"
foreach ($dll in @($x64Dll, $x86Dll)) {
if (-not (Test-Path -LiteralPath $dll -PathType Leaf)) {
throw "Registered IME DLL path does not exist: $dll"
}
}
$installRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $x64Dll))
$expectedX64 = Join-Path $installRoot "windows-ime\x64\OpenLessIme.dll"
$expectedX86 = Join-Path $installRoot "windows-ime\x86\OpenLessIme.dll"
if ($x64Dll -ne $expectedX64) {
throw "x64 COM DLL path points outside the installed IME directory. Expected '$expectedX64', got '$x64Dll'"
}
if ($x86Dll -ne $expectedX86) {
throw "x86 COM DLL path points outside the installed IME directory. Expected '$expectedX86', got '$x86Dll'"
}
if (-not (Test-Path -LiteralPath (Join-Path $installRoot "openless.exe") -PathType Leaf)) {
throw "Installed OpenLess executable not found under $installRoot"
}
Assert-RegistryKey -View Registry64 -SubKey "Software\Microsoft\CTF\TIP\$TextServiceClsid\LanguageProfile\$LangId\$ProfileGuid" -Label "TSF language profile"
Assert-RegistryKey -View Registry64 -SubKey "Software\Microsoft\CTF\TIP\$TextServiceClsid\Category\Category\$KeyboardCategoryGuid\$TextServiceClsid" -Label "TSF keyboard category"
Assert-RegistryKey -View Registry64 -SubKey "Software\Microsoft\CTF\TIP\$TextServiceClsid\Category\Category\$ImmersiveCategoryGuid\$TextServiceClsid" -Label "TSF immersive category"
Assert-RegistryKey -View Registry64 -SubKey "Software\Microsoft\CTF\TIP\$TextServiceClsid\Category\Category\$SystrayCategoryGuid\$TextServiceClsid" -Label "TSF systray category"
foreach ($key in $ExpectedBackendKeys) {
Write-Host "[trace] backend-required key: HKLM\$key"
}
Write-Host "[ok] Windows IME backend would report installed"
return $installRoot
}
function Uninstall-OpenLess {
param(
[Parameter(Mandatory = $true)]
[string]$InstallRoot
)
if ($InstallerKind -eq "nsis") {
$uninstaller = Join-Path $InstallRoot "uninstall.exe"
if (-not (Test-Path -LiteralPath $uninstaller -PathType Leaf)) {
throw "NSIS uninstaller not found: $uninstaller"
}
Invoke-CheckedProcess -FilePath $uninstaller -ArgumentList @("/S") -Label "NSIS uninstall"
} else {
Invoke-CheckedProcess -FilePath "msiexec.exe" -ArgumentList @("/x", $InstallerPath, "/qn", "/norestart") -Label "MSI uninstall"
}
}
if (-not (Test-IsAdministrator)) {
throw "Windows IME install smoke must run from an elevated Administrator PowerShell."
}
$InstallerPath = (Resolve-Path -LiteralPath $InstallerPath).Path
if ($InstallerKind -eq "nsis") {
Invoke-CheckedProcess -FilePath $InstallerPath -ArgumentList @("/S", "/AllUsers") -Label "NSIS install"
} else {
Invoke-CheckedProcess -FilePath "msiexec.exe" -ArgumentList @("/i", $InstallerPath, "/qn", "/norestart") -Label "MSI install"
}
$installRoot = Assert-OpenLessImeInstalled
if (-not $SkipUninstall) {
Uninstall-OpenLess -InstallRoot $installRoot
}
@@ -5,9 +5,12 @@ import { fileURLToPath } from "node:url";
const scriptsDir = dirname(fileURLToPath(import.meta.url));
const appRoot = join(scriptsDir, "..");
const repoRoot = join(appRoot, "..", "..");
const scriptPath = join(scriptsDir, "windows-package-msvc.ps1");
const launcherPath = join(scriptsDir, "windows-package-msvc.cmd");
const ciWorkflowPath = join(repoRoot, ".github", "workflows", "release-tauri.yml");
const imeBuildPath = join(scriptsDir, "windows-ime-build.ps1");
const imeInstallSmokePath = join(scriptsDir, "windows-ime-install-smoke.ps1");
const imeRegisterPath = join(scriptsDir, "windows-ime-register.ps1");
const imeUnregisterPath = join(scriptsDir, "windows-ime-unregister.ps1");
const imeSolutionPath = join(appRoot, "windows-ime", "OpenLessIme.sln");
@@ -15,11 +18,14 @@ const imeProjectPath = join(appRoot, "windows-ime", "OpenLessIme.vcxproj");
const imeEditSessionPath = join(appRoot, "windows-ime", "src", "edit_session.cpp");
const imeTextServicePath = join(appRoot, "windows-ime", "src", "text_service.cpp");
const tauriConfigPath = join(appRoot, "src-tauri", "tauri.conf.json");
const nsisHookPath = join(appRoot, "src-tauri", "nsis", "openless-ime-hooks.nsh");
const wixFragmentPath = join(appRoot, "src-tauri", "wix", "openless-ime.wxs");
const script = readFileSync(scriptPath, "utf8");
const launcher = readFileSync(launcherPath, "utf8");
const ciWorkflow = readFileSync(ciWorkflowPath, "utf8");
const imeBuild = readFileSync(imeBuildPath, "utf8");
const imeInstallSmoke = readFileSync(imeInstallSmokePath, "utf8");
const imeRegister = readFileSync(imeRegisterPath, "utf8");
const imeUnregister = readFileSync(imeUnregisterPath, "utf8");
const imeSolution = readFileSync(imeSolutionPath, "utf8");
@@ -27,6 +33,7 @@ const imeProject = readFileSync(imeProjectPath, "utf8");
const imeEditSession = readFileSync(imeEditSessionPath, "utf8");
const imeTextService = readFileSync(imeTextServicePath, "utf8");
const tauriConfig = JSON.parse(readFileSync(tauriConfigPath, "utf8"));
const nsisHook = readFileSync(nsisHookPath, "utf8");
const wixFragment = readFileSync(wixFragmentPath, "utf8");
const requiredFragments = [
@@ -84,6 +91,8 @@ assert.deepEqual(tauriConfig.bundle.windows.wix.componentRefs, [
"OpenLessImeDllX64Component",
"OpenLessImeDllX86Component",
]);
assert.equal(tauriConfig.bundle.windows.nsis.installMode, "both", "NSIS must request elevation so TSF can register in HKLM");
assert.equal(tauriConfig.bundle.windows.nsis.installerHooks, "nsis/openless-ime-hooks.nsh", "NSIS must install and register the TSF DLLs");
assert.match(imeSolution, /Release\|Win32/, "IME solution should include a Win32 Release configuration");
assert.match(imeProject, /Release\|Win32/, "IME project should include a Win32 Release configuration");
@@ -108,6 +117,27 @@ assert.match(wixFragment, /RegisterOpenLessImeX86/, "MSI should register x86 Ope
assert.match(wixFragment, /UnregisterOpenLessImeX64/, "MSI should unregister x64 OpenLess IME during uninstall");
assert.match(wixFragment, /UnregisterOpenLessImeX86/, "MSI should unregister x86 OpenLess IME during uninstall");
assert.match(nsisHook, /NSIS_HOOK_PREINSTALL/, "NSIS should copy IME DLLs before install completes");
assert.match(nsisHook, /NSIS_HOOK_POSTINSTALL/, "NSIS should register IME DLLs after files are installed");
assert.match(nsisHook, /NSIS_HOOK_PREUNINSTALL/, "NSIS should unregister IME DLLs before uninstall removes them");
assert.match(nsisHook, /\$%OPENLESS_IME_DLL_X64%/, "NSIS should consume the CI-built x64 IME DLL");
assert.match(nsisHook, /\$%OPENLESS_IME_DLL_X86%/, "NSIS should consume the CI-built x86 IME DLL");
assert.match(nsisHook, /SetOutPath "\$INSTDIR\\windows-ime\\x64"/, "NSIS should install the x64 IME DLL beside the app");
assert.match(nsisHook, /SetOutPath "\$INSTDIR\\windows-ime\\x86"/, "NSIS should install the x86 IME DLL beside the app");
assert.match(nsisHook, /File \/oname=OpenLessIme\.dll/, "NSIS should embed OpenLessIme.dll in the installer");
assert.match(nsisHook, /Sysnative\\regsvr32\.exe/, "NSIS should use 64-bit regsvr32 for the x64 IME");
assert.match(nsisHook, /SysWOW64\\regsvr32\.exe/, "NSIS should use 32-bit regsvr32 for the x86 IME");
assert.match(nsisHook, /Abort/, "NSIS install should fail if TSF registration fails");
assert.match(imeInstallSmoke, /\[ValidateSet\("nsis", "msi"\)\]/, "install smoke should support both Windows installers");
assert.match(imeInstallSmoke, /OpenLessImeSubmit/, "install smoke should preserve TSF backend context");
assert.match(imeInstallSmoke, /Software\\Classes\\CLSID\\\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D\}\\InprocServer32/, "install smoke should check x64 COM registration");
assert.match(imeInstallSmoke, /Software\\WOW6432Node\\Classes\\CLSID\\\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D\}\\InprocServer32/, "install smoke should check x86 COM registration");
assert.match(imeInstallSmoke, /LanguageProfile\\0x00000804\\\{9B5F5E04-23F6-47DA-9A26-D221F6C3F02E\}/, "install smoke should check the TSF language profile");
assert.match(imeInstallSmoke, /Category\\Category\\\{34745C63-B2F0-4784-8B67-5E12C8701A31\}/, "install smoke should check the keyboard TSF category");
assert.match(ciWorkflow, /windows-ime-install-smoke\.ps1[\s\S]*-InstallerKind nsis/, "CI should install and verify the NSIS artifact");
assert.match(ciWorkflow, /windows-ime-install-smoke\.ps1[\s\S]*-InstallerKind msi/, "CI should install and verify the MSI artifact");
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");
@@ -0,0 +1,72 @@
!macro OPENLESS_IME_ABORT_IF_FAILED EXIT_CODE LABEL
${If} ${EXIT_CODE} != 0
DetailPrint "OpenLess TSF IME ${LABEL} failed with exit code ${EXIT_CODE}"
Abort
${EndIf}
!macroend
!macro OPENLESS_IME_REGISTER_X64
${If} ${RunningX64}
DetailPrint "Registering OpenLess x64 TSF IME"
ExecWait '"$WINDIR\Sysnative\regsvr32.exe" /s "$INSTDIR\windows-ime\x64\OpenLessIme.dll"' $0
${If} $0 != 0
${DisableX64FSRedirection}
ExecWait '"$WINDIR\System32\regsvr32.exe" /s "$INSTDIR\windows-ime\x64\OpenLessIme.dll"' $0
${EnableX64FSRedirection}
${EndIf}
!insertmacro OPENLESS_IME_ABORT_IF_FAILED $0 "x64 registration"
${EndIf}
!macroend
!macro OPENLESS_IME_REGISTER_X86
DetailPrint "Registering OpenLess x86 TSF IME"
ExecWait '"$WINDIR\SysWOW64\regsvr32.exe" /s "$INSTDIR\windows-ime\x86\OpenLessIme.dll"' $0
!insertmacro OPENLESS_IME_ABORT_IF_FAILED $0 "x86 registration"
!macroend
!macro OPENLESS_IME_UNREGISTER_X64
${If} ${RunningX64}
DetailPrint "Unregistering OpenLess x64 TSF IME"
ExecWait '"$WINDIR\Sysnative\regsvr32.exe" /s /u "$INSTDIR\windows-ime\x64\OpenLessIme.dll"' $0
${If} $0 != 0
${DisableX64FSRedirection}
ExecWait '"$WINDIR\System32\regsvr32.exe" /s /u "$INSTDIR\windows-ime\x64\OpenLessIme.dll"' $0
${EnableX64FSRedirection}
${EndIf}
DetailPrint "OpenLess x64 TSF IME unregister exit code $0"
${EndIf}
!macroend
!macro OPENLESS_IME_UNREGISTER_X86
DetailPrint "Unregistering OpenLess x86 TSF IME"
ExecWait '"$WINDIR\SysWOW64\regsvr32.exe" /s /u "$INSTDIR\windows-ime\x86\OpenLessIme.dll"' $0
DetailPrint "OpenLess x86 TSF IME unregister exit code $0"
!macroend
!macro NSIS_HOOK_PREINSTALL
SetOutPath "$INSTDIR\windows-ime\x64"
File /oname=OpenLessIme.dll "$%OPENLESS_IME_DLL_X64%"
SetOutPath "$INSTDIR\windows-ime\x86"
File /oname=OpenLessIme.dll "$%OPENLESS_IME_DLL_X86%"
SetOutPath "$INSTDIR"
!macroend
!macro NSIS_HOOK_POSTINSTALL
!insertmacro OPENLESS_IME_REGISTER_X64
!insertmacro OPENLESS_IME_REGISTER_X86
!macroend
!macro NSIS_HOOK_PREUNINSTALL
!insertmacro OPENLESS_IME_UNREGISTER_X86
!insertmacro OPENLESS_IME_UNREGISTER_X64
!macroend
!macro NSIS_HOOK_POSTUNINSTALL
Delete "$INSTDIR\windows-ime\x64\OpenLessIme.dll"
Delete "$INSTDIR\windows-ime\x86\OpenLessIme.dll"
RMDir "$INSTDIR\windows-ime\x64"
RMDir "$INSTDIR\windows-ime\x86"
RMDir "$INSTDIR\windows-ime"
!macroend
@@ -85,6 +85,10 @@
"entitlements": "Entitlements.plist"
},
"windows": {
"nsis": {
"installMode": "both",
"installerHooks": "nsis/openless-ime-hooks.nsh"
},
"wix": {
"fragmentPaths": [
"wix/openless-ime.wxs"