diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml index 4f87aaea..f58ff75b 100644 --- a/.github/workflows/release-tauri.yml +++ b/.github/workflows/release-tauri.yml @@ -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' diff --git a/openless-all/app/scripts/windows-ime-install-smoke.ps1 b/openless-all/app/scripts/windows-ime-install-smoke.ps1 new file mode 100644 index 00000000..fd9d7e1b --- /dev/null +++ b/openless-all/app/scripts/windows-ime-install-smoke.ps1 @@ -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 +} diff --git a/openless-all/app/scripts/windows-package-msvc.test.mjs b/openless-all/app/scripts/windows-package-msvc.test.mjs index 193f0ed0..339037cc 100644 --- a/openless-all/app/scripts/windows-package-msvc.test.mjs +++ b/openless-all/app/scripts/windows-package-msvc.test.mjs @@ -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"); diff --git a/openless-all/app/src-tauri/nsis/openless-ime-hooks.nsh b/openless-all/app/src-tauri/nsis/openless-ime-hooks.nsh new file mode 100644 index 00000000..18cbc038 --- /dev/null +++ b/openless-all/app/src-tauri/nsis/openless-ime-hooks.nsh @@ -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 diff --git a/openless-all/app/src-tauri/tauri.conf.json b/openless-all/app/src-tauri/tauri.conf.json index 438315e7..26ee80e5 100644 --- a/openless-all/app/src-tauri/tauri.conf.json +++ b/openless-all/app/src-tauri/tauri.conf.json @@ -85,6 +85,10 @@ "entitlements": "Entitlements.plist" }, "windows": { + "nsis": { + "installMode": "both", + "installerHooks": "nsis/openless-ime-hooks.nsh" + }, "wix": { "fragmentPaths": [ "wix/openless-ime.wxs"