commit d2a5320ec26f41bf478276972f46a3aeb3e36dac Author: fanlusky <1730976608@qq.com> Date: Sun Aug 30 15:40:02 2026 +0800 Add installer instructions. diff --git a/installer/.gitignore b/installer/.gitignore new file mode 100644 index 00000000..f924efbd --- /dev/null +++ b/installer/.gitignore @@ -0,0 +1,14 @@ +# Build and package outputs (collected locally, not part of this repo) +Output/ +server_exe/ +tsf_dll/ +app_data/ +MetasequoiaIME.ico + +# Local test certificates (generated on each machine) +*.cer +*.crt +*.pfx +*.p12 +*.pvk +*.spc diff --git a/installer/Compile-Installer.ps1 b/installer/Compile-Installer.ps1 new file mode 100644 index 00000000..1771fbe1 --- /dev/null +++ b/installer/Compile-Installer.ps1 @@ -0,0 +1,93 @@ +[CmdletBinding()] +param( + [string]$IsccPath, + [string]$IssPath = (Join-Path $PSScriptRoot 'msime_setup.iss') +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Resolve-IsccPath { + param([string]$ExplicitPath) + + if ($ExplicitPath) { + if (-not (Test-Path -LiteralPath $ExplicitPath -PathType Leaf)) { + throw "指定的 Inno Setup 编译器不存在:$ExplicitPath" + } + return (Resolve-Path -LiteralPath $ExplicitPath).Path + } + + $pathCommand = Get-Command 'ISCC.exe' -CommandType Application -ErrorAction SilentlyContinue | + Select-Object -First 1 + if ($pathCommand) { + return $pathCommand.Source + } + + $uninstallPaths = @( + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*', + 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*', + 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*' + ) + $candidates = @( + Get-ItemProperty -Path $uninstallPaths -ErrorAction SilentlyContinue | + Where-Object { + $displayName = $_.PSObject.Properties['DisplayName'] + $installLocation = $_.PSObject.Properties['InstallLocation'] + $displayName -and $displayName.Value -like 'Inno Setup*' -and + $installLocation -and $installLocation.Value + } | + Sort-Object { + $displayVersion = $_.PSObject.Properties['DisplayVersion'] + try { [version]$displayVersion.Value } catch { [version]'0.0' } + } -Descending | + ForEach-Object { + Join-Path $_.PSObject.Properties['InstallLocation'].Value 'ISCC.exe' + } + ) + + $programRoots = @( + ${env:ProgramFiles(x86)}, + $env:ProgramFiles, + (Join-Path $env:LOCALAPPDATA 'Programs') + ) | Where-Object { $_ } + foreach ($version in @(7, 6)) { + foreach ($root in $programRoots) { + $candidates += Join-Path $root "Inno Setup $version\ISCC.exe" + } + } + + $candidate = $candidates | + Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | + Select-Object -First 1 + if ($candidate) { + return $candidate + } + + throw @' +找不到 Inno Setup 命令行编译器 ISCC.exe。请安装 Inno Setup 6.6 或更高版本,或通过 +-IsccPath 参数传入 ISCC.exe 的完整路径。 +'@ +} + +if (-not (Test-Path -LiteralPath $IssPath -PathType Leaf)) { + throw "Inno Setup 脚本不存在:$IssPath" +} + +$resolvedIssPath = (Resolve-Path -LiteralPath $IssPath).Path +$resolvedIsccPath = Resolve-IsccPath -ExplicitPath $IsccPath + +Write-Host "Inno Setup 编译器:$resolvedIsccPath" +Write-Host "安装脚本:$resolvedIssPath" + +Push-Location $PSScriptRoot +try { + & $resolvedIsccPath $resolvedIssPath + if ($LASTEXITCODE -ne 0) { + throw "Inno Setup 编译失败,退出码:$LASTEXITCODE" + } +} +finally { + Pop-Location +} + +Write-Host "安装包编译完成,输出目录:$(Join-Path $PSScriptRoot 'Output')" diff --git a/installer/Install.ps1 b/installer/Install.ps1 new file mode 100644 index 00000000..1fb709fb --- /dev/null +++ b/installer/Install.ps1 @@ -0,0 +1,21 @@ +# 启动本目录 Output\ 里刚编译好的安装包。 +# 安装包文件名从 msime_setup.iss 的 MyAppVersion 读出。 + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$issPath = Join-Path $PSScriptRoot 'msime_setup.iss' +if (-not (Test-Path -LiteralPath $issPath -PathType Leaf)) { + throw "找不到安装脚本:$issPath" +} +$issContent = Get-Content -LiteralPath $issPath -Raw +if ($issContent -notmatch '(?m)^#define\s+MyAppVersion\s+"(?[^"]+)"') { + throw '未能在 msime_setup.iss 中找到 MyAppVersion。' +} + +$installerPath = Join-Path $PSScriptRoot "Output\MetasequoiaIME_Setup_v$($Matches.version).exe" +if (-not (Test-Path -LiteralPath $installerPath -PathType Leaf)) { + throw "安装文件不存在,请先运行 Compile-Installer.ps1:$installerPath" +} + +& $installerPath diff --git a/installer/Prepare-PackageFiles.ps1 b/installer/Prepare-PackageFiles.ps1 new file mode 100644 index 00000000..faf81302 --- /dev/null +++ b/installer/Prepare-PackageFiles.ps1 @@ -0,0 +1,177 @@ +[CmdletBinding()] +param( + [string]$TargetVersion = '0.0.1', + [string]$RepoRoot = (Split-Path -Parent $PSScriptRoot) +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Assert-PathExists { + param([Parameter(Mandatory)][string]$LiteralPath, [Parameter(Mandatory)][string]$Description) + if (-not (Test-Path -LiteralPath $LiteralPath)) { + throw "$Description 不存在:$LiteralPath" + } +} + +function Copy-DirectoryContents { + param([Parameter(Mandatory)][string]$Source, [Parameter(Mandatory)][string]$Destination) + Assert-PathExists -LiteralPath $Source -Description '源目录' + New-Item -ItemType Directory -Path $Destination -Force | Out-Null + Get-ChildItem -LiteralPath $Source -Force | Copy-Item -Destination $Destination -Recurse -Force +} + +function Reset-Directory { + param([Parameter(Mandatory)][string]$LiteralPath) + if (Test-Path -LiteralPath $LiteralPath) { + Remove-Item -LiteralPath $LiteralPath -Recurse -Force + } + New-Item -ItemType Directory -Path $LiteralPath -Force | Out-Null +} + +$serverRelease = Join-Path $RepoRoot 'MetasequoiaImeServer\build-release\bin\Release' +$dictionaryReplayRelease = Join-Path $serverRelease 'MetasequoiaImeDictionaryReplay.exe' +$tsf32Release = Join-Path $RepoRoot 'MetasequoiaImeTsf\build32-release\Release\MetasequoiaImeTsf.dll' +$tsf64Release = Join-Path $RepoRoot 'MetasequoiaImeTsf\build64-release\Release\MetasequoiaImeTsf.dll' +$webviewRoot = Join-Path $RepoRoot 'MetasequoiaImeUiHtml\webview2' +$serverConfig = Join-Path $RepoRoot 'MetasequoiaImeServer\assets\config\config.toml' +$factoryConfig = Join-Path $PSScriptRoot 'default_config\config.default.toml' +$pinyinTable = Join-Path $RepoRoot 'MetasequoiaImeServer\assets\tables\pinyin.txt' +$helpcodeSource = Join-Path $RepoRoot 'MetasequoiaImeHelpCode\helpcodes' +$appIcon = Join-Path $RepoRoot 'MetasequoiaImeServer\src\resource\MetasequoiaIME.ico' +$dictionaryDb = Join-Path $RepoRoot 'MetasequoiaImeDict\out\msime.db' +$japaneseModel = Join-Path $RepoRoot 'MetasequoiaImeDict\out\dict_japanese.dat' +$japaneseModelLicense = Join-Path $RepoRoot 'MetasequoiaImeDict\source\mozc_dictionary_oss\README.txt' +$englishDb = Join-Path $RepoRoot 'MetasequoiaImeDict\out\english.db' +$customTranslations = Join-Path $RepoRoot 'MetasequoiaImeDict\MetasequoiaImeCustomDict\translations.txt' +$othersDb = Join-Path $RepoRoot 'MetasequoiaImeDict\out\others.db' + +Assert-PathExists -LiteralPath $RepoRoot -Description '源码仓库根目录' +Assert-PathExists -LiteralPath $serverRelease -Description 'Server Release 输出目录' +Assert-PathExists -LiteralPath $dictionaryReplayRelease -Description '用户词库回放程序 Release EXE' +Assert-PathExists -LiteralPath $tsf32Release -Description '32 位 TSF Release DLL' +Assert-PathExists -LiteralPath $tsf64Release -Description '64 位 TSF Release DLL' +Assert-PathExists -LiteralPath $serverConfig -Description 'Server config.toml' +Assert-PathExists -LiteralPath $factoryConfig -Description '出厂配置 default_config\config.default.toml' +Assert-PathExists -LiteralPath $pinyinTable -Description '完整拼音音节表 pinyin.txt' +Assert-PathExists -LiteralPath $helpcodeSource -Description '辅助码目录' +Assert-PathExists -LiteralPath $appIcon -Description '应用图标' +Assert-PathExists -LiteralPath $dictionaryDb -Description '词库数据库 msime.db' +Assert-PathExists -LiteralPath $japaneseModel -Description '日语整句模型 dict_japanese.dat' +Assert-PathExists -LiteralPath $japaneseModelLicense -Description 'Mozc 日语词典授权声明' +Assert-PathExists -LiteralPath $englishDb -Description '英文词库数据库 english.db' +Assert-PathExists -LiteralPath $customTranslations -Description '自定义翻译词库 translations.txt' +python -c @" +import sqlite3, sys +cols = list(sqlite3.connect(sys.argv[1]).execute('PRAGMA table_info(english_words)')) +names = {row[1] for row in cols} +pk = [row[1] for row in cols if row[5] > 0] +if 'weight' not in names or pk != ['word', 'display']: + raise SystemExit('english.db schema is stale; rebuild with weight and PRIMARY KEY(word, display)') +"@ $englishDb +if ($LASTEXITCODE -ne 0) { + throw "英文词库数据库 schema 检查失败:$englishDb" +} +Assert-PathExists -LiteralPath $othersDb -Description '杂项数据库 others.db' +Assert-PathExists -LiteralPath (Join-Path $webviewRoot 'candwnd') -Description '候选窗 HTML 目录' +Assert-PathExists -LiteralPath (Join-Path $webviewRoot 'ftb') -Description '悬浮工具栏 HTML 目录' +Assert-PathExists -LiteralPath (Join-Path $webviewRoot 'menu') -Description '菜单 HTML 目录' +Assert-PathExists -LiteralPath (Join-Path $webviewRoot 'settings\ime-settings\dist') -Description '设置页面 dist 目录' + +$targetAppData = Join-Path $PSScriptRoot 'app_data' +$targetServer = Join-Path $PSScriptRoot 'server_exe' +$targetTsf = Join-Path $PSScriptRoot 'tsf_dll' + +Reset-Directory -LiteralPath $targetAppData +if (-not (Get-Content -LiteralPath $pinyinTable | Where-Object { $_.Trim() -eq 'xing' })) { + throw "完整拼音音节表缺少 xing:$pinyinTable" +} +Copy-Item -LiteralPath $pinyinTable -Destination (Join-Path $targetAppData 'pinyin.txt') -Force +Copy-Item -LiteralPath $dictionaryDb -Destination (Join-Path $targetAppData 'msime.db') -Force +Copy-Item -LiteralPath $japaneseModel -Destination (Join-Path $targetAppData 'dict_japanese.dat') -Force +Copy-Item -LiteralPath $japaneseModelLicense -Destination (Join-Path $targetAppData 'MOZC_DICTIONARY_LICENSE.txt') -Force +Copy-Item -LiteralPath $englishDb -Destination (Join-Path $targetAppData 'english.db') -Force +Copy-Item -LiteralPath $customTranslations -Destination (Join-Path $targetAppData 'custom_translations.txt') -Force +Copy-Item -LiteralPath $othersDb -Destination (Join-Path $targetAppData 'others.db') -Force + +$defaultConfigPath = Join-Path $targetAppData 'config.default.toml' +# 出厂配置来自本仓库的 default_config,不依赖本机是否已安装输入法。 +# 安装脚本用 onlyifdoesntexist 生成用户 config.toml,升级不会覆盖已有方案/主题。 +Copy-Item -LiteralPath $factoryConfig -Destination $defaultConfigPath -Force +$defaultConfig = Get-Content -LiteralPath $defaultConfigPath -Raw +if ($defaultConfig -notmatch '(?m)^schema\s*=\s*"quanpin"\s*$') { + throw '出厂配置的 input.schema 必须是 quanpin。' +} +if ($defaultConfig -notmatch '(?m)^theme_mode\s*=\s*"system"\s*$') { + throw '出厂配置的 appearance.theme_mode 必须是 system。' +} +if ($defaultConfig -match '(?m)^diagnostic_log\s*=\s*true\s*$') { + throw '出厂配置不应默认打开 diagnostic_log。' +} +$defaultConfig = $defaultConfig.TrimEnd("`r", "`n") + "`r`n" +Set-Content -LiteralPath $defaultConfigPath -Value $defaultConfig -Encoding utf8NoBOM -NoNewline +foreach ($stagedUserConfig in @('config.toml', 'config.base.toml')) { + $stagedPath = Join-Path $targetAppData $stagedUserConfig + if (Test-Path -LiteralPath $stagedPath) { + Remove-Item -LiteralPath $stagedPath -Force + } +} + +$targetHelpcodes = Join-Path $targetAppData 'helpcodes' +Reset-Directory -LiteralPath $targetHelpcodes +Copy-DirectoryContents -Source $helpcodeSource -Destination $targetHelpcodes + +$targetHtml = Join-Path $targetAppData 'html' +$targetWebview = Join-Path $targetHtml 'webview2' +Copy-DirectoryContents -Source (Join-Path $webviewRoot 'candwnd') -Destination (Join-Path $targetWebview 'candwnd') +Copy-DirectoryContents -Source (Join-Path $webviewRoot 'ftb') -Destination (Join-Path $targetWebview 'ftb') +Copy-DirectoryContents -Source (Join-Path $webviewRoot 'menu') -Destination (Join-Path $targetWebview 'menu') +Copy-DirectoryContents -Source (Join-Path $webviewRoot 'settings\ime-settings\dist') ` + -Destination (Join-Path $targetWebview 'settings\ime-settings\dist') + +# Server Release 输出整体复制,但测试程序绝不能进入安装包。 +Reset-Directory -LiteralPath $targetServer +Copy-DirectoryContents -Source $serverRelease -Destination $targetServer +Get-ChildItem -LiteralPath $targetServer -Recurse -File -Filter '*Tests.exe' | + Remove-Item -Force + +Reset-Directory -LiteralPath $targetTsf +$targetTsf32 = Join-Path $targetTsf '32' +$targetTsf64 = Join-Path $targetTsf '64' +New-Item -ItemType Directory -Path $targetTsf32, $targetTsf64 -Force | Out-Null +Copy-Item -LiteralPath $tsf32Release -Destination $targetTsf32 -Force +Copy-Item -LiteralPath $tsf64Release -Destination $targetTsf64 -Force +Copy-Item -LiteralPath $appIcon -Destination (Join-Path $PSScriptRoot 'MetasequoiaIME.ico') -Force + +$targetIss = Join-Path $PSScriptRoot 'msime_setup.iss' +Assert-PathExists -LiteralPath $targetIss -Description '安装脚本' +$issContent = Get-Content -LiteralPath $targetIss -Raw +if ($issContent -notmatch '(?m)^#define\s+MyAppVersion\s+"[^"]+"\s*$') { + throw '未能在安装脚本中找到 MyAppVersion。' +} +$updatedIss = [regex]::Replace( + $issContent, + '(?m)^#define\s+MyAppVersion\s+"[^"]+"\s*$', + "#define MyAppVersion `"$TargetVersion`"" +) +$updatedIss = $updatedIss.TrimEnd("`r", "`n") + "`r`n" +Set-Content -LiteralPath $targetIss -Value $updatedIss -Encoding utf8NoBOM -NoNewline + +# 设置页是已构建的静态资源,同步其“当前版本”展示。 +$settingsDist = Join-Path $targetWebview 'settings\ime-settings\dist' +Get-ChildItem -LiteralPath $settingsDist -Recurse -File -Include '*.js', '*.html' | ForEach-Object { + $content = Get-Content -LiteralPath $_.FullName -Raw + $updated = [regex]::Replace( + $content, + '(
)v\d+(?:\.\d+)+(
)', + "`$1v$TargetVersion`$2" + ) + if ($updated -ne $content) { + Set-Content -LiteralPath $_.FullName -Value $updated -Encoding utf8NoBOM + } +} + +$serverBinaryCount = @(Get-ChildItem -LiteralPath $targetServer -Recurse -File -Include '*.exe', '*.dll').Count +$tsfBinaryCount = @(Get-ChildItem -LiteralPath $targetTsf -Recurse -File -Include '*.exe', '*.dll').Count +Write-Host "安装文件准备完成:$PSScriptRoot" +Write-Host "版本:$TargetVersion;Server EXE/DLL:$serverBinaryCount 个;TSF EXE/DLL:$tsfBinaryCount 个。" diff --git a/installer/README.md b/installer/README.md new file mode 100644 index 00000000..482c3ba6 --- /dev/null +++ b/installer/README.md @@ -0,0 +1,73 @@ +# Metasequoia IME Installer(本地测试流程) + +公开这份仓库的目的,是把 Metasequoia IME 的 **本地测试安装流程** 整理清楚:从相邻源码仓库收集产物、用本机自签名证书签名、用 Inno Setup 打安装包、再跑安装程序。 + +测试版本号固定为 **0.0.1**。 + +本仓库 **不包含** 正式代码签名证书、私钥、指纹,也不包含编译好的 EXE/DLL、词库或安装包。签名脚本会在你自己的机器上生成并复用一张自签名测试证书。 + +## 依赖 + +- Windows 10/11,PowerShell 7(`pwsh`) +- [Inno Setup](https://jrsoftware.org/isinfo.php) 6.6 或更高版本 +- Windows SDK(提供 `signtool.exe`) +- 下列源码仓库需要你自己准备,并先完成 Release 编译 / 词库构建: + - `MetasequoiaImeTsf` + - `MetasequoiaImeServer` + - `MetasequoiaImeUiHtml` + - `MetasequoiaImeHelpCode` + - `MetasequoiaImeDict` + +## 请先改路径 + +脚本里写死的默认布局是:本仓库和上面那些项目都在**同一个父目录**下(例如都在 `IMECodes\` 里)。这只是作者本机的相对路径,**换到你自己的机器上通常对不上**。 + +请按自己的目录改这些地方,尤其是收集安装文件时的复制源: + +- `Prepare-PackageFiles.ps1`:`$serverRelease`、`$tsf32Release`、`$tsf64Release`、`$webviewRoot`、`$helpcodeSource`、词库 `msime.db` / `english.db` 等,都是从其他项目里 **Copy** 过来的。路径不对就会直接报“不存在”。 +- `test.ps1`:TSF / Server 的编译脚本、设置页的 `pnpm run build` 目录同样要改。 + +如果只是父目录不同、各子项目文件夹名没变,可以不改脚本,运行时传入: + +```powershell +pwsh -File .\Prepare-PackageFiles.ps1 -RepoRoot "D:\your\src" +``` + +子项目不在同一父目录、或编译输出目录和默认不一致时,请直接改脚本里的那些路径。 + +## 一键测试 + +先确认上一节的路径已经改成你本机的。然后在管理员 PowerShell 中(把本机测试证书写入受信任存储需要提升权限): + +```powershell +cd path\to\msime-installer +pwsh -File .\test.ps1 +``` + +`test.ps1` 会按顺序做这些事: + +1. 若找到相邻仓库,则编译 TSF、Server,并构建设置页 +2. `Prepare-PackageFiles.ps1` — 收集 `server_exe\`、`tsf_dll\`、`app_data\`,并把 `msime_setup.iss` 的版本写成 `0.0.1` +3. `Sign-PackageBinaries-Local.ps1` — 本机自签名包内 EXE/DLL +4. `Compile-Installer.ps1` — 编译出 `Output\MetasequoiaIME_Setup_v0.0.1.exe` +5. `Sign-Installer-Local.ps1` — 本机自签名安装包 +6. `Install.ps1` — 启动安装程序 + +也可以按上面的顺序逐步运行。 + +## 本机测试证书 + +第一次运行签名脚本时,会在当前用户证书存储里创建: + +`CN=Metasequoia IME Local Test Code Signing` + +之后复用同一张证书。产物只适合本机或已导入该证书公钥的测试机,不要当作正式分发包。 + +想让另一台测试机信任这张证书,把公钥导出成 `.cer` 后在那台机器上执行: + +```powershell +certutil -addstore Root MetasequoiaImeLocalTest.cer +certutil -addstore TrustedPublisher MetasequoiaImeLocalTest.cer +``` + +不要把 `.cer` / `.pfx` 提交进本仓库。 diff --git a/installer/Sign-Installer-Local.ps1 b/installer/Sign-Installer-Local.ps1 new file mode 100644 index 00000000..2414d7b9 --- /dev/null +++ b/installer/Sign-Installer-Local.ps1 @@ -0,0 +1,180 @@ +# 用本机自签名测试证书给 Inno Setup 编译出来的安装包签名。 +# 与 Sign-PackageBinaries-Local.ps1 共用同一张本机测试证书。 +# 每台机器各自生成证书,本仓库不包含任何预置证书或指纹。 +# +# 直接运行,不需要参数: +# pwsh -File .\Sign-Installer-Local.ps1 +# +# 用的是和 Sign-PackageBinaries-Local.ps1 同一张测试证书:第一次运行时自动创建, +# 并装进本机受信任存储(这一步需要管理员 PowerShell;没有管理员权限只会警告, +# 签名照做,只是 signtool verify 的链校验过不了)。之后每次运行都会复用它。 +# +# 安装包文件名从 msime_setup.iss 的 MyAppVersion 读出,免得两处版本号打架。 + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$CertificateSubject = 'CN=Metasequoia IME Local Test Code Signing' +$CertificateValidityYears = 5 +$CodeSigningEku = '1.3.6.1.5.5.7.3.3' +$TrustStores = @('Cert:\LocalMachine\Root', 'Cert:\LocalMachine\TrustedPublisher') + +function Test-Administrator { + $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [System.Security.Principal.WindowsPrincipal]::new($identity) + return $principal.IsInRole( + [System.Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Find-SignTool { + $kitsBin = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\bin' + $candidate = Get-ChildItem -LiteralPath $kitsBin -Directory -ErrorAction SilentlyContinue | + Sort-Object { try { [version]$_.Name } catch { [version]'0.0' } } -Descending | + ForEach-Object { Join-Path $_.FullName 'x64\signtool.exe' } | + Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | + Select-Object -First 1 + if (-not $candidate) { + throw '找不到 signtool.exe;请安装 Windows SDK。' + } + return $candidate +} + +function Get-LocalTestCertificate { + $now = Get-Date + $candidates = @( + Get-ChildItem -Path 'Cert:\CurrentUser\My' | + Where-Object { + $_.Subject -eq $CertificateSubject -and + $_.HasPrivateKey -and + $_.NotBefore -le $now -and + $_.NotAfter -gt $now -and + ($_.EnhancedKeyUsageList.ObjectId -contains $CodeSigningEku) + } | + Sort-Object NotAfter -Descending + ) + if ($candidates.Count -eq 0) { + return $null + } + return $candidates[0] +} + +function Add-CertificateTrust { + param([Parameter(Mandatory)]$Certificate) + + $missing = @( + $TrustStores | Where-Object { + -not (Test-Path -LiteralPath (Join-Path $_ $Certificate.Thumbprint)) + } + ) + if ($missing.Count -eq 0) { + Write-Host '证书已在本机受信任存储中,跳过信任步骤。' + return + } + if (-not (Test-Administrator)) { + Write-Warning @" +证书还没进本机受信任存储,而写入它需要管理员权限:$($missing -join '、') +请用管理员 PowerShell 重新运行一次本脚本。在那之前签名依然有效,只是链校验会失败。 +"@ + return + } + + $temporaryFile = Join-Path ([System.IO.Path]::GetTempPath()) ` + ('msime-local-test-{0}.cer' -f $Certificate.Thumbprint) + try { + Export-Certificate -Cert $Certificate -FilePath $temporaryFile -Type CERT -Force | Out-Null + foreach ($store in $missing) { + Import-Certificate -FilePath $temporaryFile -CertStoreLocation $store | Out-Null + Write-Host "已信任:$store" + } + } + finally { + if (Test-Path -LiteralPath $temporaryFile) { + Remove-Item -LiteralPath $temporaryFile -Force + } + } +} + +function Initialize-LocalTestCertificate { + $certificate = Get-LocalTestCertificate + if ($certificate) { + Write-Host '已有本机测试证书,跳过创建。' + } + else { + # -Type CodeSigningCert 负责代码签名 EKU;Exportable 是为了以后能导出公钥给测试机。 + $certificate = New-SelfSignedCertificate ` + -Type CodeSigningCert ` + -Subject $CertificateSubject ` + -FriendlyName 'Metasequoia IME Local Test Code Signing' ` + -KeyAlgorithm RSA ` + -KeyLength 3072 ` + -HashAlgorithm SHA256 ` + -KeyExportPolicy Exportable ` + -KeyUsage DigitalSignature ` + -CertStoreLocation 'Cert:\CurrentUser\My' ` + -NotAfter (Get-Date).AddYears($CertificateValidityYears) + Write-Host '已创建本机测试证书。' + } + Write-Host "指纹:$($certificate.Thumbprint)" + Write-Host "有效期至:$($certificate.NotAfter.ToString('yyyy-MM-dd HH:mm:ss'))" + Add-CertificateTrust -Certificate $certificate + return $certificate +} + +function Test-SignedByLocalTestCertificate { + param([Parameter(Mandatory)][string]$LiteralPath) + + $signature = Get-AuthenticodeSignature -LiteralPath $LiteralPath + if (-not $signature.SignerCertificate) { + return $false + } + # 不看 Status:测试证书没装进受信任根时链校验必然失败,但文件确实已由它签过名。 + return $signature.SignerCertificate.Thumbprint.Replace(' ', '') -ieq $script:thumbprint +} + +function Invoke-Sign { + param([Parameter(Mandatory)][string]$LiteralPath) + + # 自签名证书的时间戳没有意义,测试机还可能离线,所以不加 /tr。 + & $script:signTool sign /sha1 $script:thumbprint /s My /fd sha256 /v $LiteralPath + if ($LASTEXITCODE -ne 0) { + throw "signtool.exe 签名失败,退出码:$LASTEXITCODE" + } + + & $script:signTool verify /pa /v $LiteralPath + if ($LASTEXITCODE -ne 0) { + Write-Warning "已签名但链校验失败(测试证书尚未被本机信任):$LiteralPath" + } +} + +$issPath = Join-Path $PSScriptRoot 'msime_setup.iss' +if (-not (Test-Path -LiteralPath $issPath -PathType Leaf)) { + throw "找不到安装脚本:$issPath" +} +$issContent = Get-Content -LiteralPath $issPath -Raw +if ($issContent -notmatch '(?m)^#define\s+MyAppVersion\s+"(?[^"]+)"') { + throw '未能在 msime_setup.iss 中找到 MyAppVersion。' +} +$version = $Matches.version + +$installerPath = Join-Path $PSScriptRoot "Output\MetasequoiaIME_Setup_v$version.exe" +if (-not (Test-Path -LiteralPath $installerPath -PathType Leaf)) { + throw "安装文件不存在,请先用 Inno Setup 编译 msime_setup.iss:$installerPath" +} +$resolvedInstaller = (Resolve-Path -LiteralPath $installerPath).Path + +Write-Warning '使用本机测试证书签名,安装包只能内部测试,不要对外分发。' +$certificate = Initialize-LocalTestCertificate +$script:thumbprint = $certificate.Thumbprint +$script:signTool = Find-SignTool + +if (Test-SignedByLocalTestCertificate -LiteralPath $resolvedInstaller) { + Write-Host "安装文件已由本机测试证书签名,跳过:$resolvedInstaller" +} +else { + Write-Host "正在签名安装文件:$resolvedInstaller" + Invoke-Sign -LiteralPath $resolvedInstaller +} + +Write-Host '安装文件本地签名检查完成。' +# 链校验警告会让最后一个 signtool 留下非零退出码;对本脚本来说整体是成功的。 +$global:LASTEXITCODE = 0 diff --git a/installer/Sign-PackageBinaries-Local.ps1 b/installer/Sign-PackageBinaries-Local.ps1 new file mode 100644 index 00000000..4270778d --- /dev/null +++ b/installer/Sign-PackageBinaries-Local.ps1 @@ -0,0 +1,194 @@ +# 用本机自签名测试证书给安装文件(server_exe\ 与 tsf_dll\)里的 EXE/DLL 签名。 +# 每台机器各自生成证书,本仓库不包含任何预置证书或指纹。 +# +# 直接运行,不需要参数: +# pwsh -File .\Sign-PackageBinaries-Local.ps1 +# +# 证书在第一次运行时自动创建,并装进本机受信任存储(这一步需要管理员 PowerShell; +# 没有管理员权限只会警告,签名照做,只是 signtool verify 的链校验过不了)。 +# 之后每次运行都会复用同一张证书,已经被它签过的文件也会自动跳过。 +# +# 每台机器各自生成自己的测试证书,不需要在机器之间搬私钥。想让纯测试机信任本机签名, +# 把证书的公钥导出成 .cer 拷过去,在那台机器上用 certutil 装进受信任根即可: +# certutil -addstore Root MetasequoiaImeLocalTest.cer +# certutil -addstore TrustedPublisher MetasequoiaImeLocalTest.cer + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$CertificateSubject = 'CN=Metasequoia IME Local Test Code Signing' +$CertificateValidityYears = 5 +$CodeSigningEku = '1.3.6.1.5.5.7.3.3' +$TrustStores = @('Cert:\LocalMachine\Root', 'Cert:\LocalMachine\TrustedPublisher') + +function Test-Administrator { + $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [System.Security.Principal.WindowsPrincipal]::new($identity) + return $principal.IsInRole( + [System.Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Find-SignTool { + $kitsBin = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\bin' + $candidate = Get-ChildItem -LiteralPath $kitsBin -Directory -ErrorAction SilentlyContinue | + Sort-Object { try { [version]$_.Name } catch { [version]'0.0' } } -Descending | + ForEach-Object { Join-Path $_.FullName 'x64\signtool.exe' } | + Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | + Select-Object -First 1 + if (-not $candidate) { + throw '找不到 signtool.exe;请安装 Windows SDK。' + } + return $candidate +} + +function Get-LocalTestCertificate { + $now = Get-Date + $candidates = @( + Get-ChildItem -Path 'Cert:\CurrentUser\My' | + Where-Object { + $_.Subject -eq $CertificateSubject -and + $_.HasPrivateKey -and + $_.NotBefore -le $now -and + $_.NotAfter -gt $now -and + ($_.EnhancedKeyUsageList.ObjectId -contains $CodeSigningEku) + } | + Sort-Object NotAfter -Descending + ) + if ($candidates.Count -eq 0) { + return $null + } + return $candidates[0] +} + +function Add-CertificateTrust { + param([Parameter(Mandatory)]$Certificate) + + $missing = @( + $TrustStores | Where-Object { + -not (Test-Path -LiteralPath (Join-Path $_ $Certificate.Thumbprint)) + } + ) + if ($missing.Count -eq 0) { + Write-Host '证书已在本机受信任存储中,跳过信任步骤。' + return + } + if (-not (Test-Administrator)) { + Write-Warning @" +证书还没进本机受信任存储,而写入它需要管理员权限:$($missing -join '、') +请用管理员 PowerShell 重新运行一次本脚本。在那之前签名依然有效,只是链校验会失败。 +"@ + return + } + + $temporaryFile = Join-Path ([System.IO.Path]::GetTempPath()) ` + ('msime-local-test-{0}.cer' -f $Certificate.Thumbprint) + try { + Export-Certificate -Cert $Certificate -FilePath $temporaryFile -Type CERT -Force | Out-Null + foreach ($store in $missing) { + Import-Certificate -FilePath $temporaryFile -CertStoreLocation $store | Out-Null + Write-Host "已信任:$store" + } + } + finally { + if (Test-Path -LiteralPath $temporaryFile) { + Remove-Item -LiteralPath $temporaryFile -Force + } + } +} + +function Initialize-LocalTestCertificate { + $certificate = Get-LocalTestCertificate + if ($certificate) { + Write-Host '已有本机测试证书,跳过创建。' + } + else { + # -Type CodeSigningCert 负责代码签名 EKU;Exportable 是为了以后能导出公钥给测试机。 + $certificate = New-SelfSignedCertificate ` + -Type CodeSigningCert ` + -Subject $CertificateSubject ` + -FriendlyName 'Metasequoia IME Local Test Code Signing' ` + -KeyAlgorithm RSA ` + -KeyLength 3072 ` + -HashAlgorithm SHA256 ` + -KeyExportPolicy Exportable ` + -KeyUsage DigitalSignature ` + -CertStoreLocation 'Cert:\CurrentUser\My' ` + -NotAfter (Get-Date).AddYears($CertificateValidityYears) + Write-Host '已创建本机测试证书。' + } + Write-Host "指纹:$($certificate.Thumbprint)" + Write-Host "有效期至:$($certificate.NotAfter.ToString('yyyy-MM-dd HH:mm:ss'))" + Add-CertificateTrust -Certificate $certificate + return $certificate +} + +function Test-SignedByLocalTestCertificate { + param([Parameter(Mandatory)][string]$LiteralPath) + + $signature = Get-AuthenticodeSignature -LiteralPath $LiteralPath + if (-not $signature.SignerCertificate) { + return $false + } + # 不看 Status:测试证书没装进受信任根时链校验必然失败,但文件确实已由它签过名。 + return $signature.SignerCertificate.Thumbprint.Replace(' ', '') -ieq $script:thumbprint +} + +function Invoke-Sign { + param([Parameter(Mandatory)][string]$LiteralPath) + + # 自签名证书的时间戳没有意义,测试机还可能离线,所以不加 /tr。 + & $script:signTool sign /sha1 $script:thumbprint /s My /fd sha256 /v $LiteralPath + if ($LASTEXITCODE -ne 0) { + throw "signtool.exe 签名失败,退出码:$LASTEXITCODE" + } + + & $script:signTool verify /pa /v $LiteralPath + if ($LASTEXITCODE -ne 0) { + Write-Warning "已签名但链校验失败(测试证书尚未被本机信任):$LiteralPath" + } +} + +$packageDirectories = @( + (Join-Path $PSScriptRoot 'server_exe'), + (Join-Path $PSScriptRoot 'tsf_dll') +) +foreach ($directory in $packageDirectories) { + if (-not (Test-Path -LiteralPath $directory -PathType Container)) { + throw "待签名目录不存在,请先运行 Prepare-PackageFiles.ps1:$directory" + } +} + +# 最外层的 @() 不能省:只有一个待签文件时,管道结果会是标量,.Count 在严格模式下会报错。 +$targets = @( + @( + foreach ($directory in $packageDirectories) { + Get-ChildItem -LiteralPath $directory -Recurse -File | + Where-Object { $_.Extension -in @('.exe', '.dll') } + } + ) | Sort-Object FullName -Unique +) +if ($targets.Count -eq 0) { + throw '没有找到待签名的 EXE 或 DLL。' +} + +Write-Warning '使用本机测试证书签名,产物只能内部测试,不要对外分发。' +$certificate = Initialize-LocalTestCertificate +$script:thumbprint = $certificate.Thumbprint +$script:signTool = Find-SignTool + +$signedCount = 0 +$skippedCount = 0 +foreach ($target in $targets) { + if (Test-SignedByLocalTestCertificate -LiteralPath $target.FullName) { + Write-Host "已由本机测试证书签名,跳过:$($target.FullName)" + $skippedCount++ + continue + } + Write-Host "正在签名:$($target.FullName)" + Invoke-Sign -LiteralPath $target.FullName + $signedCount++ +} + +Write-Host "包内 EXE/DLL 本地签名完成:新签名 $signedCount 个,已签名跳过 $skippedCount 个,总计 $($targets.Count) 个文件。" +# 链校验警告会让最后一个 signtool 留下非零退出码;对本脚本来说整体是成功的。 +$global:LASTEXITCODE = 0 diff --git a/installer/default_config/config.default.toml b/installer/default_config/config.default.toml new file mode 100644 index 00000000..08fbd322 --- /dev/null +++ b/installer/default_config/config.default.toml @@ -0,0 +1,341 @@ +# +# Settings page +# 设置页面 +# +[settings_page] +theme = "dark" + +# +# General +# 通用 +# +[general] +# 界面诊断日志总开关(默认关闭;不记录输入内容或候选文本) +diagnostic_log = false +# 候选框诊断日志(旧键,与 diagnostic_log 同步;默认关闭) +candidate_window_diagnostic_log = false +# 悬浮工具栏 +floating_toolbar = true +# 悬浮工具栏组件(中英文切换始终显示) +floating_toolbar_fullwidth = true +floating_toolbar_punctuation = true +floating_toolbar_character_set = true +floating_toolbar_emoji = true +floating_toolbar_screen_keyboard = false +floating_toolbar_settings = true +# 悬浮工具栏用户缩放(相对系统 DPI),可选:0.75 / 1.0 / 1.25 / 1.5 +floating_toolbar_scale = 1.0 +# 悬浮工具栏图标尺寸(px,再乘 scale),可选:16–28 +floating_toolbar_font_size = 24 +# Emoji +enable_emoji = true +# 翻页方式 +paging_minus_equal = true +paging_comma_period = true +paging_tab = true +paging_page_up_down = true +candidate_arrow_navigation = true +# 中文输入状态下显示英文前缀候选 +cn_en_mixed_input = true +# 中英混输触发所需的最少字母数,范围 1–8 +cn_en_mixed_input_min_chars = 2 +# 在竖排候选窗口中显示中英文互译(每项最多两个简短释义) +candidate_translations = true +# 中文输入状态下显示 emoji 前缀候选(第三个候选项,随云候选/AI 联想顺移) +emoji_mixed_input = true +# 中文输入状态下显示颜文字前缀候选(排在 emoji 之后) +kaomoji_mixed_input = false +# 云候选 +cloud_candidates = true +# TODO: 极简模式。极简模式下,候选窗口使用 d2d 绘制,界面只作展示候选项,无其他功能。 +clean_mode = false + +# +# 腾讯云机器翻译(竖排候选项本地词库未命中时的补全) +# +[tencent_tmt] +enabled = true +# 请自行填写腾讯云密钥,不要把真实密钥写进仓库。 +secret_id = "" +secret_key = "" +region = "ap-guangzhou" + +[frequency_adjustment] +# disabled / pin / halve / linear / promote +mode = "promote" +# 每选中几次执行一次调频,范围 1~10 +trigger_count = 1 +# 线性调频每次前移数量,范围 1~10 +linear_step = 1 + +# +# Appearance +# 外观 +# +[appearance] +# 主题模式,可选:"dark", "light", "system" +theme_mode = "system" +# 分表面主题,可选:"follow", "dark", "light"(follow = 跟随 theme_mode) +theme_settings = "follow" +theme_cand = "follow" +theme_ftb = "follow" +theme_menu = "follow" +theme_emoji = "follow" +theme_screen_keyboard = "follow" +theme_handwriting = "follow" +theme_voice = "follow" +# 字体(中文) +font = "Noto Sans SC" +# 英文字体(拉丁字母优先使用;汉字仍回落到 font) +english_font = "Segoe UI" +# 缺省字体,如果系统中没有找到指定字体,则使用缺省字体 +default_font = "Microsoft YaHei" +# 字体大小(px),可选:12–32 +font_size = 18 +# 候选窗口顶部预编辑字号(px),可选:12–32 +candidate_window_preedit_font_size = 15 +# 候选文字颜色,"auto" 跟随主题,或 #RRGGBB +cand_text_color = "auto" +# 每页候选词数量,可选:3, 4, 5, 6, 7, 8, 9 +page_size = 6 +# 候选窗口跟随光标 +candidate_window_follow_cursor = true +# 候选窗口排列方式,可选:"vertical", "horizontal" +candidate_window_layout = "vertical" +# 候选窗口与悬浮工具栏皮肤,可选:"fluent", "wechat" +candidate_skin = "willow_green" +# 候选窗口顶部预编辑,可选:"pinyin", "empty" +# pinyin -> 显示分词预编辑(与候选框当前行为一致) +# empty -> 隐藏顶部预编辑行 +candidate_window_preedit_style = "pinyin" +# TSF 行内预编辑样式,可选:"raw", "pinyin", "empty" +# raw -> 原始按键序列 +# pinyin -> 分词后的拼音序列(Server 回包 Preedit) +# empty -> 行内不显示预编辑 +tsf_preedit_style = "raw" +# tsf_preedit_style = "pinyin" +# tsf_preedit_style = "empty" + +# +# Input +# 输入 +# +[input] +character_set = "simplified" # 候选输出字形,可选:"simplified", "traditional" +# 启动/从其他输入法切换到本输入法时的默认中英文,可选:"chinese", "english" +default_ime_mode = "english" +# 中英文状态范围,可选:"app"(按应用分别记忆), "global"(所有应用保持统一) +ime_mode_scope = "app" +# 以词定字:开启后,[ 上屏高亮候选的首个汉字,] 上屏末个汉字 +word_to_character = true +# 智能标点:中文标点模式下,字母/数字后的 , . : 输出英文标点(类似小狼毫 digit_separators) +smart_punctuation = true +# 重复标点转中文:智能标点输出英文标点后,2 秒内再次输入同一标点时替换为中文标点 +smart_punctuation_repeat_to_chinese = true +# 成对标点补全:输入左侧符号时自动补全右侧符号,并将光标置于中间 +paired_punctuation = true +# 组合说明: +# legacy + shuangpin -> 使用旧版双拼会话实现(当前最稳定) +# legacy + quanpin -> 自动切换为新版引擎全拼会话 +# engine + shuangpin -> 使用新版引擎双拼会话 +# engine + quanpin -> 使用新版引擎全拼会话 +# 任意 backend + wubi -> 使用新版引擎五笔会话 +# 会话后端,可选:"legacy", "engine" +# session_backend = "legacy" +session_backend = "engine" +# 输入模式,可选:"chinese", "japanese"。切换模式时保留各语言上次使用的方案。 +mode = "chinese" +# 输入方案,可选:"quanpin", "shuangpin", "wubi" +schema = "quanpin" +# schema = "quanpin" +# schema = "wubi" +# engine + shuangpin 时的 preedit 显示方式,可选:"quanpin", "shuangpin" +# quanpin -> 预编辑显示转换后的全拼分词 +# shuangpin -> 预编辑显示原始双拼输入串 +shuangpin_preedit_mode = "shuangpin" +# 双拼方案,可选:"xiaohe", "ziranma", "shoudao", "microsoft" +# shuangpin_schema = "xiaohe" +shuangpin_schema = "xiaohe" +# 五笔方案,可选:"wubi86", "wubi98" +wubi_schema = "wubi86" +# 日语方案,当前支持:"romaji" +japanese_schema = "romaji" + +# +# Helpcode +# 辅助码 +# +[helpcode] +# 双拼辅助码 +shuangpin_helpcode = true +# 双拼辅助码方案,可选:"lantian", "ziranma", "shouyou2_0", "shouyouplus", "xiaohe" +shuangpin_helpcode_schema = "lantian" +# 是否在候选窗口中显示双拼辅助码 +show_sp_helpcode_in_candidate_window = true +# 全拼辅助码 +quanpin_helpcode = true +# 全拼辅助码方案,可选:"lantian", "ziranma", "shouyou2_0", "shouyouplus", "xiaohe" +quanpin_helpcode_schema = "ziranma" +# 是否在候选窗口中显示全拼辅助码 +show_qp_helpcode_in_candidate_window = false + +# +# Keybindings +# 快捷键 +# +[keybindings] +# 中英文切换快捷键(Ctrl+Space 由 Windows 系统管理,此处不控制) +switch_language_shift = true +switch_language_ctrl = false +switch_language_ctrl_alt_space = true + +# +# Dictionary +# 词库 +# +[dictionary] +# 词库路径 +dictionary_path = "./assets/dictionaries/msime.db" + +# +# Skin +# 皮肤 +# +[skin] +# 皮肤名称 +skin_name = "default" + +# +# Voice Input +# 语音输入 +# +[voice_input] +# 语音输入 +voice_input = true +# 全局语音快捷键(可分别启用或关闭) +hotkey_ralt = true +hotkey_ctrl_f9 = true +hotkey_ctrl_win = false +hotkey_rctrl_ralt = false +hotkey_hold_space_lock = true +# ASR 服务: doubao | openai | siliconflow | groq +# openai / siliconflow / groq 均走 OpenAI 兼容的 /v1/audio/transcriptions。 +asr_provider = "doubao" +# 豆包旧版控制台鉴权:App ID + Access Token(Secret Key 不使用) +# 新版控制台使用单 API Key 时,将 asr_app_key 留空并把 API Key 填入 asr_token。 +# 请自行填写各提供商的密钥,不要把真实密钥写进仓库。 +asr_app_key = "" +asr_token = "FAKESECRET_k4l5m6n7o8p9q0r1s2t3" +# 每个 ASR 提供商单独保存 Token,切换提供商只改当前使用哪一份,不会互相覆盖。 +asr_token_doubao = "" +asr_token_openai = "" +asr_token_siliconflow = "" +asr_token_groq = "" +asr_endpoint = "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async" +# 默认使用豆包流式语音识别模型 2.0 小时版 +asr_resource_id = "volc.seedasr.sauc.duration" +# 豆包识别选项:数字格式化(ITN)、标点预测默认开启,语义顺滑(DDC)默认关闭。 +doubao_enable_itn = true +doubao_enable_punc = true +doubao_enable_ddc = true +# 在豆包语音控制台“自学习平台 -> 热词管理”创建词表后,将热词 ID 填在这里;留空则不使用。 +doubao_boosting_table_id = "" +# OpenAI 兼容识别模型。豆包流式不使用此项;留空则按提供商填默认值。 +# SiliconFlow 当前默认使用 FunAudioLLM/SenseVoiceSmall,也可在这里填写其他模型名。 +asr_model = "" +# 文本润色: siliconflow | openai | deepseek | groq +# 均走 OpenAI 兼容的 /v1/chat/completions。 +polish_provider = "deepseek" +# 请自行填写各提供商的 API Token,不要把真实密钥写进仓库。 +polish_token = "FAKESECRET_q2r3s4t5u6v7w8x9y0z1" +polish_token_siliconflow = "" +polish_token_openai = "" +polish_token_deepseek = "" +polish_token_groq = "" +polish_endpoint = "https://api.deepseek.com/chat/completions" +# 留空则按提供商填默认值,例如 Qwen/Qwen3-8B、gpt-4o-mini。 +polish_model = "deepseek-v4-flash" +# 润色提示词: cleanup | faithful | zh2en | casual | custom_1 | custom_2 | custom_3 +polish_prompt_id = "cleanup" +# 旧版单槽位提示词,升级时作为“自定义一”的回退值。 +polish_prompt = "" +polish_prompt_custom_1 = "" +polish_prompt_custom_2 = "" +polish_prompt_custom_3 = "" +language = "zh-cn" +# 开始和结束录音提示音可分别关闭 +start_sound = true +end_sound = true +# 录音时暂时静音其他应用的播放声音,结束后自动恢复 +mute_system_audio = true +polish_text = true +# 豆包流式识别时,将已识别文本显示在输入框 inline preedit 中,松开录音键后上屏。 +# 仅在豆包流式识别且 commit_mode = "tsf" 时生效。 +stream_inline_preedit = true +# 上屏方式: tsf | sendinput | ctrl_v +commit_mode = "tsf" + +# +# Soft Keyboard +# 屏幕键盘 +# +[soft_keyboard] +theme_mode = "dark" +background_img = "" # 默认是空,没有背景图片 + +# +# Utility +# 实用功能 +# +[utility] +clipboard_history = true # 表情面板剪贴板历史,仅记录文本;关闭后立即清空 +study_english_word = "false" # 背单词功能,开启后可以在候选项旁边显示英文 +quick_phrase = true # Shift+K 进入快捷短语模式 +unicode_mode = true # Shift+U 进入 Unicode 码位输入模式 +date_time_mode = true # Shift+T 进入日期与时间快捷输入模式 +emoji_mode = true # Shift+E 进入 Emoji 快捷输入模式(支持全拼/简拼/双拼/英文) +kaomoji_mode = true # Shift+M 进入颜文字快捷输入模式(支持全拼/简拼/双拼/英文) +jianpin_mode = true # Shift+J 进入超级简拼模式(每个字母作为简拼;双拼按方案转换 zh/ch/sh) +y_mode = true # Shift+Y 进入临时英文模式(中文状态下直接打英文,上屏后回到中文) +r_mode = true # Shift+R 进入临时日语模式(中文状态下使用罗马字输入日语,上屏后回到中文) + +# +# AI Assistant +# AI 辅助 +# +[ai_assistant] +enabled = true +provider = "deepseek" +# 请自行填写各提供商的 API Token,不要把真实密钥写进仓库。 +token = "FAKESECRET_e1f2g3h4i5j6k7l8m9n0" +token_deepseek = "" +token_openai = "" +token_siliconflow = "" +token_groq = "" +endpoint = "https://api.deepseek.com/chat/completions" +model = "deepseek-v4-flash" +candidate_limit = 3 +prompt_id = "custom_1" +prompt = """你是一个中文全拼输入法联想引擎。输入为已经切分好的拼音数组、前文上下文和候选数量。 + +优先生成与拼音严格对应的中文候选:若有 N 段拼音,首选必须尽量为 N 个汉字,每段拼音对应一个汉字,不得随意增删或改变读音。结合上下文、常用程度、语义完整性和固定搭配排序。 + +若去掉分词后能明显组成更合理的英文单词、缩写、产品名或技术术语,如 `deep + seek → DeepSeek`、`git + hub → GitHub`,可优先返回英文;不要生造英文或做牵强匹配。 + +只输出合法 JSON,不要解释或输出 Markdown: + +{ +"candidates": [ +{ +"text": "候选内容", +"type": "chinese或english", +"confidence": 0.98 +} +] +} + +候选按推荐程度降序排列,数量不超过指定上限;没有合理结果时返回空数组。""" +# prompt 是旧版“自定义一”;首次修改后会写入下面的独立槽位。 +prompt_custom_1 = "" +prompt_custom_2 = "" +prompt_custom_3 = "" diff --git a/installer/msime_setup.iss b/installer/msime_setup.iss new file mode 100644 index 00000000..97329b32 --- /dev/null +++ b/installer/msime_setup.iss @@ -0,0 +1,518 @@ +; Metasequoia IME — Inno Setup script +; 源文件根目录:本脚本所在目录 +; +; 编译方法: +; 1. 安装 Inno Setup 6.6 或更高版本:https://jrsoftware.org/isinfo.php +; 2. 运行 .\Compile-Installer.ps1(或用 Inno Setup Compiler 打开本文件并按 Ctrl+F9) +; 输出安装包默认在:Output\ +; +; 本地测试打包顺序: +; 1. Prepare-PackageFiles.ps1 收集本版本的安装文件 +; 2. Sign-PackageBinaries-Local.ps1 用本机自签名测试证书给包内 EXE/DLL 签名 +; 3. 本文件用 Inno Setup 编译 +; 4. Sign-Installer-Local.ps1 用同一张本机测试证书给安装包签名 +; +; 也可以直接运行 .\test.ps1 走完整测试流程。 +; 本仓库不包含任何预置代码签名证书。 + +#define MyAppName "Metasequoia IME" +#define MyAppVersion "0.0.1" +#define MyAppPublisher "Metasequoia" +#define MyAppExeName "MetasequoiaImeServer.exe" +#define MySettingsExeName "MetasequoiaImeSettings.exe" +#define MyWatchdogName "MetasequoiaImeWatchdog.exe" +#define MyWatchdogTaskName "Metasequoia IME Watchdog" +#define MyReplayName "MetasequoiaImeDictionaryReplay.exe" +#define MyVersionDirBase "msime_v" + MyAppVersion +#define MySourceRoot "." + +[Setup] +AppId={{A7C3E91F-4B2D-4E8A-9F1C-6D5E8B0A2C4D} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +AppPublisher={#MyAppPublisher} +DefaultDirName={autopf}\metasequoiaime +DefaultGroupName={#MyAppName} +DisableDirPage=yes +DisableProgramGroupPage=yes +OutputDir=Output +OutputBaseFilename=MetasequoiaIME_Setup_v{#MyAppVersion} +SetupIconFile={#MySourceRoot}\MetasequoiaIME.ico +Compression=lzma2 +SolidCompression=yes +; 安装和卸载界面自动跟随 Windows 的浅色/深色模式。 +WizardStyle=modern dynamic +PrivilegesRequired=admin +UsedUserAreasWarning=no +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible +UninstallDisplayName={#MyAppName} +UninstallDisplayIcon={commonpf64}\metasequoiaime\MetasequoiaIME.ico +VersionInfoVersion={#MyAppVersion} + +[Languages] +Name: "chinesesimplified"; MessagesFile: "compiler:Languages\ChineseSimplified.isl" +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Dirs] +Name: "{commonpf32}\metasequoiaime\{code:GetVersionDir}" +Name: "{commonpf64}\metasequoiaime\{code:GetVersionDir}" +Name: "{commonpf64}\metasequoiaime\server" +Name: "{localappdata}\metasequoiaime" +; WebView2 子进程是中完整性,写不进内置 Administrator 的高完整性 LocalAppData。 +Name: "{commonappdata}\metasequoiaime" +Name: "{commonappdata}\metasequoiaime\webview2"; Permissions: users-modify +Name: "{commonappdata}\metasequoiaime\webview2-settings"; Permissions: users-modify + +[Files] +; 独立安装应用图标,供 Windows“已安装的应用”列表稳定显示。 +Source: "{#MySourceRoot}\MetasequoiaIME.ico"; \ + DestDir: "{commonpf64}\metasequoiaime"; Flags: ignoreversion + +; TSF DLL 使用版本独立目录,避免升级时覆盖仍被进程加载的 DLL。 +Source: "{#MySourceRoot}\tsf_dll\32\*.dll"; \ + DestDir: "{commonpf32}\metasequoiaime\{code:GetVersionDir}"; \ + Flags: ignoreversion regserver 32bit + +Source: "{#MySourceRoot}\tsf_dll\64\*.dll"; \ + DestDir: "{commonpf64}\metasequoiaime\{code:GetVersionDir}"; \ + Flags: ignoreversion regserver + +Source: "{#MySourceRoot}\server_exe\*"; \ + DestDir: "{commonpf64}\metasequoiaime\server"; \ + Flags: ignoreversion recursesubdirs createallsubdirs + +; 包内故意不带 config.toml。通配复制再排除一次,防止以后又把用户配置打进包内。 +Source: "{#MySourceRoot}\app_data\*"; DestDir: "{localappdata}\metasequoiaime"; \ + Excludes: "\config.toml,\config.base.toml,\config.default.toml"; \ + Flags: ignoreversion recursesubdirs createallsubdirs uninsneveruninstall + +; 用户配置只在首次安装时从出厂模板生成。升级时绝不覆盖已有 config.toml; +; Server 启动时再以 config.default.toml 合并:保留用户改过的值,带入新版新增项。 +Source: "{#MySourceRoot}\app_data\config.default.toml"; \ + DestDir: "{localappdata}\metasequoiaime"; DestName: "config.toml"; \ + Flags: onlyifdoesntexist uninsneveruninstall +Source: "{#MySourceRoot}\app_data\config.default.toml"; \ + DestDir: "{localappdata}\metasequoiaime"; DestName: "config.default.toml"; \ + Flags: ignoreversion uninsneveruninstall + +[Icons] +Name: "{group}\{#MyAppName}"; \ + Filename: "{commonpf64}\metasequoiaime\server\{#MySettingsExeName}"; \ + WorkingDir: "{commonpf64}\metasequoiaime\server" +Name: "{group}\卸载 {#MyAppName}"; Filename: "{uninstallexe}" + +[Registry] +Root: HKLM; Subkey: "Software\Metasequoia\MetasequoiaIME"; \ + ValueType: string; ValueName: "VersionDir"; ValueData: "{code:GetVersionDir}"; \ + Flags: uninsdeletevalue +Root: HKLM; Subkey: "Software\Metasequoia\MetasequoiaIME"; \ + ValueType: string; ValueName: "ServerPath"; \ + ValueData: "{commonpf64}\metasequoiaime\server\{#MyAppExeName}"; \ + Flags: uninsdeletevalue + +[Run] +; uiAccess=true 的程序不能用 CreateProcess 拉起(会报 740),必须用 shellexec(等同双击)。 +; postinstall 默认 runasoriginaluser:不要用安装器的提升令牌启动 WebView2。 +Filename: "{commonpf64}\metasequoiaime\server\{#MyAppExeName}"; \ + Description: "启动 Metasequoia IME Server"; \ + Flags: nowait postinstall skipifsilent shellexec runasoriginaluser + +Filename: "{commonpf64}\metasequoiaime\server\{#MyWatchdogName}"; \ + Description: "启动 Metasequoia IME Watchdog"; \ + Flags: nowait postinstall skipifsilent shellexec runasoriginaluser + +[Code] +var + VersionDirName: String; + +function GetVersionDir(Param: String): String; +var + Candidate: String; + Suffix: Integer; +begin + if VersionDirName = '' then + begin + Candidate := '{#MyVersionDirBase}'; + Suffix := 0; + while + DirExists(ExpandConstant( + '{commonpf32}\metasequoiaime\' + Candidate)) or + DirExists(ExpandConstant( + '{commonpf64}\metasequoiaime\' + Candidate)) + do + begin + Suffix := Suffix + 1; + Candidate := '{#MyVersionDirBase}.' + IntToStr(Suffix); + end; + VersionDirName := Candidate; + end; + Result := VersionDirName; +end; + +function IsUserDatabaseFile(const FileName: String): Boolean; +begin + { WAL 中可能还有尚未 checkpoint 的用户操作,必须与主库一起保留。} + Result := + (CompareText(FileName, 'msime_user.db') = 0) or + (CompareText(FileName, 'msime_user.db-wal') = 0) or + (CompareText(FileName, 'msime_user.db-shm') = 0) or + (CompareText(FileName, 'msime_user.db-journal') = 0); +end; + +function IsUserConfigFile(const FileName: String): Boolean; +begin + { config.toml 是用户配置,config.base.toml 是上次合并用的模板基线: + 没有它,Server 就无法判断某一项到底是用户改的还是旧版默认值。} + Result := + (CompareText(FileName, 'config.toml') = 0) or + (CompareText(FileName, 'config.base.toml') = 0); +end; + +function IsUserSkinDirectory(const FileName: String): Boolean; +begin + { 外部皮肤在 %LOCALAPPDATA%\metasequoiaime\skins,升级安装不得清掉。} + Result := CompareText(FileName, 'skins') = 0; +end; + +function IsPreservedAppDataItem(const FileName: String): Boolean; +begin + Result := + IsUserDatabaseFile(FileName) or + IsUserConfigFile(FileName) or + IsUserSkinDirectory(FileName); +end; + +function InitializeUninstall(): Boolean; +begin + RegQueryStringValue( + HKLM, + 'Software\Metasequoia\MetasequoiaIME', + 'VersionDir', + VersionDirName + ); + Result := True; +end; + +procedure StopProcess(const ImageName: String); +var + ResultCode: Integer; +begin + { Watchdog 必须先停,否则它可能在卸载期间重新启动 Server。} + Exec( + ExpandConstant('{sys}\taskkill.exe'), + '/F /T /IM "' + ImageName + '"', + '', + SW_HIDE, + ewWaitUntilTerminated, + ResultCode + ); +end; + +procedure DeleteWatchdogLogonTask; +var + ResultCode: Integer; +begin + { /F makes this idempotent when upgrading from a build without the task. } + Exec( + ExpandConstant('{sys}\schtasks.exe'), + '/Delete /F /TN "{#MyWatchdogTaskName}"', + '', + SW_HIDE, + ewWaitUntilTerminated, + ResultCode + ); +end; + +procedure EnsureSharedWebView2DataDir; +var + RootPath: String; + ResultCode: Integer; +begin + { Edge 子进程需要 Users 可写、中完整性的目录。安装器本身是高完整性, + 只 CreateDir 会带上高完整性标签,所以还要降完整性。 } + RootPath := ExpandConstant('{commonappdata}\metasequoiaime'); + ForceDirectories(RootPath + '\webview2'); + ForceDirectories(RootPath + '\webview2-settings'); + Exec( + ExpandConstant('{sys}\icacls.exe'), + '"' + RootPath + '" /grant *S-1-5-32-545:(OI)(CI)M /T /C /Q', + '', + SW_HIDE, + ewWaitUntilTerminated, + ResultCode + ); + Exec( + ExpandConstant('{sys}\icacls.exe'), + '"' + RootPath + '" /setintegritylevel (OI)(CI)M /T /C /Q', + '', + SW_HIDE, + ewWaitUntilTerminated, + ResultCode + ); +end; + +procedure CreateWatchdogLogonTask; +var + WatchdogPath: String; + Params: String; + ResultCode: Integer; +begin + ResultCode := -1; + WatchdogPath := ExpandConstant( + '{commonpf64}\metasequoiaime\server\{#MyWatchdogName}'); + { /F replaces the same fixed-name task during an upgrade. /IT keeps the + task in the interactive user's session; LIMITED avoids an elevated token. } + Params := + '/Create /F /TN "{#MyWatchdogTaskName}" /SC ONLOGON ' + + '/RL LIMITED /IT /TR "\"' + WatchdogPath + '\""'; + if + (not Exec( + ExpandConstant('{sys}\schtasks.exe'), + Params, + '', + SW_HIDE, + ewWaitUntilTerminated, + ResultCode + )) or + (ResultCode <> 0) + then + RaiseException( + '无法创建输入法登录启动任务(退出码:' + + IntToStr(ResultCode) + ')。'); +end; + +procedure TryDeleteTree(const Path: String); +begin + { 忽略返回值:被占用文件保留,其他能删除的文件仍继续清理。} + DelTree(Path, True, True, True); +end; + +procedure CleanAppDataExceptUserFiles; +var + AppDataPath: String; + FindRec: TFindRec; + ItemPath: String; +begin + AppDataPath := ExpandConstant('{localappdata}\metasequoiaime'); + if not DirExists(AppDataPath) then + exit; + + if FindFirst(AddBackslash(AppDataPath) + '*', FindRec) then + begin + try + repeat + if + (FindRec.Name <> '.') and + (FindRec.Name <> '..') and + (not IsPreservedAppDataItem(FindRec.Name)) + then + begin + ItemPath := AddBackslash(AppDataPath) + FindRec.Name; + if (FindRec.Attributes and FILE_ATTRIBUTE_DIRECTORY) <> 0 then + TryDeleteTree(ItemPath) + else + DeleteFile(ItemPath); + end; + until not FindNext(FindRec); + finally + FindClose(FindRec); + end; + end; +end; + +function RemoveFileWithRetry(const Path: String): Boolean; +var + Attempt: Integer; +begin + for Attempt := 1 to 5 do + begin + if not FileExists(Path) then + begin + Result := True; + exit; + end; + DeleteFile(Path); + if not FileExists(Path) then + begin + Result := True; + exit; + end; + Sleep(200); + end; + Result := not FileExists(Path); +end; + +function RemoveOldTargetDatabaseFiles(var FailedPath: String): Boolean; +var + AppDataPath: String; + FileNames: array[0..11] of String; + Index: Integer; + Path: String; +begin + AppDataPath := ExpandConstant('{localappdata}\metasequoiaime'); + { 先删 sidecar;若仍被占用,可在动主库和其他应用数据前安全中止。} + FileNames[0] := 'msime.db-wal'; + FileNames[1] := 'msime.db-shm'; + FileNames[2] := 'msime.db-journal'; + FileNames[3] := 'english.db-wal'; + FileNames[4] := 'english.db-shm'; + FileNames[5] := 'english.db-journal'; + FileNames[6] := 'others.db-wal'; + FileNames[7] := 'others.db-shm'; + FileNames[8] := 'others.db-journal'; + FileNames[9] := 'msime.db'; + FileNames[10] := 'english.db'; + FileNames[11] := 'others.db'; + + for Index := 0 to 11 do + begin + Path := AddBackslash(AppDataPath) + FileNames[Index]; + if not RemoveFileWithRetry(Path) then + begin + FailedPath := Path; + Result := False; + exit; + end; + end; + Result := True; +end; + +procedure ReplayUserDictionary; +var + ReplayPath: String; + DataPath: String; + ResultCode: Integer; +begin + DataPath := ExpandConstant('{localappdata}\metasequoiaime'); + if not FileExists(AddBackslash(DataPath) + 'msime_user.db') then + begin + Log('User dictionary replay skipped: msime_user.db does not exist.'); + exit; + end; + + ReplayPath := ExpandConstant( + '{commonpf64}\metasequoiaime\server\{#MyReplayName}'); + Log('Starting user dictionary replay.'); + if not Exec( + ReplayPath, + '--data-dir "' + DataPath + '"', + '', + SW_HIDE, + ewWaitUntilTerminated, + ResultCode + ) then + RaiseException( + '无法启动用户词库回放程序。请确认安装文件完整后重试。'); + + if ResultCode <> 0 then + RaiseException( + '用户词库回放失败(退出码:' + IntToStr(ResultCode) + + ')。安装已停止,以避免启动未恢复用户词库的新版本。'); + Log('User dictionary replay completed successfully.'); +end; + +procedure TryDeleteOldVersionDirs(const RootPath: String); +var + FindRec: TFindRec; +begin + if FindFirst( + AddBackslash(RootPath) + 'msime_v*', + FindRec + ) then + begin + try + repeat + if + ((FindRec.Attributes and FILE_ATTRIBUTE_DIRECTORY) <> 0) and + (FindRec.Name <> '.') and + (FindRec.Name <> '..') and + (CompareText(FindRec.Name, VersionDirName) <> 0) + then + TryDeleteTree(AddBackslash(RootPath) + FindRec.Name); + until not FindNext(FindRec); + finally + FindClose(FindRec); + end; + end; +end; + +function PrepareToInstall(var NeedsRestart: Boolean): String; +var + FailedPath: String; +begin + { 先锁定本次目录名,再清理能够释放的旧版本 DLL。} + VersionDirName := GetVersionDir(''); + StopProcess('{#MyWatchdogName}'); + StopProcess('{#MyAppExeName}'); + { 不能让旧 WAL/SHM 与即将复制的新主数据库混用。} + if not RemoveOldTargetDatabaseFiles(FailedPath) then + begin + Result := + '无法删除旧词库文件:' + FailedPath + #13#10 + + '它可能仍被输入法相关进程占用。请关闭相关程序后重试安装。'; + exit; + end; + { 目标词库已安全移除,再清理旧前端资源与 WebView2 用户数据。 + 用户配置和外部皮肤目录在这里保留;webview2 目录故意重建, + 由 Server 冷启动路径保证 FTB/候选窗仍能稳定揭罩。} + CleanAppDataExceptUserFiles; + TryDeleteTree(ExpandConstant('{commonappdata}\metasequoiaime\webview2')); + TryDeleteTree(ExpandConstant('{commonappdata}\metasequoiaime\webview2-settings')); + TryDeleteTree(ExpandConstant( + '{commonpf64}\metasequoiaime\server')); + TryDeleteOldVersionDirs(ExpandConstant( + '{commonpf32}\metasequoiaime')); + TryDeleteOldVersionDirs(ExpandConstant( + '{commonpf64}\metasequoiaime')); + { 随后的 [Files] 与 ssPostInstall 会写入新 Server 和登录任务。} + Result := ''; +end; + +procedure CurStepChanged(CurStep: TSetupStep); +begin + if CurStep = ssPostInstall then + begin + ReplayUserDictionary; + CreateWatchdogLogonTask; + EnsureSharedWebView2DataDir; + { Keep the old autostart intact until its scheduled-task replacement has + been created successfully, then remove the Explorer-delayed Run entry. } + RegDeleteValue( + HKLM, + 'Software\Microsoft\Windows\CurrentVersion\Run', + 'MetasequoiaImeWatchdog' + ); + end; +end; + +procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); +begin + if CurUninstallStep = usUninstall then + begin + DeleteWatchdogLogonTask; + RegDeleteValue( + HKLM, + 'Software\Microsoft\Windows\CurrentVersion\Run', + 'MetasequoiaImeWatchdog' + ); + StopProcess('{#MyWatchdogName}'); + StopProcess('{#MyAppExeName}'); + end + else if CurUninstallStep = usPostUninstall then + begin + TryDeleteTree(ExpandConstant( + '{commonpf64}\metasequoiaime\server')); + if VersionDirName <> '' then + begin + TryDeleteTree(ExpandConstant( + '{commonpf32}\metasequoiaime\' + VersionDirName)); + TryDeleteTree(ExpandConstant( + '{commonpf64}\metasequoiaime\' + VersionDirName)); + end; + TryDeleteTree(ExpandConstant('{commonpf32}\metasequoiaime')); + TryDeleteTree(ExpandConstant('{commonpf64}\metasequoiaime')); + TryDeleteTree(ExpandConstant('{localappdata}\metasequoiaime')); + TryDeleteTree(ExpandConstant('{commonappdata}\metasequoiaime')); + end; +end; diff --git a/installer/test.ps1 b/installer/test.ps1 new file mode 100644 index 00000000..328f25f6 --- /dev/null +++ b/installer/test.ps1 @@ -0,0 +1,43 @@ +# 本地测试安装流程:编译相邻源码仓库(若存在)→ 收集安装文件 → +# 本机自签名 → Inno Setup 打包 → 签安装包 → 启动安装程序。 +# +# 不使用任何预置证书。签名脚本会在本机生成并复用自签名测试证书。 + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +Set-Location $PSScriptRoot + +$repoRoot = Split-Path -Parent $PSScriptRoot +$tsfCompile = Join-Path $repoRoot 'MetasequoiaImeTsf\scripts\lcompile-release-both.ps1' +$serverCompile = Join-Path $repoRoot 'MetasequoiaImeServer\scripts\lcompile-release.ps1' +$settingsDir = Join-Path $repoRoot 'MetasequoiaImeUiHtml\webview2\settings\ime-settings' + +if (Test-Path -LiteralPath $tsfCompile -PathType Leaf) { + Push-Location (Join-Path $repoRoot 'MetasequoiaImeTsf') + try { & $tsfCompile } finally { Pop-Location } +} +else { + Write-Host '跳过 TSF 编译:未找到相邻的 MetasequoiaImeTsf 仓库。' +} + +if (Test-Path -LiteralPath $serverCompile -PathType Leaf) { + Push-Location (Join-Path $repoRoot 'MetasequoiaImeServer') + try { & $serverCompile } finally { Pop-Location } +} +else { + Write-Host '跳过 Server 编译:未找到相邻的 MetasequoiaImeServer 仓库。' +} + +if (Test-Path -LiteralPath (Join-Path $settingsDir 'package.json') -PathType Leaf) { + Push-Location $settingsDir + try { pnpm run build } finally { Pop-Location } +} +else { + Write-Host '跳过设置页构建:未找到相邻的 MetasequoiaImeUiHtml 仓库。' +} + +& (Join-Path $PSScriptRoot 'Prepare-PackageFiles.ps1') +& (Join-Path $PSScriptRoot 'Sign-PackageBinaries-Local.ps1') +& (Join-Path $PSScriptRoot 'Compile-Installer.ps1') +& (Join-Path $PSScriptRoot 'Sign-Installer-Local.ps1') +& (Join-Path $PSScriptRoot 'Install.ps1')